前端框架中的虚拟DOM和渲染器
目前前端的流行框架Vue和React都引入了虚拟DOM,它的出现了推动了前端框架具备更强大的能力,提升了开发效率,实现了更多的可能性。
虚拟DOM是啥
虚拟DOM,virtual document object model
,虚拟文档对象模型,是用存粹的JS对象来描述DOM结点,我们在框架的源码或相关的文章中,也经常用VNode
指代虚拟DOM。 snabbdom
是一个著名的虚拟DOM库,vue也从中借鉴了相关的思想,下面是一个VNode的定义。
ts
interface VNode {
// 选择器
sel: string | undefined;
// 节点数据:属性/样式/事件等
data: VNodeData | undefined;
children: Array<VNode | string> | undefined;
// 记录 vnode 对应的真实 DOM
elm: Node | undefined;
text: string | undefined;
key: Key | undefined;
}
interface VNodeData {
props?: Props;
attrs?: Attrs;
class?: Classes;
style?: VNodeStyle;
dataset?: Dataset;
on?: On;
attachData?: AttachData;
hook?: Hooks;
key?: Key;
ns?: string; // for SVGs
fn?: () => VNode; // for thunks
args?: any[]; // for thunks
is?: string; // for custom elements v1
[key: string]: any; // for any other 3rd party module
}
VNodeData
描述结点相关的属性信息。
一个 html 标签有它的名字、属性、事件、样式、子节点等诸多信息,这些内容都需要在 VNode 中体现,我们可以用如下对象来描述一个红色背景的正方形 div 元素:
js
const elementVNode = {
tag: 'div',
data: {
style: {
width: '100px',
height: '100px',
backgroundColor: 'red'
}
}
}
我们使用 tag 属性来存储标签的名字,用 data 属性来存储该标签的附加信息,比如 style、class、事件等,通常我们把一个 VNode 对象的 data 属性称为 VNodeData。
为了描述子节点,我们需要给 VNode 对象添加 children 属性,如下 VNode 对象用来描述一个有子节点的 div 元素
js
const elementVNode = {
tag: 'div',
data: null,
children: [
{
tag: 'h1',
data: null
},
{
tag: 'p',
data: null
}
]
}
为啥要引入虚拟DOM
如果没有虚拟DOM,我们之前经常用jQuery这种直接操作DOM的框架,开发业务系统。一般通过数据状态的变更,直接操纵DOM结点来更新视图。
后来有了如handlebars、jade这类的模板引擎类的框架,也只能便于初始化数据状态,生成的是一堆HTML字符串,扔给浏览器渲染。无法追踪后续的视图状态。
这样处理的的缺点有
- 维护成本高,当视图上的数据状态多的时候,程序员的心智负担非常重,随着业务逻辑的增加,导致应用难以维护
- 性能不高(相对),频繁的操纵DOM,对性能的损耗较大,DOM是浏览器经过一系列的计算、渲染、绘制消耗的资源量非常的。
视图上一个普通的空DIV结点,就有上百个属性,可见操作DOM是件成本比较高的任务。基于上面的缺陷,业界引入了VNode,虚拟DOM的概念,使用原生的JavaScript对象来描述真实的DOM结点。 将业务的数据状态处理成 一颗虚拟DOM树,根据和真实DOM树的对比,寻找差异,只更新变化的部分,通过最小化更改,来降低视图更新的成本。
引入了虚拟DOM层后,我们业务开发人员,就可以不用直接操作DOM,通过上层的库(vue、react)直接维护数据状态即可。当数据发生改变后,交由框架来自动处理,减轻了维护负担。
组件和虚拟DOM
在前端开发中,我们经常打交道的是组件,无论是vue中的模板组件或者是react中的JSX组件,它们是如何关联到虚拟DOM上的呢?
Vue中的处理过程是这样的
最终生成的组件实例如下图所示
组件实例,关联着VNode
我们通过const app = new Vue(option);app.mount('#root');
生成的组件实例,就关联到了虚拟DOM上,在程序运行期间一直存在,就像一块内存中的缓存,联系着浏览器中的真实DOM结点和程序的数据状态。
React中的处理过程是这样的,一般都是用JSX写react组件
在早期版本的react中,会转换成createElement
的形式,在react v17后,生成_jsxs
的形式,其实内涵都是一样的。 解析JSX中的 render 函数(对应类组件) 或 返回的字符串(对应函数组件),生成 虚拟DOM
可以看出,无论在react或vue中,我们在业务中定义的各种组件,都转换成了VNode的形式。形成了一颗虚拟DOM树,关联着真实的DOM结点树
当然,react中不仅有VNode,还有fiber结点的概念,我们这不展开来说了。
渲染器的作用
有了虚拟DOM结点树后,是如何映射到真实DOM树的呢,当数据状态改变的时候,如何及时更新DOM结点呢?这里我们就得引出渲染器的概念了。 渲染器,会接收传递来的VNode,对比新老结点,俗称打补丁(即:patch)
的过程。
我们以Vue框架为例,在调用 createApp
方法的时候,就会生成渲染器的实例。
js
let renderer;
function ensureRenderer() {
return (renderer ||
(renderer = createRenderer({
createElement,
createText,
setText,
setElementText,
patchProp,
insert,
remove,
})));
}
const createApp = (...args) => {
console.log('createApp: ', args);
return ensureRenderer().createApp(...args);
};
在渲染器的内部,有个 patch
方法,来对比新旧的VNode结点
js
function patch(n1, n2, container = null, anchor = null, parentComponent = null) {
const { type, shapeFlag } = n2;
switch (type) {
case Text:
processText(n1, n2, container);
break;
case Fragment:
processFragment(n1, n2, container);
break;
default:
if (shapeFlag & 1) {
console.log("处理 element");
processElement(n1, n2, container, anchor, parentComponent);
}
else if (shapeFlag & 4) {
console.log("处理 component");
processComponent(n1, n2, container, parentComponent);
}
}
}
我们引入snabbdom库,来说明整个过程。
html
<div id="app"></div>
<script type="module">
// import { h, init } from 'https://cdn.jsdelivr.net/npm/snabbdom@3.5.1/+esm'
import { h, init } from "./snabbdom@3.5.1.js";
// 1. hello world
// 参数:数组,模块
// 返回值:patch函数,作用对比两个vnode的差异更新到真实DOM
let patch = init([])
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串的话就是标签中的内容
let vnode = h('div#container', { }, 'hello VNode , ---old')
let app = document.querySelector('#app')
// 第一个参数:可以是DOM元素,内部会把DOM元素转换成VNode
// 第二个参数:VNode
// 返回值:VNde
let oldVNode = patch(app, vnode); // 首次挂载
console.log('oldVNode : ', oldVNode);
setTimeout(() => {
// 定义一个新的VNode
vnode = h('div', 'Hello Virtual DOM , --- new')
// 在 3 秒后更新挂载到视图中
const newVNode = patch(oldVNode, vnode)
console.log('newVNode : ', newVNode);
}, 3000);
</script>
可以看到渲染器主要有两个作用
- 对比新旧的结点,寻找差异,打补丁
- 将差异化的部分,跟新到真实的视图上
具体patch的实现细节,就是结点Diff,和增、删、改元素结点的内容了,大致的代码逻辑如下:
js
// 相同的结点复用
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 不同的结点,增加或删除
elm = oldVnode.elm!;
parent = api.parentNode(elm) as Node;
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
自定义渲染器
渲染器在更新Web视图的时候,会调用浏览器的DOM API,将数据的变化反映到界面上。
js
interface DOMAPI {
createElement: (
tagName: any,
options?: ElementCreationOptions
) => HTMLElement;
/**
* @experimental
* @todo Make it required when the fragment is considered stable.
*/
createDocumentFragment?: () => SnabbdomFragment;
createTextNode: (text: string) => Text;
createComment: (text: string) => Comment;
insertBefore: (
parentNode: Node,
newNode: Node,
referenceNode: Node | null
) => void;
removeChild: (node: Node, child: Node) => void;
appendChild: (node: Node, child: Node) => void;
parentNode: (node: Node) => Node | null;
nextSibling: (node: Node) => Node | null;
tagName: (elm: Element) => string;
setTextContent: (node: Node, text: string | null) => void;
getTextContent: (node: Node) => string | null;
isElement: (node: Node) => node is Element;
isText: (node: Node) => node is Text;
isComment: (node: Node) => node is Comment;
/**
* @experimental
* @todo Make it required when the fragment is considered stable.
*/
isDocumentFragment?: (node: Node) => node is DocumentFragment;
}
因为有了 VNode 这一个中间的抽象层,我们可以实现自定义的渲染器。通过改造内部的API调用,比如调用原生Native API,来操纵原生的视图。 ReactNative框架就是这样的原理。
js
function createElement(
tagName: any,
options?: ElementCreationOptions
): HTMLElement {
if (tagName === 'view') {
const boxEle = document.createElement('div');
boxEle.style.backgroundColor = 'gray';
boxEle.style.borderRadius = '20px';
boxEle.style.width = '200px';
boxEle.style.width = '100px';
return boxEle;
}
return document.createElement(tagName, options);
}
调用的时候,使用自定义的标签 view
js
// import { h, init } from 'https://cdn.jsdelivr.net/npm/snabbdom@3.5.1/+esm'
import { h, init } from "./snabbdom@3.5.1.js";
let patch = init([])
let vnode = h('view', { }, '自定义的元素')
let app = document.getElementById('app')
let oldVNode = patch(app, vnode); // 首次挂载
console.log('oldVNode : ', oldVNode);
在移动端,如果能够通过 hybrid
的方式,调用原生的视图组件,就能够生成媲美原生体验的UI。
虚拟DOM的延展
了解了虚拟DOM,知道了渲染器后,其实这只是很小的一部分。渲染器要处理的工作很多,包括:
-
控制部分组件生命周期钩子hooks(
beforeCreate
、created
等)的调用 在整个渲染周期中包含了大量的 DOM 操作、组件的挂载、卸载,控制着组件的生命周期钩子调用的时机。 -
多端渲染的桥梁 渲染器也是多端渲染的桥梁,自定义渲染器的本质就是把特定平台操作"DOM"的方法从核心算法中抽离,并提供一套API规范,以便各个平台实现。
-
与异步渲染有直接关系 Vue3 的异步渲染是基于调度器的实现,若要实现异步渲染,组件的挂载就不能同步进行,DOM的变更就要在合适的时机,一些需要在真实DOM存在之后才能执行的操作(如 ref)也应该在合适的时机进行。对于时机的控制是由调度器来完成的,但类似于组件的挂载与卸载以及操作 DOM 等行为的入队还是由渲染器来完成的,这也是为什么 Vue2 无法轻易实现异步渲染的原因。
-
包含最核心的 Diff 算法 Diff 算法是渲染器的核心特性之一,可以说正是 Diff 算法的存在才使得 Virtual DOM 如此成功。