幫你深刻了解虛擬DOM和DOM-diff

虛擬DOM和比對算法講解

本篇文章是在近期的學習中整理出來的,內容是有關 Vue2.0虛擬DOM比對算法的解釋。本篇依舊秉承着盡力通俗易懂的解釋。如若哪部分沒有解釋清楚,或者說寫的有錯誤的地方,還請各位 批評指正html

近期我還在整理 我的的Vue的所學。從0開始再一次手寫Vue。本篇內容將會在那篇文章中進行使用。前端

理論知識

爲何須要虛擬DOM

DOM是很大的,裏面元素不少。操做起來比較浪費時間,浪費性能。因此咱們須要引入虛擬dom的概念node

什麼是虛擬DOM

簡單來講,虛擬DOM其實就是用js中的對象來模擬真實DOM,再經過方法的轉換,將它變成真實DOMwebpack

優勢

  1. 最終表如今 真實DOM部分改變,保證了渲染的效率
  2. 性能提高 (對比操做真實DOM)

正式開始

思路

  1. 咱們須要獲取一個節點來掛載咱們的渲染結果
  2. 咱們須要把對象( 虛擬節點),渲染成真實節點。插入到 獲取的節點中(固然這個中間會有不少繁瑣的過程。後面會一點點的說)
  3. 在更新的過程當中,咱們須要比對 dom元素的各個屬性,能複用複用。複用不了就更新

webpack配置

// webpack.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  entry'./src/vdomLearn/index.js'// 入口文件
  output: {  // 輸出文件
    filename: 'bundle.js',
    path: path.resolve(__dirname,'dist'),
  },
  devtool'source-map',  // 源碼映射
  plugins: [ // 插件
      new HtmlWebpackPlugin({
        template: path.resolve(__dirname,'public/index.html'),
      })
  ],
}

// package.json
"scripts": {
    "start""webpack-dev-server",
    "build""webpack"
  },

獲取節點並初次渲染

首先先看一下咱們的 模板Html,沒什麼重要內容,就是有一個id='#app'的div(做爲掛載的節點)web

<!doctype html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">

    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Vue</title>
</head>
<body>
<div id="#app">

</div>
</body>
</html>

咱們建立一個名爲index.js的文件,用來做爲 入口文件算法

// 獲取掛載節點
let app = document.getElementById('#app')

// 建立虛擬節點  咱們先用死數據模擬
let oldNode = h('div',{id:'container'},
 h('span',{style:{color:'red'}},'hello')
 'hello'              
)

// 渲染函數 將 咱們建立的虛擬節點 掛載到對應節點上
render(oldNode,app)

爲何這麼叫名字呢? h是遵循Vue裏面的叫法。剩下的基本都是英語翻譯json

目標明確

通過上面的index.js文件,咱們明確了目標。數組

  1. 咱們須要一個 h方法來把 虛擬DOM變成真實DOM
  2. 還須要一個 render方法,將咱們所建立的節點掛載到 app

接下來咱們開始寫這兩個方法微信

爲了方便管理。咱們新建一個文件名爲vdom的文件夾。裏面有一個index.js文件,做爲 總導出app

// vdom/index.js
import h from './h'
import {render} from './patch';

export {
  h,render
}

h方法

爲了方便管理,咱們建立一個名爲vNode.js的文件。用來放與虛擬節點相關的內容

// vdom/vNode.js
// 主要放虛擬節點相關的
/**
 * 建立虛擬dom
 * @param tag 標籤
 * @param props 屬性
 * @param key only one 標識
 * @param children  子節點
 * @param text  文本內容
 // 返回一個虛擬節點
 * @returns {{children: *, tag: *, text: *, key: *, props: *}}
 */

export function vNode(tag,props,key,children,text=''{
  return {
    tag,
    props,
    key,
    children,
    text
  }
}
// vdom/h.js
import {vNode} from './vNode';

// 主要放渲染相關的
/**
 *  h方法就是 CreateElement
 * @param tag 標籤
 * @param props  屬性
 * @param children  孩子節點和文本
 * @returns {{children: *, tag: *, text: *, key: *, props: *}} 返回一個虛擬dom
 */

export default function h(tag,props,...children){
    // ... 是ES6語法
  let key = props.key // 標識
  delete props.key   // 屬性中沒有key 屬性
    
    // 遍歷子節點 若是子節點是對象,則證實他是一個節點。若是不是 則證實是一個文本
  children = children.map(child=>{
    if (typeof child === 'object'){
      console.log(child)
      return child
    }else {
      return vNode(undefined,undefined,undefined,undefined,child)
    }
  })
  // key 做用 only one 標識  能夠對比兩個虛擬節點是不是同一個
  // 返回一個虛擬節點
  return vNode(tag,props,key,children)
}

render方法

render方法的做用就是把 虛擬節點轉換成真實節點,並掛載到 app 節點上,咱們把它放到一個叫patch.js的文件中

// vdom/patch.js
/**
 *  渲染成 真實節點 並掛載
 * @param vNode  虛擬DOM
 * @param container 容器  即 須要向哪裏添加節點
 */

export function render(vNode, container// 把虛擬節點變成真實節點
  let el = createElem(vNode)
  container.appendChild(el) // 把 建立好的真實節點加入到 app 中
}

虛擬節點傳入後,咱們要根據虛擬節點來建立真實節點。因此咱們寫一個名爲createElem的方法,用來 把虛擬節點變成真實節點

createElem方法

// vdom/patch.js

// ...前面含有上述的render方法 我省略一下
/**
 *  根據虛擬節點建立真實節點
 * @param vNode  虛擬DOM
 * @returns {any | Text} 返回真實節點
 */

function createElem(vNode{
  let { tag, props, children, text, key } = vNode
  if (typeof tag === 'string') { // 即 div span 等
    vNode.el = document.createElement(tag) //  建立節點 將建立出來的真實節點掛載到虛擬節點上
    updateProperties(vNode)  // 更新屬性方法
    //  看是否有孩子 若是有孩子,則把這個孩子繼續渲染
    children.forEach(child => {
      return render(child, vNode.el)
    })
  } else { // 不存在 undefined   Vnode.el 對應的是虛擬節點裏面的真實dom元素
    vNode.el = document.createTextNode(text)
  }
  return vNode.el
}

難點解釋

我的以爲難以理解的一個部分應該是這個for遍歷,children是一個個虛擬子節點(用h方法建立的)。若是它有tag屬性,則證實它是一個節點。裏面可能包含有其餘節點。因此咱們要遍歷children。拿到每個虛擬子節點,繼續渲染,把 全部虛擬子節點上都掛載上真實的dom。若是是文本,直接建立文本節點就能夠了。而後把真實dom返回。

updateProperties方法

建立真實節點的過程當中,咱們爲了之後考慮。寫一個名爲updateProperties 用來更新或者初始化dom的屬性(props)

// vdom/patch.js

// ...前面含有上述的render,和createElem方法 我省略一下
/**
 * 更新或者初始化DOM props
 * @param vNode
 * @param oldProps
 */

function updateProperties(vNode, oldProps = {}{
  let newProps = vNode.props || {}// 當前的老屬性 也可能沒有屬性 以防程序出錯,給了一個空對象
  let el = vNode.el // 真實節點  取到咱們剛纔再虛擬節點上掛載的真實dom

  let oldStyle = oldProps.style || {}
  let newStyle = newProps.style || {}

  // 處理 老樣式中  要更新的樣式 若是新樣式中不存在老樣式 就置爲空
  for (let key in oldStyle) {
    if (!newStyle[key]) {
      el.style[key] = ''
    }
  }

  // 刪除  更新過程當中 新屬性中不存在的 屬性
  for (let key in oldProps) {
    if (!newProps[key]) {
      delete el[key]
    }
  }

  // 考慮一下之前有沒有
  for (let key in newProps) {
    if (key === 'style') {
      for (let styleName in newProps.style) {
        // color                       red
        el.style[styleName] = newProps.style[styleName]
      }
    } else if (key === 'class') { // 處理class屬性
      el.className = newProps.class
    } else {
      el[key] = newProps[key] // key 是id 等屬性
    }
  }
}

**思路:**其實很簡單

  1. 老屬性中的樣式不存在於 新屬性的樣式置爲空
  2. 刪除 老屬性中不存在於 新屬性 的 屬性
  3. 新屬性老屬性 沒有的,把它 添加/更新

總結和再串一次流程

這樣一來。咱們就完成了 從 虛擬dom 的建立再到 渲染 的過程

咱們再回顧一遍流程

  1. 先經過 h方法,把傳入的各個屬性進行組合,變成 虛擬dom
  2. 再經過 render方法,把傳入的 虛擬dom進行渲染和掛載
  3. 在渲染的過程當中,咱們用了 createElem方法,建立了真實節點, 並掛載到了虛擬節點的el屬性上,並返回真實節點
  4. 在執行 createElem方法的過程當中,咱們還須要對 節點的屬性進行修改和更新。因此咱們建立了 updateProperties,用來更新節點屬性
  5. 方法都執行完成後,回到了 h方法,把咱們建立好的真實節點掛載到了 app

以上就是從獲取節點,再到 初次渲染的整個過程

結果展現

結果展現.png

Dom的更新和比對算法

上述咱們敘述了 如何把虛擬dom轉換成真實dom 的過程。接下來咱們 說一下 關於dom的更新

先看 index文件

import {h,render,patch} from './vdom'

// 獲取掛載節點
let app = document.getElementById('#app')

// 建立虛擬節點  咱們先用死數據模擬
let oldNode = h('div',{id:'container'},
 h('span',{style:{color:'red'}},'hello')
 'hello'              
)

// 渲染函數 將 咱們建立的虛擬節點 掛載到對應節點上
render(oldNode,app)

// 咱們設置一個定時器, 用patch 方法來更新dom
// 把新的節點和老的節點作對比   更新真實dom 元素
setTimeout(()=>{
  patch(oldNode,newNode)
},1000)

咱們用一個patch方法來更新dom

vdom/index文件中導出這個方法

import h from './h'
import {render,patch} from './patch';

export {
  h,render,patch
}

patch文件

思路分析

咱們要作的是DOM的更新操做,須要接收兩個參數(新老DOM),遵循着 能複用就複用的原則(複用比從新渲染效率高)。而後 更新屬性。結束後再對比 子節點。並作出響應的優化

patch dom對比和更新

// vdom/patch.js
// ...省略上面的 了

/**
 *  作dom 的對比更新操做
 * @param oldNode
 * @param newNode
 */

export function patch(oldNode, newNode{
  // 傳入的newNode是 一個對象   oldNode 是一個虛擬節點 上面el爲真實節點
  // 1 先比對 父級標籤同樣不  不同直接幹掉  傳進來是虛擬節點
  if (oldNode.tag !== newNode.tag) {
    // 必須拿到父親才能夠替換兒子
    // 老節點的 父級 替換     利用createElem建立真實節點 進行替換
    oldNode.el.parentNode.replaceChild(createElem(newNode), oldNode.el)
  }
  // 對比文本 更改文本內容
  if (!oldNode.tag) { // 證實其是文本節點
    if (oldNode.el.textContent !== newNode.text) {
      oldNode.el.textContent = newNode.text
    }
  }
  // 標籤同樣 對比屬性
  let el = newNode.el = oldNode.el    // 新老標籤同樣 直接複用
  updateProperties(newNode, oldNode.props) // 更新屬性

  // 開始比對孩子  必需要有一個根節點
  let oldNodeChildren = oldNode.children || []
  let newNodeChildren = newNode.children || []
  //  三種狀況 老有新有  老有新沒有  老沒有新有
  if (oldNodeChildren.length > 0 && newNodeChildren.length > 0) {
    // 新老都有 就更新
      //  el是什麼? 就是 兩個虛擬節點渲染後的真實節點
    updateChildren(el, oldNodeChildren, newNodeChildren)
  } else if (oldNodeChildren.length > 0) {
    // 新沒有 老有
    el.innerHTML = ''
  } else if (newNodeChildren.length > 0) {
    // 老沒有 新有
    for (let i = 0; i < newNodeChildren.length; i++) {
      let child = newNodeChildren[i]
      el.appendChild(createElem(child)) // 將新兒子添加到 老的節點中
    }
  }
  return el  // 對比以後的返回真實節點
}

這段代碼的 較簡單都寫出來了。稍微難一點的在於 **比對孩子的過程當中,新老節點都有孩子。咱們就須要再來一個方法,用於新老孩子的更新 **

updateChildren方法

**做用:**更新新老節點的子節點

/**
 * 工具函數,用於比較這兩個節點是否相同
 * @param oldVnode
 * @param newVnode
 * @returns {boolean|boolean}
 */

function isSameVnode(oldVnode, newVnode{
  // 當二者標籤 和 key 相同 能夠認爲是同一個虛擬節點 能夠複用
  return (oldVnode.tag === newVnode.tag) && (oldVnode.key === newVnode.key)
}


// 虛擬Dom 核心代碼
/**
 *
 * @param parent  父節點的DOM元素
 * @param oldChildren  老的虛擬dom
 * @param newChildren  新得虛擬dom
 */

function updateChildren(parent, oldChildren, newChildren{
  // 怎麼對比? 一個一個對比,哪一個少了就把 多餘的拿出來 刪掉或者加倒後面
  let oldStartIndex = 0  // 老節點索引
  let oldStartVnode = oldChildren[0]  // 老節點開始值
  let oldEndIndex = oldChildren.length - 1 // 老節點 結束索引
  let oldEndVnode = oldChildren[oldEndIndex] // 老節點結束值

  let newStartIndex = 0  // 新節點索引
  let newStartVnode = newChildren[0]  //        新節點開始值
  let newEndIndex = newChildren.length - 1 //     新節點 結束索引
  let newEndVnode = newChildren[newEndIndex] // 新節點結束值
  /**
   *  把節點的key 創建起映射關係
   * @param child  傳入節點
   * @returns {{}}  返回映射關係
   */

  function makeIndexByKey(child{
    let map = {}
    child.forEach((item, index) => {
      map[item.key] = index
    })
    return map   // {a:0,b:1,c:2}
  }
  let map = makeIndexByKey(oldChildren)
  // 開始比較
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 主要用來解決else 操做引發的 數組塌陷
    if (!oldStartVnode) {
      oldStartVnode = oldChildren[++oldStartIndex]
    } else if (!oldEndVnode) {
      oldEndVnode = oldChildren[--oldStartIndex]
       
        // 上述先不用管 首先從這裏開始看
      // 以上代碼在一個else中有用。跳過undefined 沒有比較意義
        // 先從頭部開始比較 若是不同 再叢尾部比較
    } else if (isSameVnode(oldStartVnode, newStartVnode)) { // 從頭開始遍歷  前面插入
      patch(oldStartVnode, newStartVnode) // 用新屬性更新老屬性
      // 移動  開始下一次比較
      oldStartVnode = oldChildren[++oldStartIndex]
      newStartVnode = newChildren[++newStartIndex]
    } else if (isSameVnode(oldEndVnode, newEndVnode)) { // 從尾部開始遍歷  尾插法
      patch(oldEndVnode, newEndVnode) // 用新屬性更新老屬性
      oldEndVnode = oldChildren[--oldEndIndex]
      newEndVnode = newChildren[--newEndIndex]
    } else if (isSameVnode(oldStartVnode, newEndVnode)) {
      // 倒序操做
      // 正倒序  老的頭 新的尾部
      patch(oldStartVnode, newEndVnode) //  abc cba
      // 這一步是關鍵 插入 把老 進行倒敘                   nextSibling 某個元素以後緊跟的節點:
      // parent 是一個父級的真實dom元素
      parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling)
      oldStartVnode = oldChildren[++oldStartIndex]
      newEndVnode = newChildren[--newEndIndex]
    } else if (isSameVnode(oldEndVnode, newStartVnode)) {
      // 對比把尾部提到最前面
      patch(oldEndVnode, newStartVnode)
      //                   要插入的元素  插入元素位置
      parent.insertBefore(oldEndVnode.el, oldStartVnode.el)
      oldEndVnode = oldChildren[--oldEndIndex]
      newStartVnode = newChildren[++newStartIndex]
    } else {
      //  上述都不行了的話  則證實是亂序,先拿新節點的首項和老節點對比。若是不同,直接插在這個老節點的前面
      // 若是找到了 則直接移動老節點(以防數組塌陷)
      // 比對結束手可能老節點還有剩餘,指直接刪除
      // 這裏用到了 map
      let movedIndex = map[newStartVnode.key]
      console.log(movedIndex)
      if (movedIndex === undefined) { // 找不到的條件下
        // Vnode.el 對應的是虛擬節點裏面的真實dom元素
        parent.insertBefore(createElem(newStartVnode), oldStartVnode.el)
      } else {    // 找到的條件
        // 移動這個元素
        let moveVnode = oldChildren[movedIndex]
        patch(moveVnode, newStartVnode)
        oldChildren[movedIndex] = undefined
        parent.insertBefore(moveVnode.el, oldStartVnode.el)
      }
      newStartVnode = newChildren[++newStartIndex]
    }
  }
  // 若是比對結束後還有剩餘的新節點    直接把後面的新節點插入
  if (newStartIndex <= newEndIndex) {
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      // 獲取要插入的節點
      let ele = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].el
      // 可能前插 可能後插
      parent.insertBefore(createElem(newChildren[i]), ele)
      // parent.appendChild(createElem(newChildren[i]))
    }
  }
  //  刪除排序以後多餘的老的
  if (oldStartIndex<= oldEndIndex){
    for (let i = oldStartIndex;i<=oldEndIndex;i++){
      let child = oldChildren[i]
      if (child !== undefined){
        // 注意  刪除undefined 會報錯
        parent.removeChild(child.el)
      }
    }
  }

  // 儘可能不要用索引來做爲key 可能會致使從新建立當前元素的全部子元素
  // 他們的tag 同樣 key 同樣 須要把這兩個節點從新渲染
  // 沒有重複利用的效率高
}

先說明一下isSameVnode函數做用,當發現他們兩個 標籤同樣且key值同樣(標識),則證實他們兩個是同一個節點。

着重講述

從這個else if開始看。也就是 判斷條件爲isSameVnode(oldStartVnode, newStartVnode)開始。

核心就是 模擬鏈表增刪改 倒敘的操做。不過作了一部份優化

如下用這個else if開始說到末尾的一個else if。新節點。即要更新成的節點

  1. else if所做的事情。就是 從頭開始比對, 例如 老節點是 1 2 3 4  新節點 1 2 3 5.開始調用 patch進行更新判斷。它會先判斷是否是同一個節點。再更新文本 屬性 子節點。直到結束  把老節點內容更新成 1 2 3 5。
  2. else if所做的事情。就是 從尾部開始比對, 例如 老節點是 5 2 3 4  新節點 1 2 3 4。方法同上
  3. else if所做的事情。就是 優化了反序。例如 老節點是1 2 3 4 新節點 4 3 2 1。當不知足上述兩個條件的時候,會拿老節點的首項和新節點的末尾項相比。結束後插入到老節點的前面。利用了 insertBeforeAPI。兩個參數, 一個,要插入的元素。**二個:**插入元素的位置
  4. else if所做的事情。就是 把末尾節點提到前面。老節點1 2 3 5. 新節點 5 1 2 3  。

以上就是四個else if的做用。較爲容易理解。就是 模擬鏈表操做

接下來就是else

以上狀況都不知足的條件下,證實 新節點是亂序。這樣咱們本着 能複用就複用的原則,從頭開始比對,若是老節點中存在,就移動(注意數組塌陷)。不存在就建立。多餘的就刪除。

步驟

  1. 利用咱們建立好的 map來找要比對的元素
  2. 若是沒有找到,就建立這個元素並插入。
  3. 找到了就先 patch這個元素 移動這個元素,並把原來的位置設置爲 undefined。以防數組塌陷
  4. 移動 要被對比的元素
  5. 由於咱們設置了 undefined,因此咱們要在開始的時候要進行判斷。 這就是咱們在前面的if  else if 的緣由

while進行完畢以後。下面兩個if的做用就簡單的說了。由於while的判斷條件。因此當一個節點比另外一個節點長的時候。會有一些沒有比較的,這些必定是新的或者老的多餘的。直接添加或者刪除就好了

補充,爲何不推薦用 索引作key值

舉個例子

節點A:  a b c d         B:b a d r

索引 0 1 2 3 B:0 1 2 3

判斷條件中,他們索引不同,致使以爲他們不是同一個節點。

這樣會 從新建立,渲染這個節點,效率不如直接重複利用的高。且在節點比較大(含有許多子節點)的時候異常明顯

總結

本篇文章,從0開始講述了虛擬節點的建立 渲染 diff的過程。另外有一些配置沒有說。利用了webpack進行打包,webpack-dev-server等插件快速開發。


本文分享自微信公衆號 - 阿琛前端成長之路(lwkWyc)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索