一 现状及问题
前段时间在公司推了一套跨端组件库的方案,趁着这段时间稍微有点空把调研的过程记录下来。背景如下
随着公司当前业务量的不断增加,前端项目数量也一直在飞速发展之中。截止到目前,团队已经有 100+ 前端项目,覆盖了多个应用平台——小程序,h5,app。
而在这些不同应用平台的项目中,或多或少都有着类似的业务模块和功能单元(从前端角度出发,统称为业务组件,下同),如手写签名版,地址选择器,上传图片等。
对于这些相似的业务模块和功能单元,如果不同的端都去实现一遍相同的逻辑比较浪费时间,而且后期进行升级和维护的成本也比较高,我们团队当前是没法投入这么多资源和人力的。
那基于此,我们就在思考,有没有可能只写一套代码(或者说复用通用逻辑,而各平台之间的差异逻辑特殊处理),然后能被不同的端去进行使用呢?
答案当然是肯定的,社区已经有了那么多跨端同构框架,如 mpvue,taro,remax 等,它们既然有方案实现一套代码,多处运行,那是否可以借鉴一下它们的方式来实现一套我们自己的跨端组件库呢?
二 社区流行跨端框架原理一览
基于以上原因,我们调研了以下几个社区中最流行的跨端框架:
mpvue,meglo,taro1/2,remax,taro/next,kbone,chamelon,uni-app
这些框架从实现原理上来区分的话主要可以大致分为以下两类:
- 编译时跨端
- 运行时跨端
这里编译时和运行时的区分并不是框架只拥有编译时或只拥有运行时(通常所有的框架两者皆有)它们的区别主要在于结构层(template/jsx)是否在编译阶段就被转换成确定的小程序 wxml 模版
举个例子,下列结构层代码
<div>
<span>1</span>
<div>2</div>
</div>
编译时框架在编译阶段会把上述代码编译成如下确定的 wxml:
<view>
<text>1</text>
<view>2</view>
</view>
而运行时框架在编译阶段则会先编译成如下的 wxml:
<custom-dom>
</custom-dom>
然后在运行时,框架再把 custom-dom 渲染成最终的视图,有点类似于 vue 中动态组件的概念。
那其实不管是编译时跨端,还是运行时跨端,其实解决的问题都是一样——如何把框架的语法运行在小程序之上。
和 web 页面类似,小程序页面通常也是由结构层、样式层和逻辑层组成,它们分别对应了小程序中 wxml,wxss,js 这几个文件。而无论是 vue 或者 react 或者其他的框架,其页面的组织形式通常也能拆分成结构层、样式层和逻辑层。
那要把 vue/react 运行在小程序之上的重点问题就变成了如何转换两者之间结构层、样式层和逻辑层之间的关系。
(注:为了方便说明,上图各层只是一个大致的对应关系,具体情况可能有此不同)
接下来我们来看看编译时跨端框架是如何解决这个问题的?
2.1 编译时跨端框架
顾名思义,编译时跨端框架的工作重点在编译阶段,其一般是在编译阶段通过 babel 工具,把自己定义的 DSL(react, vue 或者 chamelon 定义的 cml) 转换成符合各目标平台规则的代码。
编译时跨端的框架主要有:mpvue,meglo,taro1/2,chamelon
这些框架在实现的细节上可能有所差别,但其本质基本上都是一致的,下面我们从样式转换,结构层转换及逻辑层转换这 3 个方面具体看看它们是如何实现编译时跨端的?
2.1.1 样式转换
样式部分的转换是最简单的,由于小程序 和 web 都是基于浏览器 WebView 做渲染引擎,所以绝大多数 css 样式都是可以同时在两者之间复用的。这里只需要考虑极少数的不兼容样式及 px 到 rpx 的转换。
注意:如果把 app 考虑进来,这里就比较复杂了,因为 app 使用的渲染引擎和浏览器不一样,大量 css 布局模型将不被支持
2.1.2 结构层转换
结构层转换比样式转换则要麻烦很多,而且这部分工作在 react 平台的难度要远大于 vue 平台。原因在于 react 平台是使用 jsx 来表示页面结构的,而 jsx 的灵活性成为了把 react 语法转换为静态的小程序模板语法的最大阻碍。
2.1.2.1 vue template 转换成小程序 template
我们先来看 vue 平台的结构层转换方式,因为 Vue 的 template 和小程序的 template 大体上是类似的,但是也有其不一致的地方,比如绑定事件的方法(小程序使用 bind, vue 使用 @method),比如 web 中的一些元素在小程序中不存在(如 div, view)
那如何把左侧的 template 替换成右侧的 template 呢?这就要借助 babel 的力量了。
如下图所示,先把左侧的 template 代码通过 babel-parser 转换成抽象语法树,然后通过 babel-types做一些替换和修改的操作,最后通过babel-generator最终生成右侧需要的 template 代码。
vue template 转换成小程序 template 的过程不算复杂,它是一个 case by case 的过程,比较耗费精力但是难度系数不算大。
2.1.2.2 react jsx 转换成小程序 template
react jsx 转换成小程序 template 的过程和 vue 类似,都是通过 babel 转换成 ast,修改 ast 再生成目标代码。
但是 react 平台由于使用 jsx 表示 template,导致其从 jsx 转换成小程序 template 的难度就更大了。
因为 jsx 本质上就是 JavaScript, 而 JavaScript 又过于动态化,目前没有任何编译器能 hold 住任何可能发生的情况。所以 jsx 在为开发提供便利性的同时就为编译阶段去分析和优化它带来了难度。
像 taro 1/2 这种类 react 编译型跨端框架有相当大一部分工作是用来做 jsx 的适配,但是由于 jsx 过于灵活,不可能适配任何情况,所以这类 react 跨端框架也会对开发者的使用进行一定的约束(这也是 taro 团队在 taro next 中把架构重新设计的很大一个原因)。
2.1.3 逻辑层转换
最后我们来看看逻辑层转换的实现,这里我们以 mpvue 为例,看看 mpvue 是怎么做逻辑层转换的?
先看一下 mpvue 整体的架构图
熟悉 vue 源码的同学应该都能看出,这幅图和 vue 框架的整体运行时结构非常相似。
我们来简单回顾一下 vue 运行时的整体流程:
当 new Vue() 的时候,vue 框架会先对 data 数据做响应式处理,当有数据变化时,会notify 给所有观察者,重新触发 render 函数,生成新的 vnode 树。之后和之前的 vnode 树进行对比,通过 patch 阶段找到差异的部分,然后通过调用 dom 原生的一些方法重新把新的 vnode 渲染到真实 dom 上。
这里再来看看 mpvue 的整体流程图,先看 template -> dom 这条链路,和 vue 运行时不同的是,mpvue 把 patch -> dom 的阶段移除了。
这里很好理解,因为小程序的双线程架构导致其逻辑层和视图层是独立的,不能直接操作 dom 元素,要想修改小程序的视图层,必须通过 setState 方法来实现。
这里需要注意的是,因为 setState 是造成小程序性能瓶颈的关键因素,所以大多数框架都对 patch -> setState 过程做了优化,如 mpvue 中,$updateDataToMp 虽然核心上是调用 setState 方法,但是其内部还是针对这一过程做了很多优化,如降低更新频率和更新数据的量。
那 mpvue 框架又怎么样才能调用到 setState 方法呢?我们看 new Vue 之后 mpvue 做了哪些事情?
首先在页面初始化的时候,mpvue 开始执行,它会先实例化一个Vue 实例,然后调用小程序官方的 Page() 生成了小程序的 page 实例,并且在 Vue 的 mounted 中会把数据同步到小程序的 page 实例上。
这时候页面中就同时存在 page 和 vue 两个实例,而 setState 方法就是通过 page 实例进行调用的。
当 Vue 中的数据发生变化时,则会触发 Vue 响应式的逻辑,在 patch -> dom 之前走的都是 vue 那一套流程,当 patch 完之后,mpvue 通过 setState 修改 page 实例上的数据,从而让小程序页面进行更新。
综上我们可以发现,mpvue 其实充当了小程序 和 vue 之间的一个中间桥接层,把数据从 vue 实例中同步到小程序实例中,而当小程序触发各种事件,则又通过这个桥接层触发在 vue 实例中注册的事件处理函数。
2.1.4 其他编译时框架原理
其他编译型类 Vue 的小程序跨端框架实现思路也基本相同。我们再来看看 Uni-app,Megalo,taro1/2 的实现原理,发现基本思路都是一致的。
Uni-app
Megalo
**Taro 1/2 **
这里需要注意,因为 taro 是类 react 类型的框架,虽然其跨端的设计思路是和其他类 vue 框架是类似的,但是它的实现路径和前者有一些不一样的地方,它的主要流程如下:
Taro 和上述类 vue 框架的不同之处在于后者是依赖 vue 运行时开发的,而 Taro 运行时彻底抛开了 React 运行时,它相当于重新写了一个 "mini react core",并且在其中加了部分和小程序兼容的逻辑。
Chameleon
这里最后再说下 Chameleon,Chameleon 的设计思想和其他所有的跨端框架都不一样,其他跨端框架可以近似理解为都是小程序增强型框架,即基于 Vue 或者 React 增强小程序开发的能力。而 Chameleon 的野心更大,它想做的是一个真正的跨平台中间层,在它这里磨平所有平台之间的差异,从宏观的角度上来看就像 Node.js(libuv)同时运行在 Windows 和 macOS 系统,都提供了一个跨平台抽象层。
下图就是它的实现原理:
我们先抛开它宏大的愿望,就小程序端/h5 端的跨端实现而言,它使用的也是编译型框架通常的使用方法,把框架本身的 DSL( Chameleon 的 CML)在编译过程中转换成小程序平台确定的 wxml,wxss,js,json,然后提供 Chameleon runtime 充当桥接层。
只不过 Chameleon 在这个基础上提供了很多附加的能力,比如导入和导出 CML 组件,原生项目混合,及多态协议等。关于这方面的内容可去 Chameleon 官网查看
以上就是编译型跨端框架的原理分析,我们可以看出编译型跨端框架,不管是类 React 技术栈或者是类 Vue 技术栈,它们的核心原理都是一样,都是把框架本身的 DSL 转换成小程序的 wxml,wxss,js,json几个文件,并且在运行时在小程序进程中和框架进程中充当一个桥接层的角色。
下来我们再来详细看看运行时跨端框架有哪些不同?
2.2 运行时跨端框架
前面我们说过了,运行时跨端框架和编译时跨端框架的不同在于,前者在编译阶段没有把结构层转换成确定的 wxml 模版,而是在运行时进行动态渲染。
2.2.1 Kbone 的跨端方法
具体是怎么做的呢,我们来看看 Kbone 的方法:
Kbone 给出的答案是既然小程序和 web 的主要差异在于小程序没有 DOM 这层,那我干脆给小程序仿造出一个“DOM 层”来抹平这个差异好了。
在仿造完 Dom 层后,再通过小程序的自定义组件来将仿造的 Dom 树渲染到小程序页面中,原理如下:
以上的渲染过程是发生在框架运行时,这么做的好处在于能大大减轻结构层转换适配的工作量,也能更好的处理一些复杂场景。
关于 Kbone 更多的设计原理可查看该文章。
2.2.1 运行时和编译时框架的对比
接下来我们再结合编译时框架做一下对比:
编译时框架-mpvue
再以 mpvue 为例,回顾一下编译时框架的整体流程
- 在编译阶段,框架把结构层的 template 编译成确定的小程序模版 wxml。
- 框架在运行时充当小程序实例和 Vue 实例中的一个桥接层,在初始化时,框架把 Vue 实例中的数据传递给小程序实例,小程序实例通过 setState 方法,然后结合第1步生成的小程序模版生成小程序页面。
- 当有数据变化时,框架把数据变更通知到小程序实例,然后小程序实例通过 setState 更新页面。
- 当用户触发相关事件(如按钮点击,滚动等),小程序接收到事件,然后再触发框架里绑定的事件处理函数。
运行时框架原理-以kbone 为例
对于运行时跨端框架来说,其和编译时框架主要的不同就在于小程序视图层的渲染机制上。
- 运行时框架会仿造一个 DOM 层,在第一次 mount 时,虚拟 dom 层会生成一份表示页面结构的树形数据(vnode)
- 框架运行时则会通过 setState 初始化这份树形数据。在小程序加载完成后,框架会通过递归自定义组件的形式,把树形数据渲染成真正的小程序页面展示给用户。
- 当框架运行时检测到数据发生变化后,就会通过 setState 去更新树形数据中对应的节点,自然相关的那部分页面也就自动更新了。
2.2.3 其他运行时框架原理
其他的运行时跨端框架的原理和 Kbone 类似:
remax:
Taro Next:
可以看出,类 react 的运行时跨端框架 remax 和 taro next 的实现原理其实和 kbone 差不多。
无非都是仿造 dom 层,remax/taro next 都是基于 react 的 react-reconciler 自定义渲染器实现了 dom 层的仿造。kbone 介入了 vue 的渲染流程实现了 dom 层的仿造。
在仿造完 dom 层后,通过 setState 把生成表示页面结构的树形数据(vnode)传递给小程序进行渲染。
而渲染的方式都是借助了小程序自定义组件的递归渲染。
三 具体分析
那在了解了社区流行的跨端框架特点之后,我们再来梳理一下跨端组件库的需求。
一般来说常见的跨端需求都是在整个项目中完全使用一套跨端代码,然后这套代码被编译成可在多端运行。
而跨端组件库的需求又略显不同,它的需求在于各端各自维护着自己的一份代码,但是一些原本需要重复开发的相似组件可以使用同一套代码进行开发和维护。如支付组件,地址选择器组件,上传文件组件等等。
这就要求我们选用的技术方案能和原生项目“混合”,我们这里的“混合”主要指的是:
- 在原生项目中使用通过跨端框架转换后的代码
除此之外,从成本和性能的角度上考虑,跨端组件库的技术选型还需要考虑以下场景:
- 最好能开箱即用,不用我们在其上做定制。
- 类 vue 技术栈
- 运行时的依赖包体积不要太大
那明确了我们的需求之后,再来看下上面我们分析的各种框架。
首先,我们来看运行时框架,前面已经说过,运行时框架采用动态渲染的方式,在编译阶段把框架的 DSL 先编译成如下的 wxml,然后在运行时,在渲染成具体的视图。
<custom-dom>
</custom-dom>
那这种方案对于我们跨端组件库来说就显得不那么友好了, 因为一般来说,组件库在使用时需要被使用方传递参数,绑定事件,而使用方此时想看看组件的源码了解一些实现逻辑可能就会有点懵了,因为在 wxml 文件中,看到的永远是类似 <custom-dom>
这种标签。
除此之外,运行时框架只有 taro-next 提供了编译成原生自定义组件的功能,而 taro-next 是基于 react 栈的,考虑到学习曲线和接入现有项目的成本,也基本被 pass 掉了。
再来看编译型的跨端框架,同样先考虑技术栈问题, taro1/2 首先出局(除了这个原因之外,其实 taro1/2 也不支持开箱即用的和原生项目混合)。
最后就剩下了几个编译型类 vue 的框架:mpvue,meglo 以及 chamelon。
对于这三者来说,mpvue,meglo 有几个比较大的问题:
- 不支持开箱即用的原生混合:虽然从原理上编译型的框架完全可以支持和原生项目进行组件级别的混合,但是 mpvue 和 meglo 在设计上可能没有考虑到这个需求,所以想要支持组件级别的混合需求,需要我们去摹改框架,而 chamelon 原生支持这个需求。
- 跨端代码的可维护性:chamelon 使用多态协议来区分不同端的代码,而 mpvue,meglo 没有区分业务逻辑和差异代码的机制,造成可维护性不如 chamelon,而在编译时 chamelon 可以只打包特定平台的代码,这样保障了编译后代码的体积。
( chamelon 的多态协议 )
到这里,符合我们需求的框架就只剩 chamelon 了,当然 chamelon 也不是一个完美的框架,它虽然支持类 vue 语法,但是和原生的 vue 还是有些区别,需要有一定的学习曲线,同时因为它的野心太大,需要做的东西太多,导致本身的 bug 不少,社区维护的不如之前积极,但是综合考虑各种成本,chamelon 还是成为了现阶段最适合实现跨端组件库的技术选型。
四 总结
本文从公司现阶段实际情况出发,分析了开发跨端组件库的必要性,在分析了当前社区流行的各种跨端技术栈,以及它们背后的实现原理之后,在综合考虑了跨端组件库的需求和团队实际情况后,决定使用 chamelon 作为我们实现跨端组件库的技术方案。
这个感觉有点复杂