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

當下的前端同窗對 React 與 Vue 的組件化開發想必不會陌生,RN 與 Weex 的跨界也常爲咱們所津津樂道。UI 框架在實現這樣的跨端渲染時須要作哪些工做,其技術方案可否借鑑乃至應用到咱們本身的項目中呢?這就是本文所但願分享的主題。html

概念簡介

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

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

在開始介紹如何爲 React / Vue 適配不一樣渲染層以前,咱們不妨回顧一下它們在老本行 DOM 中執行時的基本層次結構。好比咱們都知道,在瀏覽器中使用 React 時,咱們通常須要分別導入 reactreact-dom 兩個不一樣的 package,這時前端項目的總體結構能夠用下圖簡略地表示:node

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

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

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

React Reconciler 適配

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

上圖中咱們所須要實現的核心模塊即爲 Adapter,這是將 React 能力擴展到新渲染環境的橋樑。如何實現這樣的適配呢?web

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

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 非侵入式適配

因爲 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 的渲染層。這個方案的結構大體形如這樣:

它的源碼並很少,亮點在於這個 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 也是按照這個方案實現了到小程序的渲染層。相似地,咱們能夠簡略地畫出它的結構圖:

上圖中的 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

相關文章
相關標籤/搜索