Skip to content

跨端原理认知篇:跨端框架实现原理

一 前言

本章节主要介绍前端跨端应用的两种主流实现—基于编译时实现和基于运行时实现,然后在后面的两章节中, 会以 Taro 框架为例子,介绍 Taro 的使用和原理。

跨端开发现状

目前市面上主流的跨端方式采用的模式如出一辙。大体上的步骤是这样的:

第一步选择一个主流的框架 Vue, React 或者小程序语法作为 DSL。 第二步通过编译时和运行时的方式,让跨端应用运行起来。 第三步选择 Native 或者 webView 渲染模式进行渲染。

遵循如上的思路,很多大厂也开源了许多优秀的跨端框架,比如京东 Taro, 美团的 App, 阿里的 Rax 等等。我们可以用这些框架更便携的开发 webview h5, 小程序,甚至 Native 应用。

在这些 DSL 框架实现过程中,基本都是采用编译时运行时的方式实现的, 那么接下来我就以编译时和运行时为切入点,聊一下实现细节。

二 运行时能力

什么是运行时能力?

在前面的章节中,讲到跨端实现,在 JS 层会处理业务逻辑,最终形成一棵类似虚拟 DOM 树(够描述视图结构的对象),接下来通过指令的方式交给 Native 或者是 web 来渲染。

在构建虚拟 DOM 树过程中,通常采用一些框架基础库的能力,比如 React 的 element 结构和 fiber 树,或者是 Vue 中 template 模版和 vnode, 或者小程序基础库。这些基础库能力能够让前端开发者快速上手,开发者可以利用框架本身的特性完成复杂的业务交互,简单的说就是应用了框架运行时的能力。

运行时能力实现?

那么运行时是如何实现的呢? 一般情况下,运行时跨端框架会保存 web 框架的部分原生能力,比如 React Reconciler 调和能力,Vue 的响应式能力;还可以加上一切跨端兼容适配能力来实现跨端。

比如想通过 React 框架实现移动端小程序和 Native 端的动态化,那么通过运行时的思路大体如下:

  • 首先保存 React 的核心库 React , React 库下面的 element 能力,可以配合 babel 完成 JSX 语法的实现,来描述视图结构。
  • 接下来可以改造 React Reconciler, 可以定制化解析视图,调和形成虚拟 DOM 节点。
  • 最后要完成关键的一步,因为跨端环境一般都不是浏览器环境,没有 DOM 和 DOM 相关的 Api, 所以要实现一套核心的兼容层 Adapter 模块,是将 React 能力扩展到新渲染环境的桥梁。

还是用 React 举例子,来看一下可以用 React 的哪些运行时能力。

React Reconciler 适配层:

比如跨端框架想要使用 React 描述视图的结构 JSX 和其他能力,但是对于虚拟 DOM 想要通过独立的方式实现,那么就可以适配独立的 React Reconciler 层。来看一下核心实现原理:

js
import React from 'react'

function App(){
    const [ number, setNumber ] = React.useState(0)
    return <View>
        大前端跨端开发指南
        <Button>点赞</Button>
    </View>
}

如上是一段使用 React 的 Element 和 hooks 能力的代码。那么注册 App 的时候,就不能引用 React DOM , 而是我们写的自定义调和渲染器 myReconciler ,伪代码如下所示:

js
import React from 'react'
import { myReconcilerDOM } from 'my-reconciler'
import { App } from './app'

/* 自定义渲染器渲染*/
myReconcilerDOM.render(<App />)

myReconcilerDOM 内部的实现,可以遵循 React Reconciler 原则,代码如下:

js
/* 自定义的调和渲染器 */
const ReconcilerRenderer = {
  updateContainer(){},
  createContainer(){},
  beginWork(){},
  completeWork(){},
})

let container

export const myReconcilerDOM = {
  render (element) {
    if (!container) {
      container = ReconcilerRenderer.createContainer()
    }
    ReconcilerRenderer.updateContainer(element, container, null)
  }
}

如上可以看到跨端框架可以自己实现一套调和渲染器,里面的方法与 react-reconciler 对其。 在 React Native 中,就是自己实现了一套适配 Native 端的调和渲染器。如下所示:

React DOM 适配层:

在 RN 里面,重写了 Reconciler 中的核心方法,包括 beginWork 和 completeWork 等等 API,在 RN 中渲染本质上是通过桥的方式,向 Native 发送渲染指令。。当然我们也可以继续使用 react-reconciler ,而对其中的 DOM 部分进行改造,这要依赖于 React 框架一些良好的特性,比如在 Taro React 中,会改变 reconciler 中涉及到 DOM 操作的部分,我们来看看部分改动:

js
import Reconciler, { HostConfig } from 'react-reconciler'
/* 引入 taro 中兼容的 document 对象 */
import { document, TaroElement, TaroText } from '@tarojs/runtime'
const hostConfig = {
   /* 创建元素 */ 
   createInstance (type) {
    return document.createElement(type)
  },
   /* 创建文本 */
  createTextInstance (text) {
    return document.createTextNode(text)
  },
  /* 插入元素 */
  appendChild (parent, child) {
    parent.appendChild(child)
  },
  ...
}

这个是 taro 对 react-reconciler 的改动,在 react-reconciler 中,HostConfig 里面包含了所有有关真实 DOM 的操作,taro reconciler 会通过向 tarojs/runtime 引入 document 的方式,来劫持原生 DOM 中的 document,然后注入兼容好的方法,这样的话,当 fiber 操作 DOM 的时候,本质上是使用 Taro 提供了方法。

通过运行时生成虚拟 DOM 只是第一步,接下来还有重要的一步,就是把虚拟 DOM 映射成真正的视图。

  • 在 web 端可以用对应的元素 DOM 方法动态生成元素节点。
  • 在 Native 端可以通过各种指令向安卓或者 iOS 发送渲染指令,实现动态化。
  • 小程序动态化渲染比较麻烦,因为小程序平台,没有提供动态化插入元素的 api, 小程序的视图和 js(appService) 分别在不同的线程中运行,只有 appService 能获取外部的数据,即动态获取更新的配置 ,而唯一更新视图渠道即是通过页面/组件内的 setData 方法,将数据传递给小程序视图层。视图层支持条件渲染及列表渲染的,使用条件渲染(wx:if)及列表渲染(wx:for)的特性,即可根据 DSL 中指定的标签,渲染出不同的元素。

小程序动态模版引入

小程序支持 Template(模板代码复用)和 Component(自定义组件)。其中 Template 能够无任何副作用使用,但是不能递归。但是 Component 可以递归复用,所以可以通过 Template 层级调用+ Component 递归调用实现动态渲染模版。

这里在动态化基础库有一个 dynamic.wxml 组件,虚拟 DOM 可以通过加载动态化模版,来渲染页面。dynamic.wxml 内部如下所示:

js
/* 第二层 for 渲染子元素 */
<template name="view" >
  <view wx:if="children" >
    <!-- 遍历 children 元素,如果达到一定层级,那么递归 dynamic 组件 -->
  </view>
</template>

/* 第一层渲染 view 元素  */
<block if="{{ data.type === 'view' }}" >
   <template is='view' data="{{ data.children }}"  />
</block>

/* 第一层渲染 text 元素  */
<block if="{{ data.type === 'text' }}" >
   <template is='text' data="{{ data.children }}"  />
</block>

其原理如上所示,第一层渲染真实的元素节点,第二层渲染第一层元素的子元素,第三层渲染子元素的子元素,当达到第 n 层的时候,递归渲染 dynamic 动态组件。这样就可以动态渲染虚拟 DOM,映射成真正的小程序视图了。

跨端兼容层:

跨端框架可以运行在多个端,在不同平台 Platform 需要自定义适配。比如上面的 React 跨端兼容框架,在运行时的大部分代码是可以复用的,但是也有差异化的地方,是需要对于不同的平台,做一个 adapter 兼容适配层。

这里看一下 Taro 平台的兼容方案:

可以看到对于不同的平台,Taro 有专门的 npm 包来处理。

Vue 也有经典的跨端方案,比如美团的 mpvue, mpvue 的实现方式,也是通过 platforms 自定义适配方式处理的,我们来看一下:

js
platforms // 适配不同的平台层
├── mp
│   ├── compiler  // 编译时适配
│   │   ├── codegen
│   │   ├── directives
│   │   └── modules
│   ├── runtime // 运行时适配
│   └── util
├── web
│   ├── compiler   // 编译时适配
│   │   ├── directives
│   │   └── modules
│   ├── runtime  // 运行时适配
│   │   ├── components
│   │   ├── directives
│   │   └── modules
│   ├── server
│   │   ├── directives
│   │   └── modules
│   └── util
//...

mpvue 对于不同的平台,分别有独立的编译时运行时 适配层。对于渲染层入口,是独立于 Vue 的 core 模块,这里来看一下对于微信小程序的适配,渲染视图层,mpvue 是通过编译层来适配的,把 vue 文件,转化成小程序的 wxml 的。

所以在 mpvue 中,不需要兼容 vue 中的 DOM 元素兼容方法。来看一下 runtime/node-ops ,这个兼容了 vnode 模块。

js
// runtime/node-ops.js
const obj = {}

export function createElement (tagName: string, vnode: VNode) {
  return obj
}
export function createTextNode (text: string) {
  return obj
}

export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {}
export function removeChild (node: Node, child: Node) {}
export function appendChild (node: Node, child: Node) {}

因为在小程序中,很难通过 API 的方式接管渲染层(没有采用上面的动态组件的方案),所以在 mpvue 中的 vnode 是不需要有额外的渲染动作的,所以有关渲染的方法没有任何实质作用,基本都是空函数,本质上是通过一套代码同时生成 Vue 与小程序的两棵组件树并设法保持其同步。除此之外,mpvue 也对运行时,做了其他方便的适配,比如:

  • runtime/events 模块中渲染层事件到 Vue 中事件的转换。
  • runtime/lifecycle 模块中渲染层与 Vue 生命周期的同步。
  • runtime/render 模块中对小程序 setData 渲染的支持与优化。

三 编译时能力

上面介绍了跨端原理运行时的能力,接下来我们看一下编译时有哪些能力。

什么是编译时能力?

在跨端应用中,可以通过编译的方式,来做兼容层适配,可以把代码适配到多端,比如把 Vue 或者 React 转换成小程序代码;一般场景下,跨端框架的 cli 提供了适配多端能力。比如:

js
npm run dev:wx  // 编译成微信端代码
npm run dev:h5  // 编译成 h5 端代码
npm run dev:rn  // 编译成 rn 端代码

如上通过不同命令,会将业务代码通过编译的方式,转换成不同平台的代码,当然像如上 taro 和 mpvue 一下,跨端框架需要提供不同平台 compiler 。

编译时实现?

编译时一般依赖于 babel 工具链,通过 parse 解析成对应的 AST ,然后修改 AST 语法树, 接下来通过 generate 生成对应平台的代码。接下来以 mpvue 为例子,看一下 compiler 流程。

如上就是 mpvue 对小程序平台的兼容项目目录,其中有不同小程序的转换。这里例举一下把 vue 件转化成 wxml 文件的流程。我们来看一下:

js
function compileToMPML (compiled, options, fileExt) {
  let code
  switch (fileExt.platform) {
    case 'swan':
      code = codeGenSwan(compiled, options)
      break
    case 'wx': // 转化成 wx 小程序 wxml 文件
      code = codeGenWx(compiled, options)
      break
    case 'tt':
      code = codeGenTt(compiled, options)
      break
    case 'my':
      code = codeGenMy(compiled, options)
      break
    default:
      code = codeGenWx(compiled, options)
  }
  return code
}

如上可以看到,mpvue 的编译层,可以根据不同的平台,编译成不同的小程序。来看一下 codeGenWx 的具体实现:

js
export default function compileToMPMLCommon (compiled, options = {}, getAst) {
  const { components = {}} = options
  // 解析生成对应的 ast 语法树
  const { wxast, deps = {}, slots = {}} = getAst(compiled, options, log)
  // 生成对应的目标代码
  let code = generateCode(wxast, options)
  // 引用子模版,将目标代码注到模版中
  const importCode = Object.keys(deps).map(k => components[k] ? `<import src="${components[k].src}" />` : '').join('')
  code = `${importCode}<template name="${options.name}">${code}</template>`
  return { code, compiled, slots, importCode }
}

整个流程如下所示:首先通过 getAst 解析生成对应的 ast 语法树,然后通过 generateCode 生成对应的目标代码,最后引用子模版,将目标代码注到模版中。

在解析过程中,有标签的映射,把 h5 中的元素标签,映射成小程序的标签,如下所示:

js
export default {
  'br': 'view',
  'hr': 'view',
  'p': 'view',
  'h1': 'view',
  'h2': 'view',
  'h3': 'view',
  'h4': 'view',
  'h5': 'view',
  ...
}

还有一些指令的映射,如下所示:

js
export default {
  'if': 'wx:if',
  'iterator1': 'wx:for-index',
  'key': 'wx:key',
  'alias': 'wx:for-item',
  'v-for': 'wx:for'
}

如上通过编译模式,就可以把 vue 模版转换成小程序模版了。

主流跨端框架原理:

传统的跨端框架,并不是单纯的选择运行时方式或者是编译时方式,而是结合两者共同完成跨端任务。用运行时保留了框架原本活性,用编译时磨平各个平台的差异化。比如 Taro 框架,在老版本采用轻运行时重编译的模式,而在新版本 Taro 保留了原生框架更多的特性,采用了轻编译重运行时的方案。

四 总结

本章节介绍了跨端框架实现的原理—运行时和编译时两种方式,如果有哪里不明白的读者,可以在章节下面给我评论留言,在下面的章节中,我们将介绍 Taro 的应用与原理

前端知识体系 · wcrane