Skip to content
微信公众号

H5运行时

Taro将 JS 代码转换为 AST 后,进行了诸如将data换成state,把componentDidMount改写成onReady等等的操作,再把修改后的 AST 转换成适合小程序执行的源码。但上面这些工作,距离生成一个开箱即用的 H5 项目,距离我们的最终目标Write once, run anywhere还远远不够,因为各平台不尽相同的运行时框架、组件标准、API 标准和运行机制,除了在编译时进行多端转换,我们还需要在运行时抹平多端的差异。

H5 运行时解析

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

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

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

js
import Taro,{Component} from '@tarojs/taro'
import {View,Text,Button} from '@tarojs/components'
import './A.scss'

export default class A extends Component{
    componentWillMount(){}
    componentDidMount(){}
    componentWillReceiveProps(nextProps){}
    shouldComponentUpdate(){}
    componentWillUnmount(){}
    componentDidShow(){}
    componentDidHide(){}
    onClickHandler(){
        this.props.onClick()
    }
    render(){
        return (
            <View className='a'>
                <Button onClick={this.onClickHandler.bind(this)}>点我</Button>
                <Text>a component{this.props.t}</Text>
            </View>
        )
    }
}

我们注意到,就算是转换过的代码,也依然存在着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非常像。babel把基于新版 ECMAScript 规范的代码转换为基于旧 ECMAScript 规范的代码,而Taro希望把基于React语法的代码转换为小程序的语法。我们从babel受到了启发:既然babel可以通过运行时框架来实现新特性,那我们也同样可以通过运行时代码,实现上面这些 Web 开发中不存在的功能。

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

js
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属性的处理也十分类似,就不再赘述。这些属性的实现比起前面的代码会稍微复杂一点点,但绝对没有超纲。

js
// 示例代码
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 的具体入参出参:

参数类型必填说明
keyString本地缓存中的指定的 key
dataObject/String
successFunction接口调用成功的回调函数
failFunction接口调用失败的回调函数
completeFunction接口调用结束的回调函数(调用成功、失败都会执行)

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

js
/* 示例代码 */
function setStorage({ key, value }) {
  localStorage.setItem(key, value);
}

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

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

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

js
/* 示例代码 */
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' });
}

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

当然,也有一些 API 是 Web 端无论如何无法实现的,比如wx.login,又或者wx.scanCode。

路由

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

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

js
{
  "pages": [
    "pages/index/index",
    "pages/logs/logs"
  ],
  // ...
}

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

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

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

路由方式触发时机路由前页面路由后页面
初始化小程序打开的第一个页面onLoad, onShow
打开新页面调用 API wx.navigateTo 或使用组件 navigatoronHide
页面重定向调用 API wx.redirectTo 或使用组件 navigatoronUnloadonLoad, onShow
页面返回调用 API wx.navigateBack 或使用组件 navigator 或用户按左上角返回按钮onUnloadonShow
重启动调用 API wx.reLaunch 或使用组件 navigatoronUnloadonLoad, 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。

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

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

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

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

TIP

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

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

TIP

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

Redux 处理

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

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

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

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

js
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的由来。

本站总访问量次,本站总访客数人次
Released under the MIT License.