Vue源码分析之虚拟DOM
虚拟Dom
关于虚拟Dom的概念可以从一个简单的小例子出发,如下代码所示:
let div = document.querySelector('#container');
let s = '';
for (let k in div){s += k + ','}
运行后结果如下
可见创建一个Dom元素开销的有多大,一般对数据进行操作而改变后渲染在页面,做法就是直接删除所有旧的Dom,渲染出新的Dom
因为dom元素无法跟踪和感知数据的变化,以及上一次的数据是什么。从而采用重渲染笨重的方式来改变试图。
而虚拟dom解决的就是数据跟踪的问题,将dom所需要渲染的数据单独提取放在js对象 vnode
当中,对数据修改时,通过比较新旧虚拟
节点的差异,来决定如何以最高效的方式修改已存在的Dom元素。
- vnode数据结构
interface VNode {
sel: string | undefined;
data: VNodeData | undefined;
children: Array<VNode | string> | undefined;
elm: Node | undefined;
text: string | undefined;
key: Key | undefined;
}
interface VNodeData {
props?: Props;
attrs?: Attrs;
class?: Classes;
style?: VNodeStyle;
dataset?: Dataset;
on?: On;
hero?: Hero;
attachData?: AttachData;
hook?: Hooks;
key?: Key;
ns?: string;
fn?: () => VNode;
args?: Array<any>;
[key: string]: any;
}
| 字段 | 描述 |
| ---- | ---- | ---- |
| sel | 选择器字符串 |
| data | 描述对象属性,类似vue的 @click
:props
等 |
| children | 孩子虚拟节点数组 |
| elm | 存放真实Dom |
| text | 文本节点与children不可共存 |
| key | 区分不同vnode,类似vue的 v-for=... :key="idx"
中的key |
patchVnode
概述
对两个不同的vnode进行打补丁,使用diff算法比较新旧Dom元素的差异,并只对差异部分进行更新,原本使用的是直接全删除重新创建新的Dom
为方便描述, vnode
代表新节点, oldVnode
代表旧节点, ch
代表孩子节点数组, text
代表文本节点
流程描述
- 初始化
- 因为用户仅对 vnode 虚拟节点进行操作,而非真实的Dom,因此对于
vnode.elm
还未赋予真实的Dom - 通过sameVnode比较后,可知新旧的虚拟节点是对同一Dom进行修改,因此直接将旧dom赋予到新dom方便操作
const elm = vnode.elm = (oldVnode.elm as Node);
- hook钩子
流程最初和最末尾会触发用户传递的 prepatch
和 postpatch
两个不同的钩子函数,分别代表补丁前,和补丁后的生命周期
- 比较虚拟dom
直接对比新旧vnode对象指针地址来判断是否为同一个对象,若对象相同,无需补丁,直接返回
- 触发update钩子
如果 vnode.data != undefined
触发模块和用户的update钩子
- 对比算法
根据流程图,对比新旧节点的 text
和 ch
来分以下5种情况操作, 注意同节点下 text
和 ch
不可共存。
情况1. vnode.text != undefined
新节点存在 text 文本节点,若 oldCh 存在则移除 oldCh,最终
api.setTextContent(elm, vnode.text as string);
情况2. ch && oldCh
updateChildren 后面介绍
if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
情况3. isDef(ch)
oldText 可能有也可能没有,无论如何执行 addVnodes,添加虚拟节点和Dom节点
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
情况4. isDef(oldCh)
存在 oldCh ,移除虚拟节点和Dom节点
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
情况5. isDef(oldVnode.text)
新节点既没有text也没有ch,若存在oldText,清空文本即可
createElm
/****
* @params vnode 需要生成Dom的虚拟节点
* @params insertedVnodeQueue 用于用户传递 insert 钩子回调,这里不关注
* @return Dom
*/
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any, data = vnode.data;
//触发用户传递 init 钩子
if (data !== undefined) {
if (isDef(i = data.hook) && isDef(i = i.init)) {
i(vnode);
data = vnode.data;
}
}
let children = vnode.children, sel = vnode.sel;
//选择器为 ! 时 创建注释节点
if (sel === '!') {
if (isUndef(vnode.text)) {
vnode.text = '';
}
vnode.elm = api.createComment(vnode.text as string);
}
//选择器不为空时
else if (sel !== undefined) {
// 解析和提取选择器中 tag 和 #id 和 .class
const hashIdx = sel.indexOf('#');
const dotIdx = sel.indexOf('.', hashIdx);
// 这里不考虑0是因为用法不允许省略标签名,如 h('#app', 'no div tags');
const hash = hashIdx > 0 ? hashIdx : sel.length;
// 如果不存在 #id 或 .class,则赋予长度,处理选择器出现 #id 和 .class 顺序不同的情况
const dot = dotIdx > 0 ? dotIdx : sel.length;
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
// 生成Dom,ns为域名空间,针对svg标签情况,一般都是调用 api.createElement(tag) 情况,
const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag)
: api.createElement(tag);
// 设置 id 和 class
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '));
// 触发所有模块 create钩子,这些模块可以辅助添加:样式,类名,属性,事件等
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
// 递归创建孩子节点,并追加到 children[] 中
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i];
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
}
}
}
//文本节点则追加文本节点
else if (is.primitive(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text));
}
i = (vnode.data as VNodeData).hook; // Reuse variable
//触发用户传递的 create hook 并未 insert钩子做数据铺垫
if (isDef(i)) {
if (i.create) i.create(emptyNode, vnode);
if (i.insert) insertedVnodeQueue.push(vnode);
}
} else {
// sel 选择器不传递的情况,直接创建文本节点
vnode.elm = api.createTextNode(vnode.text as string);
}
return vnode.elm;
}
removeVnodes
这里强调一下 invokeDestroyHook 和 createRmCb 的作用,一是防止过河拆桥,而是防止重复remove
invokeDestroyHook 用于会触发用户和模块中 destroy钩子函数,该函数会深度优先递归子节点。
createRmCb 闭包,因为要求模块的 remove钩子执行到最后一个模块,才对element进行删除,否则会出现过河拆桥的情况
listeners = cbs.remove.length + 1; //listeners 记录有几个需要执行remove的模块
rm = createRmCb(ch.elm as Node, listeners);
for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm); //每执行一次模块的remove,rm就调用一次,listeners--
function removeVnodes(parentElm: Node,
vnodes: Array<VNode>,
startIdx: number,
endIdx: number): void {
for (; startIdx <= endIdx; ++startIdx) {
let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx];
if (ch != null) {
if (isDef(ch.sel)) {
invokeDestroyHook(ch);
listeners = cbs.remove.length + 1;
rm = createRmCb(ch.elm as Node, listeners);
for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
i(ch, rm);
} else {
rm();
}
} else { // Text node
api.removeChild(parentElm, ch.elm as Node);
}
}
}
}
function invokeDestroyHook(vnode: VNode) {
let i: any, j: number, data = vnode.data;
if (data !== undefined) {
if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
if (vnode.children !== undefined) {
for (j = 0; j < vnode.children.length; ++j) {
i = vnode.children[j];
if (i != null && typeof i !== "string") {
invokeDestroyHook(i);
}
}
}
}
}
function createRmCb(childElm: Node, listeners: number) {
return function rmCb() {
if (--listeners === 0) {
const parent = api.parentNode(childElm);
api.removeChild(parent, childElm);
}
};
}