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

虛擬DOM和比對算法講解

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

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

理論知識

爲何須要虛擬DOM

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

什麼是虛擬DOM

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

優勢

  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(做爲掛載的節點)算法

<!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的文件,用來做爲 入口文件json

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

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

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

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

目標明確

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

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

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

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

// 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等插件快速開發。

相關文章
相關標籤/搜索