淺析 React / Vue 跨端渲染原理與實現

doodlewind發表於2019-03-03

當下的前端同學對 React 與 Vue 的元件化開發想必不會陌生,RN 與 Weex 的跨界也常為我們所津津樂道。UI 框架在實現這樣的跨端渲染時需要做哪些工作,其技術方案能否借鑑乃至應用到我們自己的專案中呢?這就是本文所希望分享的主題。

概念簡介

什麼是跨端渲染呢?這裡的「端」其實並不侷限在傳統的 PC 端和移動端,而是抽象的渲染層 (Renderer)。渲染層並不侷限在瀏覽器 DOM 和移動端的原生 UI 控制元件,連靜態檔案乃至虛擬現實等環境,都可以是你的渲染層。這並不只是個美好的願景,在 8102 年的今天,除了 React 社群到 .docx / .pdf 的渲染層以外,Facebook 甚至還基於 Three.js 實現了到 VR 的渲染層,即 ReactVR。現在回顧 React 的 Learn Once, Write Anywhere 口號,實際上強調的就是它對各種不同渲染層的支援:

淺析 React / Vue 跨端渲染原理與實現

為什麼不直接使用渲染層的 API 呢?跨端開發的一個痛點,就在於各種不同渲染層的學習、使用與維護成本。而不管是 React 的 JSX 還是 Vue 的 .vue 單檔案元件,都能有效地解耦 UI 元件,提高開發效率與程式碼維護性。從而很自然地,我們就會希望使用這樣的元件化方式來實現我們對渲染層的控制了。

在開始介紹如何為 React / Vue 適配不同渲染層之前,我們不妨回顧一下它們在老本行 DOM 中執行時的基本層次結構。比如我們都知道,在瀏覽器中使用 React 時,我們一般需要分別匯入 reactreact-dom 兩個不同的 package,這時前端專案的整體結構可以用下圖簡略地表示:

淺析 React / Vue 跨端渲染原理與實現

很多前端同學熟悉的 UI 庫、高階元件、狀態管理等內容,實際上都位於圖中封裝後「基於 React 實現」的最頂層,連線 React 與 DOM 的 React DOM 一層則顯得有些默默無聞。而在 Vue 2.x 中,這種結構是類似的。不過 Vue 目前並未實現 React 這樣的拆分,其簡化的基本結構如下圖所示:

淺析 React / Vue 跨端渲染原理與實現

如何將它們這個為 DOM 設計的架構遷移到不同的渲染層呢?下文中會依次介紹這些實現方案:

  • 基於 React 16 Reconciler 的適配方式
  • 基於 Vue EventBus 的非侵入式適配方式
  • 基於 Vue Mixin 的適配方式
  • 基於 Vue Platform 定製的適配方式

React Reconciler 適配

之所以首先介紹 React,是因為它已經提供了成型的介面供適配之用。在 React 16 標誌性的 Fiber 架構中,react-reconciler 模組將基於 fiber 的 reconciliation 實現封裝為了單獨的一層。這個模組與我們定製渲染層的需求有什麼關係呢?它的威力在於,只要我們為 Reconciler 提供了宿主渲染環境的配置,那麼 React 就能無縫地渲染到這個環境。這時我們的執行時結構如下圖所示:

淺析 React / Vue 跨端渲染原理與實現

上圖中我們所需要實現的核心模組即為 Adapter,這是將 React 能力擴充套件到新渲染環境的橋樑。如何實現這樣的適配呢?

我們以適配著名的 WebGL 渲染庫 PIXI.js 為例,簡要介紹這一機制如何工作。首先,我們所實現的適配層,其最終的使用形式應當如下:

import * as PIXI from 'pixi.js'
import React from 'react'
import { ReactPixi } from 'our-react-pixi'
import { App } from './app'

// 目標渲染容器
const container = new PIXI.Application()

// 使用我們的渲染層替代 react-dom
ReactPixi.render(<App />, container)
複製程式碼

這裡我們需要實現的就是 ReactPixi 模組。這個模組是 Renderer 的一層薄封裝:

// Renderer 需要依賴 react-reconciler
import { Renderer } from './renderer'

let container

export const ReactPixi = {
  render (element, pixiApp) {
    if (!container) {
      container = Renderer.createContainer(pixiApp)
    }
    // 呼叫 React Reconciler 更新容器
    Renderer.updateContainer(element, container, null)
  }
}
複製程式碼

它依賴的 Renderer 是什麼形式的呢?大致是這樣的:

import ReactFiberReconciler from 'react-reconciler'

export const Renderer = ReactFiberReconciler({
  now: Date.now,
  createInstance () {},
  appendInitialChild () {},
  appendChild () {},
  appendChildToContainer () {},
  insertBefore () {},
  insertInContainerBefore () {},
  removeChild () {},
  removeChildFromContainer () {},
  getRootHostContext () {},
  getChildHostContext () {},
  prepareUpdate () {},
  // ...
})
複製程式碼

這些配置相當於 Fiber 進行渲染的一系列鉤子。我們首先提供一系列的 Stub 空實現,而後在相應的位置實現按需操作 PIXI 物件的程式碼即可。例如,我們需要在 createInstance 中實現對 PIXI 物件的 new 操作,在 appendChild 中為傳入的 PIXI 子物件例項加入父物件等。只要這些鉤子都正確地與渲染層的相應 API 繫結,那麼 React 就能將其完整地渲染,並在 setState 時依據自身的 diff 去實現對其的按需更新了。

這些連線性的膠水程式碼完成後,我們就能夠用 React 元件來控制 PIXI 這樣的第三方渲染庫了:

淺析 React / Vue 跨端渲染原理與實現

這就是基於 React 接入渲染層適配的基本實現了。

Vue 非侵入式適配

由於 Vue 暫時未提供類似 ReactFiberReconciler 這樣專門用於適配渲染層的 API,因此基於 Vue 的渲染層適配在目前有較多不同的實現方式。我們首先介紹「非侵入式」的適配,它的特點在於完全可在業務元件中實現。其基本結構形如下圖:

淺析 React / Vue 跨端渲染原理與實現

這個實現的初衷是讓我們以這種方式編寫渲染層元件:

<div id="app">
  <pixi-renderer>
    <container @tick="tickInfo" @pointerdown="scaleObject">
      <pixi-text :x="10" :y="10" content="hello world"/>
    </container>
  </pixi-renderer>
</div>
複製程式碼

首先我們實現最外層的 pixi-renderer 元件。基於 Vue 中類似 Context 的 Provide / Inject 機制,我們可以將 PIXI 注入該元件中,並基於 Slot 實現 Renderer 的動態內容:

// renderer.js
import Vue from 'vue'
import * as PIXI from 'pixi.js'

export default {
  template: `
    <div class="pixi-renderer">
      <canvas ref="renderCanvas"></canvas>
      <slot></slot>
    </div>`,
  data () {
    return {
      PIXIWrapper: { PIXI, PIXIApp: null },
      EventBus: new Vue()
    }
  },
  provide () {
    return {
      PIXIWrapper: this.PIXIWrapper,
      EventBus: this.EventBus
    }
  },
  mounted () {
    this.PIXIWrapper.PIXIApp = new PIXI.Application({
      view: this.$refs.renderCanvas
    })
    this.EventBus.$emit('ready')
  }
}
複製程式碼

這樣我們就具備了最外層的渲染層容器了。接下來讓我們看看內層的 Container 元件(注意這裡的 Container 不代表最外層的容器,只是 PIXI 中代表節點的概念):

// container.js
export default {
  inject: ['EventBus', 'PIXIWrapper'],
  data () {
    return {
      container: null
    }
  },
  render (h) { return h('template', this.$slots.default) },
  created () {
    this.container = new this.PIXIWrapper.PIXI.Container()
    this.container.interactive = true

    this.container.on('pointerdown', () => {
      this.$emit('pointerdown', this.container)
    })
    // 維護 Vue 與 PIXI 元件間同步
    this.EventBus.$on('ready', () => {
      if (this.$parent.container) {
        this.$parent.container.addChild(this.container)
      } else {
        this.PIXIWrapper.PIXIApp.stage.addChild(this.container)
      }

      this.PIXIWrapper.PIXIApp.ticker.add(delta => {
        this.$emit('tick', this.container, delta)
      })
    })
  }
}
複製程式碼

這個元件裡顯得古怪的 render 是由於其雖然無需模板,但卻可能有子元件的特點所決定的。其主要作用即是維護渲染層物件與 Vue 之間的狀態一致。最後讓我們看看作為葉子節點的 Text 元件實現:

// text.js
export default {
  inject: ['EventBus', 'PIXIWrapper'],
  props: ['x', 'y', 'content'],
  data () {
    return {
      text: null
    }
  },
  render (h) { return h() },

  created () {
    this.text = new this.PIXIWrapper.PIXI.Text(this.content, { fill: 0xFF0000 })
    this.text.x = this.x
    this.text.y = this.y
    this.text.on('pointerdown', () => this.$emit('pointerdown', this.text))

    this.EventBus.$on('ready', () => {
      if (this.$parent.container) {
        this.$parent.container.addChild(this.text)
      } else {
        this.PIXIWrapper.PIXIApp.stage.addChild(this.text)
      }
      this.PIXIWrapper.PIXIApp.ticker.add(delta => {
        this.$emit('tick', this.text, delta)
      })
    })
  }
}
複製程式碼

這樣我們就模擬出了和 React 類似的元件開發體驗。但這裡存在幾個問題:

  • 我們無法脫離 DOM 做渲染。
  • 我們必須在各個定製的元件中手動維護 PIXI 例項狀態。
  • 使用了 EventBus 和 props 兩套元件間通訊機制,存在冗餘。

有沒有其它的實現方案呢?

Vue Mixin 適配

將 DOM 節點繪製到 Canvas 的 vnode2canvas 渲染庫實現了一種特殊的技術,可以通過 Mixin 的方式實現對 Vnode 的監聽。這就相當於實現了一個直接到 Canvas 的渲染層。這個方案的結構大致形如這樣:

淺析 React / Vue 跨端渲染原理與實現

它的原始碼並不多,亮點在於這個 Mixin 的 mounted 鉤子:

mounted() {
  if (this.$options.renderCanvas) {
    this.options = Object.assign({}, this.options, this.getOptions())
    constants.IN_BROWSER && (constants.rate = this.options.remUnit ? window.innerWidth / (this.options.remUnit * 10) : 1)
    renderInstance = new Canvas(this.options.width, this.options.height, this.options.canvasId)
    // 在此 $watch Vnode
    this.$watch(this.updateCanvas, this.noop)
    constants.IN_BROWSER && document.querySelector(this.options.el || 'body').appendChild(renderInstance._canvas)
  }
},
複製程式碼

由於這裡的 updateCanvas 中返回了 Vnode(雖然這個行為似乎有些不合語義的直覺),故而這裡實際上會在 Vnode 更新時觸發對 Canvas 的渲染。這樣我們就能巧妙地將虛擬節點樹的更新與渲染層直接聯絡在一起了。

這個實現確實很新穎,不過多少有些 Hack 的味道:

  • 它需要為 Vue 元件注入一些特殊的方法與屬性。
  • 它需要耦合 Vnode 的資料結構,這在 React Reconciler 中是一種反模式。
  • 它需要自己實現對 Vnode 的遍歷與對 Canvas 物件的 getter 代理,實現成本較高。
  • 它仍然附帶了 Vue 自身到 DOM 的渲染層。

有沒有一些更加「正統」的方法呢?

Vue Platform 定製適配

可以認為 Vue 2.x 中對 Weex 的支援方式,是最貼合我們對定製渲染層的理解的。大名鼎鼎的 mpvue 也是按照這個方案實現了到小程式的渲染層。類似地,我們可以簡略地畫出它的結構圖:

淺析 React / Vue 跨端渲染原理與實現

上圖中的 Platform 是什麼呢?我們只要開啟 mpvue 的原始碼,很容易找到它在 platforms 目錄下新增的目錄結構:

platforms
├── mp
│   ├── compiler
│   │   ├── codegen
│   │   ├── directives
│   │   └── modules
│   ├── runtime
│   └── util
├── web
│   ├── compiler
│   │   ├── directives
│   │   └── modules
│   ├── runtime
│   │   ├── components
│   │   ├── directives
│   │   └── modules
│   ├── server
│   │   ├── directives
│   │   └── modules
│   └── util
└── weex
    ├── compiler
    │   ├── directives
    │   └── modules
    ├── runtime
    │   ├── components
    │   ├── directives
    │   └── modules
    └── util
複製程式碼

上面的 mp 實際上就是新增的小程式渲染層入口了。可以看到渲染層是獨立於 Vue 的 core 模組的。那麼這裡的適配需要做哪些處理呢?概括而言有以下這些:

  • 編譯期的目的碼生成(這個應當是小程式的平臺特性所決定的)。
  • runtime/events 模組中渲染層事件到 Vue 中事件的轉換。
  • runtime/lifecycle 模組中渲染層與 Vue 生命週期的同步。
  • runtime/render 模組中對小程式 setData 渲染的支援與優化。
  • runtime/node-ops 模組中對 Vnode 操作的處理。

這裡有趣的地方在於 node-ops,和筆者一開始設想中在此同步渲染層物件的狀態不同,mpvue 的實現看起來非常容易閱讀……像這樣:

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

export function createElement (tagName: string, vnode: VNode) {
  return obj
}
export function createElementNS (namespace: string, tagName: string) {
  return obj
}
export function createTextNode (text: string) {
  return obj
}
export function createComment (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) {}
export function parentNode (node: Node) {
  return obj
}
export function nextSibling (node: Node) {
  return obj
}
export function tagName (node: Element): string {
  return 'div'
}
export function setTextContent (node: Node, text: string) {
  return obj
}
export function setAttribute (node: Element, key: string, val: string) {
  return obj
}
複製程式碼

看起來這不是什麼都沒有做嗎?個人理解裡這和小程式的 API 有更多的關係:它需要與 .wxml 模板結合的 API 加大了按照配置 Reconciler 的方法將狀態管理由 Vue 接管的難度,因而較難通過這個方式直接適配小程式為渲染層,還不如通過一套程式碼同時生成 Vue 與小程式的兩棵元件樹並設法保持其同步來得划算。

到這裡我們已經基本介紹了通過新增 platform 支援 Vue 渲染層的基本方式,這個方案的優勢很明顯:

  • 它無需在 Vue 元件中使用渲染層 API。
  • 它對 Vue 業務元件的侵入相對較少。
  • 它不需要耦合 Vnode 的資料結構。
  • 它可以確實地脫離 DOM 環境。

而在這個方案的問題上,目前最大的困擾應該是它必須 fork Vue 原始碼了。除了維護成本以外,如果在基於原生 Vue 的專案中使用了這樣的渲染層,那麼就將會存在兩個具有細微區別的不同 Vue 環境,這聽起來似乎有些不清真啊…好在這塊的對外 API 已經在 Vue 3.0 的規劃中了,值得期待 XD

總結

到此為止,我們已經總結了 React 與 Vue 中定製渲染層的主要方式。重複一遍:

  • 基於 React 16 Reconciler 的適配方式,簡單直接。
  • 基於 Vue EventBus 的非侵入式適配方式,簡單但對外暴露的細節較多。
  • 基於 Vue Mixin 的適配方式,Hack 意味較強。
  • 基於 Vue Platform 定製的適配方式,最為靈活但需要 fork 原始碼。

可以看到在目前的時間節點上,沒有路徑依賴的專案在定製 Canvas / WebGL 渲染層時使用 React 較為簡單。而在 Vue 的方案選擇上,參考尤大在筆者知乎回答裡的評論,fork 原始碼修改的方式反而是向後相容性較好的方案。

除了上文中的程式碼片段外,筆者編輯本文的過程中也實現了若干渲染適配層的 POC 原型,它們可以在 renderer-adapters-poc 這個倉庫中看到。最後附上一些參考連結供感興趣的同學閱讀:

P.S. 我們 base 廈門折騰渲染的編輯器團隊開放招人中,簡歷求砸 xuebi at gaoding.com

相關文章