什么是虚构DOM?
virtual DOM,用一般js对象来形容DOM构造,因为不是实在DOM,所以称之为虚构DOM。
虚构DOM在Vue中的利用
Vue的编译器在编译模板之后,会把这些模板编译成一个渲染函数。而函数被调用的时候就会渲染并且返回一个虚构DOM的树。Vue的Virtual DOM Patching算法是基于Snabbdom的实现。
当咱们有了这个虚构的树之后,再交给一个Patch函数,负责把这些虚构DOM真正施加到实在的DOM上。在这个过程中,Vue有本身的响应式零碎来侦测在渲染过程中所依赖到的数据起源。在渲染过程中,侦测到数据起源之后就能够准确感知数据源的变动。到时候就能够依据须要从新进行渲染。当从新进行渲染之后,会生成一个新的树,将新的树与旧的树进行比照,就能够最终得出应施加到实在DOM上的改变。最初再通过Patch函数施加改变。
简略点讲,在Vue的底层实现上,Vue将模板编译成虚构DOM渲染函数。联合Vue自带的响应零碎,在应该状态扭转时,Vue可能智能地计算出从新渲染组件的最小代价并应到DOM操作上。
Snabbdom虚构DOM实现思路解读
以此html页面内容为例
body> <button id="btn">点击我扭转</button> <div id="container"></div> <script src="/xuni/bundle.js"></script></body>
const container = document.getElementById('container');const btn = document.getElementById('btn')const myVnode1 = h('ul', {}, [ h('li', {key: 'A'}, 'A'), h('li', {key: 'B'}, 'B'), h('li', {key: 'C'}, 'C'), h('li', {key: 'D'}, 'D'), h('li', {key: 'E'}, 'E')]);patch(container, myVnode1)
解读h函数
h函数就是vue中的createElement办法,它是用来创立虚构DOM,追踪DOM变动,最终返回虚构DOM,即js封装好的节点对象。
h函数的简略剖析如下:/** 低配版本的h函数,这个函数必须接管3个参数,缺一不可 相当于它的重载性能较弱 也就是说,调用的时候状态必须是以下三种之一: 状态①:h('div', {}, '文字') 状态二:h('div', {}, []) 状态三:h('div', {}, h())*/export default function(sel, data, c) { // 查看参数的个数 if (arguments.length !== 3) { throw new Error('h函数必须传入3个参数,低配版h函数'); } // 查看参数c的类型 if (typeof c === 'string' || typeof c === 'number') { // 阐明当初调用h函数就是状态① return vnode(sel, data, undefined, c, undefined); } else if (Array.isArray(c)) { // 阐明当初调用H函数就是状态② let children = [] // 遍历c,收集children for (let i = 0; i < c.length; i++) { // c[i] 必须是一个对象,如果不满足 if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel'))) { throw new Error('传入的数组参数中有项不是h函数') } // 这里不必执行c[i],因为测试语句中曾经调用了,既是执行h函数了 //此时只须要收集好就能够了 children.push(c[i]) } return vnode(sel, data,children, undefined, undefined) } else if (typeof c === 'object' && c.hasOwnProperty('sel')) { // 阐明是调用h函数状态③ // 即传入的c是惟一的孩子,间接存入children let children = [c]; return vnode(sel, data, children, undefined, undefined) } else { throw new Error('传入的第三个参数类型不对') }}
vNode函数就是用来标准h中传入的参数,将传入的参数data中的属性key提出来,作为返回值对象中的一个属性,返回整合之后的对象
// 函数的性能非常简单,就是把传入的5个参数组合成对象再返回export default function(sel, data, children, text, elm) { const key = data.key; return {sel, data, children, text, elm, key}}
调用h函数后失去的myVnode1打印如下图:
- 解读patch函数
也就是patch算法,在patch函数中传入新旧两个节点,比拟两者的不同,如雷同,则进一步进行精细化比拟,否则,在老节点之前插入新节点,并且删除旧节点,这个过程也是将虚构DOM渲染到实在DOM中,最终视图更新。
``patch.jsexport default function (oldVnode, newVnode) { // 判断传入的第一个参数,是dom节点还是虚构节点 if (oldVnode.sel === '' || oldVnode.sel == undefined) { // 传入的第一个参数是dom节点,此时要包装为虚构节点 oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode) } // console.log(oldVnode) // 判断oldv和newv是不是同一个节点 if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) { // 是则进行精细化比拟 patchVnode(oldVnode, newVnode) } else { // 不是,则暴力插入新的,删除旧的 // console.log('新老节点不是一个,则暴力插入新的,删除旧的') // 插入地位:老节点之前 let newVnodeElm = createElement(newVnode) // 插入到老节点之前 if (oldVnode.elm.parentNode && newVnodeElm) { oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm); } // 删除旧的节点 oldVnode.elm.parentNode.removeChild(oldVnode.elm) }}
其中createElement函数就是将虚构dom转换为真正dom的解决办法。
/**真正创立节点,将vnode创立为dom,插入到pivot之前*/export default function createElement (vnode) { // console.log('目标是把虚构节点', vnode, '真正变为dom'); let domNode = document.createElement(vnode.sel); // 有子节点还是文本 // 创立一个节点,这个节点当初还是孤儿节点 if (vnode.text != '' && (vnode.children == undefined || vnode.children.length === 0)) { // 它外部是文字 domNode.innerText = vnode.text } else if (Array.isArray(vnode.children) && vnode.children.length > 0) { // 它外部是子节点,须要递归创立节点 for (let i = 0; i < vnode.children.length; i++) { // 失去以后这个children let ch = vnode.children[i]; // console.log(ch) // 创立出它的dom,一旦调用createElement意味着:创立出DOM了,并且它的elm属性指向了创立出的DOM,然而还没有上树,是一个孤儿节点 let chDOM = createElement(ch) domNode.appendChild(chDOM) } // vnode.elm = domNode } // 补充elm属性 vnode.elm = domNode // 返回elm,elm是一个纯dom return vnode.elm;}
调用createElement函数之后生成真正的dom,最初dom节点通过insertBefore办法指定到旧节点之前上树。
- 精细化比拟-解读patchVnode函数
此种状况的前提是新旧节点都存在,且新旧节点惟一标识key雷同,标签名sel雷同。
patchVnode函数中次要比拟新旧虚构节点中是否有text属性,是否有子节点children
1)若新节点中有text,与旧节点相比拟,若不同,间接替换;即便旧节点没有text而是有children,也间接替换,若雷同不做解决;
2)若新节点中有子节点children,此时须要判断旧节点有无子节点children,若旧节点也有子节点,则须要更进一步子节点更新策略updateChildren,若旧节点没有子节点,则清空老节点的内容,遍历新的子节点,创立dom,上树
// 比照同一个虚构节点export default function patchVnode (oldVnode, newVnode) { // 判断新旧vnode是否是同一个对象 if (oldVnode === newVnode) return; // 判断新vnode没有没text属性 if (newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length === 0)) { console.log('新vnode有text属性') if (newVnode.text !== oldVnode.text) { // 如果新虚构节点中的text和老的虚构节点text不同,那么间接让新的text写入老的elm中即可,如果老的elm中是children,那么也会立刻隐没 oldVnode.elm.innerText = newVnode.text } } else { // 新vnode没有text属性,有children console.log('新vnode没有text属性') if (oldVnode.children != undefined && oldVnode.children.length > 0) { // 老的有children,新的也有children,就是最简单的状况,就是新老都有children updateChildren(oldVnode.elm, oldVnode.children, newVnode.children) } else { console.log('oldVnode', oldVnode) // 老的没有children,新的有children updateChildren(oldVnode.elm, oldVnode.children, newVnode.children) // 清空老的节点的内容 oldVnode.elm.innerHtml = ''; // 遍历新的vnode的子节点,创立dom,上树 for (let i = 0; i < newVnode.children.length; i++) { let dom = createElement(newVnode.children[i]); oldVnode.elm.appendChild(dom) } } }}
子节点更新测试updateChildren函数解读
上一步中提到的新老节点都有子节点时,则须要进一步的子节点比拟,大体来说就是更新节点,新增节点,删除节点,挪动节点,这个函数中利用旧节点开始标记,完结标记,新节点开始标记,完结标记四个指针地位的挪动进行指针所指地位上的元素比拟来解决什么状况下更新节点,什么状况下新增节点。export default function updateChildren (parentElm, oldCh, newCh) { console.log('我是updateChildren,oldCh', oldCh) console.log('newCh', newCh) // 旧前 let oldStartIdx = 0; // 新前 let newStartIdx = 0; // 旧后 let oldEndIdx = oldCh.length - 1; // 新后 let newEndIdx = newCh.length - 1; // 旧前节点 let oldStartVnode = oldCh[0]; // 旧后节点 let oldEndVnode = oldCh[oldEndIdx]; // 新前节点 let newStartVnode = newCh[0]; // 新后节点 let newEndVnode = newCh[newEndIdx]; // let keyMap = null; // 开始大while了 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 首先不是判断①②③④命中,而是要略过曾经加undefined标记的货色 if (oldStartVnode == null || oldCh[oldStartIdx] == undefined) { oldStartVnode = oldCh[++oldStartIdx]; } else if (oldEndVnode == null || oldCh[oldEndIdx] == undefined) { oldEndVnode = oldCh[--oldEndIdx]; } else if (newStartVnode == null || newCh[newStartIdx] == undefined) { newStartVnode = newCh[++newStartIdx]; } else if (newEndVnode == null || newCh[newEndIdx] == undefined) { newEndVnode = newCh[--newEndIdx]; } else if (checkSameVnode(oldStartVnode, newStartVnode)) { // 新前和旧前 console.log('①新前和旧前命中'); patchVnode(oldStartVnode, newStartVnode) oldStartVnode = oldCh[++oldStartIdx]; newStartVnode = newCh[++newStartIdx]; } else if (checkSameVnode(oldEndVnode, newEndVnode)) { // 新后和旧后 console.log('②新后和旧后命中'); patchVnode(oldEndVnode, newEndVnode) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (checkSameVnode(oldStartVnode, newEndVnode)) { // 新后和旧前 console.log('③新后和旧前命中'); patchVnode(oldStartVnode, newEndVnode) // 当③新后与旧前命中的时候,此时要挪动节点。挪动新前指向的这个节点到老节点的旧前的前面 // parentElm.insertBefore(createElement(oldStartVnode)) // 如何挪动节点?只有你插入一个曾经在dom树上的节点,它就会被挪动 parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (checkSameVnode(oldEndVnode, newStartVnode)) { // 新前与旧后 console.log('④新前与旧后命中'); patchVnode(oldEndVnode, newStartVnode) // 当④新前与旧后命中的时候,此时要挪动节点。挪动新前指向的这个节点到老节点的旧前的后面 // 如何挪动节点?只有你插入一个曾经在dom树上的节点,它就会被挪动 parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 都没有找到 // 寻找key的map if (!keyMap) { keyMap = {}; // 从oldStartIdx开始,到oldEndIdx完结, for (let i = oldStartIdx; i <= oldEndIdx; i++) { const key = oldCh[i].key; if (key != undefined) { keyMap[key] = i } } } console.log(keyMap) // 寻找以后这项(newStartIdx)这项在keyMap中的映射的地位序号 const idxInOld = keyMap[newStartVnode.key]; console.log(idxInOld); if (idxInOld == undefined) { // 判断,如果idxInOld是undefined示意它是全新的项 // 被退出的项(就是newStartVnode这项)当初不是真正的dom节点 parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm); } else { // 如果不是undefined,不是全新的项,而是要挪动 const elmToMove = oldCh[idxInOld]; patchVnode(elmToMove, newStartVnode); // 老的在前,新的在后 // 把这项设置为undefined,示意曾经解决完这项了 oldCh[idxInOld] = undefined; // 挪动,调用insertBedore也能够实现挪动 parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm) } // 指针下移,只挪动新的头 newStartVnode = newCh[++newStartIdx]; // newStartIdx++; } } // 持续看看有没有残余的,循环完结了start还是比old小 if (newStartIdx <= newEndIdx) { console.log('new还有残余节点没有解决,要加项。要把所有的残余节点插入到oldSartVnode之前') // 这时须要看以后的子节点,作为插入的标杆 // let before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm // console.log(before) // 遍历新的newch,增加到老的没有解决的之前 for (let i = newStartIdx; i <= newEndIdx; i++) { // insertBefore办法能够自动识别null,如果是null就会主动排到队尾去,和appendChild是统一的了 // newCh[i] 当初还没有真正的dom,所以要调用createElement()函数变为dom parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm); } } else if (oldStartIdx <= oldEndIdx) { console.log('old还有残余节点没有解决,阐明须要删除节点,(要删除项)') // 批量删除oldStart和oldEnd指针之间的项 for (let i = oldStartIdx; i <= oldEndIdx; i++) { if (oldCh[i]) { parentElm.removeChild(oldCh[i].elm) } } }}
function checkSameVnode (a, b) { return a.sel === b.sel && a.key === b.key}
新前新的没有解决的虚构节点的结尾,旧前就是旧的没有解决的虚构节点;
新后就是新的没有解决的虚构节点的最初一个,旧后就是旧的没有解决的虚构节点的最初一个;
以上代码总结来说就是子节点更新策略的四种命中形式:
①新前与旧前
②新后与旧后
③新后与旧前
④新前与旧后
命中一种就不再进行命中判断了
节点的操作分为以下几种,通过几个例子阐明:
1)新增的状况
新前节点与旧前节点进行比照是不是同一个节点,如果是同一个节点,那就不是新增也不是删除,而是更新,不须要进行节点挪动操作,而是更新节点,此时命中①;旧前与新前同时指针下移一位,再次比拟,新前等于新后或旧前等于旧后循环完结
While(新前 <= 新后 && 旧前<=就后) {
}
如果是旧节点先循环结束,而新节点还有残余,则阐明新节点中残余节点是须要被新增的
2)删除的状况
A,B旧前与新前比拟,雷同,此时旧前指针指向D,新前指针指向D,没有命中,此时开启新后与旧后(命中②)比拟,旧后的D与新后的D比拟,命中之后,两个后节点往上走,此时旧后指向C,新后指向B,毁坏了循环规定(前<后),完结循环,此时新节点循环结束
如果是新节点先循环结束,如果旧节点中还有残余节点,阐明它们是要被删除的节点
3)多删除的状况
旧前与新前开始比照到B,此时旧前指针指向C,新前指针指向D,没有命中,此时开启新后与旧后(命中②)比拟,旧后的E与新后的D比拟,没有命中,此时开启新后与旧前的比拟(命中③),新后的D与旧前的C比拟,没有命中,此时开启新前与旧后(命中④)比拟,新前的B与旧后的C比拟,依然没有命中
如果是新节点先循环结束,如果老节点中还有残余节点(旧前和新后指针两头的节点,阐明他们是要被删除的节点)
4)删除且从新排序
当④新前与旧后命中的时候,此时要挪动节点。挪动新前指向的这个节点到老节点的旧前的后面
5)倒序重新排列
当③新后与旧前命中的时候,此时要挪动节点。挪动新前指向的这个节点到老节点的旧前的前面
如果都没有命中,就须要用循环来寻找了,循环旧节点,如果新节点中没有,就将旧节点中此个设为undefined
四断定还有残余项:
①如果是旧节点先循环结束,阐明新节点中有要插入的节点
②如果是新节点先循环结束,如果老节点中还有残余节点,阐明他们是要被删除的节点
以上就是diff算法剖析我集体的学习与解读了总结下来就是:先通过h函数创立虚构dom,而后调用patch函数进行新旧虚构DOM的粗略比拟是否为同一个节点,若是暴力插入新的,删除旧的节点;不是同一个节点则进行精细化比拟patchVnode,以新节点为参照,先看新节点是不是单纯的dom节点(即有text无children),若是则更新旧的节点内容为新的节点text属性值;若不是(即有children),再看旧的节点是否有子节点,若没有,则将新节点的子节点循环插入到旧节点中;若旧节点也有子节点,那么对于新旧所有的子节点开启新一轮的精细化比拟updateChildren,即子节点更新策略(利用js的指针思维以及四种命中形式),比拟完之后将须要更新的节点上树,追加到实在的DOM上。
残缺代码:虚构dom与diff算法