百度百科:
虚拟DOM中采用的算法
把树形结构按照层级分解,只比较同级元素。不同层级的节点只有创建和删除操作。
把列表结构的每个单元添加唯一的key属性,方便比较。
虚拟DOM的目的是实现最小更新。
Diff算法是用于对比虚拟DOM树并更新视图的优化算法。
对比包括两个过程:
完整对比一个树结构数据,时间复杂度是O(n^2)。
n是树的层级。
对于两个树结构的变化,若要达到最小更新,首先要对比每个节点是否相同,也就是:
for (var x = 0; x < n; x++ ) {
// 遍历旧节点树的层级:n
var oldVnode = oldTree[x] // 获取该层的某个节点
for (var y = 0; y < n; y++) {
// 遍历新节点树的层级:n^2
var newVnode = newTree[y]
// 判断oldVnode 和 newVnode 是否相同
if (oldVnode == newVnode) {
// ...
} else {
//...
}
}
}
找到差异后还要计算最小转换方式,将差异渲染到视图中,这个操作又会遍历一遍。(这个过程不知道如何表达)
所以使用完整对比方式的时间复杂度为O(n^3)。
Diff算法的背景是对比新旧DOM树,前端操作中一般不会出现节点跨层级移动或修改。
所以Diff算法只需要对比同层级节点即可。
Snabbdom中根据节点对象的属性 key 和 sel是否相同判断两个节点是否相同,根本不需要判断子节点是否相同。省略了n^2的遍历。
所以Diff算法的时间复杂度为O(n),效率高于 完整对比。
Prev Last
A A
/ \ / \
/ \ / \
B D ====> D B
/ \
C C
例如对比上面两个树结构:
当前查看[email protected]版本,查看以安装的node_modules/snabbdom目录:
snabbdom使用gulp打包。
查看源码过程主要查看 examples 和 src 目录。
Snabbdom官方总共提供4个示例,两个svg 和 两个列表。
分析源码主要查看 h.ts、 snabbdom.ts、 vnode.ts。
源码中使用到了函数重载的概念。
函数重载一般指重载函数。
在Java中,重载函数是指多个同名的函数,它们形参的个数或类型不同,调用这个名称的函数时,编译器根据实参和形参的个数和类型,匹配对应的函数。
这就实现了调用同一个(同名)函数,完成不同的功能。
在JavaScript中没有 重载 的概念,但也可以通过判断 实参和形参的个数和类型,执行对应的功能,来实现 重载。
TypeScript中有 重载 ,不过 重载 的实现还是通过代码调整参数。
总结来说,重载指的是对同一个名称的函数进行多次定义,每次定义的形参个数或类型不同,实现一个函数可以完成多个功能。
JavaScript 不支持重载,多次定义同名函数,会被覆盖。
// 定义两个参数个数不同的add函数
function add(a, b, c) {
console.log(a + b + c)
}
// 这里将重新覆盖add函数
function add(a, b) {
console.log(a + b)
}
add(1, 2) // 3
add(1, 2, 3) // 3
实现重载:
function add(a, b, c) {
if (c !== undefined) {
console.log(a + b + c)
} else {
console.log(a + b)
}
}
add(1, 2) // 3
add(1, 2, 3) // 6
snabbdom源码使用TypeScript语法。
// 导入依赖的模块
import {vnode, VNode, VNodeData} from './vnode';
// 定义并导出了一些类型
export type VNodes = Array;
export type VNodeChildElement = VNode | string | number | undefined | null;
export type ArrayOrElement = T | T[];
export type VNodeChildren = ArrayOrElement
// 导入依赖的模块
import * as is from './is';
// 辅助函数:添加命名空间
function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {
// 仅仅是对元素子元素的VNodeData添加ns
data.ns = 'http://www.w3.org/2000/svg';
if (sel !== 'foreignObject' && children !== undefined) {
for (let i = 0; i < children.length; ++i) {
let childData = children[i].data;
if (childData !== undefined) {
addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel);
}
}
}
}
// 定义了 h 函数的4种形式的重载
// 只定义了形式,未定义实现,仅仅用于智能提示
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;
// 真正定义了实现的 h 函数
export function h(sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}, children: any, text: any, i: number;
if (c !== undefined) {
// 判断传了3个参数的形式
// 该形式下:
// b是VNodeData,存储vnode数据的对象
// c是VNodeChildren
data = b;
// 判断第三个参数
// 数组:子元素列表
// 原始类型(字符串/数字):文本节点
// 包含sel属性的对象:VNode,转化成数组,便于处理
if (is.array(c)) { children = c; }
else if (is.primitive(c)) { text = c; }
else if (c && c.sel) { children = [c]; }
} else if (b !== undefined) {
// 判断只传了2个参数的形式
// 判断第二个参数
// 数组:VNodeChildren
// 原始类型:文本节点
// 包含sel属性的对象:VNode
// 其他情况:VNodeData
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) {
// 处理children种的原始值(string/number)
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] === '#')
) {
// 如果是svg,添加命名空间
addNS(data, children, sel);
}
// 返回 VNode(通过vnode函数创建的虚拟节点)
return vnode(sel, data, children, text, undefined);
};
// 导出模块
export default h;
函数重载:
在真正定义 h 函数之前,定义了 h 函数4种形式的重载。
TypeScript支持重载,而JavaScript不支持重载,TypeScript最终会被编译成JavaScript,所以这里仅仅定义了4个重载的形式,并没有相应的实现。
这4种重载相应的实现,都在第五次定义的 h 函数中。
定义4种重载的形式的目的是利用编辑器的智能提示。
导出方式:
源码中使用了两种导出 h 函数的方法 :
这样的目的是为了使用时更方便(主要是snabbdom内部):
import { h } from '...'
导入import h from '...'
导入总结:
h() 函数的作用就是调用 vnode() 函数创建并返回一个虚拟节点。
主要关注VNode接口,和 vnode() 函数如何实现。
接口(interface)是TypeScript的语法,它的作用是约束实现这个接口的对象都拥有相同的指定属性。
VNode 接口:
children和text是互斥的,二者只能存在一个,另一个为undefined
// 导入依赖的模块
import {Hooks} from './hooks';
import {AttachData} from './helpers/attachto'
import {VNodeStyle} from './modules/style'
import {On} from './modules/eventlisteners'
import {Attrs} from './modules/attributes'
import {Classes} from './modules/class'
import {Props} from './modules/props'
import {Dataset} from './modules/dataset'
import {Hero} from './modules/hero'
// 定义并导出了3种类型:1个type 和 2个接口
export type Key = string | number;
// 接口:约束实现这个接口类型的对象,都拥有相同的属性
// 虚拟DOM类型
export interface VNode {
// selector 选择器
sel: string | undefined;
// 节点数据:属性/样式/事件等,它的类型由VNodeData接口定义
data: VNodeData | undefined;
// 子节点,和 text 互斥
children: Array | undefined;
// element:存储VNode转化的真实DOM
elm: Node | undefined;
// 节点种的内容,和 children 互斥
text: string | undefined;
// 用于优化
key: Key | undefined;
}
// 节点数据类型:?代表可选
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; // for SVGs
fn?: () => VNode; // for thunks
args?: Array; // for thunks
[key: string]: any; // for any other 3rd party module
}
// 定义vnode函数,创建vnode,它是一个VNode对象
export function vnode(sel: string | undefined,
data: any | undefined,
children: Array | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
// VNode类型的key属性,由data传递
let key = data === undefined ? undefined : data.key;
// 返回与VNode类型匹配的对象,这个对象用于描述虚拟节点
return {sel, data, children, text, elm, key};
}
// 导出vnode函数
export default vnode;
源码在 src/snabbdom.ts 文件中,核心函数是 patch(oldVnode, newVnode),它是整个Snabbdom的核心,俗称“打补丁”。
patch 把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点。
init() 函数内部返回了 patch() 函数,所以学习 patch() 函数之前,需要看看 init() 函数内部做了哪些工作。
在函数内部返回一个函数,被称为高阶函数。
Vue中旧使用了很多高阶函数。
高阶函数的好处是为了让(返回的)函数使用更方便:
Snabbdom入口函数init生成patch函数,在init函数内部缓存了两个参数,即在返回的patch函数中可以通过闭包访问到init中初始化的模块和DOM操作的api。
// snabbdom.ts
/* global module, document, Node */
// 导入依赖的模块(接口类型 和 方法)
import {Module} from './modules/module';
import {Hooks} from './hooks';
import vnode, {VNode, VNodeData, Key} from './vnode';
import * as is from './is';
import htmlDomApi, {DOMAPI} from './htmldomapi';
// 定义了一些类型 和 辅助函数
function isUndef(s: any): boolean { return s === undefined; }
function isDef(s: any): boolean { return s !== undefined; }
type VNodeQueue = Array;
const emptyNode = vnode('', {}, [], undefined, undefined);
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
function isVnode(vnode: any): vnode is VNode {
return vnode.sel !== undefined;
}
type KeyToIndexMap = {[key: string]: number};
type ArraysOf = {
[K in keyof T]: (T[K])[];
}
type ModuleHooks = ArraysOf;
function createKeyToOldIdx(children: Array, beginIdx: number, endIdx: number): KeyToIndexMap {
// 省略的代码
}
// 创建一个常量,存放了钩子函数的名字
const hooks: (keyof Module)[] = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
// 导出 h thunk init 3个方法
export {h} from './h';
export {thunk} from './thunk';
/**
* 定义并导出 init 函数
* @param modules 模块:处理样式、属性、事件等
* @param domApi (可选)DOM操作的方法
*/
export function init(modules: Array>, domApi?: DOMAPI) {
let i: number, j: number, cbs = ({} as ModuleHooks);
// 初始化转换虚拟节点的 API
// 默认是htmlDomApi对象,该对象封装了一些DOM操作的方法
// 如将虚拟DOM转化为真实DOM,删除、添加、插入DOM等
// 如果想把虚拟DOM转化为 HTML 字符串 或者 转化成其他类型的内容
// 可以通过传递 domApi, 传入一些自定义操作,把虚拟DOM转化成具体想要的内容
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
// 循环:
// 把传入的所有模块的钩子函数,统一存储到 cbs(callbacks) 对象中
// 将同一钩子的函数存储在一起,方便统一调用
// cbs.create = [], cbs.update = []...
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).push(hook);
}
}
}
function emptyNodeAt(elm: Element) {
// 省略的代码
}
function createRmCb(childElm: Node, listeners: number) {
// 省略的代码
}
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
// 省略的代码
}
function addVnodes(parentElm: Node,
before: Node | null,
vnodes: Array,
startIdx: number,
endIdx: number,
insertedVnodeQueue: VNodeQueue) {
// 省略的代码
}
function invokeDestroyHook(vnode: VNode) {
// 省略的代码
}
function removeVnodes(parentElm: Node,
vnodes: Array,
startIdx: number,
endIdx: number): void {
// 省略的代码
}
function updateChildren(parentElm: Node,
oldCh: Array,
newCh: Array,
insertedVnodeQueue: VNodeQueue) {
// 省略的代码
}
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
// 省略的代码
}
// init 内部返回 patch 函数
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
// 省略的代码
return vnode;
};
}
patch()函数是Snabbdom的核心,它的作用是对比两个vnode,把vnode的差异渲染到真实DOM,并返回新的vnode。
// init 内部返回 patch 函数
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
// 定义一个常量:存储新插入节点的队列,为了方便触发这些节点上设置的钩子函数
const insertedVnodeQueue: VNodeQueue = [];
// 执行所有模块的 pre 钩子函数
// pre:预处理,处理节点之前先执行的内容
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// oldVnode 可以是两种类型: VNode | Element
// 判断 oldVnode 不是 VNode(那就是Element)
if (!isVnode(oldVnode)) {
// 将其转化成 空的VNode
oldVnode = emptyNodeAt(oldVnode);
}
// 判断是否是相同的节点(key和sel相同)
if (sameVnode(oldVnode, vnode)) {
// 相同节点
// patchVnode 找节点的差异并更新 DOM
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 不是相同节点:用新节点创建真实DOM,插入旧节点前面,然后删除就旧节点
// 获取当前 DOM 元素
elm = oldVnode.elm as Node;
// 获取当前 DOM 元素的父节点
parent = api.parentNode(elm);
// 调用createElm:
// 1. 将 vnode 转化成真实DOM 并存储到 vnode.elm 属性上
// 2. 触发 init/create 钩子函数
// 3. 将新插入的节点(vnode)添加到队列(insertedVnodeQueue)
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 如果存在父节点,在旧节点位置之后插入新节点对应的 DOM
api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
// 移除旧节点
removeVnodes(parent, [oldVnode], 0, 0);
}
}
// 遍历新插入的节点,并触发节点中的insert钩子函数
for (i = 0; i < insertedVnodeQueue.length; ++i) {
(((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
}
// 执行模块的 post 钩子函数
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
return vnode;
};
使用一开始的代码演示调试patch执行过程,调用patch的位置打断点。
调试中遇到一个小问题:
由于查看项目中的node_modules/snabbdom源码时,直接将注释写在了文件中。
打包调试时,第一次点击F11本应进入patch函数内部,但是缺进入了patchVnode,并且直到执行结束也没有进入patch函数。
而且在patch函数内部无法打断点(行号为灰色)。
在网上查找的解决方法说,左下角出现了{}
图标,说明调试工具将这个文件认为是压缩过的,所以不能断点调试。
点击{}
重新格式化代码即可打断点。
但是尝试后发现会新打开一个:formatted
的文件,在里面确实可以打断点,但依然没有进入patch函数内部。
回想自己做的事情,只有添加了注释,于是将源码恢复最初的样子,再次运行,可以正常调试。
所以问题出在添加的注释上,而调试工具中查看源码是通过Source Map。
Source Map存储的是代码行列的映射。
所以问题的原因就是在源文件增加的注释影响了行的位置,但是map文件使用的是模块打包好的,所以在调试时定位位置错误。
于是尝试恢复注释,重新打包生成map文件,再次调试就正常了。
在 patch() 函数内部调用了3个复杂的函数:createElm()、patchVnode()、removeVnodes()。
createElm() 函数的作用:
sel
为!
时:创建注释节点
sel
不为空时:
sel
为空时:创建文本节点function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any, data = vnode.data;
if (data !== undefined) {
// 执行用户设置的 init 钩子函数
if (isDef(i = data.hook) && isDef(i = i.init)) {
i(vnode);
data = vnode.data;
}
}
// 把 vnode 转换成 真实DOM对象,并存储到vnode的elm属性上(没有渲染到页面)
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) {
// 选择器不为空
// 解析选择器:
// 解析的顺序默认把id选择器放在了class选择器前面
// 所以sel传值的时候id应该在class前面,否则会出问题
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;
// 创建普通标签 或 带有命名空间(ns)的标签
const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag)
: api.createElement(tag);
// 设置id
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
// 设置class
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);
if (is.array(children)) {
// 如果 vnode 中有子节点,创建子 vnode 对应的 DOM 元素,并追加到 DOM 树上
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)) {
// 如果是原始值(string/number),表示文本内容,则创建文本节点,并追加到 DOM 树上
api.appendChild(elm, api.createTextNode(vnode.text));
}
// 获取钩子函数对象
i = (vnode.data as VNodeData).hook; // Reuse variable
if (isDef(i)) {
// 执行用户传入的钩子 create
if (i.create) i.create(emptyNode, vnode);
// 判断如果有 insert 钩子函数,就把 vnode 添加到队列中,为后续执行 insert 钩子做准备
// patch函数在最后负责执行队列中 vnode 的insert钩子函数
if (i.insert) insertedVnodeQueue.push(vnode);
}
} else {
// 选择器为空,表示是文本节点
vnode.elm = api.createTextNode(vnode.text as string);
}
// 返回创建好的DOM元素
return vnode.elm;
}
演示如何传入用户定义的钩子函数
let vnode = h(
'div#container.cls',
{
hook: {
init(vnode) {
console.log(vnode.elm)
},
create(emptyVnode, vnode) {
console.log(vnode.elm)
}
}
},
'hello world!'
)
// 批量添加节点
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) {
// 如果节点不为空,创建真实DOM,插入到参考节点前
api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before);
}
}
}
// 调用模块、节点和子节点的 destroy 钩子函数
function invokeDestroyHook(vnode: VNode) {
let i: any, j: number, data = vnode.data;
if (data !== undefined) {
// 执行用户设置的 destroy 钩子函数
if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
// 执行模块的 destroy 钩子函数
for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
// 如果有子节点并且不是原始值,递归调用invokeDestroyHook,执行 destroy 钩子函数
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 removeVnodes(parentElm: Node,
vnodes: Array<VNode>,
startIdx: number,
endIdx: number): void {
// 循环vnodes数组中指定范围的每一个元素
for (; startIdx <= endIdx; ++startIdx) {
// ch获取当前遍历的元素
let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx];
// 判断元素为null不做任何处理
if (ch != null) {
if (isDef(ch.sel)) {
// 如果是元素节点
// 执行 destroy 钩子函数
// invokeDestroyHook 会执行当前节点及所有子节点的 destroy 钩子函数
invokeDestroyHook(ch);
// 获取模块中 remove 钩子函数的个数并+1
// 它的作用是在模块的remove钩子函数全部执行完后执行删除元素,防止多次删除元素
listeners = cbs.remove.length + 1;
// createRmCb也是一个高阶函数,创建并返回一个用于删除元素的回调函数
rm = createRmCb(ch.elm as Node, listeners);
// 执行模块的 remove 钩子函数
// 这里rm不会被调用
for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
// 这里rm最终会被调用
if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
// 获取并调用 用户设置 的 remove 钩子函数
i(ch, rm);
} else {
// 如果用户没有设置 remove 钩子函数,直接调用删除元素的回调函数
rm();
}
} else { // Text node
// 如果是文本节点,直接调用删除元素的方法
api.removeChild(parentElm, ch.elm as Node);
}
}
}
}
patchVnode(oldVnode, newVnode) 函数的作用是对比新旧两个节点,更新它们的差异。
整体执行过程:
// patch函数内部调用patchVnode之前会判断心就节点是否相同(key和sel相同)
// 如果相同才会调用patchVnode,对比二者的差异
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
let i: any, hook: any;
// 首先执行用户设置的 prepatch 钩子函数
if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
i(oldVnode, vnode);
}
// 获取旧节点对应的DOM元素
const elm = vnode.elm = (oldVnode.elm as Node);
// 获取旧节点的子节点
let oldCh = oldVnode.children;
// 获取新节点的子节点
let ch = vnode.children;
// 如果新旧节点相同,什么也不做
if (oldVnode === vnode) return;
// 暂未发现 vnode.data === undefined 的场景
if (vnode.data !== undefined) {
// 执行模块的 update 函数
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
// 执行用户设置的 update 函数
i = vnode.data.hook;
if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
}
if (isUndef(vnode.text)) {
// 如果 新节点没有 text
if (isDef(oldCh) && isDef(ch)) {
// 新旧节点都有children
// 但是二者的子节点不同,调用 updateChildren (使用diff算法)对比更新
if (oldCh !== ch) updateChildren(elm, oldCh as Array, ch as Array, insertedVnodeQueue);
} else if (isDef(ch)) {
// 新节点有children,旧节点没有
// 先清空DOM元素的内容(只有文本)
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
// 批量添加新节点的children
addVnodes(elm, null, ch as Array, 0, (ch as Array).length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 旧节点有children,新节点没有
// 说明新节点是个空元素,直接移除所有子元素
removeVnodes(elm, oldCh as Array, 0, (oldCh as Array).length - 1);
} else if (isDef(oldVnode.text)) {
// 旧节点有 text
// 仅清空文本内容即可
api.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
// 如果 新节点有 text, 但是与旧节点的text不同
// 只需更新节点中的内容 为 新的文本内容
if (isDef(oldCh)) {
// 如果旧节点有children
// 移除所有子节点
removeVnodes(elm, oldCh as Array, 0, (oldCh as Array).length - 1);
}
// 更新文本内容
api.setTextContent(elm, vnode.text as string);
}
// 最后执行用户设置的 postpatch 钩子函数
if (isDef(hook) && isDef(i = hook.postpatch)) {
i(oldVnode, vnode);
}
}
updateChildren() 是整个 Virtual DOM 的核心,内部使用 diff 算法,对比新旧节点的 children,更新DOM 。
5种场景按顺序依次比较节点是否相同(key 和 sel 相同)
对节点的 更新 / 移动 指的是对 DOM 的操作。
获取新旧节点对比后的差异,直接通过 setTextContent 和 insertBefore 更新视图。
oldVnode只用于对比,并未被改动,所以patch方法最后返回的是 newVnode。
根据与 oldVnode 对比的结果进行处理:
Snabbdom中实现移动节点 和 插入新节点,使用的是 DOM APIs (insertBefore)方法。
insertBefore 被 父节点 调用,接收两个参数:
以下介绍的对节点的操作都是指的 oldVnode 对象。
Scene1 和 Scene2 是开始节点的新旧对比 和 结束节点的新旧对比,两种情况类似:
Scene1:
Scene2 同 Scene1 类似,只不过方向是从后往前:
Scene3:
Scene4:
Scene5:
遍历对比完 oldVnode,最后进行批量判断处理环境。
function updateChildren(parentElm: Node,
oldCh: Array,
newCh: Array,
insertedVnodeQueue: VNodeQueue) {
// 创建索引 和 索引指向的vnode
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) {
// 索引变化后,可能会把节点设置为空(实际设置为undefined,所以这里是双等号)
// 设置为空的场景参考文档 Scene5
// 节点为空移动索引
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
// 场景 Scene1 - Scene4
} 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)) { // Vnode moved right
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)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
// 场景 Scene5
// 新旧开始节点和结束节点都不相同
} else {
// 获取旧节点开始结束之间的所有节点的key和位置,组成键值对对象
// 使用 newStartVnode 的 key 在旧节点键值对中寻找相同 key 的节点
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 寻找与newStartVnode相同key的节点
idxInOld = oldKeyToIdx[newStartVnode.key as string];
if (isUndef(idxInOld)) { // New element
// 找不到,说明新节点是新增节点
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
newStartVnode = newCh[++newStartIdx];
} else {
// 找到后再判断 sel
elmToMove = oldCh[idxInOld];
// sel 不同,说明是不同的节点,按照新增节点处理
// sel 相同,说明是相同节点,按照 sameVnode 处理,类似Scene3和Scene4
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];
}
}
}
// 批量进行添加未遍历的新节点和删除未遍历的旧节点表示的DOM元素
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
// 如果就节点数组先遍历完,说明有未遍历的新节点
// 把剩余的新节点表示的DOM元素插入
before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else {
// 如果新节点数组先遍历完,说明有未遍历的旧节点
// 把剩余的旧节点表示的DOM元素移除
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}
从源码可知,给VNode设置key之后,当在对元素列表排序,或者给列表插入新项的时候会重用上一次对应的DOM对象,减少渲染次数,因此会提高性能。
也就是Scene5中判断sameVnode的场景。
Hooks 钩子是 挂载到 DOM 节点生命周期的一种方法。
模块使用钩子来扩展Snabbdom,并在常规代码中使用钩子在虚拟节点声明中的期望点执行任意代码。
源码:src/hooks.ts 文件中定义了Snabbdom中预定义的所有钩子函数。
常用钩子:
使用限制:
import {VNode} from './vnode';
// 定义每个钩子的类型
export type PreHook = () => any;
export type InitHook = (vNode: VNode) => any;
export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any;
export type InsertHook = (vNode: VNode) => any;
export type PrePatchHook = (oldVNode: VNode, vNode: VNode) => any;
export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any;
export type PostPatchHook = (oldVNode: VNode, vNode: VNode) => any;
export type DestroyHook = (vNode: VNode) => any;
export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any;
export type PostHook = () => any;
export interface Hooks {
// patch 函数开始执行的时候触发
pre?: PreHook;
// createElm 函数开始之前的时候触发
// 在把 VNode 转换成真实 DOM 之前触发
init?: InitHook;
// createElm 函数末尾调用
// 创建完真实 DOM 后触发
create?: CreateHook;
// patch 函数末尾执行
// 真实 DOM 添加到 DOM 树种触发
insert?: InsertHook;
// patchVnode 函数开头调用
// 开始对比两个 VNode 的差异之前触发
prepatch?: PrePatchHook;
// patchVnode 函数开头调用
// 两个 VNode 对比过程中触发,比 prepatch 稍晚
update?: UpdateHook;
// patchVnode 的最末尾调用
// 两个 VNode 对比结束执行
postpatch?: PostPatchHook;
// removeVnodes -> invokeDestroyHook 中调用
// 在删除元素之前触发,子节点的 destroy 也会被触发
destroy?: DestroyHook;
// removeVnodes 中调用
// 元素被删除的时候触发
remove?: RemoveHook;
// patch 函数的最后调用
// patch 全部执行完毕触发
post?: PostHook;
}
// src/modules/module.ts
import {PreHook, CreateHook, UpdateHook, DestroyHook, RemoveHook, PostHook} from '../hooks';
export interface Module {
pre: PreHook;
create: CreateHook;
update: UpdateHook;
destroy: DestroyHook;
remove: RemoveHook;
post: PostHook;
}
import {VNode, VNodeData} from '../vnode';
import {Module} from './module';
// 定义一些类型
// because those in TypeScript are too restrictive: https://github.com/Microsoft/TSJS-lib-generator/pull/237
declare global {
interface Element {
setAttribute(name: string, value: string | number | boolean): void;
setAttributeNS(namespaceURI: string, qualifiedName: string, value: string | number | boolean): void;
}
}
export type Attrs = Record
// 定义一些常量
const xlinkNS = 'http://www.w3.org/1999/xlink';
const xmlNS = 'http://www.w3.org/XML/1998/namespace';
const colonChar = 58;
const xChar = 120;
// 定义模块核心函数
function updateAttrs(oldVnode: VNode, vnode: VNode): void {
var key: string, elm: Element = vnode.elm as Element,
oldAttrs = (oldVnode.data as VNodeData).attrs,
attrs = (vnode.data as VNodeData).attrs;
// 如果新旧节点都没有 attrs 属性,直接返回
if (!oldAttrs && !attrs) return;
// 如果新旧节点的 attes 属性相同,直接返回
if (oldAttrs === attrs) return;
oldAttrs = oldAttrs || {};
attrs = attrs || {};
// update modified attributes, add new attributes
// 遍历新节点所有属性,与旧节点同key的属性对比,更新修改过的属性,添加新增的属性
for (key in attrs) {
const cur = attrs[key];
const old = oldAttrs[key];
// 如果属性相同就不需做任何操作
if (old !== cur) {
// 如果新节点属性值是 布尔类型时:selected checked
if (cur === true) {
elm.setAttribute(key, "");
} else if (cur === false) {
elm.removeAttribute(key);
// 新节点属性值不是 布尔类型时
} else {
// 判断是否是命名空间的属性:
// xml xmlns 属性可以在文档中定义命名空间
// xmlns:
模块中的工作其实就是定义一些钩子函数,在DOM元素创建好后,对DOM元素做一些额外的操作,例如属性、样式、事件 或 自定义的操作。
使用模块的时机是init()函数的开始位置,遍历传入的所有模块并整理到cbs对象中。
在patch()函数执行到特定时机时,执行cbs中所有的钩子函数。