在Vue2渲染层做了根本性的改动,那就是引入了虚拟DOM。vue的虚拟dom是基于 snabbdom 改造过来的。了解 snabbdom的原理之后再回过头来看 vue的虚拟DOM结构的实现。就难度不大了!
一、背景
为什么需要 Virtual DOM
在前端刀耕火种的时代,jquery 可谓是一家独大。然而慢慢地人们发现,在我们的代码中布满了一系列操作 DOM 的代码。这些代码难以维护,又容易出错,最重要的是,DOM操作非常耗费性能。 而Vue和React是数据驱动视图,那么它们如何有效控制DOM操作呢? 当项目达到一定的复杂度,想减少计算次数比较难。能不能把计算更多地转移为JS计算?因为JS执行速度很快,JS执行速度和DOM更新速度完全不是一个数量级的。于是有人想到用JS模拟DOM结构,计算出最小的变更,然后操作一次DOM即可。 实现 Virtual DOM Virtual DOM 主要包括以下三个方面:
- 使用 js 数据对象 表示 DOM 结构 -> VNode
- 比较新旧两棵 虚拟 DOM 树的差异 -> diff
- 将差异应用到真实的 DOM 树上 -> patch
下面开始来研究 snabbdom 是如何实现这些方面的
目录
项目链接:https://github.com/snabbdom/snabbdom 首先看一下整体的目录结构,源码主要是在 src 里面,其他的目录:test 、examples 分别是测试用例以及例子。这里我们先关注源码部分:
── h.ts 创建vnode的函数
── helpers
└── attachto.ts
── hooks.ts 定义钩子
── htmldomapi.ts 操作dom的一些工具类
── is.ts 判断类型
── modules 模块
├── attributes.ts
├── class.ts
├── dataset.ts
├── eventlisteners.ts
├── hero.ts
├── module.ts
├── props.ts
└── style.ts
── snabbdom.bundle.ts 入口文件
── snabbdom.ts 初始化函数
── thunk.ts 分块
── tovnode.ts dom元素转vnode
── vnode.ts 虚拟节点对象
snabbdom.bundle.ts 入口文件
我们先从入口文件开始看起:
import { init } from './snabbdom';
import { attributesModule } from './modules/attributes';
import { classModule } from './modules/class';
import { propsModule } from './modules/props';
import { styleModule } from './modules/style';
import { eventListenersModule } from './modules/eventlisteners';
import { h } from './h';
var patch = init([
attributesModule,
classModule,
propsModule,
styleModule,
eventListenersModule
]) as (oldVNode: any, vnode: any) => any;
export const snabbdomBundle = { patch, h: h as any };
export default snabbdomBundle;
我们可以看到,入口文件主要导出两个函数:
- patch函数 , 由 snabbdom.ts 的 init 方法,根据传入的 module 来初始化
- h函数 ,在 h.ts 里面实现
看起来h函数比 patch 要简单一些,我们去看看到底做了些什么。
二、h函数
介绍
这里是 typescript 的语法,定义了一系列的重载方法。 h 函数主要根据传进来的参数,返回一个 vnode 对象
- 在使用 Vue 的时候的 h() 函数和snabbdom中的h函数功能一样都是用来穿件VNode对象,但是Vue中增强了 h
函数,实现了组件的机制:
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
- h() 函数最早见于 hyperscript,使用 JavaScript 创建超文本,也就是 html 字符串
- Snabbdom 中的 h() 函数源于 hyperscript,但是不是用来创建超文本,而是创建 VNode
源码
源码位置:src/h.ts
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}, children: any, text: any, i: number;
if (c !== undefined) {
data = b;
if (is.array(c)) { children = c; }
else if (is.primitive(c)) { text = c; }
else if (c && c.sel) { children = [c]; }
} else if (b !== undefined) {
if (is.array(b)) { children = b; }
else if (is.primitive(b)) { text = b; }
else if (b && b.sel) { children = [b]; }
else { data = b; }
}
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
}
}
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
addNS(data, children, sel);
}
return vnode(sel, data, children, text, undefined);
};
export default h;
h 函数比较简单,主要是提供一个方便的工具函数,方便创建 vnode 对象
三、vnode对象
vnode 是一个对象,用来表示相应的 dom 结构 代码位置 :./src/vnode.ts
定义 vnode 类型
export interface VNode {
sel: string | undefined;
data: VNodeData | undefined;
children: Array<VNode | string> | undefined;
elm: Node | undefined;
text: string | undefined;
key: Key | undefined;
}
定义 VNodeData 的类型
export 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;
}
创建 VNode 对象
export function vnode(
sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined
): VNode {
let key = data === undefined ? undefined : data.key;
return {
sel: sel,
data: data,
children: children,
text: text,
elm: elm,
key: key
};
}
export default vnode;
四、patch方法
在开始解析这块源码的时候,先给大家补一个知识点。关于 两颗 Virtual Dom 树对比的策略
diff策略
1、同级比较 对比的时候,只比较同一层级,不跨级比较,减少算法复杂度。 2、就近复用 为了尽可能不发生 DOM 的移动,会就近复用相同的 DOM 节点,复用的依据是判断是否是同类型的 dom 元素 3、tag不相同,则直接删掉重建,不再深度比较 4、tag和key两者都相同,则认为是相同节点,不再深度比较
init 方法
在 ./src/snabbdom.ts 中,主要是 init 方法。 init 方法主要是传入 modules ,domApi , 然后返回一个 patch 方法 注册钩子:
const hooks: (keyof Module)[] = [
'create',
'update',
'remove',
'destroy',
'pre',
'post'
];
这里主要是注册一系列的钩子,在不同的阶段触发 将各个模块的钩子方法,挂到统一的钩子上 这里主要是将每个 modules 下的 hook 方法提取出来存到 cbs(也就是callbacks回调函数集) 里面
- 初始化的时候,将每个 modules 下的相应的钩子都追加都一个数组里面。create、update…
- 在进行 patch 的各个阶段,触发对应的钩子去处理对应的事情
- 这种方式比较方便扩展。新增钩子的时候,不需要更改到主要的流程
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = [];
for (j = 0; j < modules.length; ++j) {
const hook = modules[j][hooks[i]];
if (hook !== undefined) {
(cbs[hooks[i]] as Array<any>).push(hook);
}
}
}
这些模块的钩子,主要用在更新节点的时候,会在不同的生命周期里面去触发对应的钩子,从而更新这些模块。 例如元素的 attr、props、class 之类的!
sameVnode
判断是否是相同的虚拟节点
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
patch
init 方法最后返回一个 patch 方法 。
patch 方法主要的逻辑如下 :
- 触发 pre 钩子
- 如果老节点非 vnode, 则新创建空的 vnode
- 新旧节点为 sameVnode 的话,则调用 patchVnode 更新 vnode , 否则创建新节点
- 触发收集到的新元素 insert 钩子
- 触发 post 钩子
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
}
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
elm = oldVnode.elm as Node;
parent = api.parentNode(elm);
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
api.insertBefore(
parent,
vnode.elm as Node,
api.nextSibling(elm)
);
removeVnodes(parent, [oldVnode], 0, 0);
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
(((insertedVnodeQueue[i].data as VNodeData).hook as Hooks)
.insert as any)(insertedVnodeQueue[i]);
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
整体的流程大体上是这样子,接下来我们来关注更多的细节!
patchVnode 方法
它的作用是对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM。当新旧节点时同一个节点时,就会调用patchVnode对新旧节点差异进行局部更新。首先我们研究 patchVnode 了解相同节点是如何更新的。
patchVnode 方法主要的逻辑如下 : 第一个过程:触发prepatch和update钩子函数
第二个过程:对比新旧节点,更新差异
- 如果 vnode.text(文本节点) 存在
- 如果 oldVnode.children 和 vnode.children 都有值
- 调用 updateChildren()使用 diff 算法对比子节点,更新子节点
- 如果 vnode.children 有值, oldVnode.children 无值
- 清空 DOM 元素,然后调用 addVnodes() ,批量添加子节点
- 如果 oldVnode.children 有值, vnode.children 无值
- 调用 removeVnodes() ,批量移除子节点
- 如果 oldVnode.text 有值
- 如果 vnode.text存在并且和 oldVnode.text 不等
- 如果老节点有子节点,全部移除
- 设置 DOM 元素的 textContent 为 vnode.text
第三个过程:触发postpatch钩子函数
这里在对比的时候,就会直接更新元素内容了。并不会等到对比完才更新 DOM 元素 具体代码细节:
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
let i: any, hook: any;
if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
i(oldVnode, vnode);
}
const elm = vnode.elm = (oldVnode.elm as Node);
let oldCh = oldVnode.children;
let ch = vnode.children;
if (oldVnode === vnode) return;
if (vnode.data !== undefined) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
i = vnode.data.hook;
if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
}
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
} else if (isDef(ch)) {
?
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
?
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
if (isDef(oldCh)) {
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
}
api.setTextContent(elm, vnode.text as string);
}
if (isDef(hook) && isDef(i = hook.postpatch)) {
i(oldVnode, vnode);
}
}
updateChildren 方法
patchVnode 里面最重要的方法,也是整个 diff 里面的最核心方法
updateChildren 主要的逻辑如下:
1、优先处理特殊场景,先对比两端。也就是
- 旧 vnode 头 vs 新 vnode 头
- 旧 vnode 尾 vs 新 vnode 尾
- 旧 vnode 头 vs 新 vnode 尾
- 旧 vnode 尾 vs 新 vnode 头
2、首尾不一样的情况,寻找 key 相同的节点,找不到则新建元素 3、如果找到 key,但是,元素选择器变化了,也新建元素 4、如果找到 key,并且元素选择没变, 则移动元素 5、两个列表对比完之后,清理多余的元素,新增添加的元素
不提供 key 的情况下,如果只是顺序改变的情况,例如第一个移动到末尾。这个时候,会导致其实更新了后面的所有元素 具体代码细节:
function updateChildren(
parentElm: Node,
oldCh: Array<VNode>,
newCh: Array<VNode>,
insertedVnodeQueue: VNodeQueue
) {
let oldStartIdx = 0,
newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx: any;
let idxInOld: number;
let elmToMove: VNode;
let before: any;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(
parentElm,
oldStartVnode.elm as Node,
api.nextSibling(oldEndVnode.elm as Node)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(
parentElm,
oldEndVnode.elm as Node,
oldStartVnode.elm as Node
);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(
oldCh,
oldStartIdx,
oldEndIdx
);
}
idxInOld = oldKeyToIdx[newStartVnode.key as string];
if (isUndef(idxInOld)) {
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm as Node
);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm as Node
);
} else {
patchVnode(
elmToMove,
newStartVnode,
insertedVnodeQueue
);
oldCh[idxInOld] = undefined as any;
api.insertBefore(
parentElm,
elmToMove.elm as Node,
oldStartVnode.elm as Node
);
}
newStartVnode = newCh[++newStartIdx];
}
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
before =
newCh[newEndIdx + 1] == null
? null
: newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
} else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}
addVnodes 方法
addVnodes 就比较简单了,主要功能就是添加 Vnodes 到 真实 DOM 中
function addVnodes(
parentElm: Node,
before: Node | null,
vnodes: Array<VNode>,
startIdx: number,
endIdx: number,
insertedVnodeQueue: VNodeQueue
) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx];
if (ch != null) {
api.insertBefore(
parentElm,
createElm(ch, insertedVnodeQueue),
before
);
}
}
}
removeVnodes 方法
删除 VNodes 的主要逻辑如下:
- 循环触发 destroy 钩子,递归触发子节点的钩子
- 触发 remove 钩子,利用 createRmCb , 在所有监听器执行后,才调用 api.removeChild,删除真正的DOM节点
function createRmCb(childElm: Node, listeners: number) {
return function rmCb() {
if (--listeners === 0) {
const parent = api.parentNode(childElm);
api.removeChild(parent, childElm);
}
};
}
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 {
api.removeChild(parentElm, ch.elm as Node);
}
}
}
}
createElm 方法
将 vnode 转换成真正的 DOM 元素
主要逻辑如下:
- 触发 init 钩子
- 处理注释节点
- 创建元素并设置 id , class
- 触发模块 create 钩子
- 处理子节点
- 处理文本节点
- 触发 vnodeData 的 create 钩子
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any,
data = vnode.data;
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) {
const hashIdx = sel.indexOf('#');
const dotIdx = sel.indexOf('.', hashIdx);
const hash = hashIdx > 0 ? hashIdx : sel.length;
const dot = dotIdx > 0 ? dotIdx : sel.length;
const tag =
hashIdx !== -1 || dotIdx !== -1
? sel.slice(0, Math.min(hash, dot))
: sel;
const elm = (vnode.elm =
isDef(data) && isDef((i = (data as VNodeData).ns))
? api.createElementNS(i, tag)
: api.createElement(tag));
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
if (dotIdx > 0)
elm.setAttribute('class',sel.slice(dot + 1).replace(/\./g, ' '));
for (i = 0; i < cbs.create.length; ++i)
cbs.create[i](emptyNode, vnode);
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;
if (isDef(i)) {
if (i.create) i.create(emptyNode, vnode);
if (i.insert) insertedVnodeQueue.push(vnode);
}
} else {
vnode.elm = api.createTextNode(vnode.text as string);
}
return vnode.elm;
}
五、钩子
源码路径 : ./src/hooks.ts
这个文件主要是定义了 Virtual Dom 在实现过程中,在其执行过程中的一系列钩子。方便外部做一些处理:
export interface Hooks {
pre?: PreHook;
init?: InitHook;
create?: CreateHook;
insert?: InsertHook;
prepatch?: PrePatchHook;
update?: UpdateHook;
postpatch?: PostPatchHook;
destroy?: DestroyHook;
remove?: RemoveHook;
post?: PostHook;
}
六、模块
|