uni-app黑魔法:小程序自定义组件运行到H5平台
引言
移动互联网的初期,囿于设备硬件性能限制,流量以原生App为主,iOS、Android是当时两大平台。
随着硬件及OS的更新换代,H5可承载的体验逐步完善,为提高开发效率、节约资源(复用代码)以及热更新等目的,Hybrid模式成为主流;以及轻应用、服务号等平台的助推,H5网页流量暴涨,成为第三大平台。
2017年1月9日,微信发布小程序,历经3年发展,在今年主题为”未完成 Always Beta“的微信公开课 PRO上,微信团队披露,2019年小程序日活跃用户超过 3 亿,全年累计成交额达8000亿,同比增长超160%。
看到小程序如此惊人的增长力,我们有理由相信,有中国特色的小程序互联网时代已经到来,微信小程序也已成为继iOS、Android、H5之后的第四大流量平台。
平台分裂,为不同平台编写相同的业务代码,是件无趣的事情。
有追求的程序员,一直在探索代码复用的方案,Hybrid App即是代表。
而在如今的小程序时代,对于同样基于WEB技术的H5和小程序,如何实现代码复用,是很多前端工程师探索的方向。业内也已有不少成熟方案,从场景上来说,大致分为三类:
基于跨端框架,从头开发,一套代码,发行多个平台,比如DCloud出品的[代码]uni-app[代码]、京东凹凸实验室的[代码]taro[代码]
复用H5代码,转换H5代码在小程序环境中执行;适用于有H5平台沉淀,未开发小程序或小程序完善度较低的开发者;
美团的mpvue框架是早期探索解决这个问题的代表,但因小程序不支持dom操作,故mpvue适用于vue的无dom操作的H5代码转换;
最近微信官方推出的kbone,也是为了解决“把 Web 端的代码挪到小程序环境内执行”;不过,kbone 相比 mpvue 前进了一步(当然也有了新的性能缺陷),因为:
kbone实现了一个适配器,在适配层里模拟出了浏览器环境,让 Web 端的代码可以不做什么改动便可运行在小程序里。
复用小程序代码,转换小程序代码在web环境中运行;适用于有小程序代码沉淀,未开发H5或H5平台完善度较低的开发者;这个方向业内成熟的方案还比较少。
[代码]uni-app[代码]近期支持了小程序自定义组件运行到H5平台,是对如上第三种场景的一种探索。
需求场景
鉴于小程序的低成本获客特征,很多厂商选择先开发小程序,验证业务模式后,再扩展至H5、App等其它平台。
开发者虽可借助转换器将小程序代码转换为[代码]uni-app[代码]项目(或其它跨端框架项目),快速实现多平台发行;但不少开发者是不敢轻易决策将跨端版本替换之前线上的小程序版本的,毕竟线上版本已稳定运行了一段时间。
常选的方案是:让原生小程序版本和[代码]uni-app[代码]跨端版本并行一段时间,微信平台继续使用原生版本,其它平台使用[代码]uni-app[代码]跨端版本;经过一段时间验证[代码]uni-app[代码]版本稳定后,再使用[代码]uni-app[代码]版替换掉原生小程序版本。
在这段并行的时间内,开发者需要同时维护微信原生、[代码]uni-app[代码]两个版本,新增业务需编写两份逻辑相同的代码,重复劳动,成本叠加,如何改善?
借助[代码]uni-app[代码] 支持将微信小程序组件运行到H5平台的特性,我们给出一种思路:
开发者在原生小程序项目中,将新增业务以自定义组件的方式开发,优先上线小程序;
拷贝小程序组件的[代码]wxml/wxss/js/json[代码]文件到[代码]uni-app[代码] 项目下,通过[代码]uni-app[代码]的编译器及运行时,保证小程序自定义组件在H5平台的正确运行。
这个方案的好处是:
优先小程序开发,毕竟小程序早已上线,有存量用户
复用小程序组件,新增业务仅需开发一套代码即可,降低开发成本
不止自己开发的小程序组件,业内开源的三方小程序组件,均可复制到[代码]uni-app[代码]项目项目中,运行到H5平台。
另外,部分公司的产品经理,会要求不同平台有不同的交互,但核心业务逻辑是相同的,开发者常会通过维护不同项目的方式来满足产品经理需求。此时,采取如上方案,同样可满足多个项目复用相同业务逻辑的诉求。
实际上,[代码]uni-app[代码]之前已支持将小程序自定义组件运行到App平台,对于有小程序组件沉淀或优先小程序的开发者来说,这是个好消息,一套业务组件,快速运行到iOS、Android、H5、微信小程序这四大流量平台(实际上也可运行到QQ小程序平台)。
[图片]
uni-app 引用小程序组件演示
[代码]uni-app[代码]项目中使用自定义组件的方法很简单,分为三步:
1、拷贝小程序自定义组件到[代码]uni-app[代码]项目根目录下的[代码]wxcomponents[代码]文件夹下
2、在 [代码]pages.json[代码] 对应页面的 [代码]style -> usingComponents[代码]引入组件,如:
[代码]{
"pages": [
{
"path": "index/index",
"style": {
"usingComponents": {
"custom": "/wxcomponents/custom/index"
}
}
}
]
}
[代码]
3、在页面中使用自定义组件,如:
[代码]<!-- 页面模板 (index.vue) -->
<view>
<!-- 在页面中对自定义组件进行引用 -->
<custom name="uni-app"></custom>
</view>
[代码]
方案实现思路
简单介绍下[代码]uni-app[代码]的多端发行原理。
[代码]uni-app[代码]基于[代码]Vue.js[代码] runtime,页面文件遵循[代码]Vue[代码].js 单文件组件 (SFC) 规范,天然对H5的支持比较好,发行到H5平台时,先通过[代码]vue-loader[代码]解析[代码].vue[代码]文件,导出[代码]Vue.js[代码] 组件选项对象,然后在运行时补充规范实现:
组件:框架提供内置组件(view/swiper/picker等)的实现,保证平台UI及交互的一致性
接口:在H5平台封装框架接口,比如路由跳转,showToast等界面交互
生命周期:Vue.js的理念是一切皆为组件,没有应用和页面的概念;框架需创造出应用及页面的概念,模拟onLaunch、onShow等钩子
uni-app发行到小程序平台时,逻辑又有不同,主要工作有2块:
编译器:将[代码].vue[代码]文件拆分成[代码]wxml/wxss/js/json[代码]4个原生页面文件
运行时:[代码]Vue.js[代码]和小程序都是逻辑视图层框架,都有数据绑定功能;运行时会实现[代码]Vue.js[代码]到小程序的数据同步,及小程序到[代码]Vue.js[代码]的事件代理
小程序自定义组件类似小程序原生的页面开发,一个自定义组件同样由[代码]wxml/wxss/js/json[代码] 4个文件组成,另有单独的组件规范(如[代码]Component[代码] 构造器、[代码]Behaviors[代码]特性等)。
所以,小程序自定义组件运行到H5平台,可借助[代码]uni-app[代码]已有平台功能快速实现:
编译阶段:将[代码]wxml/wxss/js/json[代码]4个文件合并为[代码].vue[代码]文件(类似 [代码]uni-app[代码] 发行到小程序的逆过程),然后调用[代码]uni-app[代码]发行H5平台的编译过程,通过[代码]vue-loader[代码]解析[代码].vue[代码]文件,导出 [代码]Vue.js[代码] 组件选项对象
运行阶段:实现 [代码]Component[代码] 构造器、[代码]Behaviors[代码]特性,模拟自定义组件特有的生命周期
编译:转换文件(mp2vue)
小程序自定义组件发行到H5平台,在编译环节主要有2项工作:
将自定义组件的[代码]wxml/wxss/js/json[代码] 4个文件组成,编译转换成[代码].vue[代码]文件,即小程序转vue,可简写为[代码]mp2vue[代码]
通过[代码]vue-loader[代码]解析[代码].vue[代码]文件,导出 [代码]Vue.js[代码] 组件选项对象
其中,步骤2是[代码]Vue.js[代码]项目的标准编译过程,略过不提;我们重点阐述步骤1。
[代码]mp2vue[代码]将4个独立[代码]wxml/wxss/js/json[代码] 的文件合并成一个[代码].vue[代码]文件,并组装成[代码]template[代码]、[代码]script[代码]、[代码]style[代码] 这种三段式的结构,流程包括:
[代码]wxml[代码]文件生成[代码]template[代码]节点,同时完成指令、事件等模板语法转换
[代码]js/json[代码]文件生成[代码]script[代码]节点,同时完成组件注册过
[代码]wxss[代码]文件生成[代码]style[代码]节点,自动转换部分css兼容语法
合并为[代码].vue[代码]文件
具体实现上,[代码]uni-app[代码]编译前先扫描[代码]wxcomponents[代码]目录,若存在则认为有小程序自定义组件,启动文件转换工作([代码]uni-migration[代码]插件来完成):
[代码]//加载转换器
const migrate = require('@dcloudio/uni-migration')
//扫描wxcomponents目录
const wxcomponents = path.resolve(process.env.UNI_INPUT_DIR, 'wxcomponents')
if (fs.existsSync(wxcomponents)) {
migrate(wxcomponents, false, {
silent: true
}) // 转换 mp-weixin 小程序组件
}
[代码]
接着开始对[代码]wxml/wxss/js/json[代码]文件逐个解析,并合并为一个[代码].vue[代码]文件:
[代码]module.exports = function transformFile(input, options) {
//首先转换json文件,判断是否为组件
const [jsCode, isComponent] = transformJsonFile(filepath + '.json', deps)
options.isComponent = isComponent
//转换 wxml 文件
const [templateCode, wxsCode = '', wxsFiles = []] = transformTemplateFile(filepath + templateExtname, options)
//转换wxss文件
const styleCode = transformStyleFile(filepath + styleExtname, options, deps) || ''
//转换js文件
const scriptCode = transformScriptFile(filepath + '.js', jsCode, options, deps)
// 生成合并后的.vue文件源码
return [
`${commentsCode}<template>
${templateCode}
</template>
${wxsCode}
<script>
${scriptCode}
</script>
<style platform="mp-weixin">
${styleCode}
</style>`,
deps,
wxsFiles
]
}
[代码]
进一步细节说明,wxml文件转为template节点时,需完成各项指令、事件等模板语法的转换,例如:
小程序自定义组件
Vue组件
描述
wx:if
v-if
条件渲染
wx:for
v-for
列表渲染
bindtap
@click
元素点击事件
将一个最简自定义组件,按照如上流程转换,结果示意如下:
[图片]
运行时:模拟小程序组件环境
[代码]uni-app[代码]的编译器并不转换小程序组件的 JS 代码,依然保留[代码]Component[代码]构造器的写法,甚至其中的API依然是[代码]wx.[代码]开头的方式,这些都依赖[代码]uni-app[代码]在H5平台的运行时来解决,主要有如下几部分内容:
[代码]Component[代码]构造器:解析小程序组件的各种选项配置,转换为[代码]Vue[代码]组件定义,包括变通实现其中的差异部分,如小程序组件特有的”组件所在页面的生命周期“
[代码]Behaviors[代码]特性:转换为Vue的混入(mixin)
数据响应:在H5平台实现[代码]setData[代码]接口及[代码]this.data.xx = yy[代码]的数据通讯机制
API前缀:可在运行时通过代理机制,自动将[代码]wx.xx[代码]替换为[代码]uni.xx[代码],这个比较简单,不详述
Component构造器
[代码]uni-app[代码]在H5平台定义了一个[代码]Component[代码]函数,执行到小程序的[代码]Component[代码]构造器函数后,开始循环解析其属性,并转换成Vue组件属性,流程示意代码如下:
[代码]export function Component (options) {
const componentOptions = parseComponent(options)
componentOptions.mixins.unshift(polyfill)
componentOptions.mpOptions.path = global['__wxRoute']
initRelationsHandler(componentOptions)
global['__wxComponents'][global['__wxRoute']] = componentOptions
}
export function parseComponent (mpComponentOptions) {
const {
data,
options,
methods,
behaviors,
lifetimes,
observers,
relations,
properties,
pageLifetimes,
externalClasses
} = mpComponentOptions
const vueComponentOptions = {
mixins: [],
props: {},
watch: {},
mpOptions: {
mpObservers: []
}
}
// 开始逐个解析所有属性
parseData(data, vueComponentOptions)
parseOptions(options, vueComponentOptions)
parseMethods(methods, vueComponentOptions)
parseBehaviors(behaviors, vueComponentOptions)
parseLifetimes(lifetimes, vueComponentOptions)
parseObservers(observers, vueComponentOptions)
parseRelations(relations, vueComponentOptions)
parseProperties(properties, vueComponentOptions)
parsePageLifetimes(pageLifetimes, vueComponentOptions)
parseExternalClasses(externalClasses, vueComponentOptions)
parseLifecycle(mpComponentOptions, vueComponentOptions)
parseDefinitionFilter(mpComponentOptions, vueComponentOptions)
// 返回 Vue 组件
return vueComponentOptions
}
[代码]
在这个过程中,需处理小程序自定义组件和 Vue组件的属性对应关系及细节差异,如小程序组件的[代码]lifetimes[代码]:
小程序自定义组件
Vue/uni-app
描述
created
onServiceCreated
小程序的[代码]created[代码]触发时,可以访问子组件信息,而[代码]Vue[代码]的[代码]created[代码]访问不到,故需[代码]uni-app[代码]框架映射到其它时机(onServiceCreated)执行
attached
onServiceAttached
同上
ready
mounted
Vue 生命周期
moved
-
Vue中不存在该钩子,暂不支持转换
detached
destroyed
Vue 生命周期
小程序的[代码]pageLifetimes[代码](组件所在页面的生命周期)在Vue中是没有的,需要映射为[代码]uni-app[代码]封装的页面生命周期:
小程序自定义组件
uni-app
描述
ready
onPageShow
页面被展示时执行
hide
onPageHide
页面被隐藏时执行
resize
onPageResize
页面尺寸变化时执行
[代码]Behaviors[代码]特性的实现过程,类似[代码]Component[代码]构造器,不再赘述。
数据响应
[代码]Vue[代码]和小程序都有一套数据绑定系统,但机制不同,比如在Vue体系下,数据赋值是这样的:
[代码]this.a = 1
[代码]
但在小程序中,数据赋值方式则是这样的:
[代码]this.setData({
a:1
}) //响应式
this.data.a = 2 //非响应式
[代码]
另外,小程序和[代码]Vue[代码]在数据的[代码]properties[代码]、[代码]observer[代码]等方面都存在不少差异,经过我们评估,若将小程序的数据响应用法直接映射到[代码]Vue[代码]体系下,复杂度较高且有性能压力,故[代码]uni-app[代码]在H5平台按照微信的语法规范,单独实现了一套数据响应系统。
[代码]// 小程序的setData在H5平台的实现
function setData (data, callback) {
if (!isPlainObject(data)) {
return
}
Object.keys(data).forEach(key => {
if (setDataByExprPath(key, data[key], this.data)) {
!hasOwn(this, key) && proxy(this, SOURCE_KEY, key);
}
});
this.$forceUpdate();//数据变化,强制视图更新(响应式)
isFn(callback) && this.$nextTick(callback);
}
[代码]
将[代码]setData[代码]挂载到 vm 对象上,可通过[代码]this.setData[代码]这种小程序的方式调用;同时将数据绑定到data属性上,支持[代码]this.data.xx[代码]的访问方式。
[代码]export function initState (vm) {
const instanceData = JSON.parse(JSON.stringify(vm.$options.mpOptions.data || {}))
vm[SOURCE_KEY] = instanceData
//vm对象上挂载 setData 方法,实现小程序方法
vm.setData = setData
const propertyDefinition = {
get () {
return vm[SOURCE_KEY]
},
set (value) {
vm[SOURCE_KEY] = value
}
}
//小程序用法,可通过this.data.xx访问
Object.defineProperties(vm, {
data: propertyDefinition,
properties: propertyDefinition
})
Object.keys(instanceData).forEach(key => {
proxy(vm, SOURCE_KEY, key)
})
}
[代码]
虽然数据响应是[代码]uni-app[代码]自己实现的,但渲染依然使用了Vue框架的[代码]render[代码]函数,此时需小程序规范中的[代码]this.data.xx[代码]和Vue规范中的[代码]this.xx[代码]保持一致,通过代理的方式实现:
[代码]// mp/polyfill/state/proxy.js
const sharedPropertyDefinition = {
enumerable: true,
configurable: true
};
function proxy (target, sourceKey, key) {
sharedPropertyDefinition.get = function proxyGetter () {
return this[sourceKey][key]
};
sharedPropertyDefinition.set = function proxySetter (val) {
this[sourceKey][key] = val;
};
Object.defineProperty(target, key, sharedPropertyDefinition);
}
[代码]
这里仅列出了主要的几步,中间涉及细节很多;部分无法通过[代码]Vue[代码]扩展机制实现的功能,只好修改[代码]Vue.js[代码]的内核源码,比如[代码]updateProperties[代码]支持、小程序[代码]wxs[代码]、[代码]externalClasses[代码]等功能在H5平台的支持,都需要定制部分 Vue.js runtime 源码。
结语
本文分享了[代码]uni-app[代码]将微信小程序自定义组件发行到H5平台的实现思路,希望对大家有所启发。
但这种方案,归根到底是为了解决多套项目并存时的业务重复开发的问题。
如果你是从头开发,我们建议直接选择业内成熟的跨端框架,既可以保持一套代码,更省力的维护,还可以借助框架的成熟生态(如跨端UI库及插件市场),基于成熟轮子,快速完成业务的上线开发;
[代码]uni-app[代码]框架代码,包括小程序组件发行到H5平台的代码,全部开源在github,如果大家对本文逻辑有疑问,欢迎提交issue交流。