React / Vue 跨端渲染原理與實現探討

跨端渲染是渲染層並不侷限在瀏覽器 DOM 和移動端的原生 UI 控件,連靜態文件乃至虛擬現實等環境,均可以是你的渲染層。這並不僅是個美好的願景,在今天,除了 React 社區到 .docx / .pdf 的渲染層之外,Facebook 甚至還基於 Three.js 實現了到 VR 的渲染層,即 ReactVR。vue

跨端開發的一個痛點,就在於各類不一樣渲染層的學習、使用與維護成本。而不論是 React 的 JSX 仍是 Vue 的 .vue 單文件組件,都能有效地解耦 UI 組件,提升開發效率與代碼維護性。從而很天然地,咱們就會但願使用這樣的組件化方式來實現咱們對渲染層的控制了。

React Reconciler 適配

在 React 16 標誌性的 Fiber 架構中, react-reconciler 模塊將基於 fiber 的 reconciliation 實現封裝爲了單獨的一層。這個模塊與咱們定製渲染層的需求有什麼關係呢?它的威力在於, 只要咱們爲 Reconciler 提供了宿主渲染環境的配置,那麼 React 就能無縫地渲染到這個環境
首先,咱們所實現的適配層,其最終的使用形式應當以下:

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

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

// 使用咱們的渲染層替代 react-dom
ReactPixi.render(<App />, container)git

這裏咱們須要實現的就是  ReactPixi 模塊。這個模塊是 Renderer 的一層薄封裝:

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

let containercanvas

export const ReactPixi = {
render (element, pixiApp) {
if (!container) {
container = Renderer.createContainer(pixiApp)
}
// 調用 React Reconciler 更新容器
Renderer.updateContainer(element, container, null)
}
}小程序

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

Vue 非侵入式適配

因爲 Vue 暫時未提供相似  ReactFiberReconciler 這樣專門用於適配渲染層的 API,所以基於 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 的渲染層。

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 定製適配

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

// 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 16 Reconciler 的適配方式,簡單直接。
  • 基於 Vue EventBus 的非侵入式適配方式,簡單但對外暴露的細節較多。
  • 基於 Vue Mixin 的適配方式,Hack 意味較強。
  • 基於 Vue Platform 定製的適配方式,最爲靈活但須要 fork 源碼。
能夠看到在目前的時間節點上,沒有路徑依賴的項目在定製 Canvas / WebGL 渲染層時使用 React 較爲簡單。而在 Vue 的方案選擇上,參考尤大在筆者 知乎回答裏的評論,fork 源碼修改的方式反而是向後兼容性較好的方案。
相關文章
相關標籤/搜索