运行时揭秘 - H5 运行时

通过前面的文章《JSX 转换微信小程序模板的实现》,我们对Taro所做的编译转换工作有了一定的了解。Taro将 JS 代码转换为 AST 后,进行了诸如将data换成state,把componentDidMount改写成onReady等等的操作,再把修改后的 AST 转换成适合小程序执行的源码。

但上面这些工作,距离生成一个开箱即用的 H5 项目,距离我们的最终目标Write once, run anywhere还远远不够。要达成这个大目标,我们在《Taro 多端统一开发设计思路及架构》一文中提到过:因为各平台不尽相同的运行时框架、组件标准、API 标准和运行机制,除了在编译时进行多端转换,我们还需要在运行时抹平多端的差异。这篇文章将会对这部分运行时的工作进行阐述。

H5 运行时解析

首先,我们选用Nerv作为 Web 端的运行时框架。你可能会有问题:同样是类React框架,为何我们不直接用React,而是用Nerv呢?

为了更快更稳。开发过程中前端框架本身有可能会出现问题。如果是第三方框架,很有可能无法得到及时的修复,导致整个项目的进度受影响。Nerv就不一样。作为团队自研的产品,出现任何问题我们都可以在团队内部快速得到解决。与此同时,Nerv也具有与React相同的 API,同样使用 Virtual DOM 技术进行优化,正常使用与React并没有区别,完全可以满足我们的需要。

使用Taro之后,我们书写的是类似于下图的代码:

13进阶篇 7:运行时揭秘 - H5 运行时 - 图1

我们注意到,就算是转换过的代码,也依然存在着viewbutton等在 Web 开发中并不存在的组件。如何在 Web 端正常使用这些组件?这是我们碰到的第一个问题。

组件实现

我们不妨捋一捋小程序和 Web 开发在这些组件上的差异:

13进阶篇 7:运行时揭秘 - H5 运行时 - 图2

作为开发者,你第一反应或许会尝试在编译阶段下功夫,尝试直接使用效果类似的 Web 组件替代:用div替代view,用img替代image,以此类推。

费劲心机搞定标签转换之后,上面这个差异似乎是解决了。但很快你就会碰到一些更加棘手的问题:hover-start-timehover-stay-time等等这些常规 Web 开发中并不存在的属性要如何处理?

回顾一下:在前面讲到多端转换的时候,我们说到了babel。在Taro中,我们使用babylon生成 AST,babel-traverse去修改和移动 AST 中的节点。但babel所做的工作远远不止这些。

我们不妨去babelplayground 看一看代码在转译前后的对比:在使用了@babel/preset-envBUILT-INS之后,简单的一句源码new Map(),在babel编译后却变成了好几行代码:

13进阶篇 7:运行时揭秘 - H5 运行时 - 图3

注意看这几个文件:core-js/modules/web.dom.iterablecore-js/modules/es6.array.iteratorcore-js/modules/es6.map。我们可以在core-js的 Git 仓库找到他们的真身。很明显,这几个模块就是对应的 es 特性运行时的实现。

从某种角度上讲,我们要做的事情和babel非常像。babel把基于新版 ECMAScript 规范的代码转换为基于旧 ECMAScript 规范的代码,而Taro希望把基于React语法的代码转换为小程序的语法。我们从babel受到了启发:既然babel可以通过运行时框架来实现新特性,那我们也同样可以通过运行时代码,实现上面这些 Web 开发中不存在的功能。

举个例子。对于view组件,首先它是个普通的类 React 组件,它把它的子组件如实展示出来:

  1. import Nerv, { Component } from 'nervjs';
  2. class View extends Component {
  3. render() {
  4. return (
  5. <div>{this.props.children}</div>
  6. );
  7. }
  8. }

这太简单。接下来,我们需要对hover-start-time做处理。与Taro其他地方的命名规范一致,我们这个View组件接受的属性名将会是驼峰命名法:hoverStartTimehoverStartTime参数决定我们将在View组件触发touch事件多久后改变组件的样式。hover-stay-time属性的处理也十分类似,就不再赘述。这些属性的实现比起前面的代码会稍微复杂一点点,但绝对没有超纲。

  1. // 示例代码
  2. render() {
  3. const {
  4. hoverStartTime = 50,
  5. onTouchStart
  6. } = this.props;
  7. const _onTouchStart = e => {
  8. setTimeout(() => {
  9. // @TODO 触发touch样式改变
  10. }, hoverStartTime);
  11. onTouchStart && onTouchStart(e);
  12. }
  13. return (
  14. <div onTouchStart={_onTouchStart}>
  15. {this.props.children}
  16. </div>
  17. );
  18. }

再稍加修饰,我们就能得到一个功能完整的 Web 版 View 组件

view可以说是小程序最简单的组件之一了。text的实现甚至比上面的代码还要简单得多。但这并不说明组件的实现之路上就没有障碍。复杂如swiperscroll-viewtabbar,我们需要花费大量的精力分析小程序原生组件的 API,交互行为,极端值处理,接受的属性等等,再通过 Web 技术实现。

API 适配

除了组件,小程序下有一些 API 也是 Web 开发中所不具备的。比如小程序框架内置的wx.request/wx.getStorage等 API;但在 Web 开发中,我们使用的是fetch/localStorage等内置的函数或者对象。

13进阶篇 7:运行时揭秘 - H5 运行时 - 图4

小程序的 API 实现是个巨大的黑盒,我们仅仅知道如何使用它,使用它会得到什么结果,但对它内部的实现一无所知。

如何让 Web 端也能使用小程序框架中提供的这些功能?既然已经知道这个黑盒的入参出参情况,那我们自己打造一个黑盒就好了。

换句话说,我们依然通过运行时框架来实现这些 Web 端不存在的能力。

具体说来,我们同样需要分析小程序原生 API,最后通过 Web 技术实现。有兴趣可以在 Git 仓库中看到这些原生 API 的实现。下面以wx.setStorage为例进行简单解析。

wx.setStorage是一个异步接口,可以把key: value数据存储在本地缓存。很容易联想到,在 Web 开发中也有类似的数据存储概念,这就是localStorage。到这里,我们的目标已经十分明确:我们需要借助于localStorage,实现一个与wx.setStorage相同的 API。

我们首先查阅官方文档了解这个 API 的具体入参出参:

参数 类型 必填 说明
key String 本地缓存中的指定的 key
data Object/String 需要存储的内容
success Function 接口调用成功的回调函数
fail Function 接口调用失败的回调函数
complete Function 接口调用结束的回调函数(调用成功、失败都会执行)

而在 Web 中,如果我们需要往本地存储写入数据,使用的 API 是localStorage.setItem(key, value)。我们很容易就可以构思出这个函数的雏形:

  1. /* 示例代码 */
  2. function setStorage({ key, value }) {
  3. localStorage.setItem(key, value);
  4. }

我们顺手做点优化,把基于异步回调的 API 都给做了一层 Promise 包装,这可以让代码的流程处理更加方便。所以这段代码看起来会像下面这样:

  1. /* 示例代码 */
  2. function setStorage({ key, value }) {
  3. localStorage.setItem(key, value);
  4. return Promise.resolve({ errMsg: 'setStorage:ok' });
  5. }

看起来很完美,但开发的道路不会如此平坦。我们还需要处理其余的入参:successfailcompletesuccess回调会在操作成功完成时调用,fail会在操作失败的时候执行,complete则无论如何都会执行。setStorage函数只会在key值是String类型时有正确的行为,所以我们为这个函数添加了一个简单的类型判断,并在异常情况下执行fail回调。经过这轮变动,这段代码看起来会像下面这样:

  1. /* 示例代码 */
  2. function setStorage({ key, value, success, fail, complete }) {
  3. let res = { errMsg: 'setStorage:ok' }
  4. if (typeof key === 'string') {
  5. localStorage.setItem(key, value);
  6. success && success(res);
  7. } else {
  8. fail && fail(res);
  9. return Promise.reject(res);
  10. }
  11. complete && complete(res);
  12. return Promise.resolve({ errMsg: 'setStorage:ok' });
  13. }

这个函数的最终版本可以在 Taro 仓库中找到。

把这个 API 实现挂载到Taro模块之后,我们就可以通过Taro.setStorage来调用这个 API 了。

当然,也有一些 API 是 Web 端无论如何无法实现的,比如wx.login,又或者wx.scanCode。我们维护了一个 API 实现情况的列表,在实际的多端项目开发中应该尽可能避免使用它们。

路由

作为小程序的一大能力,小程序框架中以栈的形式维护了当前所有的页面,由框架统一管理。用户只需要调用wx.navigateTo,wx.navigateBack,wx.redirectTo等官方 API,就可以实现页面的跳转、回退、重定向,而不需要关心页面栈的细节。但是作为多端项目,当我们尝试在 Web 端实现路由功能的时候,就需要对小程序和 Web 端单页应用的路由原理有一定的了解。

小程序的路由比较轻量。使用时,我们先通过app.json为小程序配置页面列表:

  1. {
  2. "pages": [
  3. "pages/index/index",
  4. "pages/logs/logs"
  5. ],
  6. // ...
  7. }

在运行时,小程序内维护了一个页面栈,始终展示栈顶的页面(Page对象)。当用户进行跳转、后退等操作时,相应的会使页面栈进行入栈、出栈等操作。

路由方式 页面栈表现
初始化 新页面入栈(push)
打开新页面 新页面入栈(push)
页面重定向 当前页面出栈,新页面入栈(pop, push)
页面返回 页面不断出栈,直到目标返回页(pop)
Tab 切换 页面全部出栈,只留下新的 Tab 页面
重加载 页面全部出栈,只留下新的页面

同时,在页面栈发生路由变化时,还会触发相应页面的生命周期:

路由方式 触发时机 路由前页面 路由后页面
初始化 小程序打开的第一个页面 onLoad, onShow
打开新页面 调用 API wx.navigateTo 或使用组件 navigator onHide onLoad, onShow
页面重定向 调用 API wx.redirectTo 或使用组件 navigator onUnload onLoad, onShow
页面返回 调用 API wx.navigateBack 或使用组件 navigator 或用户按左上角返回按钮 onUnload onShow
重启动 调用 API wx.reLaunch 或使用组件 navigator onUnload onLoad, onShow

对于 Web 端单页应用路由,我们则以react-router为例进行说明:

首先,react-router开始通过history工具监听页面路径的变化。

在页面路径发生变化时,react-router会根据新的location对象,触发 UI 层的更新。

至于 UI 层如何更新,则是取决于我们在Route组件中对页面路径和组件的绑定,甚至可以实现嵌套路由。

可以说,react-router的路由方案是组件级别的。

具体到Taro,为了保持跟小程序的行为一致,我们不需要细致到组件级别的路由方案,但需要为每次路由保存完整的页面栈。

实现形式上,我们参考react-router:监听页面路径变化,再触发 UI 更新。这是React的精髓之一,单向数据流。

13进阶篇 7:运行时揭秘 - H5 运行时 - 图5

@tarojs/router包中包含了一个轻量的history实现。history中维护了一个栈,用来记录页面历史的变化。对历史记录的监听,依赖两个事件:hashchangepopstate

  1. /* 示例代码 */
  2. window.addEventListener('hashchange', () => {});
  3. window.addEventListener('popstate', () => {})

对于使用 Hash 模式的页面路由,每次页面跳转都会依次触发popstatehashchange事件。由于在popstate的回调中可以取到当前页面的 state,我们选择它作为主要跳转逻辑的容器。

作为 UI 层,@tarojs/router包提供了一个Router组件,维护页面栈。与小程序类似,用户不需要手动调用Router组件,而是由Taro自动处理。

对于历史栈来说,无非就是三种操作:push, pop,还有replace。在历史栈变动时触发Router的回调,就可以让Router也同步变化。这就是Taro中路由的基本原理。

只有三种操作,说起来很简单,但实际操作中有一个难点。设想你正处在一个历史栈的中间:(…、a、b、你、b,c),c 是栈顶。
这时候,你通过hashchange事件得知页面 Hash 变化了,肯定是页面发生跳转了。不过很遗憾,跳转后的页面 Hash 是 b。这时候,你能知道这次路由变动到底是前进还是后退吗?

我们在hashchange回调中,通过history.replaceState API,在 state 中记录了页面的跳转次数。从而可以在popstate中推断导致跳转的具体行为。具体可以在这里看到相关实现。

@tarojs/router实现中还有一些小细节需要处理。比如如何加入compomentDidShow之类原本不存在的生命周期?
我们选择在运行时进行这个操作。对于在入口config中注册的页面文件,我们继承了页面类并对componentDidMount做了改写,简单粗暴地插入了componentDidShow的调用。

Redux 处理

每当提到React的数据流,我们就不得不提到Redux。通过合并ReducerRedux可以让大型应用中的数据流更加规则、可预测。

我们在Taro中加入了Redux的支持,通过导入@tarojs/redux,即可在小程序端使用Redux的功能。

对于 Web 端,我们尝试直接使用nerv-redux包提供支持,但这会带来一些问题。

我们使用与下面类似的代码:

  1. import Nerv from 'nervjs'
  2. import { connect } from 'nerv-redux'
  3. @connect(() => {})
  4. class Index extends Nerv.Componnet {
  5. componentDidShow() { console.log('didShow') }
  6. componentDidMount() { console.log('didMount') }
  7. render() { return '' }
  8. }

但这个componentDidShow并没有执行。为什么?

回想一下前面讲的componentDidShow的实现:我们继承,并且改写 componentDidMount

但是对于使用Redux的页面来说,我们继承的类,是经过@connect修饰过的一个高阶组件。

问题就出在这里:这个高阶组件的签名里并没有componentDidShow这一个函数。所以我们的 componentDidMount 内,理所当然是取不到componentDidShow的。

为了解决这个问题,我们对react-redux代码进行了一些小改装,这就是@taro/redux-h5的由来。

小结

这个章节对 H5 端的运行时环境进行了解析,包括组件库的原理和实现,还有端能力 API 的实现。

看完这篇文章,你可能就对Taro解决问题的两个方式非常熟悉了,无非就是编译时运行时

说起来可能非常简单,但这并不意味着实现起来也很简单。需要对小程序原生 API 功能、交互等进行透彻的分析和细心的实现。无论这其中有多少坑多少工作量,只要是为了提升开发体验,我们认为都是值得的。