Vue 系列(九):初识渲染器
在第一篇文章中,我们简单提到了渲染器,之后我们介绍了编译器,随后又通过好几篇文章介绍了响应式系统。本篇我们将正式开始介绍 Vue
中的渲染器。
在介绍渲染器之前,我们先来看一个例子,这个例子是我们在介绍编译器的时候所提到的:
:::: code-group ::: code-group-item 模板代码
<template>
<div>
<p>Text1</p>
<p>Text2</p>
</div>
</template>
::: ::: code-group-item 渲染函数
import { h } from "vue";
function render() {
return h("div", [h("p", "Text1"), h("p", "Text2")]);
}
::: ::::
我们知道,编译器会把模板代码编译为渲染函数,如上所示的 render
,在 Vue
中我们可以通过这个 render
函数编程式地创建组件虚拟 DOM 树,具体可以查阅官方文档。这一步操作的本质还是将模板代码编译为了虚拟 DOM,我们的最终目的是将模板代码转换为真实 DOM,那么这就需要 Vue
内部的渲染器来实现了。
什么是渲染器
通过上图我们知道,渲染器的作用就是将虚拟DOM转换为真实DOM。渲染器把虚拟DOM转换为真实DOM的过程叫做挂载(mount),大家在使用 Vue
时候应该用到过 mounted
钩子,该钩子函数就会在渲染器完成挂载操作后被触发执行。那么渲染器要把真实DOM挂载到何处呢?我们在创建一个 Vue
应用的时候一定使用过 Vue
提供的 app.mount
:
import { createApp } from 'vue'
const app = createApp(/* ... */)
app.mount('#app')
上面的代码就是将应用实例挂载在一个容器元素中的例子。们要想让渲染器挂载真实DOM,同样也需要给她指明一个容器,这样渲染器才能知道将真实DOM挂载到何处。
通过上面的分析,我们就可以写出渲染器中渲染函数的伪代码:
const render = (vNode: VNode | null, idSelector: string) => {
// todo
}
interface VNode
export enum TYPE {
ELEMENT = 'ELEMENT',
TEXT = 'TEXT',
COMMENT = 'COMMENT'
}
export interface VNode {
type: TYPE,
tag?: string,
content?: string,
children?: Array<VNode>,
props?: Record<string, string | null>
}
说明
本篇文章只是简述渲染器的原理,不是源码分析,实际上在 Vue
中渲染方法 render
是定义在渲染器 renderer
中的。另外,这里的 render
方法虽然与文章开头提到的渲染函数名字相同,但是她们有本质的区别,文章开头的渲染函数返回的是虚拟DOM,而这里的 render
则是用来将虚拟DOM转换为真实DOM的,可以理解为 Vue
内部的方法。
渲染器和响应式系统有何关系
我们在前面花了大量的文章去介绍了响应式系统,那么响应式系统跟渲染器有什么联系呢?先来看一个例子:
const content = ref(123);
setTimeout(() => {
effect(() => {
const vNode = {
type: TYPE.TEXT,
content: content.value
}
render(vNode, '#demo');
});
});
setTimeout(() => {
content.value = 456
}, 1000);
在上面的代码中,我们首先定义一个 ref
,然后在 effect
方法中定义了一个虚拟节点 vNode
,该虚拟节点为一个文本节点。我们通过 render
方法将该文本节点挂载到页面中。当我们修改 content
的值的时候,副作用函数会重新执行,也就是会重新渲染页面。这样我们就可以实现自动渲染页面,这就是响应式系统与渲染器的关系。
实现一个简单的渲染器
通过前面的介绍,我们基本知道了什么是渲染器。那么我们该如何实现一个简单的渲染器呢?在实现之前,我们先分析一下渲染器是在什么场景下实现什么功能,还是通过上面的例子:
effect(() => render(vNode, '#demo'));
如上面的代码所示,每次 vNode
发生改变的时候都会触发副作用函数的执行,也就是会重新执行渲染函数 render
,那么渲染函数执行的时候就会有如下三种情况:
初次渲染:只有新节点,那么此时直接执行挂载(mount) 操作
打补丁:新旧节点都存在,这个时候就需要进行新旧节点的比较,然后再更新变更点。我们一般称这种操作为打补丁(patch)
卸载:当新节点为
null
且旧节点存在时,这个时候需要清空容器,我们称这种操作为卸载(unmount)
根据上图我们可以先写出如下代码:
查看代码
// 定义容器的数据结构
interface Container extends Element {
oldVNode?: VNode | null
}
const render = (vNode: VNode | null, idSelector: string) => {
// 获取容器
const container: Container | null = document.querySelector(idSelector);
// 容器不存在的时候,直接返回
if (!container) return;
if (vNode) {
// 挂载或者更新
patch(container.oldVNode, vNode, container);
} else if (container.oldVNode) {
// 卸载操作
unmount(container.oldVNode);
}
// 将旧的虚拟DOM存储在container中
container.oldVNode = vNode;
};
在上面的代码中,我们首先定义了一个数据结构 Container
,用来表述容器。其中 oldVNode
则表示当前容器下真实DOM所对应的虚拟DOM,这样我们就能将真实DOM与虚拟DOM相关联。
在 render
方法中,当 vNode
存在时,说明是挂载或者更新,其实 mount
也算是一种特殊的更新操作,所以我们都通过 patch
方法来实现挂载和更新的功能。当 vNode
不存在且 oldVNode
存在时,说明是卸载操作,执行 unmount
。最后我们通过 container.oldVNode = vNode
将虚拟DOM与对应的真实DOM进行关联。
有了大致的实现思路,接下来我们就来逐个实现其中的功能方法。
patch
我们将挂载和打补丁统一写在 patch
方法中:
const patch = (oldVNode: VNode | null | undefined, newVNode: VNode, container: Container) => {
if (!oldVNode) {
// 旧节点不存在,说明是挂载操作
mountElement(newVNode, container);
} else {
// 新旧节点都存在,需要进行更新操作
patchElement(oldVNode, newVNode, container);
}
};
通过上面的代码可以知道,patch
方法接收三个参数,新旧虚拟节点以及容器,当旧节点不存在的时候执行挂载操作,反之则进行打补丁的操作。我们先来看看如何实现 mountElement
:
<div>element</div>
<!-- comment -->
text
通过上面的代码我们知道,在 HTML
中一般有三种节点:
标签节点
注释
文本
所以我们只需要实现如何挂载这三种节点即可,先来看看注释节点和文本节点,她们的实现相对简单,只需要将节点挂载到相应的容器中去就可以了:
const mountElement = (node: VNode, container: Container) => {
// 挂载DOM元素
if (node.type === TYPE.ELEMENT) {
// todo
} else if (node.type === TYPE.COMMENT) {
// 挂载注释
const comment = node.content ? node.content : '';
const commentNode = document.createComment(comment);
container.appendChild(commentNode);
} else if (node.type === TYPE.TEXT) {
// 挂载文本
const text = node.content ? node.content : '';
const textNode = document.createTextNode(text);
container.appendChild(textNode);
}
};
标签节点相对复杂一些,因为标签节点下面可能会有子节点,所以我们要递归调用 mountElement
:
const mountElement = (node: VNode, container: Container) => {
// 挂载DOM元素
if (node.type === TYPE.ELEMENT) {
if (!node.tag) return;
const element: Container = document.createElement(node.tag);
// 挂载子节点
if (node.children) {
node.children.forEach(child => mountElement(child, element));
}
container.appendChild(element);
}
// 省略其他代码
};
我们通过递归调用 mountElement
实现了对标签节点的挂载。接下来我们看看如何实现 patchElement
,我们知道,patch
操作需要对比新旧节点,然后找出需要更新的节点。这其实就是我们后面要讲的 Diff
算法,这里我们先简化操作,如果是打补丁操作,我们首先将旧节点卸载,然后进行新节点的挂载:
const patchElement = (oldVNode: VNode, newVNode: VNode, container: Container) => {
// 卸载旧节点
unmount(oldVNode);
// 挂载新节点
mountElement(newVNode, container);
};
unmount
在实现 patchElement
的时候,我们需要先将旧节点卸载,接下来我们就来看看如何实现卸载的功能。卸载其实很简单,只需要找到父节点,然后调用 removeChild
将其移除就行了。要实现这一点,我们需要将虚拟DOM与真实DOM进行关联:
interface VNode {
type: TYPE,
tag?: string,
content?: string,
children?: Array<VNode>,
props?: Record<string, string | null>,
el?: Node,
}
我们给 VNode
增加了一个属性 el
,当进行挂载操作的时候,我们将真实DOM赋值给 el
,这样就将虚拟节点与真实节点绑定在了一起:
查看代码
const mountElement = (node: VNode, container: Container) => {
// 挂载DOM元素
if (node.type === TYPE.ELEMENT) {
if (!node.tag) return;
const element: Container = document.createElement(node.tag);
// 将真实DOM与虚拟DOM相关联
node.el = element;
// 挂载子节点
if (node.children) {
node.children.forEach(child => mountElement(child, element));
}
container.appendChild(element);
} else if (node.type === TYPE.COMMENT) {
// 挂载注释
const comment = node.content ? node.content : '';
const commentNode = document.createComment(comment);
node.el = commentNode;
container.appendChild(commentNode);
} else if (node.type === TYPE.TEXT) {
// 挂载文本
const text = node.content ? node.content : '';
const textNode = document.createTextNode(text);
node.el = textNode;
container.appendChild(textNode);
}
};
接下来我们就可以实现 卸载(unmount) 操作了:
const unmount = (vNode: VNode) => {
const el = vNode.el;
if (!el) return;
if (vNode.children && vNode.children.length > 0) {
vNode.children.forEach((child: VNode) => unmount(child));
}
const parent = el.parentNode;
if (parent) {
parent.removeChild(el);
}
};
通过代码可以看到,当有子节点的时候说明是标签节点,我们需要递归调用 unmount
进行节点的卸载,如果是其它情况,直接卸载即可。
处理属性
在进行节点挂载的时候,我们还需要考虑节点中存在属性的情况:
const patchProps = (el: Element, key: string, newValue: string | null) => {
if (!newValue) {
el.removeAttribute(key);
} else if (typeof newValue === 'string') {
el.setAttribute(key, newValue);
}
}
可以看到,patchProps
接收三个参数,需要挂载属性的标签节点 el
,属性名称 key
,属性值 newValue
。通过 setAttribute
进行属性的挂载。定义好更新属性的方法之后,我们还需要修改 mountElement
:
const mountElement = (node: VNode, container: Container) => {
// 挂载DOM元素
if (node.type === TYPE.ELEMENT) {
if (!node.tag) return;
const element: Container = document.createElement(node.tag);
// 将真实DOM与虚拟DOM相关联
node.el = element;
if (node.props && node.el) {
for(const key in node.props) {
patchProps(node.el as Element, key, node.props[key])
}
}
// 省略其他代码
}
// 省略其他代码
};
这样我们就实现了对属性的渲染。这里只是简单说明渲染的原理,其实在 Vue
中,渲染属性远不止这么简单。
最后
本篇我们简单介绍了一下渲染器,渲染器的作用就是将虚拟DOM转换为真实DOM。本文为了叙述清楚渲染器的原理,省去了很多细节,其实在 Vue
内部,渲染器是非常复杂的,大家可以参阅渲染器的源码,本篇文章代码请戳