この記事では、vue の仮想 DOM と Diff アルゴリズムを深く理解し、仮想 DOM と Diff アルゴリズムを詳細に分析します。
最近仮想 DOM と Diff をレビューし、多くの情報を読み、vue についての理解を深めるためにこの長い記事を要約します。
この記事では、Vue の仮想 DOM と Diff アルゴリズムをより詳細に分析します。いくつかの重要なポイントは、(グラフィックス マスターのおかげで) 他の場所からいくつかの画像を移動することによって説明されており、より詳細なソース コードの解釈も含まれています。良い記事だと思ったら高評価をお願いします 間違いがあればコメント欄でご指摘ください もちろん分からないことがあれば大歓迎です質問をする。 [関連する推奨事項: "vue.js チュートリアル "]
仮想 DOM について話す前に、実際の DOM のレンダリングについて話しましょう。
ブラウザの実際の DOM レンダリングのプロセスは、次の部分に大別されます。
DOM ツリーの構築 。 HTML パーサーを通じて HTML タグを解析して処理し、DOM ツリーに構築します。パーサーは非ブロッキング リソース (画像、CSS) に遭遇すると解析を続行しますが、スクリプト タグ (特に async および defer 属性なし) に遭遇した場合は、レンダリングをブロックし、HTML 解析を停止します。そのため、script タグを本文の下に置くのが最善です。
CSSOM ツリーを構築します。 DOM の構築と同様に、ブラウザはスタイル ルールを CSSOM に構築します。ブラウザは CSS で設定されたルールをたどり、CSS セレクターに基づいて親子関係、兄弟関係などのノード ツリーを作成します。
レンダー ツリーを構築します。このステップでは、DOM と CSSOM を関連付け、各 DOM 要素にどの CSS ルールを適用するかを決定します。関連するすべてのスタイルを DOM ツリー内の各表示ノードに照合し、CSS カスケードに基づいて各ノードの計算されたスタイルを決定します。非表示のノード (head、display:none を含む属性を持つノード) は Render ツリーに生成されません。
レイアウト/リフロー。ブラウザが初めてノードの位置とサイズを決定することをレイアウトと呼びます。後で ノードの位置とサイズが変更された場合、このステップによりレイアウト調整 (リフロー ) がトリガーされます。
ペイント/再ペイント。テキスト、色、境界線、影、およびボタンや画像などの置換された要素を含む、要素のすべての表示部分を画面に描画します。 テキスト、色、境界線、影およびその他の要素が変更されると、Repaintがトリガーされます。再描画が最初の描画よりも早く行われるようにするために、多くの場合、画面上の描画は複数のレイヤーに分割されます。コンテンツを GPU レイヤーにプロモートすると (変換、フィルター、変更、不透明度によってトリガーできます)、描画と再描画のパフォーマンスを向上させることができます。
合成。この手順では、描画プロセスでレイヤーを結合し、正しいコンテンツが画面上に表示されるように正しい順序で描画されるようにします。
<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>
<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>
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>
无差别频繁更新导致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) } }
更多编程相关知识,请访问:编程入门!!
以上がvue の仮想 DOM と Diff アルゴリズムについての深い理解の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。