从 Taro 看跨平台开发框架

3,512 阅读10分钟

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 的 BaseComponentcreateComponent,这两位便是 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 变化后会直接触发 vuerender patch 等阶段,如果使我们平常自己使用 vue ,这里就会开始调用浏览器的更新 dom api来改变 dom,但是因为小程序中没有操作 dom 的方法,所以在 mpvue 中会屏蔽 patch 之后的 dom 操作方法(通过置空对应 dom api),转而使用 $updateDataToMp 将 vue 中的 data 更新到小程序的 data 中,可以视作调用一次小程序原生的 setData 方法。

相比 Taro 的编译时自己造各种语法转换的轮子,mpvue 借助于 vue 和小程序都有模板的特点,使其在编译时的工作量大大减小,而在运行时,相比 Taro 的只是使用了 React 的写法,mpvue 则是直接将 vue 跑在了小程序的 js 引擎里,简单地替换了其操作 domapi,大大的减少了工作量,从而避免了项目复杂度引起的更多问题,同时完整的支持了 vue 的特性而非 Taro 的假支持。

Babel 的问题

在通过 Babel 完成了对 React 语法的适配的同时,Babel 带来的学习和维护成本也成为了 Taro 新的痛点。

维护难

大量转换代码都在 TaroCLI 中,这意味着每次需要新增/改动一个功能,例如支持解析 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 的方式发生了变化,通过 webpackloaderplugin 把编译时的处理分化抽离,使得 2.0 版本的 CLI 体积相当轻量,只是做了初始化一个编译对象的工作,剩下的处理大都交由 webpack 进行,如此大大降低了参与 Taro 的维护和开发成本。

Taro Next【3.0】 跨平台架构

从浏览器中使用 react vue 等框架带来的思考

Taro Next 的架构诞生来源于对现代框架开发的思考,从图中我们可以清楚地看到,不管你使用什么框架,最后为了在浏览器上进行渲染,都会调用浏览器的 DOM BOM API ,也就是说在前端开发框架的最底层始终都是 BOMDOM ,那么我们换一个方向想:是不是只要有了 BOM 和 DOM,都能轻松地适配框架呢?

把 DOM 和 BOM 搬到小程序

答案当然是肯定的,小程序和 web 开发最大的不同点就在于为了性能/安全性,小程序的 webview(渲染层) 移除了所有的 DOMBOM 操作,Taro 为了保证各种前端框架都能在小程序中跑起来,给出的解决方案就是新增一个 runtime 包,其作用就是为小程序扩展一套简易的 BOMDOM API ,用户每次通过框架都会更改 runtime 中的虚拟 dom,而每次 render 则会触发小程序自己的 setData 从而达到框架到小程序运行的目的,这也就是 Taro Next开放式框架最大的特点:通过提供统一的 Runtime ,来支持各种不同的框架接入小程序

Taro 中接入框架

理想情况下,在有了 DOMBOM 之后,完全基于这些 API 开发的框架就可以直接接入 TaroTaro 新增框架支持了,但是某些框架,例如 React,为了保证渲染的兼容性,其有一层自己的封装(React Dom),为了抹平这一部分差异,Taro 提供了胶水层和接入框架的 Taro 插件在运行时解决这一类问题,具体的例子可以参考 小程序跨框架开发的探索与实践 | Taro 文档 React 和 Vue 的实现部分来了解。

Taro 的事件派发

有了 TaroRuntime 层,理论上 Taro 可以实现一套各框架通用的事件逻辑,而不是使用小程序的事件。

为了做一个通用的事件处理机制,我们需要不管在哪个节点发生了事件的都需要精确定位节点,而 Runtime 实际上是提供了一个唯一值的,且其在 eventSource 中也存储了这个 sid 和对应节点的映射关系。

基于此,我们可以通过 Runtime中的 document.getElementById 方法清楚地知道哪个节点发生了事件。

通过这些 api 的配合,Taro 推出了如下的事件机制:

新架构的编译时

我们从之前的文章中提到,Taro 在过去的版本中一直有将 render 方法中的 JSX 通过 Babel 转换成模板的做法,但是在新架构中编译时发生了一些变化。

在有了 TaroRuntime 之后,框架对 Dom 的操作映射到了 Runtime 中,每次 render 都会改变 Runtime 中的虚拟 Dom 树,而虚拟 Dom 树的节点数据可以作为小程序的 data 用于渲染整个小程序页面。

于是 Taro 在 3.x 中提供了一个公用模板,这个模板包含了小程序的所有基本组件,且每个子模板都支持循环嵌套,也就是说我们只要有小程序的 domdata,我们就可以把这个 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 从而直接开始开发。

总结

image.png

引用

小程序跨框架开发的探索与实践 | Taro 文档

凹凸技术揭秘 · Taro · 从跨端到开放式跨端跨框架

JELLY | 凹凸技术揭秘 · Taro · 开放式跨端跨框架之路