Home > Article > Web Front-end > In-depth understanding of virtual DOM and Diff algorithm in vue
This article will give you an in-depth understanding of the virtual DOM and Diff algorithm in vue, and a detailed analysis of the virtual DOM and Diff algorithm. I hope it will be helpful to everyone.
Recently reviewed virtual DOM and Diff, read a lot of information, and hereby summarize this long article to deepen my understanding of vue.
This article analyzes Vue's virtual DOM and Diff algorithm in more detail. Some key points are illustrated by moving some pictures from elsewhere (thanks to the graphics master), and also includes a more detailed source code interpretation. . If you think the article is good, please give me a thumbs up. If there are any mistakes, you are welcome to point them out in the comment area. Of course, if there is anything you don’t understand, you are welcome to ask questions. [Related recommendations: "vue.js Tutorial"]
Before talking about virtual DOM, let’s talk about the rendering of real DOM.
The process of browser real DOM rendering is roughly divided into the following parts
Building DOM tree . Parse and process HTML tags through HTML parser and build them into DOM trees. When the parser encounters non-blocking resources (images, css), it will continue to parse, but if it encounters script tags (especially without async and defer attribute), will block rendering and stop HTML parsing, which is why it is best to put the script tag under the body.
Build CSSOM tree. Similar to building DOM, the browser will also build style rules into CSSOM. The browser will traverse the rule set in CSS and create a node tree with parent-child, sibling, etc. relationships based on the CSS selector.
Build Render Tree. This step associates the DOM and CSSOM and determines what CSS rules should be applied to each DOM element. Match all relevant styles to each visible node in the DOM tree, and determine the calculated style of each node based on CSS cascade. Invisible nodes (head, nodes with attributes including display:none) will not be generated into the Render tree.
Layout/Reflow. The first time the browser determines the location and size of the node is called layout. If the node location and size changes later, this step triggers layout adjustment, which is reflow.
Paint/Repaint. Draws every visible part of an element to the screen, including text, color, borders, shadows, and replaced elements such as buttons and images. If text, color, border, shadow and other elements change, Repaint will be triggered. To ensure that redrawing occurs faster than the initial draw, the drawing on the screen is often broken up into several layers. Promoting content to the GPU layer (which can be triggered by transform, filter, will-change, opacity) can improve drawing and redrawing performance.
Compositing. This step merges the layers in the drawing process, ensuring they are drawn in the correct order to display the correct content on the screen.
The above is a DOM rendering process. If the dom is updated, the dom needs to be re-rendered once. If the following situation exists
<body> <div id="container"> <div class="content" style="color: red;font-size:16px;"> This is a container </div> .... <div class="content" style="color: red;font-size:16px;"> This is a container </div> </div> </body> <script> let content = document.getElementsByClassName('content'); for (let i = 0; i < 1000000; i++) { content[i].innerHTML = `This is a content${i}`; // 触发回流 content[i].style.fontSize = `20px`; } </script>
Then it is necessary to actually operate the DOM 1 million times and trigger the reflow 1 million times. Every DOM update will follow the process to update the real DOM without any difference. So it caused a lot of performance waste. If there are complex operations in the loop that frequently trigger reflow and redraw, it will easily affect performance and cause lags. In addition, it should be noted here that virtual DOM does not mean that it is faster than DOM. Performance depends on the scenario. The performance of virtual DOM is positively related to the template size. The comparison process of the virtual DOM does not distinguish the size of the data. When there are only a few dynamic nodes inside the component, the virtual DOM will still traverse the entire vdom, which is an extra layer of operations compared to direct rendering.
<div class="list"> <p class="item">item</p> <p class="item">item</p> <p class="item">item</p> <p class="item">{{ item }}</p> <p class="item">item</p> <p class="item">item</p> </div>
For example, in the above example, virtual DOM. Although there is only one dynamic node, the virtual DOM still needs to traverse the class, text, label and other information of the entire diff list, and finally DOM rendering is still required. If it is just DOM operation, you only need to operate a specific DOM and then render it. The core value of virtual DOM is that it can describe the real DOM through js, which is more expressive. Through declarative language operations, it provides developers with a more convenient and faster development experience, and without manual optimization, in most scenarios, The lower limit of performance is guaranteed and the price/performance ratio is higher.
Virtual DOM is essentially a js object that represents the real DOM structure through objects. Tag is used to describe tags, props is used to describe attributes, and children are used to represent nested hierarchical relationships.
const vnode = { tag: 'div', props: { id: 'container', }, children: [{ tag: 'div', props: { class: 'content', }, text: 'This is a container' }] } //对应的真实DOM结构 <div id="container"> <div class="content"> This is a container </div> </div>
The update of the virtual DOM will not operate the DOM immediately. Instead, it will use the diff algorithm to find the nodes that need to be updated, update them as needed, and save the updated content as a js object. After the update is completed, Mount to the real DOM to achieve real DOM update. Through virtual DOM, three problems of operating real DOM are solved.
无差别频繁更新导致DOM频繁更新,造成性能问题
频繁回流与重绘
开发体验
另外由于虚拟DOM保存的是js对象,天然的具有跨平台的能力,而不仅仅局限于浏览器。
优点
总结起来,虚拟DOM的优势有以下几点
小修改无需频繁更新DOM,框架的diff算法会自动比较,分析出需要更新的节点,按需更新
更新数据不会造成频繁的回流与重绘
表达力更强,数据更新更加方便
保存的是js对象,具备跨平台能力
不足
虚拟DOM同样也有缺点,首次渲染大量DOM时,由于多了一层虚拟DOM的计算,会比innerHTML插入慢。
主要分三部分
通过js建立节点描述对象
diff算法比较分析新旧两个虚拟DOM差异
将差异patch到真实dom上实现更新
Diff算法
为了避免不必要的渲染,按需更新,虚拟DOM会采用Diff算法进行虚拟DOM节点比较,比较节点差异,从而确定需要更新的节点,再进行渲染。vue采用的是深度优先,同层比较的策略。
新节点与旧节点的比较主要是围绕三件事来达到渲染目的
创建新节点
删除废节点
更新已有节点
如何比较新旧节点是否一致呢?
function sameVnode(a, b) { return ( a.key === b.key && a.asyncFactory === b.asyncFactory && ( ( a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b) //对input节点的处理 ) || ( isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error) ) ) ) } //判断两个节点是否是同一种 input 输入类型 function sameInputType(a, b) { if (a.tag !== 'input') return true let i const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type //input type 相同或者两个type都是text return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB) }
可以看到,两个节点是否相同是需要比较标签(tag),属性(在vue中是用data表示vnode中的属性props), 注释节点(isComment) 的,另外碰到input的话,是会做特殊处理的。
创建新节点
当新节点有的,旧节点没有,这就意味着这是全新的内容节点。只有元素节点,文本节点,注释节点才能被创建插入到DOM中。
删除旧节点
当旧节点有,而新节点没有,那就意味着,新节点放弃了旧节点的一部分。删除节点会连带的删除旧节点的子节点。
更新节点
新的节点与旧的的节点都有,那么一切以新的为准,更新旧节点。如何判断是否需要更新节点呢?
// 判断vnode与oldVnode是否完全一样 if (oldVnode === vnode) { return; }
// 是否是静态节点,key是否一样,是否是克隆节点或者是否设置了once属性 if ( isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic) && vnode.key === oldVnode.key && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce)) ) { vnode.componentInstance = oldVnode.componentInstance; return; }
//判断新节点是否有文本 if (isUndef(vnode.text)) { //如果没有文本,处理子节点的相关代码 .... } else if (oldVnode.text !== vnode.text) { //新节点文本替换旧节点文本 nodeOps.setTextContent(elm, vnode.text) }
新节点与旧节点都有子节点
只有新节点有子节点
只有旧节点有子节点
新节点与旧节点都没有子节点
都有子节点
对于都有子节点的情况,需要对新旧节点做比较,如果他们不相同,那么需要进行diff操作,在vue中这里就是updateChildren方法,后面会详细再讲,子节点的比较主要是双端比较。
//判断新节点是否有文本 if (isUndef(vnode.text)) { //新旧节点都有子节点情况下,如果新旧子节点不相同,那么进行子节点的比较,就是updateChildren方法 if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) } } else if (oldVnode.text !== vnode.text) { //新节点文本替换旧节点文本 nodeOps.setTextContent(elm, vnode.text) }
只有新节点有子节点
只有新节点有子节点,那么就代表着这是新增的内容,那么就是新增一个子节点到DOM,新增之前还会做一个重复key的检测,并做出提醒,同时还要考虑,旧节点如果只是一个文本节点,没有子节点的情况,这种情况下就需要清空旧节点的文本内容。
//只有新节点有子节点 if (isDef(ch)) { //检查重复key if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(ch) } //清除旧节点文本 if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') //添加新节点 addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) } //检查重复key function checkDuplicateKeys(children) { const seenKeys = {} for (let i = 0; i < children.length; i++) { const vnode = children[i] //子节点每一个Key const key = vnode.key if (isDef(key)) { if (seenKeys[key]) { warn( `Duplicate keys detected: '${key}'. This may cause an update error.`, vnode.context ) } else { seenKeys[key] = true } } } }
只有旧节点有子节点
只有旧节点有,那就说明,新节点抛弃了旧节点的子节点,所以需要删除旧节点的子节点
if (isDef(oldCh)) { //删除旧节点 removeVnodes(oldCh, 0, oldCh.length - 1) }
都没有子节点
这个时候需要对旧节点文本进行判断,看旧节点是否有文本,如果有就清空
if (isDef(oldVnode.text)) { //清空 nodeOps.setTextContent(elm, '') }
整体的逻辑代码如下
function patchVnode( oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly ) { // 判断vnode与oldVnode是否完全一样 if (oldVnode === vnode) { return } if (isDef(vnode.elm) && isDef(ownerArray)) { // 克隆重用节点 vnode = ownerArray[index] = cloneVNode(vnode) } const elm = vnode.elm = oldVnode.elm if (isTrue(oldVnode.isAsyncPlaceholder)) { if (isDef(vnode.asyncFactory.resolved)) { hydrate(oldVnode.elm, vnode, insertedVnodeQueue) } else { vnode.isAsyncPlaceholder = true } return } // 是否是静态节点,key是否一样,是否是克隆节点或者是否设置了once属性 if (isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic) && vnode.key === oldVnode.key && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce)) ) { vnode.componentInstance = oldVnode.componentInstance return } let i const data = vnode.data if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) { i(oldVnode, vnode) } const oldCh = oldVnode.children const ch = vnode.children if (isDef(data) && isPatchable(vnode)) { //调用update回调以及update钩子 for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode) } //判断新节点是否有文本 if (isUndef(vnode.text)) { //新旧节点都有子节点情况下,如果新旧子节点不相同,那么进行子节点的比较,就是updateChildren方法 if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) } else if (isDef(ch)) { //只有新节点有子节点 if (process.env.NODE_ENV !== 'production') { //重复Key检测 checkDuplicateKeys(ch) } //清除旧节点文本 if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') //添加新节点 addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) } else if (isDef(oldCh)) { //只有旧节点有子节点,删除旧节点 removeVnodes(oldCh, 0, oldCh.length - 1) } else if (isDef(oldVnode.text)) { //新旧节点都无子节点 nodeOps.setTextContent(elm, '') } } else if (oldVnode.text !== vnode.text) { //新节点文本替换旧节点文本 nodeOps.setTextContent(elm, vnode.text) } if (isDef(data)) { if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode) } }
配上流程图会更清晰点
子节点的比较更新updateChildren
新旧节点都有子节点的情况下,这个时候是需要调用updateChildren方法来比较更新子节点的。所以在数据上,新旧节点子节点,就保存为了两个数组。
const oldCh = [oldVnode1, oldVnode2,oldVnode3]; const newCh = [newVnode1, newVnode2,newVnode3];
子节点更新采用的是双端比较的策略,什么是双端比较呢,就是新旧节点比较是通过互相比较首尾元素(存在4种比较),然后向中间靠拢比较(newStartIdx,与oldStartIdx递增,newEndIdx与oldEndIdx递减)的策略。
比较过程
向中间靠拢
这里对上面出现的新前,新后,旧前,旧后做一下说明
新前,指的是新节点未处理的子节点数组中的第一个元素,对应到vue源码中的newStartVnode
新后,指的是新节点未处理的子节点数组中的最后一个元素,对应到vue源码中的newEndVnode
旧前,指的是旧节点未处理的子节点数组中的第一个元素,对应到vue源码中的oldStartVnode
旧后,指的是旧节点未处理的子节点数组中的最后一个元素,对应到vue源码中的oldEndVnode
子节点比较过程
接下来对上面的比较过程以及比较后做的操作做下说明
if (sameVnode(oldStartVnode, newStartVnode)) { // 更新子节点 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // 新旧各向后一步 oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] }
if (sameVnode(oldEndVnode, newEndVnode)) { //更新子节点 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) // 新旧向前 oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] }
if (sameVnode(oldStartVnode, newEndVnode)) { patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) //将旧子节点数组第一个子节点移动插入到最后 canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) //旧向后 oldStartVnode = oldCh[++oldStartIdx] //新向前 newEndVnode = newCh[--newEndIdx]
if (sameVnode(oldEndVnode, newStartVnode)) { patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) //将旧后移动插入到最前 canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) //旧向前 oldEndVnode = oldCh[--oldEndIdx] //新向后 newStartVnode = newCh[++newStartIdx] }
进行到这一步对于没有设置key的节点,第一次会通过createKeyToOldIdx建立key与index的映射 {key:index}
// 对于没有设置key的节点,第一次会通过createKeyToOldIdx建立key与index的映射 {key:index} if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
然后拿新节点的key与旧节点进行比较,找到key值匹配的节点的位置,这里需要注意的是,如果新节点也没key,那么就会执行findIdxInOld方法,从头到尾遍历匹配旧节点
//通过新节点的key,找到新节点在旧节点中所在的位置下标,如果没有设置key,会执行遍历操作寻找 idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) //findIdxInOld方法 function findIdxInOld(node, oldCh, start, end) { for (let i = start; i < end; i++) { const c = oldCh[i] //找到相同节点下标 if (isDef(c) && sameVnode(node, c)) return i } }
如果通过上面的方法,依旧没找到新节点与旧节点匹配的下标,那就说明这个节点是新节点,那就执行新增的操作。
//如果新节点无法在旧节点中找到自己的位置下标,说明是新元素,执行新增操作 if (isUndef(idxInOld)) { createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) }
如果找到了,那么说明在旧节点中找到了key值一样,或者节点和key都一样的旧节点。如果节点一样,那么在patchVnode之后,需要将旧节点移动到所有未处理节点之前,对于key一样,元素不同的节点,将其认为是新节点,执行新增操作。执行完成后,新节点向后一步。
//如果新节点无法在旧节点中找到自己的位置下标,说明是新元素,执行新增操作 if (isUndef(idxInOld)) { // 新增元素 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { // 在旧节点中找到了key值一样的节点 vnodeToMove = oldCh[idxInOld] if (sameVnode(vnodeToMove, newStartVnode)) { // 相同子节点更新操作 patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // 更新完将旧节点赋值undefined oldCh[idxInOld] = undefined //将旧节点移动到所有未处理节点之前 canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // 如果是相同的key,不同的元素,当做新节点,执行创建操作 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } //新节点向后 newStartVnode = newCh[++newStartIdx]
当完成对旧节点的遍历,但是新节点还没完成遍历,那就说明后续的都是新增节点,执行新增操作,如果完成对新节点遍历,旧节点还没完成遍历,那么说明旧节点出现冗余节点,执行删除操作。
//完成对旧节点的遍历,但是新节点还没完成遍历, if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm // 新增节点 addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else if (newStartIdx > newEndIdx) { // 发现多余的旧节点,执行删除操作 removeVnodes(oldCh, oldStartIdx, oldEndIdx) }
子节点比较总结
上面就是子节点比较更新的一个完整过程,这是完整的逻辑代码
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { 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, idxInOld, vnodeToMove, refElm // removeOnly is a special flag used only by <transition-group> // to ensure removed elements stay in correct relative positions // during leaving transitions const canMove = !removeOnly if (process.env.NODE_ENV !== 'production') { checkDuplicateKeys(newCh) } //双端比较遍历 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { //旧前向后移动 oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { // 旧后向前移动 oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { //新前与旧前 //更新子节点 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // 新旧各向后一步 oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { //新后与旧后 //更新子节点 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) //新旧各向前一步 oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // 新后与旧前 //更新子节点 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) //将旧前移动插入到最后 canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) //新向前,旧向后 oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // 新前与旧后 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) //将旧后移动插入到最前 canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) //新向后,旧向前 oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 对于没有设置key的节点,第一次会通过createKeyToOldIdx建立key与index的映射 {key:index} if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) //通过新节点的key,找到新节点在旧节点中所在的位置下标,如果没有设置key,会执行遍历操作寻找 idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) //如果新节点无法在旧节点中找到自己的位置下标,说明是新元素,执行新增操作 if (isUndef(idxInOld)) { // 新增元素 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { // 在旧节点中找到了key值一样的节点 vnodeToMove = oldCh[idxInOld] if (sameVnode(vnodeToMove, newStartVnode)) { // 相同子节点更新操作 patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // 更新完将旧节点赋值undefined oldCh[idxInOld] = undefined //将旧节点移动到所有未处理节点之前 canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // 如果是相同的key,不同的元素,当做新节点,执行创建操作 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } //新节点向后一步 newStartVnode = newCh[++newStartIdx] } } //完成对旧节点的遍历,但是新节点还没完成遍历, if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm // 新增节点 addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else if (newStartIdx > newEndIdx) { // 发现多余的旧节点,执行删除操作 removeVnodes(oldCh, oldStartIdx, oldEndIdx) } }
更多编程相关知识,请访问:编程入门!!
The above is the detailed content of In-depth understanding of virtual DOM and Diff algorithm in vue. For more information, please follow other related articles on the PHP Chinese website!