虚拟 DOM 和 diff 算法
- 虚拟 dom 如何被渲染成函数(h 函数)产生?
- diff 算法的原理
- 虚拟 dom 如何通过 diff 变成真正的 dom(虚拟 dom 变成真正的 dom.也是涵盖在 diff 算法里面的)
snabbdom 是虚拟 dom 库
diff 算法是发生在虚拟 dom 上的
h 函数
init 方法返回 path,path 可以使虚拟 dom 上树
用法
h 函数可以嵌套(会产生虚拟 dom 树) 多个子节点需要使用[],但是只有一个子节点的时候不需要使用
diff
特点
- key 很重要,key 是唯一的标识,告诉 diff 算法,在更改前他们是同一个节点
- 是同一个虚拟节点(选择器相同且 key 相同)才会 diff 算法,否则暴力删除插入新的节点
- 只会同层比较,不会跨层比较,同层的时候会全部对比 在 vue 的开发当中(2 3 )两个很少出现
如何定义同一个节点
源码:key 相同且选择器要相同
diff 流程
1. 判断当前老节点是不是 vnode
1.1 是的话就直接往2走
1.2 不是的话就让他变成虚拟节点
2. 判断新老虚拟节点是不是同一个节点(key 相同,且选择器相同)
2.1 是的话就精细的比较,
2.2 不是的话就直接暴力插入新的节点,删除旧的节点(为什么先插入后删除呢,是因为直接删除掉的话就没有标杆了,位置无法固确定)
注:标杆节点是为了使用 insertBefore 方法,dom 上树有两个方法,一个就是 insertBefore,一个就是 appendChild(这个就没有标杆)
对 2.1 流程的继续延伸
1. old和new在内存中是不是同一个对象
1.1 是的话就什么都不做
1.2 不是的话执行 2
2. new有没有text属性
2.1 有的话,判断是否和old的text是否相同,
2.1.1 相同就啥都不做,
2.2.2 不相同就把old的文本改成new里面的文本(即使老的没有text也无所谓,只要不相同替换就完事了)
2.2 没有(意味着new有children)的话,执行 3
3. old有没有children
3.1 没有的话就意味着是有text,然后1. 清空old的text。2. 并且把new的children添加到dom上。
3.2 有的话就是最复杂了,就是新老都有children,需要深度diff来找到差异了
对 3.2 流程的继续延伸
(对所有的算法杂糅在一起很不好)
1.新增节点
新增节点的时候插入到所有未处理节点之前,而不是所有未处理节点之后
- 遍历 new 的所有 children
- 在 new 的遍历里面遍历 old 的 children
- 如果 new 的 child 在 old 里面存在,就给他打个存在的标记
- 需要 old 也加个指针 O,在 new 里面加指针 N,避免 old 的有没有处理对比的节点
2.删除节点
- 把没有处理的节点都
3.更新节点
(好的算法,分开)
diff 算法更新优化策略
四种命中查找
需要四个指针,新前,新后,旧前,旧后.
- 新前与旧前(命中,就都往下走)
- 新后与旧后(命中,就都往上走)
- 新后与旧前(命中,此时旧需要移动新后指向的节点到老节点的旧后的后面,也就是旧前的节点没了,需要移动到旧后的后面)(新往上走,旧往下走 )
- 新前与旧后(命中,此时就需要移动新前指向的节点到老节点的旧前的前面,也就是旧后的节点没了,需要移动到旧前的后面)(新往下走,旧往上走 )
如果四个都没有命中,
就需要循环来找了,把新里面这个界面在旧里面去循环找,
- 找到的话就以为这是位置移动了(这种情况下,在旧里面找到了,那么新前就往下走)
- 找不到就说明之前没有这个节点,那么就会把这个新前的这个节点塞到旧前节点的上面
(命中一种就不在命中判断,命中不了就继续往下判断,旧往下移,新往上移)
循环结束条件
只有新前<=新后&&旧前<=旧后,才继续执行循环,否则就循环结束了
循环结束有剩余节点情况
- 如果旧的先循环结束,那么新的就是新增节点操作.那么新节点中的剩余就是要增加的节点
- 如果新节点先循环结束,那么新的就是删除节点操作.那么老节点中的剩余就是要删除的节点
根据上面(四种命中查找)书写更新子节点方法
function updateChildren(
parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue
) {
let oldStartIdx = 0;
let 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: KeyToIndexMap | undefined;
let idxInOld: number;
let elmToMove: VNode;
let before: any;
// 这边是执行循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
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];
// 命中 1 新前与旧前
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
// 命中 2 新后与旧后
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
// 命中 3 新后与旧前
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
// 不用appendchild是因为这个旧后并不一定是最后一个节点,
// 所以这里使用的是移动节点,旧后的下一个节点
api.insertBefore(
parentElm,
oldStartVnode.elm!,
api.nextSibling(oldEndVnode.elm!)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
// 命中 4 新前与旧后
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
// 这里就是四种都没有命中,也就是最麻烦的
} else {
// 如果keyMap(oldKeyToIdx)不存在,先制作老的key的map,这样就不用每次都去使用循环的方法了
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 寻找当前这项,(newStartIdx)这项在keyMap中映射的序号关系
idxInOld = oldKeyToIdx[newStartVnode.key as string];
// 如果是undefined,那么说明旧的里面没有这个项,就是新加的
if (isUndef(idxInOld)) {
// New element 那么就创建新节点
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
} else { // 如果不是全新的,也不是undefined,那么就是要移动
elmToMove = oldCh[idxInOld];
// 如果不是同一个节点
if (elmToMove.sel !== newStartVnode.sel) {
// 别加入的项,就是newStartVnode这项,不过目前只是个vnode,需要创建节点
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
} else {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined as any;
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
}
}
// 指针也要下移
newStartVnode = newCh[++newStartIdx];
}
}
// 循环结束的时候,新前还是小于新后,那说明是从旧那里结束了,那说明还有新的节点要添加(新增的情况)
if (newStartIdx <= newEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
}
// 循环结束的时候,旧前还是小于旧后,那说明是从新那里结束了,那说明还有旧的节点要删除(删除的情况)
if (oldStartIdx <= oldEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
总结
组件使用为 snabbdom,
AST 抽象语法树
抽象语法树的本质就是一个 JS 对象; 对象包含 tag attrs type children 等属性; 模板语法-->转成字符串-->转成 AST 语法树
模板语法--->正常的 HTML 语法(算法编写难度极大)
模板语法--->抽象语法树---->正常的 HTML 语法(算法编写难度较小)
抽象语法树和虚拟节点的关系
模板语法-->抽象语法树-->渲染函数(h 函数)-->虚拟节点---经过diff--->界面