运行时揭秘 - 小程序运行时

为了使 Taro 组件转换成小程序组件并运行在小程序环境下, Taro 主要做了两个方面的工作:编译以及运行时适配。编译过程会做很多工作,例如:将 JSX 转换成小程序 .wxml 模板,生成小程序的配置文件、页面及组件的代码等等。编译生成好的代码仍然不能直接运行在小程序环境里,那运行时又是如何与之协同工作的呢?

注册程序、页面以及自定义组件

在小程序中会区分程序、页面以及组件,通过调用对应的函数,并传入包含生命周期回调、事件处理函数等配置内容的 object 参数来进行注册:

  1. Component({
  2. data: {},
  3. methods: {
  4. handleClick () {}
  5. }
  6. })

而在 Taro 里,它们都是一个组件类:

  1. class CustomComponent extends Component {
  2. state = { }
  3. handleClick () { }
  4. }

那么 Taro 的组件类是如何转换成小程序的程序、页面或组件的呢?

例如,有一个组件:customComponent,编译过程会在组件底部添加一行这样的代码(此处代码作示例用,与实际项目生成的代码不尽相同):

  1. Component(createComponent(customComponent))

createComponent 方法是整个运行时的入口,在运行的时候,会根据传入的组件类,返回一个组件的配置对象。

在小程序里,程序的功能及配置与页面和组件差异较大,因此运行时提供了两个方法 createApp 和 createComponent 来分别创建程序和组件(页面)。createApp 的实现非常简单,本章我们主要介绍 createComponent 做的工作。

createComponent 方法主要做了这样几件事情:

  1. 将组件的 state 转换成小程序组件配置对象的 data
  2. 将组件的生命周期对应到小程序组件的生命周期
  3. 将组件的事件处理函数对应到小程序的事件处理函数

接下来将分别讲解以上三个部分。

组件 state 转换

其实在 Taro(React) 组件里,除了组件的 state,JSX 里还可以访问 props 、render 函数里定义的值、以及任何作用域上的成员。而在小程序中,与模板绑定的数据均来自对应页面(或组件)的 data 。因此 JSX 模板里访问到的数据都会对应到小程序组件的 data 上。接下来我们通过列表渲染的例子来说明 state 和 data 是如何对应的:

在 JSX 里访问 state

在小程序的组件上使用 wx:for 绑定一个数组,就可以实现循环渲染。例如,在 Taro 里你可能会这么写:

  1. {
  2. state = {
  3. list: [1, 2, 3]
  4. }
  5. render () {
  6. return (
  7. <View>
  8. {this.state.list.map(item => <View>{item}</View>)}
  9. </View>
  10. )
  11. }
  12. }

编译后的小程序组件模板:

  1. <view>
  2. <view wx:for="{{list}}" wx:for-item="item">{{item}}</view>
  3. </view>

其中 state.list 只需直接对应到小程序(页面)组件的 data.list 上即可。

在 render 里生成了新的变量

然而事情通常没有那么简单,在 Taro 里也可以这么用:

  1. {
  2. state = {
  3. list = [1, 2, 3]
  4. }
  5. render () {
  6. return (
  7. <View>
  8. {this.state.list.map(item => ++item).map(item => <View>{item}</View>)}
  9. </View>
  10. )
  11. }
  12. }

编译后的小程序组件模板是这样的:

  1. <view>
  2. <view wx:for="{{$anonymousCallee__1}}" wx:for-item="item">{{item}}</view>
  3. </view>

在编译时会给 Taro 组件创建一个 _createData 的方法,里面会生成 $anonymousCallee__1 这个变量, $anonymousCallee__1 是由编译器生成的,对 this.state.list 进行相关操作后的变量。 $anonymousCallee__1 最终会被放到组件的 data 中给模板调用:

  1. var $anonymousCallee__1 = this.state.list.map(function (item) {
  2. return ++item;
  3. });

render 里 return 之前的所有定义变量或者对 props、state 计算产生新变量的操作,都会被编译到 _createData 方法里执行,这一点在前面 JSX 编译成小程序模板的相关文章中已经提到。每当 Taro 调用 this.setState API 来更新数据时,都会调用生成的 _createData 来获取最新数据。

将组件的生命周期对应到小程序组件的生命周期

生命周期的对应工作主要包含两个部分:初始化过程和状态更新过程。

初始化过程里的生命周期对应很简单,在小程序的生命周期回调函数里调用 Taro 组件里对应的生命周期函数即可,例如:小程序组件 ready 的回调函数里会调用 Taro 组件的 componentDidMount 方法。它们的执行过程和对应关系如下图:

12进阶篇 6:运行时揭秘 - 小程序运行时 - 图1

小程序的页面除了渲染过程的生命周期外,还有一些类似于 onPullDownRefresh 、 onReachBottom 等功能性的回调方法也放到了生命周期回调函数里。这些功能性的回调函数,Taro 未做处理,直接保留了下来。

小程序页面的 componentWillMount 有一点特殊,会有两种初始化方式。由于小程序的页面需要等到 onLoad 之后才可以获取到页面的路由参数,因此如果是启动页面,会等到 onLoad 时才会触发。而对于小程序内部通过 navigateTo 等 API 跳转的页面,Taro 做了一个兼容,调用 navigateTo 时将页面参数存储在一个全局对象中,在页面 attached 的时候从全局对象里取到,这样就不用等到页面 onLoad 即可获取到路由参数,触发 componentWillMount 生命周期。

状态更新:

12进阶篇 6:运行时揭秘 - 小程序运行时 - 图2

Taro 组件的 setState 行为最终会对应到小程序的 setData。Taro 引入了如 nextTick ,编译时识别模板中用到的数据,在 setData 前进行数据差异比较等方式来提高 setState 的性能。

如上图,组件调用 setState 方法之后,并不会立刻执行组件更新逻辑,而是会将最新的 state 暂存入一个数组中,等 nextTick 回调时才会计算最新的 state 进行组件更新。这样即使连续多次的调用 setState 并不会触发多次的视图更新。在小程序中 nextTick 是这么实现的:

  1. const nextTick = (fn, ...args) => {
  2. fn = typeof fn === 'function' ? fn.bind(null, ...args) : fn
  3. const timerFunc = wx.nextTick ? wx.nextTick : setTimeout
  4. timerFunc(fn)
  5. }

除了计算出最新的组件 state ,在组件状态更新过程里还会调用前面提到过的 _createData 方法,得到最终小程序组件的 data,并调用小程序的 setData 方法来进行组件的更新。

组件更新如何触发子组件的更新呢?

这里用到了小程序组件的 properties 的 observer 特性,给子组件传入一个 prop 并在子组件里监听 prop 的更改,这个 prop 更新就会触发子组件的状态更新逻辑。细心的 Taro 开发者可能会发现,编译后的代码里会给每个自定义的组件传入一个 __triggerObserer 的值,它的作用正是用于触发子组件的更新逻辑。

由于小程序在调用 setData 之后,会将数据使用 JSON.stringify 进行序列化,再拼接成脚本,然后再传给视图层渲染,这样的话,当数据量非常大的时候,小程序就会变得异常卡顿,性能很差。Taro 在框架级别帮助开发者进行了优化。

  • 首先,在编译的过程中会找到所有在模板中用到到字段 ,并存储到组件的 $usedState 字段中。例如,编译后的小程序模板:
  1. <view>{{a}}<view>

那么在编译后的组件类里就会多这样一个字段:

  1. {
  2. $usedState = ['a']
  3. }

在计算完小程序的 data 之后,会遍历 $usedState 字段,将多余的内容过滤掉,只保留模板用到的数据。例如,即使原本组件的状态包含:

  1. {
  2. state = {
  3. a: 1,
  4. b: 2,
  5. c: 3
  6. }
  7. }

最终 setData 的数据也只会包含 $usedState 里存在的字段:

  1. {
  2. a: 1
  3. }
  • 其次在 setData 之前进行了一次数据 Diff,找到数据的最小更新路径,然后再使用此路径来进行更新。例如:
  1. // 初始 state
  2. this.state = {
  3. a: [0],
  4. b: {
  5. x: {
  6. y: 1
  7. }
  8. }
  9. }
  10. // 调用 this.setState
  11. this.setState({
  12. a: [1, 2],
  13. b: {
  14. x: {
  15. y: 10
  16. }
  17. }
  18. })

在优化之前,会直接将 this.setState 的数据传给 setData,即:

  1. this.$scope.setData({
  2. a: [1, 2],
  3. b: {
  4. x: {
  5. y: 10
  6. }
  7. }
  8. })

而在优化之后的数据更新则变成了:

  1. this.$scope.setData({
  2. 'a[0]': 1,
  3. 'a[1]': 2,
  4. 'b.x.y': 10
  5. })

这样的优化对于小程序来说意义非常重大,可以避免因为数据更新导致的性能问题。

事件处理函数对应

在小程序的组件里,事件响应函数需要配置在 methods 字段里。而在 JSX 里,事件是这样绑定的:

  1. <View onClick={this.handleClick}></View>

编译的过程会将 JSX 转换成小程序模板:

  1. <view bindclick="handleClick"></view>

在 createComponent 方法里,会将事件响应函数 handleClick 添加到 methods 字段中,并且在响应函数里调用真正的 this.handleClick 方法。

在编译过程中,会提取模板中绑定过的方法,并存到组件的 $events 字段里,这样在运行时就可以只将用到的事件响应函数配置到小程序组件的 methods 字段中。

在运行时通过 processEvent 这个方法来处理事件的对应,省略掉处理过程,就是这样的:

  1. function processEvent (eventHandlerName, obj) {
  2. obj[eventHandlerName] = function (event) {
  3. // ...
  4. scope[eventHandlerName].apply(callScope, realArgs)
  5. }
  6. }

这个方法的核心作用就是解析出事件响应函数执行时真正的作用域 callScope 以及传入的参数。在 JSX 里,我们可以像下面这样通过 bind 传入参数:

  1. <View onClick={this.handleClick.bind(this, arga, argb)}></View>

小程序不支持通过 bind 的方式传入参数,但是小程序可以用 data 开头的方式,将数据传递到 event.currentTarget.dataset 中。编译过程会将 bind 方式传递的参数对应到 dataset 中,processEvent 函数会从 dataset 里取到传入的参数传给真正的事件响应函数。

至此,经过编译之后的 Taro 组件终于可以运行在小程序环境里了。为了方便用户的使用,小程序运行时还提供了更多的特性,接下来会举一个例子来说明。

对 API 进行 Promise 化的处理

Taro 对小程序的所有 API 进行了一个分类整理,将其中的异步 API 做了一层 Promise 化的封装。例如,wx.getStorage经过下面的处理对应到Taro.getStorage(此处代码作示例用,与实际源代码不尽相同):

  1. Taro['getStorage'] = options => {
  2. let obj = Object.assign({}, options)
  3. const p = new Promise((resolve, reject) => {
  4. ['fail', 'success', 'complete'].forEach((k) => {
  5. obj[k] = (res) => {
  6. options[k] && options[k](res)
  7. if (k === 'success') {
  8. resolve(res)
  9. } else if (k === 'fail') {
  10. reject(res)
  11. }
  12. }
  13. })
  14. wx['getStorage'](obj)
  15. })
  16. return p
  17. }

就可以这么调用了:

  1. // 小程序的调用方式
  2. Taro.getStorage({
  3. key: 'test',
  4. success() {
  5. }
  6. })
  7. // 在 Taro 里也可以这样调用
  8. Taro.getStorage({
  9. key: 'test'
  10. }).then(() => {
  11. // success
  12. })

百度/支付宝小程序运行时

Taro 在支持转换到 微信小程序 的同时,已经支持转换到 百度/支付宝小程序 了,这两家小程序的使用方式与微信小程序相似程度非常高,所以其运行时机制也与微信小程序基本一致,读者朋友可以自行类比,或通过源码一探究竟。

小结

本章节主要讲解了两个方面的内容:

  • Taro 小程序运行时是如何配合编译过程,抹平了状态、事件绑定以及生命周期的差异,使得 Taro 组件运行在小程序环境中。
  • 通过运行时对原生 API 进行扩展,实现了诸如事件绑定时通过 bind 传递参数、通过 Promise 的方式调用原生 API 等特性。
  • 本章节参考 Taro 源码