作者:vivo 互联网前端团队-Wan Anwen、Hu Feng、Feng Wei、Xie Tao
进入互联网“下半场”,靠“人海战术”的研发模式已经不再具备竞争力,如何通过技术升级提升研发效能?前端通过Babel等编译技术发展实现了工程化体系升级,如何进一步通过编译技术赋能前端开发?或许我们 wepy 到uniapp 编译的转换实践,能给你带来启发。
一、 背景
随着小程序的出现,借助微信的生态体系和海量用户,使服务以更加便捷方式的触达用户需求。基于此背景,团队很早布局智能导购小程序(为 vivo 各个线下门店导购提供服务的用户运营工具)的开发。
早期的小程序开发工程体系还不够健全,和现在的前端的工程体系相差较大,表现在对模块化,组件化以及高级JavaScript 语法特性的支撑上。所以团队在做技术选型时,希望克服原生小程序工程体系上的不足,经过对比最后选择了腾讯出品的 wepy 作为整体的开发框架。
在项目的从0到1阶段,wepy 确实帮助我们实现了快速的业务迭代,满足线下门店导购的需求。但随着时间的推移,在技术上,社区逐步沉淀出以 uniapp 为代表的 Vue 栈体系和以 Taro 为代表的 React 栈跨端的体系,wepy 目前的社区活跃度比较低。另外随着业务进入稳定阶段,除少量的 wepy 小程序,H5 项目和新的小程序都是基于 Vue 和 uniapp 来构建,团队也是希望统一技术栈,实现更好的跨端开发能力,降低开发和维护成本,提升研发效率。
二、思考
随着团队决定将智能导购小程序从 wepy 迁移到 uniapp 的架构体系,我们就需要思考,如何进行项目的平稳的迁移,同时兼顾效率和质量?通过对当前的项目状态和技术背景进行分析,团队梳理出2个原则3种迁移思路。
2.1 渐进式迁移
核心出发点,保证项目的平稳过渡,给团队更多的时间,在迭代中逐步的进行架构迁移。希望以此来降低迁移中的风险和不可控的点。基于此,我们思考两个方案:
方案一 融合两套架构体系
在目前的项目中引入和 uniapp 的项目体系,一个项目融合了 wepy 和 uniapp 的代码工程化管理,逐步的将 wepy 的代码改成 uniapp 的代码,待迁移完成删除 wepy 的目录。这种方案实现起来不是很复杂,但是缺点是管理起来比较复杂,两套工程化管理机制,底层的编译机制,各种入口的配置文件等,管理起来比较麻烦。另外团队每个人都需要消化 wepy 到 uniapp 的领域知识迁移,不仅仅是项目的迁移也是知识体系的迁移。
方案二 设计 wepy-webpack-loader
以 uniapp 为工程体系基础,核心思路是将现有 wepy 代码融入到 uniapp 的体系中来。我们都知道 uniapp 的底层依赖于 Vue 的 cli 的技术体系,最底层通过 webpack 实现对 Vue 单组件文件和其他资源文件的 bundle。
基于此,我们可以开发一个 wepy 的 webpack 的 loader,wepy-loader 类似于 vue-loader 的能力,通过该 loader 对 wepy 文件进行编译打包,然后最终输出小程序代码。想法很简单,但我们想要实现 wepy-loader工作量还是比较大的,需要对 wepy 的底层编译器进一步进行分析拆解,分析 wepy 的依赖关系,区分是组件编译还是 page 编译等,且 wepy 底层编译器的代码比较复杂,实现成本较高。
2.2 整体性迁移
构建一个编译器实现 wepy 到 uniapp 的自动代码转换。
通过对 wepy 和 uniapp 整体技术方案的梳理,加深了对两套架构差异性的认知和理解,尤其 wepy 上层语法和 Vue 的组件开发的代码上的差异性。基于团队对编译的认知,我们认为借助 babel 等成熟编译技术是有能力实现这个转换的过程,另外,通过编译技术会极大的提升整体的迁移的效率。
2.3 方案对比
通过团队对方案的深入讨论和技术预研,最终大家达成一致使用编译转换的方式(方案三)来进行本次的技术升级。最终,通过实现 wepy 到 uniapp 的编译转换器,使原本 25人/天的工作量,6s 完成。
如下动图所示:
三、架构设计
3.1 wepy 和 uniapp 单文件组件转换
通过对 wepy 和 uniapp 的学习,充分了解两者之间的差异性和相识点。wepy 的文件设计和 Vue 的单文件非常的相似,包含 template 和 script 和 style 的三部分组成。
如下图所示,
所以我们将文件拆解为 script,template,style 样式三个部分,通过 transpiler 分别转换。同时这个过程主要是对 script 和 template 进行转换,样式和 Vue 可以保持一致性最终借助 Vue 进行转换即可。
同时 wepy 还有自己的 runtime运行时的依赖,为了确保项目对 wepy 做到最小化的依赖,方便后续完全和 wepy 的依赖进行完全解耦,我们抽取了一个 wepy-adapter 模块,将原先对于 wepy 的依赖转换为对wepy-adapter 的依赖。
整体转换设计,如下图所示:
3.2 编译器流水线构建
如上图所示,整个编译过程就是一条流水线的架构设计,在每个阶段完成不同的任务。主要流程如下:
3.2.1 项目资源分析
不同的项目依赖资源不同的处理流程,扫描项目中的源码和资源文件进行分类,等待后续的不同的流水线处理。
静态资源文件(图片,样式文件等)不需要经过当中流水线的处理,直达目标 uniapp 项目的对应的目录。
3.2.2 AST抽象语法树转换
针对 wepy 的源文件(app,page,component等)对 script,template 等部分,通过 parse 转换成相对应的AST抽象语法树,后续的代码转换都是基于对抽象语法树的结构改进。
3.2.3 代码转换实现 - Transform code
根据 wepy 和 uniapp 的 Vue 的代码实现上的差异,通过对ast进行转换实现代码的转换。
3.2.4 代码生成 - code emitter
根据步骤三转换之后最终的ast,进行对应的代码生成。
四、项目搭建
整体项目结构如下图所示:
4.1 单仓库的管理模式
使用 lerna 进行单仓库的模块化管理,方便进行模块的拆分和本地模块之间依赖引用。另外单仓库的好处在于,和项目相关的信息都可以在一个仓库中沉淀下来,如文档,demo,issue 等。不过随着 lerna 社区不再进行维护,后续会将 lerna 迁移到 pnpm 的 workspace 的方案进行管理。
4.2 核心模块
- wepy-adapter - wepy运行期以来的最小化的polyfill
- wepy-chameleon-cli - 命令行工具模块
- wepy-chameleon-transpiler - 核心的编译器模块,按照one feature,one module方式组织
4.3 自动化任务构建等
Makefile - *nix世界的标准方式
4.4 scripts 自动化管理
shipit.ts 模块的自动发布等自动化能力
4.5 单元测试
- 采用Jest作为基础的测试框架,使用typescript来作为测试用例的编写。
- 使用@swc/jest作为ts的转换器,提升ts的编译速度。
- 现在社区的vitest直接提供了对ts的集成,借助vite带来更快的速度,计划迁移中。
五、核心设计实现
5.1 wepy template 模版转换
5.1.1 差异性梳理
下面我们可以先来大致看一下wepy的模板语法和uniapp的模板语法的区别。
图:wepy模板和uni-app模板
从上图可以看出,wepy模板使用了原生微信小程序的wxml语法,并且在采用类似Vue的组件引入机制的同时,保留了wxml< import/ >、< include/ >标签的能力。同时为了和wxml中循环渲染dom节点的语法做区别,引入了新的< Repeat/ >标签来渲染引入的子组件,而uni-app则是完全使用Vue风格的语法来进行开发。
所以总结wepy和uni-app模板语法的主要区别有两点:
- wepy使用了一些特定的标签用来导入或者复用其他wxml文件例如< import >和< include >。
- wxml使用了xml命名空间的方式来定义模板指令,并且对指令值的处理更像是使用模板引擎对特定格式的变量进行替换。
下表列举一些两者模板指令的对应转换关系。
此外,还有一些指令的细节需要处理,例如在wepy中wx:key="id"指令会自动解析为wx:key="{{item.id}}",这里便不再赘述。
5.1.2 核心转换设计
编译器对template转换主要就需要完成以下三个步骤:
- 处理wepy引入的特殊的标签例如。
- 将wxml中使用的指令、特殊标签等转换为Vue模板的语法。
- 收集引入的组件信息传递给下游的wepy-page-transform模块。
- wepy特殊标签转换
首先我们会处理wepy模板中的特殊标签< import/ >、< include/ >,主要是将wxml的文件引入模式转换成Vue模板的组件引入模式,同时还需要收集引入的wxml的文件地址和展示的模板名称。由于< include/ >可以引入wxml文件中除了< template/ >和< wxs/ >的所有代码,为了保证转换后组件的复用性,我们将引入的xx.wxml文件拆成了xx.vue和xx-incl.vue两个文件,使用< import/ >标签的会导入xx.vue,而使用< include/ >标签的会导入xx-incl.vue,转换import的核心代码实现如下:
transformImport() {
// 获取所有import标签
const imports = this.$('import')
for (let i = 0; i < imports.length; i++) {
const node = imports.eq(i)
if (!node.is('import')) return
const importPath = node.attr('src')
// 收集引入的路径信息
this.importPath.push(importPath)
// 将文件名统一转换成短横线风格
let compName = TransformTemplate.toLine(
path.basename(importPath, path.extname(importPath))
)
let template = node.next('template')
while (template.is('template')) {
const next = template.next('template')
if (template.attr('is')) {
const children = template.children()
// 生成新的组件标签例如
// <import src="components/list.wxml" />
// <template is="subList" /> => <list is="subList" />
const comp = this.$(`<${compName} />`)
.attr(template.attr())
.append(children)
comp.attr(TransformTemplate.toLine(this.compName), comp.attr('is'))
comp.removeAttr('is')
// 将当前标签替换为新生成的组件标签
template.replaceWith(comp)
}
template = next
}
node.remove()
}
}
具体的WXML文件拆分方案请看WXML转换部分。
- wepy 属性转换
上文中已经介绍了,wepy模板中的属性使用了命名空间+模板字符串风格的动态属性,我们需要将他们转换成Vue风格的属性。转换需要操作模板中的节点及其属性,这里我们使用了cheerio, 快速、灵活、类jQuery核心实现,可以利用jQuery的语法非常方便的对模板字符串进行处理。
上述流程中一个分支中的转换函数会处理相应的wepy属性,以保证后续可以很方便的对转换模块进行完善和修改。由于属性名称转换只是简单的做一下相应的映射,我们重点分析一下动态属性值的转换过程。
WXML中使用双中括号来标记动态属性中的变量及WXS表达式,并且如果变量是WXS对象的话还可以省略对象的大括号例如
< view wx:for="{{list}}" > {{item}} < /view >、< template is="objectCombine" data="{{for: a, bar: b}}" >< /template >
所以当我们取到双中括号中的值时会有以下两种情况:
- 得到WXS的表达式;
- 得到一个没有中括号包裹的WXS对象。此时我们可以先对表达式尝试转换,如果有报错的话,给表达式包裹一层中括号再进行转换。考虑到WXS的语法类似于Javascript的子集,我们依然使用babel对其进行解析并处理。
核心代码实现如下:
/**
*
* @param value 需要转换的属性值
*/
private transformValue(value: string): string {
const exp = value.match(TransformTemplate.dbbraceRe)[1]
try {
let seq = false
traverse(parseSync(`(${exp})`), {
enter(path) {
// 由于WXS支持对象键值相等的缩写{{a,b,c}},故此处需要额外处理
if (path.isSequenceExpression()) {
seq = true
}
},
})
if (!seq) {
return exp
}
return `{${exp}}`
} catch (e) {
return `{${exp}}`
}
}
到这里,我们已经能够处理wepy模板中绝大部分的动态属性值的转换。但是,上文也提及到了,wepy采用的是类似模板引擎的方式来处理动态属性的,即WXML支持这种动态属性< view id="item-{{index}}" >,如果这个 < view / >标签使用了wx:for指令的话,id属性会被编译成item-0、item-1... 这个问题我们也想了多种方案去解决,例如字符串拼接、正则处理等,但是都不能很好的覆盖全部场景,总会有特殊场景的出现导致转换失败。
最终,我们还是想到了模板引擎,Javascript中也有类似于模板引擎的元素,那就是模板字符串。使用模板字符串,我们仅仅需要把WXML中用来标记变量的双括号{{}}转换成Javascript中的${}即可。
5.2 Wepy App 转换
5.2.1 差异性梳理
wepy 的 App 小程序实例中主要包含小程序生命周期函数、config 配置对象、globalData 全局数据对象,以及其他自定义方法与属性。
核心代码实现如下:
import wepy from 'wepy'
// 在 page 中,通过 this.$parent 来访问 app 实例
export default class MyAPP extends wepy.app {
customData = {}
customFunction() {}
onLaunch() {}
onShow() {}
// 对应 app.json 文件
// build 编译时会根据 config 属性自动生成 app.json 文件
config = {}
globalData = {}
}
uniapp的 App.vue 可以定义小程序生命周期方法,globalData全局数据对象,以及一些自定义方法,核心代码实现如下:
<script>
export default {
globalData: {
text: 'text'
}
onLaunch: function() {
console.log('App Launch,app启动')
},
onShow: function() {
console.log('App Show,app展现在前台')
},
onHide: function() {
console.log('App Hide,app不再展现在前台')
},
methods: {
// .....
}
}
<script>
5.2.2 核心转换设计
如图, 核心转换设计流程:
- 对 app.py 进行 parse,拆分出script和style部分,对script部分使用babel进行parse生成AST。
- 通过对 AST 分析出,小程序的生命周期方法,globalData全局数据,自定义方法等。
- 对于AST进行uniapp转换,生命周期方法和全局数据转成对象的方法和属性,对自定义方法转换到method内。
- 其中对 globalData 的访问,要进行替换通过 getApp()进行访问。
- 抽取 ast 中的 config 字段,输出到 app.json 配置文件。
- 抽取 wepy.config.js 中的 config 字段,传入 wepy 的 app 实例。
核心代码实现:
let APP_EVENT = ['onLaunch', 'onShow', 'onHide', 'onError', 'onPageNotFound']
//....
// 实现wepy app到uniapp App.vue的转换
t.program([
...body.filter((node: t.Node) => !t.isExportDeclaration(node)),
// 插入appClass
...appClass,
...body
.filter((node: t.Node) => t.isExportDeclaration(node))
.map((node: object) => {
// 对导出的app进行处理
if (t.isExportDeclaration(node)) {
// 提前config属性
const { appEvents, methods, props } = this.clzProperty
// 重新导出vue style的对象
return t.exportDefaultDeclaration(
t.objectExpression([
// mixins
...mixins,
// props
...Object.keys(props)
.filter((elem) => elem !== 'config')
.map((elem) =>
this.transformClassPropertyToObjectProperty(props[elem])
),
// app events
...appEvents.map((elem) =>
this.transformClassMethodToObjectMethod(elem)
),
// methods
t.objectProperty(
t.identifier('methods'),
t.objectExpression([
...methods.map((elem) =>
this.transformClassMethodToObjectMethod(elem)
),
])
),
])
)
}
return node
}),
])
// .....
5.2.3 痛点难点
在运行期,app.wpy 会继承 wepy.App 类,这样就会在运行期和 wepy.App 产生依赖关系,怎么最小化弱化这种关系。抽取wepy的最小化以来的polyfill,随着业务中代码剔除对wepy的api调用,最终去除对polyfill的依赖。
5.3 wepy component 转换
对于wepy component 的转换主要可以细化到对 component 中 template、script、style 三部分代码块的转换。
其中, style 部分由于已经兼容 Vue 的规范,所以我们无需做额外处理。而 template 模块主要是需要对 wepy template 中特殊的标签、属性、事件等内容进行处理,转化为适配 uni的template,上文做了详细的说明。
我们只需要专注于处理 script 模块的代码转换即可。从架构设计的思路来看,component script 的转换主要是是做以下两件事:
- 编译期可确定代码块的转换。
- 运行期动态注入代码的兼容。
wepy-component-transform 就是基于以上这两个标准设计出来的实现转换逻辑的模块。
5.3.1 差异性梳理
首先先解释一下什么是“编译期可确定代码块”,我们来看一个 wepy 和 Vue 语法对比示例:
从直观上来说,这个 script 的模板的语法大致和 Vue 语法类似,这意味着我们解析出来的 AST 结构和 Vue 文件对应的 AST 结构上类似,基于这一点来看编译转换的工作量大致有底了。
从细节来看, wpy 文件script 模块中的 API 语法和 Vue 中有声明及使用上的不同,其中包含:
- wepy 自身的包依赖注入及运行时依赖
- props/data/methods 声明方式不同
- 生命周期钩子不同
- 事件发布/订阅的注册和监听机制不同。
- ....等等
为了确定这个第5点等等还存在哪些使用场景,我们需要对 wepy 自身的逻辑和玩法有一个详尽的了解和熟悉,通过在团队内组织的 wepy 源码走读,再结合wepy 实际生产项目中的代码相互印鉴,我们最终才将 wepy 语法逻辑与 uni-app Vue 语法逻辑的异同梳理清楚。
5.3.2 核心转换设计
我们简单梳理一下 wepy-component-transform 这个模块的结构,可以分为以下三个部分:
- 预处理 wepy component script 代码 AST 节点部分
- 构建 Vue AST
- 通过 generate 吐出代码
1.预处理 AST
基于前文转换设计这一节我们知道, wepy 变色龙的转换器中对代码的 AST 解析主要依赖 babel AST 三板斧(traverse、types、generate)来实现,通过分析各个差异点代码语句转换后的 AST 节点,就可以通过 traverse 中的钩子来进行节点的前置处理,这里安利一下 https://astexplorer.net/,我们可以通过它快速分析代码块 AST 节点、模拟场景及验证转换逻辑:
预处理 AST,目的是提前将 wepy 源码中的代码块解析为 AST Node 节点后,按语法进行归集到预置的 clzProperty 对象中,其中:
- props 对象用来盛放 ClassProperty 语法的 ast 节点
- notCompatibleMethods 数组用来盛放非生命周期函数白名单内的函数 AST 节点。
- appEvents 数组用来盛放生命周期函数白名单内的函数 AST 节点。
- listenEvents 数组用来盛放 发布/订阅事件注册的函数 AST 节点。
核心代码实现如下所示:
import { NodePath, traverse, types } from '@babel/core'
this.clzProperty = {
props: {},
notCompatibleMethods: [],
appEvents: [],
listenEvents: []
}
traverse() {
ClassProperty: (path) => {
const name = path.node.key.name
this.clzPropertyprops[name] = path.node
},
ClassMethod: (path) => {
const methodName = path.node.key.name
// 判断是否存在于生命周期白名单内
const isCompEvent = TOTAL_EVENT.includes(methodName)
if (isCompEvent) {
this.clzProperty.appEvents.push(path.node)
} else {
this.clzProperty.notCompatibleMethods.push(path.node)
}
},
ObjectMethod: (path: any) => {
if (path.parentPath?.container?.key?.name === 'events') {
this.clzProperty.listenEvents.push(path.node)
}
}
}
这里要注意一点,由于对 wepy 来说,实际上 page 也属于 component 的一种实现,所以两者的 event 会有一定的重合,而且由于 wepy 中生命周期和 Vue 生命周期的差异性,我们需要对如 attached、detached、ready 等钩子做一些 hack。
2.构建 Vue AST
buildCompVueAst 函数即为 构建 Vue AST 部分。从直观上来看,这个函数只做了一件事,即用 types.program 重新生成一个 AST 节点结构,然后将原有的 wepy 语法转换为 vue 语法。但是实际上我们还需要处理许多额外的兼容逻辑,简单罗列一下:
- created 重叠问题
- methods 中函数的收集
- events 中函数的调用处理
created 重叠问题主要是为了解决 created/attached/onLoad/onReady 这4个生命周期函数都会转换为 created 导致的多次重复声明问题。我们需要针对若存在 created 重叠问题时,将其余钩子中的代码块取出并 push 到第一个 created 钩子函数内部。代码示例如下:
const body = this.ast.program.body
const { appEvents, notCompatibleMethods, props, listenEvents } =
this.clzProperty
// 处理多个 created 生命周期重叠问题
const createIndexs: number[] = []
const sameList = ['created', 'attached', 'onLoad', 'onReady']
appEvents.forEach((node, index) => {
const name: string = node.key.name
if (sameList.includes(name)) {
createIndexs.push(index)
}
})
if (createIndexs.length > 1) {
// 取出源节点内代码块
const originIndex = createIndexs[0]
const originNode = appEvents[originIndex]
const originBodyNode = originNode.body.body
// 留下的剩余节点需要取出其代码块并塞入源节点中
// 塞入完成后删除剩余节点
createIndexs.splice(0, 1)
createIndexs.forEach((index) => {
const targetNode = appEvents[index]
const targetBodyNode = targetNode.body.body
// 将源节点内代码块塞入目标节点中
originBodyNode.push(...targetBodyNode)
// 删除源节点
appEvents.splice(index, 1)
})
}
由于 wepy 中非 methods 中函数的特殊性,所以我们需要在转换时将独立声明的函数、events 中的函数都抽离出来再 push 到 methods 中,伪代码逻辑如下所示:
buildCompVueAst() {
const body = this.ast.program.body
return t.program([
...body.map((node) => {
return t.exportDefaultDeclaration(
t.objectExpression([
...Object.keys(props)
.map((elem) => {
if (elem === 'methods') {
const node = props[elem]
// 1.events 内函数插入 methods 中
// 2.与生命周期平级的函数抽离出来插入 methods 中
node.value.properties.push(
...listenEvents,
...notCompatibleMethods
)
}
return props[elem]
})
])
)
})
])
}
events 中函数的调用处理主要是为了抹平 wepy 中发布订阅事件调用和 Vue 调用的差异性。在 wepy 中,事件的注册通过在 events 中声明函数,事件的调用通过 this.$emit 来触发。而 vue 中我们采用的是 EventBus 方案来兼容 wepy 中的写法,即手动为 events 中的函数创建 this.$on 形式的调用,并将其代码块按顺序塞入 created 中来初始化。
首先我们要判断文件中是否已有 created 函数,若存在,则获取其对应的代码块并调用 forEachListenEvents 函数将 events 中的监听都 push 进去。
若不存在,则初始化一个空的 created 容器,并调用 forEachListenEvents 函数。核心代码实现如下所示:
buildCompVueAst() {
const obp = [] as types.ObjectMethod[]
// 获取class属性和方法
const body = node.declaration.body.body
const targetNodeArray = body.filter(child =>
child.key.name === 'created'
)
if (targetNodeArray.length > 0) {
let createdNode = targetNodeArray[0]
this.forEachListenEvents(createdNode)
} else {
const targetNode = t.objectMethod(
'method',
t.identifier('created'),
[],
t.blockStatement([])
)
this.forEachListenEvents(targetNode)
if (targetNode.body && targetNode.body.body.length > 0) {
obp.push(targetNode)
}
}
return obp
}
forEachListenEvents 函数主要是通过 wepy 中 声明的 events 事件名和入参,借助 babel types 手动创建对应的 AST Node,最终生成对应的形如 this.eventBus.on("canceldeposit", this.canceldeposit) 形式的监听,其中,this.canceldeposit 为原有 events 中的事件被移入 methods 后的函数,相关伪代码实现如下所示:
// 根据 events 中的 methods 构建事件监听的调用
// 并塞入 created 中
forEachListenEvents(targetNode: types.ObjectMethod) {
this.clzProperty.listenEvents.forEach((item) => {
const methodsNode: any = item
// 形如 this.$on('test', ()=>{})
if (methodsNode?.key?.name) {
// 创建 this 表达式
const thisEx = t.thisExpression()
// 创建 $on 表达式
const ide = t.identifier('$eventBus.$on')
// 合并 this.$on 表达式
const om = t.memberExpression(thisEx, ide)
// 创建事件名称参数节点
const eventNameIde = t.stringLiteral(
methodsNode.key.name.toString().trim()
)
// 获取方法体内代码内容节点
const meNode = t.memberExpression(
t.thisExpression(),
t.identifier(methodsNode.key.name.toString().trim())
)
const ceNode = t.callExpression(om, [eventNameIde, meNode])
const esNode = t.expressionStatement(ceNode)
// 将合成后的代码插入到 created 中
targetNode.body.body.push(esNode)
}
})
}
3.emitter vue 代码生成
构建完 Vue AST 之后,我们可以调用 generate 函数生成源码字符串:
transform() {
const ast = this.buildCompVueAst()
const compVue = this.genCode(ast)
return { compVue, wxs: this.buildWxs() }
}
5.4 Wepy page 转换
5.4.1 差异性梳理
上面的章节已经给大家分析了template、component的代码转换逻辑,这一节主要带大家一起看下如何转换page文件。page转换的逻辑即如何实现wepy 的 page.wpy 模块转换为 uniapp 的 page.vue 模块。
首先我们来看下wepy 的 page 小程序实例:
<script>
import wepy from 'wepy';
import Counter from '../components/counter';
export default class Page extends wepy.page {
config = {};
components = {counter1: Counter};
data = {};
methods = {};
events = {};
onLoad() {};
// Other properties
}
</script>
<template lang="wxml">
<view>
</view>
<counter1></counter1>
</template>
<style lang="less">
/** less **/
</style>
可以看到,wepy的page类也是通过继承来实现的,页面文件 page.wpy 中所声明的页面实例继承自 wepy.page 类,该类的主要属性介绍如下:
5.4.2 核心转换设计
基于page的api特性以及实现方案,具体的转换设计思路如下:
5.4.3 痛点难点
1.非阻塞异步与异步
在进行批量pages转换时,需要同时对pages.json进行读取、修改、再修改的操作,这就涉及到使用阻塞 IO/ 异步 IO来处理文件的读写,当使用异步IO时,会发起多个进程同时处理pages.json, 每个读取完成后单独处理对应的内容,数据不是串行修改,最终导致最终修改的内容不符合预期,因此在遇到并行处配置文件时,需要使用阻塞式io来读取文件,保障最终数据的唯一性,具体代码如下:
// merge pageConfig to app config
const rawPagesJson = fs.readFileSync(path.join(dest, 'src/pages.json'))
// 数据操作
fs.writeFileSync(
path.join(dest, 'src', 'pages.json'),
prettJson(pagesJson)
)
2.复杂的事件机制
在转换过程中,我们也碰到一个比较大的痛点:page.wepy 继承至 wepy.page,wepy.page 代码较复杂,需要将明确部分单独抽离出来。例如说 events 中组件间数据传递:$broadcast
、$emit
、$invoke
,$broadcast
、$invoke
需要熟悉其使用场景,转换为 Vue 中公共方法。
5.5 Wepy WXML 转换
template转换章节中提到了wepy模板中可以直接引入wxml文件,但是uni-app使用的Vue模板不支持直接引入wxml,故我们需要将wxml文件处理为uniapp可以引入的Vue文件。我们先来看一下wepy中引入的wxml文件的大致结构。
<template name="foo">
<view class="foo-content">
<text class="text1">{{item.text1}}</text>
<image class="pic" src="{{pic.url}}" mode="aspectFill"></image>
</view>
</template>
<template name="bar">
<view class="bar-content">
<image class="bar" src="{{pic.url}}" mode="aspectFill"></image>
<text class="text2">{{item.text2}}</text>
</view>
</template>
<view class="footer">
this is footer
</view>
<!-- index.wepy -->
<!-- 引入文件 -->
<import src="somePath/fooBar.wxml" />
<!-- 确定展示的template及传入属性 -->
<script is="foo" data="{{item, pic}}" />
<!-- or, 此时仅会展示<template/>以外的内容即footer -->
<include src="somePath/fooBar.wxml">
5.5.1 差异性梳理
从上面的代码可以看出,一个WXML文件中支持多个不同name属性的< template/ >标签,并且支持通过在引入设置data来传入属性。从上面的示例模板中我们可以分析出,除了需要将wepy使用的WXML语法转换成vue模板语法外(这里的转换交给了template模块来处理),我们还需要处理以下的问题。
- 确定引入组件时的传参格式
- 确定组件中传入对象的属性有哪些
- 处理< import/ >和< include/ >引入的文件时的情况
5.5.2 核心转换设计
1.确定引入组件时的传入属性方式
首先需要将wepy组件引入形式改成Vue的组件引入方式。以上面的代码为例,即将< import/ > 、< script/ >对的引入形式改写成< component-name / > 引入方式。我们会在转换开始前对代码进行扫描,收集模板中的引入文件信息,传递给wepy-page-transform模块处理,在转换后的Vue组件的< script/ >中进行引入。并且将< script is="foo" data="{{item, pic}}" / > 转换为< FooBar is="foo" :data=(待定) / > 。这里就需要确定属性传递的方式。
从上面的代码中可以看到,在WXML文件的< template/ >会自动使用传入的data属性作为隐式的命名空间,从而不需要使用data.item来获取item属性。这里很自然的就会想到原来的< script is="foo" data="{{item, pic}}" / >可以转换成< FooBar compName="foo" :key1="val1" :key2="val2" ... / >。
其中,key1,val1,key2,val2等为原data属性对象中的键值对,compName用来指定展示的部分。这样处理的好处是,引入的WXML文件中使用相应的传入的属性就不需要做额外的修改,并且比较符合我们一般引入Vue组件时传入属性的方式。
虽然这种方案可以较少的改动WXML文件中的模板,但是由于传入的对象可能会在运行期间进行修改,我们在编译期间比较难以确定传入的data对象中的键值对。考虑到实现的时间成本及难易程度,我们没有选择这种方案。
目前我们所采用的方案是不去改变原有的属性传入方式,即将组件引入标签转换为< FooBar compName="foo" :data="{item, pic}" / >。从而省去分析传入对象在运行时的变动。这里就引出了第二个问题,如何确定组件中传入的参数有哪些。
2.确定组件中的传入的对象属性
由于Vue的模板中不会自动使用传入的对象作为命名空间,我们需要手动的找到当前待转换的模板中所使用到的所有的变量。相应的代码如下:
searchVars() {
const self = this
const domList = this.$('template *')
// 获取wxml文件中template节点下的所有text节点
const text = domList.text()
const dbbraceRe = new RegExp(TransformTemplate.dbbraceRe, 'g')
let ivar
// 拿到所有被{{}}包裹的动态表达式
while ((ivar = dbbraceRe.exec(text))) {
addVar(ivar[1])
}
// 遍历所有节点的属性,获取所有的动态属性
for (let i = 0; i < domList.length; i++) {
const dom = domList.eq(i)
const attrs = Object.keys(dom.attr())
for (let attr of attrs) {
const value = dom.attr(attr)
if (!TransformTemplate.dbbraceRe.test(value)) continue
const exp = value.match(TransformTemplate.dbbraceRe)[1]
try {
addVar(exp)
} catch (e) {
addVar(`{${exp}}`)
}
}
}
function addVar(exp: string) {
traverse(parseSync(`(${exp})`), { // 利用babel分析表达式中的所有变量
Identifier(path) {
if (
path.parentPath.isMemberExpression() &&
!path.parentPath.node.computed &&
path.parentPath.node.property === path.node
)
return
self.vars.add(path.node.name) // 收集变量
},
})
}
}
收集到所有的变量信息后,模板中的所有变量前面需要加上传入的对象名称,例如item.hp_title需要转换成data.item.hp_title。考虑到模板的简洁性和后续的易维护性,我们把转换统一放到< script/ >的computed字段中统一处理即可:
<template>
<!--...-->
</template>
<script>
export default {
props: ['data', 'compName'],
computed: {
item() {
return data.item
},
pic() {
return data.pic
}
}
}
</script>
3.处理 < import/ >和< include/ >两种引入方式
wepy模板有两种引入组件的方式,一种是使用< import/ >< script/ >标签对进行引入,还有一种是使用< include/ > 进行引入,< include/ > 会引入WXML文件中除了< template/ >和< wxs/ >的其他标签。这里的处理方式就比较简单,我们把< include/ > 会引入的部分单独抽取出来,生成TItem-incl.vue文件,这样即保证了生成代码的可复用性,也降低< import/ >标签引入的部分生成的TItem.vue文件中的逻辑复杂度。生成的两个文件的结构如下:
<!--TItem.vue-->
<template>
<view>
<template v-if="compName == 'foo'">
<view class="foo">
<!--...-->
</view>
</template>
<template v-if="compName == 'bar'">
<view class="bar">
<!--...-->
</view>
</template>
</view>
</template>
<script>
export default {
props: ['compName', 'data'],
computed: {
item() {
return this.data.item
},
pic() {
return this.data.pic
}
}
}
</script>
<!--TItem-incl.vue-->
<template>
<view>
<view class="footer">
this is footer
</view>
</view>
</template>
六、阶段性成果
截止到目前,司内的企微导购小程序项目通过接入变色龙编译器已经顺利的从 wepy 迁移到了 uniApp 架构,原本预计需要 25人/天 的迁移工作量在使用了编译器转换后缩短到了 10s。这不仅仅只是提高了迁移的效率,也降低了迁移中的知识迁移成本,给后续业务上的快速迭代奠定的扎实的基础。
迁移后的企微导购小程序项目经测试阶段验证业务功能 0 bug,目前已经顺利上线。后续我们也会持续收集其他类似的业务诉求,帮助业务兄弟们低成本完成迁移。
七、总结
研发能效的提升是个永恒的话题,此次我们从编译这个角度出发,和大家分享了从wepy到uniapp的架构升级探索的过程,通过构建代码转换的编译器来提升整体的架构升级效率,通过编译器消化底层的领域和知识的差异性,取得了不错的效果。
当然,我们目前也有还不够完善的地方,如:编译器脚手架缺乏对于部分特性颗粒度更细的控制、代码编译转换过程中日志的输出更友好等等。后续我们也有计划将 wepy 变色龙编译器在社区开源共建,届时欢迎大家一起参与进来。
现阶段编译在前端的使用场景越来越多,或许我们真的进入了Compiler is our framework的时代。