运行时揭秘 - 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之后,我们书写的是类似于下图的代码:
我们注意到,就算是转换过的代码,也依然存在着view、button等在 Web 开发中并不存在的组件。如何在 Web 端正常使用这些组件?这是我们碰到的第一个问题。
组件实现
我们不妨捋一捋小程序和 Web 开发在这些组件上的差异:
作为开发者,你第一反应或许会尝试在编译阶段下功夫,尝试直接使用效果类似的 Web 组件替代:用div替代view,用img替代image,以此类推。
费劲心机搞定标签转换之后,上面这个差异似乎是解决了。但很快你就会碰到一些更加棘手的问题:hover-start-time、hover-stay-time等等这些常规 Web 开发中并不存在的属性要如何处理?
回顾一下:在前面讲到多端转换的时候,我们说到了babel。在Taro中,我们使用babylon生成 AST,babel-traverse去修改和移动 AST 中的节点。但babel所做的工作远远不止这些。
我们不妨去babel的 playground 看一看代码在转译前后的对比:在使用了@babel/preset-env的BUILT-INS之后,简单的一句源码new Map(),在babel编译后却变成了好几行代码:
注意看这几个文件:core-js/modules/web.dom.iterable,core-js/modules/es6.array.iterator,core-js/modules/es6.map。我们可以在core-js的 Git 仓库找到他们的真身。很明显,这几个模块就是对应的 es 特性运行时的实现。
从某种角度上讲,我们要做的事情和babel非常像。babel把基于新版 ECMAScript 规范的代码转换为基于旧 ECMAScript 规范的代码,而Taro希望把基于React语法的代码转换为小程序的语法。我们从babel受到了启发:既然babel可以通过运行时框架来实现新特性,那我们也同样可以通过运行时代码,实现上面这些 Web 开发中不存在的功能。
举个例子。对于view组件,首先它是个普通的类 React 组件,它把它的子组件如实展示出来:
import Nerv, { Component } from 'nervjs';class View extends Component {render() {return (<div>{this.props.children}</div>);}}
这太简单。接下来,我们需要对hover-start-time做处理。与Taro其他地方的命名规范一致,我们这个View组件接受的属性名将会是驼峰命名法:hoverStartTime。hoverStartTime参数决定我们将在View组件触发touch事件多久后改变组件的样式。hover-stay-time属性的处理也十分类似,就不再赘述。这些属性的实现比起前面的代码会稍微复杂一点点,但绝对没有超纲。
// 示例代码render() {const {hoverStartTime = 50,onTouchStart} = this.props;const _onTouchStart = e => {setTimeout(() => {// @TODO 触发touch样式改变}, hoverStartTime);onTouchStart && onTouchStart(e);}return (<div onTouchStart={_onTouchStart}>{this.props.children}</div>);}
再稍加修饰,我们就能得到一个功能完整的 Web 版 View 组件 。
view可以说是小程序最简单的组件之一了。text的实现甚至比上面的代码还要简单得多。但这并不说明组件的实现之路上就没有障碍。复杂如swiper,scroll-view,tabbar,我们需要花费大量的精力分析小程序原生组件的 API,交互行为,极端值处理,接受的属性等等,再通过 Web 技术实现。
API 适配
除了组件,小程序下有一些 API 也是 Web 开发中所不具备的。比如小程序框架内置的wx.request/wx.getStorage等 API;但在 Web 开发中,我们使用的是fetch/localStorage等内置的函数或者对象。
小程序的 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)。我们很容易就可以构思出这个函数的雏形:
/* 示例代码 */function setStorage({ key, value }) {localStorage.setItem(key, value);}
我们顺手做点优化,把基于异步回调的 API 都给做了一层 Promise 包装,这可以让代码的流程处理更加方便。所以这段代码看起来会像下面这样:
/* 示例代码 */function setStorage({ key, value }) {localStorage.setItem(key, value);return Promise.resolve({ errMsg: 'setStorage:ok' });}
看起来很完美,但开发的道路不会如此平坦。我们还需要处理其余的入参:success、fail和complete。success回调会在操作成功完成时调用,fail会在操作失败的时候执行,complete则无论如何都会执行。setStorage函数只会在key值是String类型时有正确的行为,所以我们为这个函数添加了一个简单的类型判断,并在异常情况下执行fail回调。经过这轮变动,这段代码看起来会像下面这样:
/* 示例代码 */function setStorage({ key, value, success, fail, complete }) {let res = { errMsg: 'setStorage:ok' }if (typeof key === 'string') {localStorage.setItem(key, value);success && success(res);} else {fail && fail(res);return Promise.reject(res);}complete && complete(res);return Promise.resolve({ errMsg: 'setStorage:ok' });}
这个函数的最终版本可以在 Taro 仓库中找到。
把这个 API 实现挂载到Taro模块之后,我们就可以通过Taro.setStorage来调用这个 API 了。
当然,也有一些 API 是 Web 端无论如何无法实现的,比如wx.login,又或者wx.scanCode。我们维护了一个 API 实现情况的列表,在实际的多端项目开发中应该尽可能避免使用它们。
路由
作为小程序的一大能力,小程序框架中以栈的形式维护了当前所有的页面,由框架统一管理。用户只需要调用wx.navigateTo,wx.navigateBack,wx.redirectTo等官方 API,就可以实现页面的跳转、回退、重定向,而不需要关心页面栈的细节。但是作为多端项目,当我们尝试在 Web 端实现路由功能的时候,就需要对小程序和 Web 端单页应用的路由原理有一定的了解。
小程序的路由比较轻量。使用时,我们先通过app.json为小程序配置页面列表:
{"pages": ["pages/index/index","pages/logs/logs"],// ...}
在运行时,小程序内维护了一个页面栈,始终展示栈顶的页面(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的精髓之一,单向数据流。
@tarojs/router包中包含了一个轻量的history实现。history中维护了一个栈,用来记录页面历史的变化。对历史记录的监听,依赖两个事件:hashchange和popstate。
/* 示例代码 */window.addEventListener('hashchange', () => {});window.addEventListener('popstate', () => {})
对于使用 Hash 模式的页面路由,每次页面跳转都会依次触发popstate和hashchange事件。由于在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.replaceStateAPI,在 state 中记录了页面的跳转次数。从而可以在popstate中推断导致跳转的具体行为。具体可以在这里看到相关实现。
@tarojs/router实现中还有一些小细节需要处理。比如如何加入compomentDidShow之类原本不存在的生命周期?
我们选择在运行时进行这个操作。对于在入口config中注册的页面文件,我们继承了页面类并对componentDidMount做了改写,简单粗暴地插入了componentDidShow的调用。
Redux 处理
每当提到React的数据流,我们就不得不提到Redux。通过合并Reducer,Redux可以让大型应用中的数据流更加规则、可预测。
我们在Taro中加入了Redux的支持,通过导入@tarojs/redux,即可在小程序端使用Redux的功能。
对于 Web 端,我们尝试直接使用nerv-redux包提供支持,但这会带来一些问题。
我们使用与下面类似的代码:
import Nerv from 'nervjs'import { connect } from 'nerv-redux'@connect(() => {})class Index extends Nerv.Componnet {componentDidShow() { console.log('didShow') }componentDidMount() { console.log('didMount') }render() { return '' }}
但这个componentDidShow并没有执行。为什么?
回想一下前面讲的componentDidShow的实现:我们继承,并且改写 componentDidMount。
但是对于使用Redux的页面来说,我们继承的类,是经过@connect修饰过的一个高阶组件。
问题就出在这里:这个高阶组件的签名里并没有componentDidShow这一个函数。所以我们的 componentDidMount 内,理所当然是取不到componentDidShow的。
为了解决这个问题,我们对react-redux代码进行了一些小改装,这就是@taro/redux-h5的由来。
小结
这个章节对 H5 端的运行时环境进行了解析,包括组件库的原理和实现,还有端能力 API 的实现。
看完这篇文章,你可能就对Taro解决问题的两个方式非常熟悉了,无非就是编译时与运行时。
说起来可能非常简单,但这并不意味着实现起来也很简单。需要对小程序原生 API 功能、交互等进行透彻的分析和细心的实现。无论这其中有多少坑多少工作量,只要是为了提升开发体验,我们认为都是值得的。
