系列文章:
Virtual DOM是对DOM的抽象, 在Web中我们用JS对象来描述DOM,把DOM从浏览器抽离出来后,我们得到一个更加轻便且灵活的描述对象,不依赖于HTML解析器我们更加容易做到组件的复用和跨平台。虚拟DOM相当于一个中间层,连接我们编写的代码和真实的DOM,在真实渲染之前我们有空间去做更多优化工作,例如DOM Diff,后面会在Diff的章节中详细讲述Vue DOM Diff 的优化。
通常我们会有一个createElement方法来创建vnode,也就是上图右边的描述对象,例如React.createElement。举个🌰,我们要生成虚拟DOM,需要不断调用这个函数来生成对象。
createElement('ul', { id: 'list' }, [{ createElement('li', { class: 'item' }, ['Item 1']), createElement('li', { class: 'item' }, ['Item 2']), createElement('li', { class: 'item' }, ['Item 3']), }]) 复制代码
这里面的三个参数分别是标签名,节点的属性,和子元素,当然在react和vue中的创建函数中其他额外的配置。但是通常我们不会这样写这种又长又臭的代码,这里面的工作通常是编译时webpack通过loader来完成转换,React中的JSX和Vue中的template最终都会转化为一堆createElement
的调用。
另外我们还需要一个渲染方法,将虚拟DOM渲染成真实的DOM,首次渲染把vnode挂载在容器上标记已经进行初次挂载,当vnode更新时,调用patch方法去比对更新。
function render(vnode, container) { // 区分首次渲染和再次渲染 if (container.vnode) { patch(container.vnode, vnode, container) } else { mount(vnode, container) } container.vnode = vnode } 复制代码
事实上,Angular和Reat的变化侦测有一个共同点,那就是他们不知道哪些状态(state)变了。因此,就需要进行比较暴力的比对,React是通过虚拟DOM的的比对,Angular是使用脏检查的流程。
Vue.js的变化侦测和它们都不一样,它在一定程度上知道具体哪些状态发生了变化,这样就可以通过更细粒度的绑定来更新视图。也就是说,在Vue.js中,当状态发生变化时,它在一定程度上知道哪些节点使用了该状态,从而对这节点进行更新操作,根本不需要比对。事实上,在Vue.js1.0的时候就是这样实现的。
但是这样做其实也有一定的代价。因为粒度太细,每个绑定都会有一个对应的watcher来观察状态的变化,这样就会有一些内存开销以及依赖追踪的开销。当状态被越多的节点使用时,或者也没越复杂时,性能表现便不那么理想。
因此,Vue.js2.0开始选择了一个中等粒度的解决方案,那就是引入虚拟DOM。组件级别的watcher实例,也就是说即使组件内有10个节点使用了某个状态,但其实也只有一个watcher来观察这个状态,组件内的所有状态变更都会触发组件的渲染watcher执行,在更新组件的时候虚拟DOM被派上用场,更新的过程会去比对新旧vnode的差异,仅对更变的节点内容做操作,尽量去减少DOM操作,用js的运算成本去较少DOM的操作成本。
createElement方法接受三个参数,分别是标签名,节点的属性,和子元素集合,最基本的结构如下👇。
function createElement(tag, data, children = null) { return { tag, data, children } } 复制代码
然后我们添加为对象添加一个flag,属性标记vnode的类型,目前有HTML、TEXT、COMPONENT三种类型,这里我们只考虑元素节点和文本节点。
const vnodeType = { HTML: 'HTML', TEXT: 'TEXT', COMPONENT: 'COMPONENT' } function createElement(tag, data, children = null) { let flag if (typeof tag === 'string') { flag = vnodeType.HTML } else if (typeof tag === 'function') { flag = vnodeType.COMPONENT } else { flag = vnodeType.TEXT } return { flag, tag, data, children } } 复制代码
为了方便后续patch,在创建vnode的过程中,给children也做一个标记,这里把children的类型分为三类。
最后我们还要添加一个key和el,key是用户传入的,用过react和vue的同学都知道,它是元素的唯一标记,通常用于提高列表patch性能。el默认为空,在渲染真实DOM的时候会把真实元素存入el中。
const childType = { EMPTY: 'EMPTY', SINGLE: 'SINGLE', MULTIPLE: 'MULTIPLE' } function createElement(tag, data, children = null) { let flag if (typeof tag === 'string') { flag = vnodeType.HTML } else if (typeof tag === 'function') { flag = vnodeType.COMPONENT } else { flag = vnodeType.TEXT } let childrenFrag if (children === null) { childrenFrag = childType.EMPTY } else if (Array.isArray(children)) { const length = children.length if (length === 0) { childrenFrag = childType.EMPTY } else { childrenFrag = childType.MULTIPLE } } else { childrenFrag = childType.SINGLE if (isPrimitive(children)) { children = createTextNode(children + '') } } return { flag, tag, data, children, childrenFrag } } 复制代码
把虚拟DOM挂载到指定的容器中,首次渲染直接解析vnode,然后appendChild```到容器中,当再次渲染时条用patch方法去对比新旧vnode,替换需要变更的内容。
function render(vnode, container) { // 区分首次渲染和再次渲染 if (container.vnode) { patch(container.vnode, vnode, container) } else { mount(vnode, container) } container.vnode = vnode } 复制代码
我们先实现mount方法,让页面能够渲染vnode,然后patch部分在下一节diff中继续探讨。
mount方法接受三个参数,vnode 需要挂载的对象,container挂载容器,flagNode参考元素,如果传入flagNode,会调用insertBefore在该元素前面插入DOM,如果没有则默认使用appendChild,把元素放在容器container最后。
function mount(vnode, container, flagNode) { const { flag } = vnode if (flag === vnodeType.HTML) { mountElement(vnode, container, flagNode) } else if (flag === vnodeType.TEXT) { mountText(vnode, container) } } 复制代码
mountElement,mountText会分别调用document.createElement和document.createTextNode创建真实的DOM节点然后添加到vnode的el上,再往页面插入,与mountText不同,mountElement还需要额外处理节点的data和挂载children。
function mountElement(vnode, container, flagNode) { const { tag, data, children, childrenFrag } = vnode const dom = document.createElement(tag) vnode.el = dom if (data) { for (const key in data) { patchData(dom, key, null, data[key]) } } if (childrenFrag !== childType.EMPTY) { if (childrenFrag === childType.SINGLE) { mount(children, dom) } else if (childrenFrag === childType.MULTIPLE) { for (let i = 0; i < children.length; i++) { mount(children[i], dom) } } } flagNode ? container.insertBefore(dom, flagNode) : container.appendChild(dom) } function mountText(vnode, container) { const dom = document.createTextNode(vnode.children) vnode.el = dom container.appendChild(dom) } 复制代码
这里我们再简单说一下patchData方法,它的作用就是解析data,然后根据key类型的不同选择不同的挂载方法,例如style,会去遍历style对象,逐个把它设置到el.style上,再如下面的@事件绑定,取出事件名和回调函数,往节点身上去绑定。
另外patchData还接受prev和next参数,会去清除旧的数据,用next新数据覆盖。
function patchData(el, key, prev, next) { switch (key) { case 'style': for (const k in next) { el.style[k] = next[k] } for (const k in prev) { if (!next || !next.hasOwnProperty(k)) { el.style[k] = '' } } break case 'class': el.className = next break default: if (key[0] === '@') { if (prev) { el.removeEventListener(key.slice(1), prev) } if (next) { el.addEventListener(key.slice(1), next) } } else { el.setAttribute(key, next) } } } 复制代码
到目前为止页面已经能正常渲染虚拟DOM了,接下来我们来研究一下数据更新是如何进行Diff以达最小单位地修改视图。
diff算法是patch里面的核心算法,在我们实际操作DOM的过程中,很少有跨级元素的复用,因此vue和react的diff算法都是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有O(n)。
在比较子节点的时候,vue在updateChildren做了一些优化,主要是去猜children的头尾节点,因为我们大多数操作都是把元素移动到头部或者尾部,或者在头尾或者其他位置插入一些元素,后面会在updateChildren一节中将会详细分析如何实现的。
首先在patch入口中先做一下分支处理,如果vnode的类型的不一样,比如旧的是一个文本节点,新的是一个元素节点,这种情况就没有对比的必要了,直接replaceVnode整个替换掉就好了。
当vnode类型都为元素节点时,调用patchElement方法,这是后续要说的重点,当vnode类型都为文本节点是调用patchText,这个处理就比较简单了,去比较文本是否相同,不同的话直接替换旧文本。
function patch(prev, next, container) { const nextFlag = next.flag const prevFlag = prev.flag // vnode类型不一样,直接替换新的。 if (nextFlag !== prevFlag) { replaceVnode(prev, next, container) } else if (nextFlag === vnodeType.HTML) { patchElement(prev, next, container) } else if (nextFlag === vnodeType.TEXT) { patchText(prev, next) } } 复制代码
接下来我们讲一下patchElement方法,这里面做的事情也不多,它会去比较tag是否相同,不同的话直接替换旧节点,相同的话去更新data,然后去继续比对children。
function patchElement(prev, next, container) { // 标签不一样,直接替换新的 if (prev.tag !== next.tag) { replaceVnode(prev, next, container) } // 更新data const el = next.el = prev.el const nextData = next.data const prevData = prev.data if (nextData) { // 新增或覆盖旧属性 for (const key in nextData) { const prevVal = prevData[key] const nextVal = nextData[key] patchData(el, key, prevVal, nextVal) } } if (prevData) { // 删除新vnode没有的属性 for (const key in prevData) { const prevVal = prevData[key] if (prevVal && !nextData.hasOwnProperty(key)) { patchData(el, key, prevVal, null) } } } // 更新子元素 patchChildren( prev.childrenFrag, next.childrenFrag, prev.children, next.children, el ) } 复制代码
patchChildren
我们传入了childrenFrag
,根据我们定义的childrenFrag分类处理,一共有三种children类型,两组对比就是3*3九种情况,我们用两层switch case
去处理。
const childType = { EMPTY: 'EMPTY', SINGLE: 'SINGLE', MULTIPLE: 'MULTIPLE' } 复制代码
function patchChildren( prevChildFrag, nextChildFrag, prevChildren, nextChildren, container ) { switch (prevChildFrag) { case childType.SINGLE: switch (nextChildFrag) { case childType.SINGLE: patch(prevChildren, nextChildren, container) break case childType.EMPTY: container.removeChild(prevChildren.el) break case childType.MULTIPLE: container.removeChild(prevChildren.el) for (let i = 0; i < nextChildren.length; i++) { mount(nextChildren[i], container) } break } break case childType.EMPTY: switch (nextChildFrag) { case childType.SINGLE: mount(nextChildren, container) break case childType.EMPTY: break case childType.MULTIPLE: for (let i = 0; i < nextChildren.length; i++) { mount(nextChildren[i], container) } break } break case childType.MULTIPLE: switch (nextChildFrag) { case childType.SINGLE: for (let i = 0; i < prevChildren.length; i++) { container.removeChild(prevChildren[i].el) } mount(nextChildren, container) break case childType.EMPTY: for (let i = 0; i < prevChildren.length; i++) { container.removeChild(prevChildren[i].el) } break case childType.MULTIPLE: updateChildren(prevChildren, nextChildren, container) } break } } 复制代码
updateChildren的方法看起来比较复杂,但是目标只有一个就是去优先复用节点而不是去新建一个,整个遍历过程是一个双指针的遍历,大致流程分为三个部分:
这里看不懂不要紧,下面我们将逐点结合代码分析。
function updateChildren( prevChildren, nextChildren, container ) { let oldStartIdx = 0 let oldEndIdx = prevChildren.length - 1 let newStartIdx = 0 let newEndIdx = nextChildren.length - 1 let oldStartVnode = prevChildren[0] let oldEndVnode = prevChildren[oldEndIdx] let newStartVnode = nextChildren[0] let newEndVnode = nextChildren[newEndIdx] let oldKeyToIdx, vnodeToMove /* 新旧只要有一个左游标超出右游标,循环结束 */ while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (oldStartVnode === undefined) { /* 因为后面对比key的时候如果找到相同会把它置为undefined,循环到该节点直接跳过 */ oldStartVnode = prevChildren[++oldStartIdx] } else if (oldEndVnode === undefined) { /* 因为后面对比key的时候如果找到相同会把它置为undefined,循环到该节点直接跳过 */ oldEndVnode = prevChildren[--oldEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { /* 旧头和新头都相同,patch旧节点 */ patch(oldStartVnode, newStartVnode, container) oldStartVnode = prevChildren[++oldStartIdx] newStartVnode = nextChildren[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { /* 旧尾和新尾都相同,patch旧节点 */ patch(oldEndVnode, newEndVnode, container) oldEndVnode = prevChildren[--oldEndIdx] newEndVnode = nextChildren[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { /* 旧头和新尾相同,patch旧节点,把旧节点移动到右侧 */ patch(oldStartVnode, newEndVnode, container) container.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling) oldStartVnode = prevChildren[++oldStartIdx] newEndVnode = nextChildren[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { /* 旧尾和新头相同,patch旧节点,把旧节点移动到左侧 */ patch(oldEndVnode, newStartVnode, container) container.insertBefore(oldEndVnode.el, oldStartVnode.el) oldEndVnode = prevChildren[--oldEndIdx] newStartVnode = nextChildren[++newStartIdx] } else { /* 头尾对比完毕,开始对比key */ if (!newStartVnode.key) { /* newStartVnode没有key,创建新元素 */ mount(newStartVnode, container, oldStartVnode.el) } else { /* oldKeyToIdx: oldChildren key的映射对象 例如[{tag: 'div', key: 'key1'}, {tag: 'div', key: 'key2'}] => {key1: 0, key2: 1} */ if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(prevChildren, oldStartIdx, oldEndIdx) let idxInOld = oldKeyToIdx[newStartVnode.key] if (!idxInOld) { /* newStartVnode有key,但是在旧的vnode没找着,同样创建新元素 */ mount(newStartVnode, container, oldStartVnode.el) } else { vnodeToMove = prevChildren[idxInOld] if (sameVnode(vnodeToMove, newStartVnode)) { /* 找到可以被复用的元素 */ patch(vnodeToMove, newStartVnode, container) /* 旧vnode置为undefined */ prevChildren[idxInOld] = undefined /* 移动找到的元素 */ container.insertBefore(vnodeToMove.el, oldStartVnode.el) } else { /* 找到相同key,但是是不是用一个元素,可能tag不同等,同样创建新元素 */ mount(newStartVnode, container, oldStartVnode.el) } } } /* 更新一下游标循环继续 */ newStartVnode = nextChildren[++newStartIdx] } } /* while循环结束 */ if (oldStartIdx > oldEndIdx) { /* 旧vnode节点集合先被遍历完成,说明还有新节点需要加入 */ for (; newStartIdx <= newEndIdx; newStartIdx++) { /* nextChildren[newEndIdx + 1] === undefined,newEndIdx在最右边,这个时候flagNode = null,默认会appendChild */ const flagNode = nextChildren[newEndIdx + 1] === undefined ? null : nextChildren[newEndIdx + 1].el mount(nextChildren[newStartIdx], container, flagNode) } } else if (newStartIdx > newEndIdx) { /* 新vnode节点集合先被遍历完成,说明需要移除多余的节点 */ for (; oldStartIdx <= oldEndIdx; oldStartIdx++) { container.removeChild(prevChildren[oldStartIdx].el) } } } 复制代码
首尾互相对比
我们为prevChildren和nextChildren都定义了两个首尾的游标,遍历的过程中,左右游标都会向中间靠拢,当其中一个左游标超出右游标后结束循环。
所以我们现在有了四对:
下面用一个🌰去说明执行过程,假设现在初次渲染页面有了a,b,c,d四个节点,然后数据更新,输入d,b,e,c,a。图示是diff的过程。
1.第一次循环,命中sameVnode(oldStartVnode, newEndVnode),发现oldStartVnode和newEndVnode都是a,于是去patch更新一下旧节点oldStartVnode,然后把它往右侧移动,oldStartIdx,newEndIdx游标向中间靠拢一格。
else if (sameVnode(oldStartVnode, newEndVnode)) { /* 旧头和新尾相同,patch旧节点,把旧节点移动到右侧 */ patch(oldStartVnode, newEndVnode, container) container.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling) oldStartVnode = prevChildren[++oldStartIdx] newEndVnode = nextChildren[--newEndIdx] } 复制代码
2.第二次循环,命中sameVnode(oldEndVnode, newStartVnode),发现oldEndVnode和newStartVnode都是d,于是去patch更新一下旧节点oldEndVnode,然后把dom d移动往左侧移动,oldEndIdx,newStartIdx游标向中间靠拢一格。
else if (sameVnode(oldEndVnode, newStartVnode)) { /* 旧尾和新头相同,patch旧节点,把旧节点移动到左侧 */ patch(oldEndVnode, newStartVnode, container) container.insertBefore(oldEndVnode.el, oldStartVnode.el) oldEndVnode = prevChildren[--oldEndIdx] newStartVnode = nextChildren[++newStartIdx] } 复制代码
3.第三次循环,命中sameVnode(oldStartVnode, newStartVnode),发现oldStartVnode和newStartVnode都是b,于是直接去patch一下oldStartVnode,oldStartIdx和newStartIdx游标都向右一格。
else if (sameVnode(oldStartVnode, newStartVnode)) { /* 旧头和新头都相同,patch旧节点 */ patch(oldStartVnode, newStartVnode, container) oldStartVnode = prevChildren[++oldStartIdx] newStartVnode = nextChildren[++newStartIdx] } 复制代码
4.第四次循环,命中sameVnode(oldEndVnode, newEndVnode),发现oldEndVnode和newEndVnode都是c,然后去patch一下oldEndVnode,oldEndIdx和newEndIdx都向左一格。
else if (sameVnode(oldEndVnode, newEndVnode)) { /* 旧尾和新尾都相同,patch旧节点 */ patch(oldEndVnode, newEndVnode, container) oldEndVnode = prevChildren[--oldEndIdx] newEndVnode = nextChildren[--newEndIdx] } 复制代码
循环结束,补齐或者删除多余元素
这个时候oldStartIdx > oldEndIdx循环结束,此时页面上dom为d,b,c,a,还有一个e节点没有插入,这个需要批量插入未处理好的节点,那么插入到哪里呢?找到newEndIdx,这个游标指向的是右侧未处理的节点,它的右侧都是被处理过的元素,找到newEndIdx+1,然后往它前面insert。
/* while循环结束 */ if (oldStartIdx > oldEndIdx) { /* 旧vnode节点集合先被遍历完成,说明还有新节点需要加入 */ for (; newStartIdx <= newEndIdx; newStartIdx++) { /* nextChildren[newEndIdx + 1] === undefined,newEndIdx在最右边,这个时候flagNode = null,默认会appendChild */ const flagNode = nextChildren[newEndIdx + 1] === undefined ? null : nextChildren[newEndIdx + 1].el mount(nextChildren[newStartIdx], container, flagNode) } } 复制代码
同理,如果newStartIdx > newEndIdx时,新的VNode节点已经遍历完了,但是老的节点还有剩余,说明真实DOM节点多余了,需要从文档中删除,这时候将这些多余的真实DOM删除。
else if (newStartIdx > newEndIdx) { /* 新vnode节点集合先被遍历完成,说明需要移除多余的节点 */ for (; oldStartIdx <= oldEndIdx; oldStartIdx++) { container.removeChild(prevChildren[oldStartIdx].el) } } 复制代码
寻找key相同的节点
在循环的过程中,首尾都检索不到,这个时候会去查key map oldKeyToIdx
,它是prevChildren
key的映射对象,看里面有没有newStartVnode
的key,如果找到的话,patch一下移动它的位置去复用,找不到的话新建一个节点。
/* 头尾对比完毕,开始对比key */ if (!newStartVnode.key) { /* newStartVnode没有key,创建新元素 */ mount(newStartVnode, container, oldStartVnode.el) } else { /* oldKeyToIdx: prevChildren key的映射对象 例如[{tag: 'div', key: 'key1'}, {tag: 'div', key: 'key2'}] => {key1: 0, key2: 1} */ if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(prevChildren, oldStartIdx, oldEndIdx) let idxInOld = oldKeyToIdx[newStartVnode.key] if (!idxInOld) { /* newStartVnode有key,但是在旧的vnode没找着,同样创建新元素 */ mount(newStartVnode, container, oldStartVnode.el) } else { vnodeToMove = prevChildren[idxInOld] if (sameVnode(vnodeToMove, newStartVnode)) { /* 找到可以被复用的元素 */ patch(vnodeToMove, newStartVnode, container) /* 旧vnode置为undefined */ prevChildren[idxInOld] = undefined /* 移动找到的元素 */ container.insertBefore(vnodeToMove.el, oldStartVnode.el) } else { /* 找到相同key,但是是不是用一个元素,可能tag不同等,同样创建新元素 */ mount(newStartVnode, container, oldStartVnode.el) } } } 复制代码
到这里整个patch的过程都过了一遍,过程比较复杂的部分都集中在patchChildren
和updateChildren
中,patchChildren
根据children的不同类型进行处理,当新旧vnode都是列表的时候,需要updateChildren
去遍历比对。
写法是参考vue源码的patch部分,去除了源码中的比较细粒度的分支后,留下比较核心的流程,这应该会有助于去理解这个diff的过程,当你有了一个总体的认识后,再回到源码就发现一切的非常熟悉了。
在最后附上👉源码地址,如果觉得文章有帮助的话点个赞不过分吧!
参考文章