Skip to content

15从编译到运行,跨端解析小程序多端方案

客观来说,小程序在用户规模及商业化方面的巨大成功,并不能掩盖其技术环节的设计问题和痛点。从孱弱简陋的小程序开发体验,到整体架构实现,再到小程序 APIs 碎片化现状,就注定了小程序多端方案层出不穷,展现出百家争鸣的局面。

欣欣向荣的小程序多端方案背后有着深广且有趣的技术话题,这一讲,就让我们一同解析小程序多端方案技术。

小程序生态如今已经如火如荼地开展开来,自腾讯微信小程序后,各巨头也纷纷建立起自己的小程序。这些小程序的设计原理类似,但是对于开发者来说,开发层面并不互通。在此背景下,效率为先,也就有了各种小程序多端方案。

小程序多端方案的愿景很简单,就是使用一种 DSL,可以"write once,run evrywhere",这也就不再需要开发完微信小程序,再开发头条小程序、百度小程序。小程序多端方案也许听起来很神奇,但技术实现上我们可以大体划分为三类:

  • 编译时方案

  • 运行时方案

  • 编译时和运行时的结合方案

事实上,单纯的编译时方案或运行时方案都不能完全满足跨端需求,因此两者结合而成的第三种------编译时和运行时的结合方案,是目前的主流技术。

基于以上技术方案,小程序多端方案最终对外提供的使用方式可以分为:

  • 类 Vue 风格框架

  • 类 React 风格框架

  • 自定义 DSL 框架

下面我们将具体深入小程序多端方案的实现。

小程序多端------编译时方案

顾名思义,编译时方案的工作量主要集中在编译转化环节 上。这类多端框架在编译阶段,基于 AST(抽象语法树)技术进行各平台小程序适配。

目前社区上存在较多基于 Vue DSL 和 React DSL 的静态编译方案。其实现理念类似,但也有区别,我们分开来看。

Vue DSL 静态编译

Vue 的设计风格和各小程序设计风格更加接近,因此 Vue DSL 静态编译方案相对容易。Vue 中单文件组件主要由:

  • template

  • script

  • style

组成,它分别对应了小程序中的:

  • .wxml 文件,template 文件

  • .js 文件,.json 文件

  • .wxss 文件

其中,因为小程序基本都可以接受 H5 环境中的 CSS,因此style 部分基本可以直接平滑迁移。template 转换为 .wxml 文件,需要进行 HTML 标签、模版语法的转换。以微信小程序举例,转换目标如下图:

编译过程图

那么上图表述的编译过程具体应该如何实现呢?可能你会想到正则,但正则的能力有限,复杂度也较高。更普遍的做法,如 mpvue、uni-app 等,都依赖了 AST(抽象语法树)技术。AST(抽象语法树)其实并不复杂,Babel 生态就为我们提供了很多开箱即用的 AST 分析和操作工具。下图是一个简单的 Vue 模版经过 AST 分析后的产出:

对应模版代码:

java
<a><b v-if="a" /></a>

经过 AST 解析为:

java
type: 1
tag: a
attrsList: []
attrsMap: {}
rawAttrsMap: {}
children:
  - type: 1
    tag: b
    attrsList: []
    attrsMap:
      v-if: a
    rawAttrsMap: {}
    children: []
    if: a
    ifConditions:
      - exp: a
        block: '[Circular ~.children.0]'
    plain: true
    static: false
    staticRoot: false
    ifProcessed: true
plain: true
static: false
staticRoot: false

基于以上类似 JSON 一般的 AST 产出结果,我们可以生成小程序指定的 DSL。整体流程如图:

熟悉 Vue 原理的同学可能会知道,Vue 中 template 会被 vue-loader 编译,我们的小程序多端方案就需要将 Vue 模版编译为小程序 .wxml 文件,思路异曲同工。可是,也许你会有疑问:Vue 中的 script 部分,怎么和小程序结合呢?这就需要在小程序运行时下文章功夫了,请继续阅读。

小程序多端------运行时方案

前面我们介绍了 Vue 单文件组件的 template 编译过程,而 script 部分的处理会更加困难。试想,对于一段 Vue 代码,我们通过响应式理念监听数据变化,触发视图修改,放到小程序中,多端方案要做的就是监听数据变化,调用 setData() 方法,触发小程序渲染层变化。

一般在 Vue 单文件组件的 script 部分,我们会使用以下代码:

java
new Vue({
  data() {},
  methods: {},
  components: {}
})

来初始化一个 Vue 实例。对于多端方案来说,就完全可以引入一个 Vue 的运行时版,对上述代码进行解析和执行。事实上,mpvue 就是 fork 了一份 Vue.js 的代码,因此内置了 Vue runtime 能力,同时添加了小程序平台的支持。

具体还需要做哪些小程序平台特性支持呢?举一个例子,以微信小程序为例,微信小程序平台规定,小程序页面中需要有一个 Page() 方法,以生成一个小程序实例,其中 Page() 方法是小程序官方提供的 API。

那么对于业务方写的new Vue()代码,多端平台要手动执行微信小程序平台的 Page(),完成初始化处理,如下:

经过上述步骤,我们的多端方案内置了 Vue 运行时版,并实例化了一个 Vue 实例,同时在初始阶段调用了小程序平台的 Page() 方法,因此也就有了一个小程序实例。

下面的工作,就是在运行时将 Vue 实例和小程序实例进行关联,以做到:数据变动时,小程序实例能够调用 setData() 方法,进行渲染层更新。

思想确立后,如何实施呢?首先这就需要你对 Vue 原理足够清楚了:Vue 基于响应式,对数据进行监听,在数据改动时,新生成一份虚拟节点 VNode。接下来对比新旧两份虚拟节点,找到 Diff,并进行 patch 操作,最终更新了真实的 DOM 节点

因为小程序架构中,并没有提供操作小程序节点的 API 方法,因此对于小程序多端方案,我们显然不需要进行 Vue 源码中的 patch 操作。又因为小程序隔离了渲染进程(渲染层)和逻辑进程(逻辑层),我们不需要处理渲染层,只需要调用 setData() 方法,更新一份最新的数据就可以了。

因此,借助 Vue 现有的能力,我们秉承"数据部分让 Vue 运行时版接手,渲染部分让小程序架构接手"的理念,就能实现一个类 Vue 风格的多端框架。框架原理如图:

类 Vue 风格的多端框架原理图

当然,整个框架的设计还要考虑事件处理等模块,我们就不再具体展开。

至此,编译时和运行时方案组合在一起,我们就实现了一个类 Vue 风格的小程序多端框架的技术方案架构。目前社区上都是采用了这一套技术架构方案,但是不同框架有各自的特点,比如网易考拉 Megalo 在上述方案的基础上,将整个数据结构进行了扁平化,目的是在调用 setData() 方法时,可以获得更好的性能

探索并没有到此为止,事实上,类 React 的小程序多端方案架构虽然道理和类 Vue 方案差不多,也需要将编译时和运行时相结合,但很多重要环节的处理却更加复杂,这是怎么回事呢?我们继续探索。

小程序多端------类 React 风格的编译时和运行时结合方案

类 React 风格的小程序多端方案,存在多项棘手的问题,其中之一就是:如何将 JSX 转换为小程序模版?

我们知道不同于 Vue 模版理念,React 生态选择了 JSX 来表达视图,但是 JSX 过于灵活,单纯基于 AST(抽象语法树)技术很难进行一对一转换。比如:

js
function CompParent({children, ...props}) {
  return typeof children === 'function' ? children(props) : null
}
function Comp() {
  return (
    <CompParent>
      {props => <div>{props.data}</div>}
    </CompParent>
  )
}

这段代码是 React 中,利用 JSX 表达能力实现的 Render Prop 模式,这也是静态编译的噩梦:如果不将代码运行,很难计算出需要表达的视图结果

针对这个"JSX 处理"问题,类 React 风格的小程序多端方案就可以分成两个流派:

  • 强行静态编译型,代表有:京东的 Taro 1/2,去哪儿的 Nanachi 等;

  • 运行时处理型,代表有:Taro Next,蚂蚁的 Remax。

强行静态编译型需要业务使用方在编写代码时,规避掉一些难以在编译阶段处理的动态化的写法,因此这类多端框架说到底是使用了限制的、阉割版的 JSX。比如在早期 Taro 版本的文档中,就有了清晰的说明:

因此,我认为强行静态编译 JSX 是一条死胡同,并不是一个完美的解决方案。事实上,Taro 发展到了 v3 版本之后,也意识到了这个问题,所以和蚂蚁 Remax 方案一样,Taro 新版本进行了架构升级,在运行时增加了对 React JSX 以及后续流程处理。具体是怎么做到的呢?请你继续阅读。

React 设计理念助力多端小程序起飞

我认为在运行时开发者能够处理 React JSX 的核心基础其实在于 React 的设计理念,React 将自身能力充分解耦,并提供给社区接入关键环节。这里我们需要先进行一些 React 原理解析。

React 核心理念可以分为三大部分:

  • React Core:处理最核心的 APIs,与终端平台和渲染解耦,主要提供了下面这些能力:

    1. React.createElement()

    2. React.createClass()

    3. React.Component

    4. React.Children

    5. React.PropTypes

  • React Renderer:渲染器定义了一个 React Tree 如何构建接轨不同平台,比如:

    1. React-dom 渲染组件树为 DOM elements;

    2. React Native 渲染组件树为不同原生平台视图。

  • Reconciler:负责 diff 算法,接驳 patch 行为。可以被 React-dom、React Native、React ART 这些 renderers 共用,并提供基础计算能力。现在 React 主要有两种类型的 reconcilers:

    1. Stack reconciler,React 15 以及更早期 React 版本使用;

    2. Fiber reconciler,新一代的架构。

更多基础内容,如 React Components、React Instances、React Elements,我们就不再一一展开。这里需要你了解的是:

  • React team 将 Reconciler 部分作为一个独立的 npm 包(react-reconciler 发布);

  • 在 React 环境下,不同平台,可以依赖一个 hostConfig 配置,和 react-reconciler 互动,连接并使用 Reconciler 能力;

  • 因此,不同平台的 renderers 在 HostConfig 中内置基本方法,即可构造自己的渲染逻辑。

核心架构可以总结为下图:

React 的 Reconciler 并不关心 renderers 中的节点是什么形状,只会把这个计算结果透传到 HostConfig 中定义的方法中,我们在这些方法(比如 appendChild、removeChild、insertBefore)中,完成渲染的准备和目的。而 HostConfig 其实就是一个对象:

java
const HostConfig = {
  //TODO We will specify all required methods here
}

翻看 react-reconciler 源码,可以总结出,完整的 hostConfig 包含了:

java
HostConfig.getPublicInstance
HostConfig.getRootHostContext
HostConfig.getChildHostContext
HostConfig.prepareForCommit
HostConfig.resetAfterCommit
HostConfig.createInstance
HostConfig.appendInitialChild
HostConfig.finalizeInitialChildren
HostConfig.prepareUpdate
HostConfig.shouldSetTextContent
HostConfig.shouldDeprioritizeSubtree
HostConfig.createTextInstance
HostConfig.scheduleDeferredCallback
HostConfig.cancelDeferredCallback
HostConfig.setTimeout
HostConfig.clearTimeout
HostConfig.noTimeout
HostConfig.now
HostConfig.isPrimaryRenderer
HostConfig.supportsMutation
HostConfig.supportsPersistence
HostConfig.supportsHydration
// -------------------
//      Mutation
//     (optional)
// -------------------
HostConfig.appendChild
HostConfig.appendChildToContainer
HostConfig.commitTextUpdate
HostConfig.commitMount
HostConfig.commitUpdate
HostConfig.insertBefore
HostConfig.insertInContainerBefore
HostConfig.removeChild
HostConfig.removeChildFromContainer
HostConfig.resetTextContent
HostConfig.hideInstance
HostConfig.hideTextInstance
HostConfig.unhideInstance
HostConfig.unhideTextInstance
// -------------------
//     Persistence
//     (optional)
// -------------------
HostConfig.cloneInstance
HostConfig.createContainerChildSet
HostConfig.appendChildToContainerChildSet
HostConfig.finalizeContainerChildren
HostConfig.replaceContainerChildren
HostConfig.cloneHiddenInstance
HostConfig.cloneUnhiddenInstance
HostConfig.createHiddenTextInstance
// -------------------
//     Hydration
//     (optional)
// -------------------
HostConfig.canHydrateInstance
HostConfig.canHydrateTextInstance
HostConfig.getNextHydratableSibling
HostConfig.getFirstHydratableChild
HostConfig.hydrateInstance
HostConfig.hydrateTextInstance
HostConfig.didNotMatchHydratedContainerTextInstance
HostConfig.didNotMatchHydratedTextInstance
HostConfig.didNotHydrateContainerInstance
HostConfig.didNotHydrateInstance
HostConfig.didNotFindHydratableContainerInstance
HostConfig.didNotFindHydratableContainerTextInstance
HostConfig.didNotFindHydratableInstance
HostConfig.didNotFindHydratableTextInstance

React reconciler 阶段会在不同的时机,调用上面这些方法。比如在 reconciler 阶段新建节点时会调用 createInstance 等方法;在提交阶段创建新的子节点时,调用 appendChild 方法。

依照 React 支持 web 和原生(React Native)的思路,如下图:

你可以类比出一套更好的 React 支持多端小程序的架构设计,如下图:

我们知道类 Vue 风格的多端框架,可以将 Vue template 编译为小程序模版。那么有了数据,类 React 风格的多端框架,在初始化时如何渲染出来页面呢?

以 Remax 为例,上图所示 VNodeData 数据中,包含了节点信息,比如 type="view",我们可以通过递归 VNodeData 这个数据结构,根据不同的 type 渲染出不同的小程序模版

总结一下,在初始化阶段以及第一次 mount 时,我们通过 setData() 方法初始化小程序。具体是通过递归数据结构,渲染小程序页面。接着,在数据发生变化时,我们通过 React reconciler 阶段的计算信息,以及自定义配置的 HostConfig 衔接函数,更新数据,并通过 setData() 方法触发小程序的渲染更新。

了解了类 React 风格的多端方案架构设计,我们可以结合实际框架实现,来进一步巩固思想,看一看实践中,开源方案的实施情况,请继续阅读。

剖析一款"网红"框架 ------ Taro Next

在 2019 年 GMTC 大会上,京东 Taro 团队介绍了《小程序跨框架开发的探索与实践》,其中的 v3 理念就与上述思路吻合(目前仍然在版本开发中:NervJS-taro)。在分享中的一处截图如下:

由上图即可推知:Taro 团队提供的 taro-react包,是用来连接 React reconciler 和 taro-runtime 的。它主要负责:

  • 实现 HostConfig 配置

  • 实现 render 函数

比如,HostConfig 在 taro-react 源码中的实现为:

java
const hostConfig: HostConfig<
  string, // Type
  Props, // Props
  TaroElement, // Container
  TaroElement, // Instance
  TaroText, // TextInstance
  TaroElement, // HydratableInstance
  TaroElement, // PublicInstance
  object, // HostContext
  string[], // UpdatePayload
  unknown, // ChildSet
  unknown, // TimeoutHandle
  unknown // NoTimeout
> & {
  hideInstance (instance: TaroElement): void
  unhideInstance (instance: TaroElement, props): void
} = {
  // 创建 element 实例
  createInstance (type) {
    return document.createElement(type)
  },
  // 创建 text node 实例
  createTextInstance (text) {
    return document.createTextNode(text)
  },
  getPublicInstance (inst: TaroElement) {
    return inst
  },
  getRootHostContext () {
    return {}
  },
  getChildHostContext () {
    return {}
  },
  // appendChild 方法实现
  appendChild (parent, child) {
    parent.appendChild(child)
  },
  // appendInitialChild 方法实现
  appendInitialChild (parent, child) {
    parent.appendChild(child)
  },
  // appendChildToContainer 方法实现
  appendChildToContainer (parent, child) {
    parent.appendChild(child)
  },
  // removeChild 方法实现
  removeChild (parent, child) {
    parent.removeChild(child)
  },
  // removeChildFromContainer 方法实现
  removeChildFromContainer (parent, child) {
    parent.removeChild(child)
  },
  // insertBefore 方法实现
  insertBefore (parent, child, refChild) {
    parent.insertBefore(child, refChild)
  },
  // insertInContainerBefore 方法实现
  insertInContainerBefore (parent, child, refChild) {
    parent.insertBefore(child, refChild)
  },
  // commitTextUpdate 方法实现
  commitTextUpdate (textInst, _, newText) {
    textInst.nodeValue = newText
  },
  finalizeInitialChildren (dom, _, props) {
    updateProps(dom, {}, props)
    return false
  },
  prepareUpdate () {
    return EMPTY_ARR
  },
  commitUpdate (dom, _payload, _type, oldProps, newProps) {
    updateProps(dom, oldProps, newProps)
  },
  hideInstance (instance) {
    const style = instance.style
    style.setProperty('display', 'none')
  },
  unhideInstance (instance, props) {
    const styleProp = props.style
    let display = styleProp?.hasOwnProperty('display') ? styleProp.display : null
    display = display == null || typeof display === 'boolean' || display === '' ? '' : ('' + display).trim()
    // eslint-disable-next-line dot-notation
    instance.style['display'] = display
  },
  shouldSetTextContent: returnFalse,
  shouldDeprioritizeSubtree: returnFalse,
  prepareForCommit: noop,
  resetAfterCommit: noop,
  commitMount: noop,
  now,
  scheduleDeferredCallback,
  cancelDeferredCallback,
  clearTimeout: clearTimeout,
  setTimeout: setTimeout,
  noTimeout: -1,
  supportsMutation: true,
  supportsPersistence: false,
  isPrimaryRenderer: true,
  supportsHydration: false
}

以 insertBefore 方法为例:

java
insertBefore (parent, child, refChild) {
  parent.insertBefore(child, refChild)
},

parent 实际上是一个 TaroNode 对象,其 insertBefore 方法在 taro-runtime 中给出。taro-runtime 模拟了 DOM/BOM APIs,但是在小程序环境中,它并不能直接操作 DOM 节点,而是操作数据(即前文提到的 VNodeData,对应 Taro 里面的 TaroNode )。比如源码中,仍然以 insertBefore 方法举例,相关处理逻辑为

java
public insertBefore<T extends TaroNode> (newChild: T, refChild?: TaroNode | null, isReplace?: boolean): T {
    newChild.remove()
    newChild.parentNode = this
    // payload 数据
    let payload: UpdatePayload
    // 存在 refChild(TaroNode 类型)
    if (refChild) {
      const index = this.findIndex(this.childNodes, refChild)
      this.childNodes.splice(index, 0, newChild)
      if (isReplace === true) {
        payload = {
          path: newChild._path,
          value: this.hydrate(newChild)
        }
      } else {
        payload = {
          path: `${this._path}.${Shortcuts.Childnodes}`,
          value: () => this.childNodes.map(hydrate)
        }
      }
    } else {
      this.childNodes.push(newChild)
      payload = {
        path: newChild._path,
        value: this.hydrate(newChild)
      }
    }

    CurrentReconciler.insertBefore?.(this, newChild, refChild)
    this.enqueueUpdate(payload)
    return newChild
}

整体 Taro Next 的类 React 多端方案架构如图,出自《小程序跨框架开发的探索与实践》分享:

了解了不同框架风格(Vue 和 React)的多端小程序技术架构方案,并不意味着我们就能直接写出一个新的框架,和社区上成熟方案相争锋指日可待了。一个成熟的技术方案除了实现主体架构,还包括多方面的内容,比如性能优化。

如何在已有思路基础上,完成更好的设计,也值得开发者深思,我们将继续展开这个话题。

小程序多端方案优化方向

一个成熟的小程序多端方案要考虑的环节是立体的,比如以 kbone 为代表,运行时方案都是通过模拟 Web 环境来彻底对接前端生态,而 Remax 只是简单的通过 react reconciler 连接 React 和小程序。如何从更高的角度,衡量和理解小程序多端方案的更多技术方向,我们从下面几个话题来继续阐述。

性能优化方向

从前文我们可以了解到,小程序多端框架主要由编译时和运行时两部分组成,一般来说,编译时做的事情越多,下的功夫越大,也就意味着运行时越轻量,负担越小,因此性能也就会更好。比如,我们可以在编译时做到 AOT(Ahead of Time)性能调优、Dead Code Elimination 等。而厚重的运行时一般意味着需要将完整的组件树在逻辑层传输到视图层,也就导致数据传输量更大,且页面中会存在更多的监听器。

另一方面,随着终端性能的增强,找到编译时和运行时所承担工作的平衡点,也显得至关重要。以 mpvue 框架为主,一般编译时都会完成静态模版 的编译工作;而以 Remax 为代表,动态构建视图层表达就放在了运行时完成

在我看来,关于运行时和编译时的各中取舍,需要基于大量 benchmark 调研,也需要开发设计者广阔的技术视野和选型能力。除此之外,一般我们可以从以下几个方面来进一步实现性能优化。

  • 框架包 size。小程序的初始加载性能直接依赖于资源的包大小,因此小程序多端框架的包 size,至关重要。为此,各解决方案都从不同的角度完成瘦身,比如 Taro 力争实现更轻量的 DOM/BOM APIs,不同于 jsdom(size:2.1M),Taro 的核心的 DOM/BOM APIs 代码才 1000 行不到。

  • 数据更新粒度。在数据更新阶段,小程序的 setData() 所负载的数据一直是重要的优化方向,目前已经成为默认的常规手段,那么利用框架来完成 setData() 方法调用优化也就顺其自然了。比如数据负载的扁平化处理和增量处理,都是常见的优化手段。

未来发展方向

好的技术架构决定着未来发展潜力,上文我们提到了 React 将 React core、React-dom 等解耦,才奠定了现代化小程序多端方案的可行性。而小程序多端方案的设计,也决定着自身的未来应用空间。在此层面上,我认为开发者可重点考虑以下几个方面。

  • 工程化方案 。小程序多端需要有一体化的工程解决方案,在设计上可以与 Webpack 等工程化工具深度融合绑定,并对外提供服务。但需要兼顾关键环节的可插拔性,能够适应多种工程化工具,对于未来发展和当下应用场景来说,尤其重要。

  • 框架方案 。React 和 Vue 无疑是当前最重要的前端框架,目前小程序多端方案也都以二者为主。但是Flutter 和 Angular,甚至更小众的框架也应该得到重视。考虑到投入产出比,如果小程序多端团队难以面面俱到地支持这些框架和新 DSL,那么交给社区寻求支持,也是一个思路。比如,Taro 团队将支持的重点放在 React/Vue,而快应用以及 Flutter 和 Angular,暂且交给社区来适配和维护。

  • 跟进 Web 发展 。在运行时,小程序多端方案一般需要在小程序逻辑层中运行 React 或者是 Vue 的运行时版,然后通过适配层,实现自定义渲染器。这就要求设计开发者需要跟进 Web 发展及 Web 框架的运行时能力,且实现适配层。这无疑对技术能力和水平提出了较高要求。如何处理 Web 和 Web 框架的关系、如何保持兼容互通,决定了小程序多端方案的生死。

  • 渐进增强型能力 。无论是和 Web 兼容互通还是多种小程序之间的差异磨平,对于多端方案来说,很难从理论上彻底实现"write once,run evrywhere"。因此,这就需要框架级别上实现一套渐进增强能力 。这种能力,可以是语法或 DSL 层面的暂时性妥协/便利性扩展,也可以通过暴露全局变量,进行不同环境的业务分发。比如腾讯开源的 OMIX 框架:OMIX 有自己的一套 DSL,但整体保留小程序已有的语法。在小程序已有语法之上,OMIX 还进行了扩充和增强,比如引入了 Vue 中比较有代表性的 computed。

总结

这一讲我们针对小程序多端方案进行了原理层面的分析,同时站在更高的视角,对不同方案和多端框架进行了比对和技术展望。实际上,理解全部内容需要你对 React 和 Vue 框架原理有更深入的了解,也需要对编译原理和宿主环境(小程序底层实现架构)有清晰的认知。

本讲内容如下:

从小程序发展元年开始,到 2018 微信小程序的起飞,再到后续各大厂商快速跟进、各大寡头平台自建小程序生态,小程序现象带给我们的不仅仅是业务价值方面的讨论和启迪,也应该是对相关技术架构的巡礼和探索。作为开发者,我认为对技术的深度挖掘和运用,是能够始终矗立在时代风口浪尖的重要根基。

下一讲,我将带你分析 Flutter 和原生跨平台技术栈,同时梳理当下相关技术热点。跨平台其实是一个老生常谈的话题,技术方案也是历经变迁,但始终热点不断。下一讲的内容和今天的内容也有着千丝万缕的联系,别走开,我们下一讲再见!