从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 编译器流水线构建

如上图所示,整个编译过程就是一条流水线的架构设计,在每个阶段完成不同的任务。主要流程如下:

1. 项目资源分析

不同的项目依赖资源不同的处理流程,扫描项目中的源码和资源文件进行分类,等待后续的不同的流水线处理。

静态资源文件(图片,样式文件等)不需要经过当中流水线的处理,直达目标 uniapp 项目的对应的目录。

3.2.2 AST抽象语法树转换

针对 wepy 的源文件(app,page,component等)对 script,template 等部分,通过 parse 转换成相对应的AST抽象语法树,后续的代码转换都是基于对抽象语法树的结构改进。

3. 代码转换实现 - Transform code

根据 wepy 和 uniapp 的 Vue 的代码实现上的差异,通过对ast进行转换实现代码的转换。

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转换主要就需要完成以下三个步骤:

  1. 处理wepy引入的特殊的标签例如。
  2. 将wxml中使用的指令、特殊标签等转换为Vue模板的语法。
  3. 收集引入的组件信息传递给下游的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()
// 生成新的组件标签例如
//
//