diff算法属于是老生常谈了,也是趁着这次手写源码,把这套流程实实在在的写一遍。
之前写render函数的时候已经实现了部分patch方法,patch当时就说了有第一次从虚拟dom生成真实dom的功能,也有两个虚拟dom做比较更新的功能,前者我们已经实现,这次主要是实心dom更新。
为方便我们测试 我们先随便在一个文件写下两个dom的更新操作,我这里写在index.js里
js
// compileToFunction就是render函数会生成虚拟dom
let render1 = compileToFunction(`<ul key="1" style="color:red">
{{name}}
</ul>`)
let vm1 = new Vue({data:{name:'hk'}})
let preVnode = render1.call(vm1)
// 生成真实dom
let el = createElm(preVnode)
document.body.appendChild(el)
let render2 = compileToFunction(`<ul key="1" style="color:red; background:blue">
{{name}}
</ul>`)
let vm2 = new Vue({data:{name:'hk'}})
let nextVnode = render2.call(vm2)
// 如果没有diff算法就是直接替换 类似于下边
//setTimeout(() => {
// let newEl = createElm(nextVnode)
// el.parentNode.replaceChild(newEl,el)
//},1000)
// 上边提到patch有更新的功能 所以我们应该是用patch方法去完成dom比对和更新
patch(preVnode, nextVnode)
以下我们去完善patch方法
思路:
- 判断是不是相同节点(注意diff算法永远是同级比较),通过tag和key来比较,非相同节点,直接删除老节点换新的
- 如果是相同节点,复用旧节点,判断是不是文本,如果是文本,判断新文本和旧文本是否一致,不一致新文本赋值给就文本
- 对比属性,这要分style和其他属性,但做的操作是一样的就是,要循环遍历旧属性,看新节点上有没有没有的话删掉该属性,然后循环遍历新的属性直接赋值(新的覆盖老的)
- 对比子节点,首先判断新旧是不是都有子节点,如果都有子节点进行核心对比优化算法(下边单独说),如果只有新的有把新的挂到旧的上,如果只有旧的有,就把旧的删除
js
export function patchProps(el, oldProps = {} ,props = {}) {
// 老的属性中有 新的没有 要删除老的
let oldStyles = oldProps.style || {};
let newStyle = props.style || {};
for (const key in oldStyles) {
if(!newStyle[key]) {
el.style[key]=''
}
}
for (const key in oldProps) {
if(!props[key]) {
el.removeAttribute(key)
}
}
// 用新的覆盖老的
for (let key in props) {
if(key === 'style') {
for (const styleName in props.style) {
el.style[styleName] = props.style[styleName]
}
} else {
el.setAttribute(key, props[key])
}
}
}
export function patch(oldVnode, vnode) {
// 初渲染流程
const isRealElement = oldVnode.nodeType // nodeType 原生方法
if(isRealElement) {
const elm = oldVnode //真实元素
const parentElm = elm.parentNode //父元素
let newEle = createElm(vnode)
parentElm.insertBefore(newEle, elm.nextSibling)
parentElm.removeChild(elm)
return newEle
} else {
patchVnode(oldVnode, vnode)
}
}
function patchVnode(oldVnode, vnode) {
// diff 算法
// 1、非相同节点 直接删除老的换新的 没有对比
// 2、两个节点是同一节点(判断节点的tag和key)比较两个节点的属性是否有差异
// 3、节点比较完毕后需要比较两个儿子
if(!isSameVnode(oldVnode, vnode)) { // tag === tag key === key
let el = createElm(vnode)
oldVnode.el.parentNode.replaceChild(el, oldVnode.el)
return el
}
// 文本的情况 文本我们期望比较内容
let el = vnode.el = oldVnode.el
if(!oldVnode.tag) { // 是文本
if(oldVnode.text !== vnode.text) {
el.textContent = vnode.text
}
}
// 是标签 比对属性
patchProps(el, oldVnode.data, vnode.data)
// 比较子节点 一方有儿子一方没儿子
// 两方都有儿子
let oldChildren = oldVnode.children || []
let newChildren = vnode.children || []
if(oldChildren.length>0 && newChildren.length >0) {
// 完整diff算法
updateChildren(el, oldChildren, newChildren)
}else if(newChildren.length > 0) { // 没有老的,新的
mountChildren(el, newChildren)
}else if(oldChildren.length>0) { // 新的没有 老的有 删除
// unmountChildren(el, oldChildren)
el.innerHTML = ''
}
return el
}
function mountChildren(el, newChildren) {
for (let i = 0; i < newChildren.length; i++) {
let child = newChildren[i]
el.appendChild(createElm(child))
}
}
新旧都有子节点会调用updateChildren去比对子节点。 这里其实就是对数组常见的操作方法进行优化例如:push,pop,shift,unshift,sort,reverse, 而vue比对方法,采用了双指针的方式进行循环比对(新头,旧头,新尾,旧尾)新或旧头大于尾则循环停止
循环结束 如果新头还是小于等于新尾则,将新的剩余节点插入,因为有可能是尾指针向前导致跳出循环,所以要判断新尾节点后还有没有节点有则插入到后一个节点之前。
如果是旧头小于等于旧尾则删除掉尾部节点
- 新头跟旧头比,相同则递归比较两个的子节点,同时新头旧头+1
- 新尾和旧尾对比,相同则递归比较子节点,同时新尾旧尾-1
- 交叉比对,应对 abcd => dabc这种情况旧尾和新头对比,相同递归比较子节点,将旧尾插入到旧头之前,同时新头+1 旧尾-1
- 交叉比对,应对 abcd => bcda 旧头和新尾进行对比,相同则递归比较子节点,将旧头插入到旧尾的下一个之前,同时旧头+1 新尾-1
- 乱序比对,仍然是为了最大限度的复用原节点,所以对原节点做了一个map,key就是原节点的key,value是他的index,新的从第一个开始从map上找如果能找到,就把旧节点对应下标的节点插入到就头前,同时将原位置标记为undefind不直接删除防止数组塌陷,然后递归对比子节点,新头后移。如果找不到则创建一个节点插入到旧头之前。
js
function updateChildren(el, oldChildren, newChildren) {
// 我们为了比较两个儿子的时候, 提升性能 有些优化手段
// vue2 中通过双指针方式进行比较
let oldStartIndex = 0;
let newStartIndex = 0;
let oldEndIndex = oldChildren.length - 1;
let newEndIndex = newChildren.length - 1;
let oldStartVnode = oldChildren[0];
let newStartVnode = newChildren[0];
let oldEndVnode = oldChildren[oldEndIndex];
let newEndVnode = newChildren[newEndIndex];
function makeIndexByKey(children) {
let map = {};
children.forEach((child, index) => {
map[child.key] = index;
});
return map;
}
let map = makeIndexByKey(oldChildren);
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
if (!oldStartVnode) {
oldStartVnode = oldChildren[++oldStartIndex];
} else if (!oldEndVnode) {
oldEndVnode = oldChildren[--oldEndIndex];
// 双方有一方头指针大于尾指针 则停止循环
} else if (isSameVnode(oldStartVnode, newStartVnode)) {
// 比较开头节点
patchVnode(oldStartVnode, newStartVnode); // 如果是相同节点 则递归比较子节点
oldStartVnode = oldChildren[++oldStartIndex];
newStartVnode = newChildren[++newStartIndex];
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// 比较结尾节点
patchVnode(oldEndVnode, newEndVnode); // 如果是相同节点 则递归比较子节点
oldEndVnode = oldChildren[--oldEndIndex];
newEndVnode = newChildren[--newEndIndex];
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// 交叉比对 abcd dabc
patchVnode(oldEndVnode, newStartVnode);
el.insertBefore(oldEndVnode.el, oldStartVnode.el);
newStartVnode = newChildren[++newStartIndex];
oldEndVnode = oldChildren[--oldEndIndex];
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// 交叉比对 abcd dabc
patchVnode(oldStartVnode, newEndVnode);
el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
newEndVnode = newChildren[--newEndIndex];
oldStartVnode = oldChildren[++oldStartIndex];
} else {
// 乱序比对
// 根据老的列表做一个映射关系,用新的去找,找到则移动,找不到则添加,最后多余的就删除
let moveIndex = map[newStartVnode.key]; //如果拿到则说明是要移动的索引
if (moveIndex !== undefined) {
let moveVnode = oldChildren[moveIndex];
el.insertBefore(moveVnode.el, oldStartVnode.el);
oldChildren[moveIndex] = undefined; // 标识这个子节点已经移走了
patchVnode(moveVnode, newStartVnode);
} else {
el.insertBefore(createElm(newStartVnode), oldStartVnode.el);
}
newStartVnode = newChildren[++newStartIndex];
}
}
if (newStartIndex <= newEndIndex) {
// 多余一个插入进去
for (let i = newStartIndex; i <= newEndIndex; i++) {
let childEl = createElm(newChildren[i]);
// 可能是向后追加 也可能是向前追加
// el.appendChild(childEl)
let anchor = newChildren[newEndIndex + 1]
? newChildren[newEndIndex + 1].el
: null;
el.insertBefore(childEl, anchor);
}
}
if (oldStartIndex <= oldEndIndex) {
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
if (oldChildren[i]) {
let childEl = oldChildren[i].el;
el.removeChild(childEl);
}
}
}
}
看下效果
不会弄屏幕动图,不太好演示,实际上已经达到效果,无论是向后追加,向前追加,交叉比对,乱序比对,都最大程度复用老节点,实现diff算法。