跨端渲染是渲染层并不局限在浏览器 DOM 和移动端的原生 UI 控件,连静态文件乃至虚拟现实等环境,都可以是你的渲染层。这并不只是个美好的愿景,在今天,除了 React 社区到 .docx / .pdf 的渲染层以外,Facebook 甚至还基于 Three.js 实现了到 VR 的渲染层,即 ReactVR。

跨端开发的一个痛点,就在于各种不同渲染层的学习、使用与维护成本。而不管是 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'

// 目标渲染容器
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)
}
}

这些配置相当于 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 源码修改的方式反而是向后兼容性较好的方案。

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

React / Vue 跨端渲染原理与实现探讨的更多相关文章

  1. Egg + Vue 服务端渲染工程化实现

    在实现 egg + vue 服务端渲染工程化实现之前,我们先来看看前面两篇关于Webpack构建和Egg的文章: 在 Webpack工程化解决方案easywebpack 文章中我们提到了基于 Vue ...

  2. nextjs服务端渲染原理

    1. 简单的介绍一下 nextjs是react进行服务端渲染的一个工具,默认以根目录下的pages为渲染路由 比如我在pages目录下创建一个index.js文件,然后export default一个 ...

  3. React 在服务端渲染的实现

    原文地址:Server-Side React Rendering 原文作者:Roger Jin 译者:牧云云 React 在服务端渲染的实现 React是最受欢迎的客户端 JavaScript 框架, ...

  4. [译]React 在服务端渲染的实现

    原文地址:Server-Side React Rendering 原文作者:Roger Jin React 在服务端渲染的实现 React是最受欢迎的客户端 JavaScript 框架,但你知道吗(可 ...

  5. vue服务端渲染axios预取数据

    首先是要参考vue服务端渲染教程:https://ssr.vuejs.org/zh/data.html. 本文主要代码均参考教程得来.基本原理如下,拷贝的原文教程. 为了解决这个问题,获取的数据需要位 ...

  6. vue服务端渲染简单入门实例

    想到要学习vue-ssr的同学,自不必多说,一定是熟悉了vue,并且多多少少做过几个项目.然后学习vue服务端渲染无非解决首屏渲染的白屏问题以及SEO友好. 话不多说,笔者也是研究多日才搞明白这个服务 ...

  7. vue服务端渲染提取css

    vue服务端渲染,提取css单独打包的好处就不说了,在这里主要说的是抽取css的方法 要从 *.vue 文件中提取 CSS,可以使用 vue-loader 的 extractCSS 选项(需要 vue ...

  8. React 16 服务端渲染的新特性

    React 16 服务端渲染的新特性 React 16 中关于服务端渲染的新特性 快速介绍React 16 服务端渲染的新特性,包括数组.性能.流等 React 16 终于来了!

  9. [vue] vue服务端渲染nuxt.js

    初始化 使用脚手架工具 create-nuxt-app 快速创建 npx create-nuxt-app <项目名> npx create-nuxt-app 执行一些选择 在集成的服务器端 ...

随机推荐

  1. AtomicInteger 源码分析

    AtomicInteger AtomicInteger 能解决什么问题?什么时候使用 AtomicInteger? 支持原子更新的 int 值. 如何使用 AtomicInteger? 1)需要被多线 ...

  2. 从 spring-cloud-alibaba-nacos-config 进入 nacos-client

    sc 的 bootstrap context 是 main application context 的 parent,需要在 main application context 中使用的 bean 可以 ...

  3. spring boot 整合 RabbitMQ 错误

    1.错误 org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.spr ...

  4. SpringBoot错误经验

    1.在application.properties 添加 debug=true,可以看见项目的执行流程有助于调bug 2.如果错误显示端口号被占用 cmd 步骤1 查看端口号应用情况:netstat ...

  5. 阿里云SLB产品HTTP、HTTPS、UDP协议使用

    1.http协议测试 第一步:添加http监听服务,前端端口为8080,后端端口为80,健康检查中检查端口为后端端口80: 第二步:在绑定的服务器上安装服务,步骤如下 centos系统中启动http协 ...

  6. VUe.js 父组件向子组件中传值及方法

    父组件向子组件中传值 1.  Vue实例可以看做是大的组件,那么在其内部定义的私有组件与这个实例之间就出现了父子组件的对应关系. 2. 父子组件在默认的情况下,子组件是无妨访问到父组件中的数据的,所以 ...

  7. 为什么说 Babel 将推动 JavaScript 的发展【转】

    Babel是一个转换编译器,它能将 ES6 转换成可以在浏览器中运行的代码.Babel 由来自澳大利亚的开发者Sebastian McKenzie创建.他的目标是使 Babel 可以处理 ES6 的所 ...

  8. Python基础-1 python由来 Python安装入门 注释 pyc文件 python变量 获取用户输入 流程控制if while

    1.Python由来 Python前世今生 python的创始人为吉多·范罗苏姆(Guido van Rossum).1989年的圣诞节期间,吉多·范罗苏姆为了在阿姆斯特丹打发时间,决心开发一个新的脚 ...

  9. python基础-4.1 open 打开文件练习:修改haproxy配置文件

    1.如何在线上环境优雅的修改配置文件? 配置文件名称ini global log 127.0.0.1 local2 daemon maxconn 256 log 127.0.0.1 local2 in ...

  10. [2019杭电多校第六场][hdu6635]Nonsense Time

    题目链接:http://acm.hdu.edu.cn/showproblem.php?pid=6635 题意是说一开始所有数都冻结,第i秒会解冻第ki个数,求每秒状态下的最长上上升子序列长度. 这种题 ...