首先先简单介绍一下虚拟DOM和diff算法
diff算法的简单介绍
//旧节点
<div class="box">
<h3>我是标题</h3>
<ul>
<li>牛奶</li>
<li>咖啡</li>
<li>可乐</li>
</ul>
</div>
变为
//新节点
<div class="box">
<h3>我是标题</h3>
<span>我是新span</span>
<ul>
<li>牛奶</li>
<li>咖啡</li>
<li>可乐</li>
<li>雪碧</li>
</ul>
</div>
在vue中,新节点通过v-if将span标签呈现到dom中,然后往数组中push了一项雪碧,那么此时就有一个难题,总不能把旧节点全部拆掉,把新节点替换上去吧,虽然计算机处理速度很快,但是面对更大型的DOM,那么代价还是有点昂贵。
面对上述问题,下面就是我们所讲的diff算法。其实我们只需要把span标签和雪碧插入到旧节点的位置,那么这就是diff算法,它可以进行精细化比对,实现最小量更新。
虚拟DOM的简单介绍
真实DOM
<div class="box">
<h3>我是标题</h3>
<ul>
<li>牛奶</li>
<li>咖啡</li>
<li>可乐</li>
</ul>
</div>
虚拟DOM
{
"sel": "div",
"data": {
"class": { "box": true }
},
"children": [
{
"sel": "h3",
"data": {},
"text": "我是标题"
},
{
"sel": "ul",
"data": {},
"children": [
{ "sel": "li", "data": {}, "text": "牛奶" },
{ "sel": "li", "data": {}, "text": "咖啡" },
{ "sel": "li", "data": {}, "text": "可乐" }
]
}
]
}
一句话就是:虚拟DOM就是把真实DOM抽象成数据来表示。
接下来介绍的有如下内容:
- snabbdom简介
h函数介绍及手写snabbdom的h函数(虚拟DOM如何被渲染函数(h函数)产生)- snabbdom的
diff算法原理 - 虚拟DOM如何通过
diff变为真正的DOM(涵盖在diff算法当中)
snabbdom简介
snabbdom是著名的虚拟DOM库,是diff算法的鼻祖,Vue源码借鉴了snabbdom。其中介绍了diff算法、子节点的更新策略等。
官方git:github.com/snabbdom/sn…
本次介绍不研究DOM如何变为虚拟DOM,属于模板编译原理范畴。
h函数介绍
h函数用来产生虚拟节点(vnode),比如这样调用h函数:
h('a',{props:{href:'http://www.baidu.com'}},'百度一下')
将得到这样的虚拟节点。
{'sel':'a','data':{props:{href:'http://www.baidu.com'}},'text':'百度一下'}
它表示真正的DOM节点:
<a href="http://www.baidu.com">百度一下</a>
一个虚拟节点有如下属性:
{
children:undefined,
data:{},
elm:undefined,
key:undefined,
sel:'div',
text:'我是盒子'
}
- children:当前元素的子元素,数组类型。
- data:属性、样式等。
- elm:对应的真正的虚拟节点,如果是
undefined表示当前节点还未真正的渲染到dom中。 - key:唯一标识,例如vue中的key属性
- text:节点的文字属性
先来看snabbdom是如何使用h函数即让虚拟节点渲染到DOM中。
import {init} from 'snabbdom/build/init'
import {classModule} from 'snabbdom/build/modules/class'
import {propsModule} from 'snabbdom/build/modules/props'
import {styleModule} from 'snabbdom/build/modules/style'
import {eventListenersModule} from 'snabbdom/build/modules/eventlisteners'
import {h} from 'snabbdom/build/h'
const patch = init([
// Init patch function with chosen modules
classModule, // makes it easy to toggle classes
propsModule, // for setting properties on DOM elements
styleModule, // handles styling on elements with support for animations
eventListenersModule // attaches event listeners
])
const container = document.getElementById("container")//表示真正的DOM节点,这个节点必须通过webpack配置的静态资源下的html页面声明此元素。
const myVnode = h('p', {props:{class:{p1:true}, '我是p标签')//myVnode就是通过h函数生成的虚拟节点
patch(container, myVnode) //patch函数就是比较渲染在页面中的DOM和将要进行比较的myVode虚拟结点,然后将虚拟节点就会渲染到DOM中。
h函数可以嵌套使用,从而得到虚拟DOM,比如这样使用:
h('ul', {}, [
h('li', {}, [
h('p',{},'p1'),
h('p',{},'p2')
]),
h('li', {}, 'p3')
])
得到这样的虚拟DOM树:
看了大概使用方法及概念后,下面就开始手写h函数:
声明:此函数为低配版,只写了核心功能!
import vnode from "./vnode"
// 形态一 h('div',{},'文字')
// 形态二 h('div',{},[])
// 形态三 h('div',{},h())
export default function (sel, data, c) {
if (arguments.length !== 3) {
throw Error("h函数参数必须是3个")
} else if (typeof c === 'string' || typeof c === 'number') {
// 第一种形态:直接返回
return vnode(sel, data, undefined, c, undefined)
} else if (Array.isArray(c)) {
// 第二种形态:对子元素进行Vnode函数处理,最终返回虚拟节点。
let children = []
for (let i = 0; i < c.length; i++) {
// 这里不用执行c[i],测试用例已执行过
if (!(c[i] && c[i].hasOwnProperty('sel'))) {
throw Error("参数不是h函数")
}
// 收集children
children.push(c[i])
}
return vnode(sel, data, children, undefined, undefined)
} else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
// 第三种形态
let children = [c]
return vnode(sel, data, children, undefined, undefined)
} else {
throw Error("参数不对")
}
}
接下来就是核心函数patch,大体思维是这样:
import vnode from "./vnode"
import createElement from './createElement'
import patchVnode from './patchVnode'
export default function (oldVnode, newVnode) {
// 如果是真实节点,将其包装为虚拟节点。
if (!oldVnode.sel) {
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
}
// 判断新老元素是否为同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
console.log('是同一个节点,进行精细化比较');
// 维护一个函数来进行递归
patchVnode(oldVnode, newVnode)
} else {
console.log('删除旧的,插入新的');
let newVnodeElm = createElement(newVnode)
// 以父元素为准(parentNode),插入到老节点之前
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
}
patchVnode函数(精细化比较)
import createElement from "./createElement";
import updateChildren from "./updateChildren";
export default function patchVnode(oldVnode, newVnode) {
if (oldVnode === newVnode) return
// 判断新结点中是否有text属性
if (newVnode.text !== '' && (!newVnode.children || !newVnode.children.length)) {
// console.log('新结点有text属性!');
if (newVnode.text !== oldVnode.text) {
// console.log('新结点老节点text属性相同!');
oldVnode.elm.innerText = newVnode.text
}
} else {
// console.log('newVnode中没有text属性(证明有children属性)');
// 判断老结点是否有children属性,有的话就需要进行最复杂的情况计算。。
if (oldVnode.children && oldVnode.children.length) {
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
} else {
oldVnode.elm.innerText = ""
for (let i = 0; i < newVnode.children.length; i++) {
let ch = createElement(newVnode[i])
oldVnode.elm.appendChild(ch)
}
}
}
}
updateChildren函数,接下来就是diff算法的最核心之处。
一共分为一下四种比对方法,如果都没有命中,那么就使用for循环来比较
import createElement from "./createElement";
import patchVnode from "./patchVnode";
function isSameNode(a, b) {
return a.sel === b.sel && a.key === b.key
}
export default function updateChildren(parentElm, oldch, newch) {
let oldStartIdx = 0;
let oldEndIdx = oldch.length - 1;
let oldStartVnode = oldch[0];
let oldEndVnode = oldch[oldEndIdx];
let newStartIdx = 0;
let newEndIdx = newch.length - 1;
let newStartVnode = newch[newStartIdx];
let newEndVnode = newch[newEndIdx];
let keyMap = null;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 略过undefined的项
if (!oldStartVnode || !oldch[oldStartIdx]) {
oldStartVnode = oldch[++oldStartIdx]
} else if (!oldEndVnode || !oldch[oldEndIdx]) {
oldEndVnode = oldch[--oldEndIdx]
} else if (!newStartVnode || !newch[newStartIdx]) {
newStartVnode = newch[++newStartIdx]
} else if (!newEndVnode || !newch[newEndIdx]) {
newEndVnode = newch[--newEndIdx]
} else if (isSameNode(oldStartVnode, newStartVnode)) {
// 命中1:新前与旧前
console.log('命中1:新前与旧前');
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldch[++oldStartIdx]
newStartVnode = newch[++newStartIdx]
} else if (isSameNode(oldEndVnode, newEndVnode)) {
// console.log('sel:',oldEndVnode.sel,newEndVnode.sel);
// console.log('key:',oldEndVnode.key,newEndVnode.key);
console.log('命中2:新后与旧后');
// 进行结点比较
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldch[--oldEndIdx]
newEndVnode = newch[--newEndIdx]
} else if (isSameNode(oldStartVnode, newEndVnode)) {
console.log(oldStartVnode, newEndVnode, 'inner');
console.log('命中3:新后与旧前');
// 进行结点比较。
patchVnode(oldStartVnode, newEndVnode)
// 顺序替换:将老结点移动到旧后的后面。
parentElm.insertBefore(oldStartVnode.elm, oldStartVnode.elm.nextsibling)
oldStartVnode = oldch[++oldStartIdx]
newEndVnode = newch[--newEndIdx]
} else if (isSameNode(newStartVnode, oldEndVnode)) {
console.log('命中4:新前与旧后');
// 进行结点比较。
patchVnode(newStartVnode, oldEndVnode)
// 顺序替换:将老结点移动到旧前的前面。
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldch[--oldEndIdx]
newStartVnode = newch[++newStartIdx]
} else {
// 四种情况都没有找到
// 制作keyMap一个映射对象,这样每次就不用都遍历老对象了。
if (!keyMap) {
keyMap = {}
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
let key = oldch[i].key
if (key !== undefined) {
keyMap[key] = i
}
}
}
let idxInOld = keyMap[newStartVnode.key]
if (idxInOld === undefined) {
// 要加项(newStartVnode)
// newStartVnode现在还不是真正的结点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
// 如果不是undefined就表示要移项(elmToMove)
let elmToMove = oldch[idxInOld]
// console.log(elmToMove, 'elmToMove');
// 把这项设置为undefined,表示已经处理完这项了。
patchVnode(elmToMove, newStartVnode)
oldch[idxInOld] = undefined;
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
}
newStartVnode = newch[++newStartIdx]
console.log(keyMap, 'keyMap');
// 查找出当前元素在老结点中的位置
}
}
// 当while条件结束有几种可能性:
// 1.新children已结束,但旧children还未结束,表示要进行旧children的删除。例如以下表示:
// 旧 :1,2,3,4,5 新:1,2,3
// 2.新children未结束,但旧children已结束,表示要进行旧children的增加。
// 新:1,2,3 旧 :1,2,3,4,5
if (newStartIdx <= newEndIdx) {
console.log('新结点还有剩余结点没处理,要加项');
// before:标杆元素
// let before = !newch[newStartIdx + 1]? null : newch[newStartIdx + 1].elm
let before = !oldch[oldStartIdx] ? null : oldch[oldStartIdx].elm
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore的第二个参数如果为null,和appendChild意思一样。如果不是null,就将元素插入到标杆元素前面
parentElm.insertBefore(createElement(newch[i]), before)
}
} else if (oldStartIdx <= oldEndIdx) {
console.log('旧结点中还有剩余结点未处理,要减项');
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldch[i]) {
parentElm.removeChild(oldch[i].elm)
}
}
}
}
createElement函数(挂载到DOM树)
// 把虚拟DOM变为真实DOM
// 判断text与child属性不能共存
// 判断是否为文字
// 对DOM的child进行递归,然后把child插入到父元素真实DOM中
// 将最外层元素返回
export default function createElement(vnode) {
console.log('用来把虚拟DOM变为真实DOM');
let domNode = document.createElement(vnode.sel)
// 判断是否为文字
if (vnode.text !== "" && !vnode.children) {
domNode.innerText = vnode.text
} else if (Array.isArray(vnode.children) && vnode.children.length) {
for (let i = 0; i < vnode.children.length; i++) {
let ch = vnode.children[i]
let chNode = createElement(ch)
domNode.appendChild(chNode) //真正上树操作
}
}
vnode.elm = domNode
return domNode
}