跨端渲染是渲染层并不局限在浏览器 DOM 和移动端的原生 UI 控件,连静态文件乃至虚拟现实等环境,都可以是你的渲染层。这并不只是个美好的愿景,在今天,除了 React 社区到 .docx
/ .pdf
的渲染层以外,Facebook 甚至还基于 Three.js 实现了到 VR 的渲染层,即 ReactVR。
.vue
单文件组件,都能有效地解耦 UI 组件,提高开发效率与代码维护性。从而很自然地,我们就会希望使用这样的组件化方式来实现我们对渲染层的控制了。React Reconciler 适配
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'
// 目标渲染容器
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)
}
}
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.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 源码。