2017 年 1 月 9 日凌晨,微信正式推出小程序,为移动端家族增加了新的业务形态和玩法,自此各大平台纷纷跟进,短短4年内,各大巨型应用都已经拥有了自己的小程序生态,而这对开发者而言不可避免的带来了更多重复的开发工作,这促进了很多小程序框架(mpvue/ Chameleon/uni-app/taro)向多端适配上面迈进。
Taro 作为在开源社区中最为活跃的小程序开发框架,其框架架构的变化无疑是我们学习小程序跨平台开发框架的最好教程,所以今天我们通过本文来讨论 Taro 是怎样如何做框架支持多 DSL 的实现探索,使得开发者可以使用任意热门框架/语法/DSL 来编写小程序应用,同时复用相关生态的。
如何用 React 写小程序
在开始探讨 Taro
的做法之前,我们可以先想一想自己有没有什么好的办法来让小程序跑在 React
上。
有一个简单的例子,我们在浏览器不支持 ES6 的写法时我们是怎么在代码里写 ES6
的呢?
从 Babel 开始
上述问题不需赘述,通过 Babel
我们可以将代码转换成抽象语法树(AST
),再将语法树根据我们需要适应的平台来转换生成支持的代码。那么同理,其实我们完全可以将 React
的写法先转换成抽象语法树,再生成对应小程序的代码来达成我们用 React
来写小程序的目的。
实际上初代的 Taro
最基本的原理就是如此,为此 Taro
在1.x 的版本中专门维护了一个包 taro/packages/taro-transformer-wx at v1.3 · NervJS/taro 来将 React
的各种写法根据 AST 转换成小程序代码来达到使用 React DSL
书写小程序的目的。
Taro 的初代架构
基于 Babel 转换代码的原理,Taro 初代的架构分为 编译时 和 运行时
编译时
在初版 Taro
的编译时,Taro
会根据配置来将入口文件中的 config 进行遍历处理,判断页面依赖、获取页面模板并根据对应编译平台输出成对应平台的小程序文件。
其中原 Taro
组件的 render
部分会被移除,通过 Babel
来解析生成页面的模板(xxx.wxml),其余部分则会被 Taro
通过 Babel
转换成其运行时包装处理的部分,大概的结果我们可以通过一个简单的例子来看:
// 编译前
import Taro, { Component } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import './index.css'
export default class Index extends Component {
componentWillMount () { }
componentDidMount () { }
componentWillUnmount () { }
componentDidShow () { }
componentDidHide () { }
config = {
navigationBarTitleText: '首页'
}
render () {
return (
<View className='index'>
<Text>Hello world!</Text>
</View>
)
}
}
// 编译后
var Index = (_temp2 = _class = function (_BaseComponent) {
_inherits(Index, _BaseComponent);
function Index() {
var _ref;
var _temp, _this, _ret;
_classCallCheck(this, Index);
for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
return _ret = (_temp = (_this = _possibleConstructorReturn(this, (_ref = Index.__proto__ || Object.getPrototypeOf(Index)).call.apply(_ref, [this].concat(args))), _this), _this.$usedState = [], _this.config = {
navigationBarTitleText: '首页'
}, _this.customComponents = [], _temp), _possibleConstructorReturn(_this, _ret);
}
_createClass(Index, [{
key: '_constructor',
value: function _constructor(props) {
_get(Index.prototype.__proto__ || Object.getPrototypeOf(Index.prototype), '_constructor', this).call(this, props);
this.$$refs = new _tarojs_taro_weapp__WEBPACK_IMPORTED_MODULE_0___default.a.RefsArray();
}
}, {
key: 'componentWillMount',
value: function componentWillMount() {}
}, {
key: 'componentDidMount',
value: function componentDidMount() {}
}, {
key: 'componentWillUnmount',
value: function componentWillUnmount() {}
}, {
key: 'componentDidShow',
value: function componentDidShow() {}
}, {
key: 'componentDidHide',
value: function componentDidHide() {}
}, {
key: '_createData',
value: function _createData() {
this.__state = arguments[0] || this.state || {};
this.__props = arguments[1] || this.props || {};
var __isRunloopRef = arguments[2];
var __prefix = this.$prefix;
;
Object.assign(this.__state, {});
return this.__state;
}
}]);
return Index;
}(_tarojs_taro_weapp__WEBPACK_IMPORTED_MODULE_0__["Component"]), _class.$$events = [], _class.$$componentPath = "pages/index/index", _temp2);
/* harmony default export */ __webpack_exports__["default"] = (Index);
Component(__webpack_require__(/*! @tarojs/taro-weapp */ "./node_modules/@tarojs/taro-weapp/index.js").default.createComponent(Index, true));
我们可以明显看到编译后的代码中 render
函数已经不存在了,且代码中原先引用 Component
的部分被替换成了 Taro 的 BaseComponent
和 createComponent
,这两位便是 Taro
初版运行时的核心。
运行时
Taro 的运行时会通过内置的 BaseComponent 和 createComponent 来达成对小程序页面的组件化,并完成对 data、props、声明周期事件等的劫持。
在 Taro 设计的初期,由于微信小程序刚推出的自定义组件功能并不完善,实现不了传入自定义函数等问题,无法满足组件化灵活使用的需求,所以 Taro 的组件化架构是采用 template 标签来实现的,其有两个主要问题:
- JS 逻辑与模板隔离,需要分别处理,导致组件传参非常麻烦,难以对齐
- template 实现的自定义组件无法嵌套子组件
所以在小程序更新了自定义组件,并完善了其自定义函数的传递问题之后, Taro 跟进了该项更新,通过将 Taro
的组件直接编译成小程序的原生组件的 Component
方法调用,并在运行时把各个生命周期的回调绑定到对应的组件声明周期,将 props 、函数等绑定到小程序组件对应的配置中,来对组件参数、生命周期适配、以及事件的处理,从而借助小程序的组件化能力来实现 Taro
的组件处理。
暂时无法在文档外展示此内容
在编译时会将引用的 react 通过 babel 直接替换成 taro-weapp,所以实际上初版的 Taro 中 react 只是纯写法而已。
和 mpvue 的区别
在同类的框架中, mpvue
作为官方出品的小程序框架也分为编译时和运行时。
编译时
在 mpvue
的编译时,其做的工作和 Taro 一样,都是将其 vue
template
语法通过编译器转换成小程序的 wxml
格式。
运行时
在 mpvue
的运行时,其会完成一次 Vue 实例化的过程,在实例化后会调用 Page 来实例化小程序页面,而小程序的 data
则会被 vue
的响应式 data
拦截,在 data
变化后会直接触发 vue
的 render
patch
等阶段,如果使我们平常自己使用 vue
,这里就会开始调用浏览器的更新 dom api
来改变 dom
,但是因为小程序中没有操作 dom
的方法,所以在 mpvue
中会屏蔽 patch
之后的 dom
操作方法(通过置空对应 dom api),转而使用 $updateDataToMp
将 vue 中的 data 更新到小程序的 data 中,可以视作调用一次小程序原生的 setData 方法。
相比 Taro
的编译时自己造各种语法转换的轮子,mpvue
借助于 vue
和小程序都有模板的特点,使其在编译时的工作量大大减小,而在运行时,相比 Taro
的只是使用了 React
的写法,mpvue
则是直接将 vue
跑在了小程序的 js
引擎里,简单地替换了其操作 dom
的 api
,大大的减少了工作量,从而避免了项目复杂度引起的更多问题,同时完整的支持了 vue
的特性而非 Taro
的假支持。
Babel 的问题
在通过 Babel
完成了对 React
语法的适配的同时,Babel
带来的学习和维护成本也成为了 Taro
新的痛点。
维护难
大量转换代码都在 Taro
的 CLI
中,这意味着每次需要新增/改动一个功能,例如支持解析 Markdown
文件或者支持 JSX
的一个新语法,就需要直接改动 CLI
,这意味着功能之间的耦合极高,一不小心就可能导致维护者新增功能的同时影响到了原功能。
上手难
Babel
的代码处理判断分支复杂,且本身如果对 AST
不熟悉的同学还需要熟悉各种概念和变量含义,这导致了社区用户很难参与到 Taro
的开发中去,而且 Taro
这种高度定制化的处理,Babel
的社区为项目带来的帮助几近于无,全靠自己摸索,这更加大了上手的困难度。
扩展难
在最开始的版本里,Taro
使用的构建系统是自研的一套系统,其在设计之初没有考虑到后续的扩展性,导致开发者想要添加自定义的功能无从下手。
Taro 2.0 的变化
暂时无法在文档外展示此内容
为了解决自建构建系统和 Babel
带来的问题,Taro 2.0
使用 webpack
作为底层的构建系统,在其上层又增加了一层插件层来解决这些问题。
通过 Taro
自己的插件层,用户可以直接控制 webpack
的配置,而且可以自行在 Taro
层的构建生命周期中做自己需要的特殊处理,从而大大提高了用户使用 Taro
的灵活性和可扩展性。举个例子,如果想为 Taro
增加 less
文件的解析,在最初的版本里,你只能修改 CLI
来内置支持的预处理器,而通过 2.x 版本,你可以通过 Taro Plugin
修改 webpack
配置支持预处理对应文件。
而基于 webpack
的方式也让之前把所有编译时处理逻辑内置到 CLI
的方式发生了变化,通过 webpack
的 loader
和 plugin
把编译时的处理分化抽离,使得 2.0 版本的 CLI
体积相当轻量,只是做了初始化一个编译对象的工作,剩下的处理大都交由 webpack 进行,如此大大降低了参与 Taro
的维护和开发成本。
Taro Next【3.0】 跨平台架构
从浏览器中使用 react vue 等框架带来的思考
Taro Next
的架构诞生来源于对现代框架开发的思考,从图中我们可以清楚地看到,不管你使用什么框架,最后为了在浏览器上进行渲染,都会调用浏览器的 DOM BOM API
,也就是说在前端开发框架的最底层始终都是 BOM
和 DOM
,那么我们换一个方向想:是不是只要有了 BOM 和 DOM,都能轻松地适配框架呢?
把 DOM 和 BOM 搬到小程序
答案当然是肯定的,小程序和 web 开发最大的不同点就在于为了性能/安全性,小程序的 webview
(渲染层) 移除了所有的 DOM
和 BOM
操作,Taro
为了保证各种前端框架都能在小程序中跑起来,给出的解决方案就是新增一个 runtime
包,其作用就是为小程序扩展一套简易的 BOM
和 DOM
API
,用户每次通过框架都会更改 runtime
中的虚拟 dom,而每次 render
则会触发小程序自己的 setData
从而达到框架到小程序运行的目的,这也就是 Taro Next
的开放式框架最大的特点:通过提供统一的 Runtime
,来支持各种不同的框架接入小程序。
Taro 中接入框架
理想情况下,在有了 DOM
和 BOM
之后,完全基于这些 API
开发的框架就可以直接接入 Taro
为 Taro
新增框架支持了,但是某些框架,例如 React
,为了保证渲染的兼容性,其有一层自己的封装(React Dom
),为了抹平这一部分差异,Taro
提供了胶水层和接入框架的 Taro
插件在运行时解决这一类问题,具体的例子可以参考 小程序跨框架开发的探索与实践 | Taro 文档 React 和 Vue 的实现部分来了解。
Taro 的事件派发
有了 Taro
的 Runtime
层,理论上 Taro
可以实现一套各框架通用的事件逻辑,而不是使用小程序的事件。
为了做一个通用的事件处理机制,我们需要不管在哪个节点发生了事件的都需要精确定位节点,而 Runtime
实际上是提供了一个唯一值的,且其在 eventSource
中也存储了这个 sid
和对应节点的映射关系。
基于此,我们可以通过 Runtime
中的 document.getElementById
方法清楚地知道哪个节点发生了事件。
通过这些 api 的配合,Taro 推出了如下的事件机制:
新架构的编译时
我们从之前的文章中提到,Taro
在过去的版本中一直有将 render
方法中的 JSX
通过 Babel
转换成模板的做法,但是在新架构中编译时发生了一些变化。
在有了 Taro
的 Runtime
之后,框架对 Dom
的操作映射到了 Runtime
中,每次 render
都会改变 Runtime
中的虚拟 Dom
树,而虚拟 Dom
树的节点数据可以作为小程序的 data
用于渲染整个小程序页面。
于是 Taro
在 3.x 中提供了一个公用模板,这个模板包含了小程序的所有基本组件,且每个子模板都支持循环嵌套,也就是说我们只要有小程序的 dom
树 data
,我们就可以把这个 data
通过这个模板循环渲染成完整的小程序节点。
<wxs module="xs" src="./utils.wxs" />
<template name="taro_tmpl">
<block wx:for="{{root.cn}}" wx:key="uid">
<template is="tmpl_0_container" data="{{i:item,l:''}}" />
</block>
</template>
...
<template name="tmpl_0_slide-view">
<slide-view show="{{i.show}}" buttons="{{i.buttons}}" bindbuttontap="eh" bindshow="eh" children="{{i.children}}" bindhide="eh" id="{{i.uid}}">
<block wx:for="{{i.cn}}" wx:key="uid">
<template is="{{xs.e(cid+1)}}" data="{{i:item,l:l}}" />
</block>
</slide-view>
</template>
<template name="tmpl_0_video-swiper">
<video-swiper swiperKey="{{i.swiperKey}}" items="{{i.items}}" current="{{i.current}}" videoShow="{{i.videoShow}}" id="{{i.uid}}">
<block wx:for="{{i.cn}}" wx:key="uid">
<template is="{{xs.e(cid+1)}}" data="{{i:item,l:l}}" />
</block>
</video-swiper>
</template>
<template name="tmpl_0_container">
<template is="{{xs.a(0, i.nn, l)}}" data="{{i:i,cid:0,l:xs.f(l,i.nn)}}" />
</template>
...
得益于小程序可以通过 a.b.c.d
来作为 data
的键值来更新,在 Runtime
层还可以做到细粒度更低的更新。
这样的做法,完全省略了之前需要用 Babel
进行 JSX
语法转化成小程序模板的过程,也就是说在新版本的 Taro
中,是没有多余的编译时处理的,在编译时只需要做的事就是通过 webpack
为全局注入Taro Runtime
和做框架到 Runtime
的桥接,其可以说是全运行时的。
开放式架构
基于 Taro 本身的插件机制,结合 webpack ,Taro 在 3.1 版本中将原有支持各平台的处理完全抽离到了其本身的插件里,这意味着之后无论是扩展支持的平台、还是扩展支持的框架,开发者都可以自行开发或者寻找对应的插件接入 Taro 从而直接开始开发。