Vue 跨端渲染原理與實現


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


當下的前端同學對 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 時,我們一般需要分別導入 react 與 react-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 為例,簡要介紹這一機制如何工作。首先,我們所實現的適配層,其最終的使用形式應當如下:

<code>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(, container)
複製代碼/<code>

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

<code>// 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)
}
}
複製代碼/<code>

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

<code>import ReactFiberReconciler from 'react-reconciler'

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

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

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


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


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

Vue 非侵入式適配

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


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


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

<code>

<pixi-renderer>
<container>
<pixi-text>
/<container>
/<pixi-renderer>

複製代碼/<code>

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

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

export default {
template: `

<canvas>
<slot>
`,
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')
}
}
複製代碼/<code>

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

<code>// 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)
})
})
}
}
複製代碼/<code>

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

<code>// 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)
})
})
}
}
複製代碼/<code>

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

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

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

Vue Mixin 適配

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


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


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

<code>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)
}

},
複製代碼/<code>

由於這裡的 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 目錄下新增的目錄結構:

<code>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
複製代碼/<code>

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

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

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

<code>// 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
}
複製代碼/<code>

看起來這不是什麼都沒有做嗎?個人理解裡這和小程序的 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 這個倉庫中看到。

倉庫地址:https://github.com/doodlewind/render-adapters-poc

原鏈接:https://juejin.im/post/5bbc99986fb9a05d3c8014b0


分享到:


相關文章: