从 wepy 到 uniapp 变形记
作者: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 的核心代码实现如下:
具体的 WXML 文件拆分方案请看 WXML 转换部分。
wepy 属性转换
上文中已经介绍了,wepy 模板中的属性使用了命名空间+模板字符串风格的动态属性,我们需要将他们转换成 Vue 风格的属性。转换需要操作模板中的节点及其属性,这里我们使用了 cheerio, 快速、灵活、类 jQuery 核心实现,可以利用 jQuery 的语法非常方便的对模板字符串进行处理。
上述流程中一个分支中的转换函数会处理相应的 wepy 属性,以保证后续可以很方便的对转换模块进行完善和修改。由于属性名称转换只是简单的做一下相应的映射,我们重点分析一下动态属性值的转换过程。
WXML 中使用双中括号来标记动态属性中的变量及 WXS 表达式,并且如果变量是 WXS 对象的话还可以省略对象的大括号例如
所以当我们取到双中括号中的值时会有以下两种情况:
得到 WXS 的表达式;
得到一个没有中括号包裹的 WXS 对象。此时我们可以先对表达式尝试转换,如果有报错的话,给表达式包裹一层中括号再进行转换。考虑到 WXS 的语法类似于 Javascript 的子集,我们依然使用 babel 对其进行解析并处理。
核心代码实现如下:
到这里,我们已经能够处理 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 全局数据对象,以及其他自定义方法与属性。
核心代码实现如下:
uniapp 的 App.vue 可以定义小程序生命周期方法,globalData 全局数据对象,以及一些自定义方法,核心代码实现如下:
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 实例。
核心代码实现:
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 节点。
核心代码实现如下所示:
这里要注意一点,由于对 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 钩子函数内部。代码示例如下:
由于 wepy 中非 methods 中函数的特殊性,所以我们需要在转换时将独立声明的函数、events 中的函数都抽离出来再 push 到 methods 中,伪代码逻辑如下所示:
events 中函数的调用处理主要是为了抹平 wepy 中发布订阅事件调用和 Vue 调用的差异性。在 wepy 中,事件的注册通过在 events 中声明函数,事件的调用通过 this.$emit 来触发。而 vue 中我们采用的是 EventBus 方案来兼容 wepy 中的写法,即手动为 events 中的函数创建 this.$on 形式的调用,并将其代码块按顺序塞入 created 中来初始化。
首先我们要判断文件中是否已有 created 函数,若存在,则获取其对应的代码块并调用 forEachListenEvents 函数将 events 中的监听都 push 进去。
若不存在,则初始化一个空的 created 容器,并调用 forEachListenEvents 函数。核心代码实现如下所示:
forEachListenEvents 函数主要是通过 wepy 中 声明的 events 事件名和入参,借助 babel types 手动创建对应的 AST Node,最终生成对应的形如 this.on("canceldeposit", this.canceldeposit) 形式的监听,其中,this.canceldeposit 为原有 events 中的事件被移入 methods 后的函数,相关伪代码实现如下所示:
3.emitter vue 代码生成
构建完 Vue AST 之后,我们可以调用 generate 函数生成源码字符串:
5.4 Wepy page 转换
5.4.1 差异性梳理
上面的章节已经给大家分析了 template、component 的代码转换逻辑,这一节主要带大家一起看下如何转换 page 文件。page 转换的逻辑即如何实现 wepy 的 page.wpy 模块转换为 uniapp 的 page.vue 模块。
首先我们来看下 wepy 的 page 小程序实例:
可以看到,wepy 的 page 类也是通过继承来实现的,页面文件 page.wpy 中所声明的页面实例继承自 wepy.page 类,该类的主要属性介绍如下:
5.4.2 核心转换设计
基于 page 的 api 特性以及实现方案,具体的转换设计思路如下:
5.4.3 痛点难点
1.非阻塞异步与异步
在进行批量 pages 转换时,需要同时对 pages.json 进行读取、修改、再修改的操作,这就涉及到使用阻塞 IO/ 异步 IO 来处理文件的读写,当使用异步 IO 时,会发起多个进程同时处理 pages.json, 每个读取完成后单独处理对应的内容,数据不是串行修改,最终导致最终修改的内容不符合预期,因此在遇到并行处配置文件时,需要使用阻塞式 io 来读取文件,保障最终数据的唯一性,具体代码如下:
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 文件的大致结构。
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 的模板中不会自动使用传入的对象作为命名空间,我们需要手动的找到当前待转换的模板中所使用到的所有的变量。相应的代码如下:
收集到所有的变量信息后,模板中的所有变量前面需要加上传入的对象名称,例如 item.hp_title 需要转换成 data.item.hp_title。考虑到模板的简洁性和后续的易维护性,我们把转换统一放到< script/ >的 computed 字段中统一处理即可:
3.处理 < import/ >和< include/ >两种引入方式
wepy 模板有两种引入组件的方式,一种是使用< import/ >< script/ >标签对进行引入,还有一种是使用< include/ > 进行引入,< include/ > 会引入 WXML 文件中除了< template/ >和< wxs/ >的其他标签。这里的处理方式就比较简单,我们把< include/ > 会引入的部分单独抽取出来,生成 TItem-incl.vue 文件,这样即保证了生成代码的可复用性,也降低< import/ >标签引入的部分生成的 TItem.vue 文件中的逻辑复杂度。生成的两个文件的结构如下:
六、阶段性成果
截止到目前,司内的企微导购小程序项目通过接入变色龙编译器已经顺利的从 wepy 迁移到了 uniApp 架构,原本预计需要 25 人/天 的迁移工作量在使用了编译器转换后缩短到了 10s。这不仅仅只是提高了迁移的效率,也降低了迁移中的知识迁移成本,给后续业务上的快速迭代奠定的扎实的基础。
迁移后的企微导购小程序项目经测试阶段验证业务功能 0 bug,目前已经顺利上线。后续我们也会持续收集其他类似的业务诉求,帮助业务兄弟们低成本完成迁移。
七、总结
研发能效的提升是个永恒的话题,此次我们从编译这个角度出发,和大家分享了从 wepy 到 uniapp 的架构升级探索的过程,通过构建代码转换的编译器来提升整体的架构升级效率,通过编译器消化底层的领域和知识的差异性,取得了不错的效果。
当然,我们目前也有还不够完善的地方,如:编译器脚手架缺乏对于部分特性颗粒度更细的控制、代码编译转换过程中日志的输出更友好等等。后续我们也有计划将 wepy 变色龙编译器在社区开源共建,届时欢迎大家一起参与进来。
现阶段编译在前端的使用场景越来越多,或许我们真的进入了 Compiler is our framework 的时代。
版权声明: 本文为 InfoQ 作者【vivo互联网技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/20b40bf3418e1915d242c17b7】。文章转载请联系作者。
评论