從瞭解到深刻虛擬DOM和實現diff算法

Virtual DOM 和 diff 算法

前言javascript

虛擬DOMdiff 算法 ,你們有的時候就會常常聽到,那麼它們是什麼實現的呢,這是小浪我在學習的 虛擬DOMdiff 的時候總結,在這裏就來帶你們來深刻了解 virtual DOMdiff 算法,從 snabbdom 的基礎使用 ,到本身實現一個丐版 snabbdom,本身實現 h函數(建立虛擬DOM) patch函數(經過比較新舊虛擬DOM更新視圖),這裏我也畫了幾個動圖 來幫助你們理解 diff 的四種優化策略,文章有點長,但願你們耐心閱讀,最後會貼出全部代碼,你們能夠動手試試喔html

最後但願你們能給小浪一個 前端

往期精彩:vue

手寫一個簡易vue響應式帶你瞭解響應式原理java

從使用到本身實現簡單Vue Router看這個就好了node

前端面試必不可少的基礎知識,雖然少可是你不能不知道git

1.介紹

Virtual DOM 簡單的介紹github

JavaScript按照DOM的結構來建立的虛擬樹型結構對象,是對DOM的抽象,比DOM更加輕量型web

爲啥要使用Virtual DOM面試

  • 固然是前端優化方面,避免頻繁操做DOM,頻繁操做DOM會可能讓瀏覽器迴流和重繪,性能也會很是低,還有就是手動操做 DOM 仍是比較麻煩的,要考慮瀏覽器兼容性問題,當前jQuery等庫簡化了 DOM操做,可是項目複雜了,DOM操做仍是會變得複雜,數據操做也變得複雜
  • 並非全部狀況使用虛擬DOM 都提升性能,是針對在複雜的的項目使用。若是簡單的操做,使用虛擬DOM,要建立虛擬DOM對象等等一系列操做,還不如普通的DOM 操做
  • 虛擬DOM 能夠實現跨平臺渲染,服務器渲染 、小程序、原生應用都使用了虛擬DOM
  • 使用虛擬DOM改變了當前的狀態不須要當即的去更新DOM 並且更新的內容進行更新,對於沒有改變的內容不作任何操做,經過先後兩次差別進行比較
  • 虛擬 DOM 能夠維護程序的狀態,跟蹤上一次的狀態

2.snabbdom 介紹

首先來介紹下 snabbdom

咱們要了解虛擬DOM ,那麼就先了解它的始祖,也就是 snabbdom

snabbdom 是一個開源的項目,Vue 裏面的 虛擬DOM 當初是借鑑了 snabbdom,咱們能夠經過了解snabbdom 的虛擬DOM 來理解 Vue 的虛擬DOM,Vue 的源碼太多,snabbdom 比較簡潔,因此用它來展開 虛擬 DOM 的研究

經過npm 進行安裝

npm install snabbdom
複製代碼

1.snabbdom簡單使用

下面來寫個簡單的例子使用下 snabbdom

<body>
  <div id="app"></div>
  <script src="./js/test.js"></script>
</body>
複製代碼

寫個 test.js 進行使用

/* test.js */

// 導入 snabbdom
import { h, init, thunk } from 'snabbdom'
// init() 方法返回一個 patch 函數 用來比較兩個虛擬DOM 的差別 而後更新到真實的DOM裏
// 這裏暫時傳入一個空數組 []
let patch = init([])
// h 方法是用來建立 Virtual DOM
// 第一個參數是 虛擬DOM 標籤
// 第二個參數是 虛擬DOM 的數據
// 第三個參數是 虛擬DOM 的子虛擬DOM
// 它有好幾種傳參方式 h函數作了重載 這裏就 用上面的傳參
// 並且能夠進行嵌套使用
let vnode = h('div#box', '測試', [
  h('ul.list', [
    h('li', '我是一個li'),
    h('li', '我是一個li'),
    h('li', '我是一個li'),
  ]),
])
// 獲取到 html 的 div#app
let app = document.querySelector('#app')
// 用來比較兩個虛擬DOM 的差別 而後更新到真實的DOM裏
let oldNode = patch(app, vnode)
// 再來模擬一個異步請求
setTimeout(() => {
  let vNode = h('div#box', '從新獲取了數據', [
    h('ul.list', [
      h('li', '我是一個li'),
      h('li', '經過path判斷了差別性'),
      h('li', '更新了數據'),
    ]),
  ])
  // 再來進行比較差別判斷是否更新
  patch(oldNode, vNode)
}, 3000)

複製代碼

image-20210726224703891

能夠看見把 虛擬DOM更新到了 真實DOM ,直接 把以前的 div#app 給替換更新了

9

過了3秒進行對比虛擬DOM 的 差別來添加到真實DOM ,這裏改變了第二個和第三個 li 用h函數渲染成虛擬DOMoldNode 不同因此進行了對比更新

2.介紹下 snabbdom中的模塊

幾個模塊 這裏簡單過一下

模塊名 簡介
attributes DOM 自定義屬性,包括兩個布爾值 checked selected,經過setAttribute() 設置
props 是DOM 的 property屬性,經過 element[attr] = value 設置
dataset data- 開頭的屬性 data-src...
style 行內樣式
eventListeners 用來註冊和移除事件

有了上面的介紹,那咱們就來簡單的使用一下

/* module_test.js */

// 第一步固然是先導入 snabbdom 的 init() h()
import { h, init } from 'snabbdom'

// 導入模塊
import attr from 'snabbdom/modules/attributes'
import style from 'snabbdom/modules/style'
import eventListeners from 'snabbdom/modules/eventlisteners'

// init()註冊模塊 返回值是 patch 函數用來比較 兩個虛擬DOM 差別 而後添加到 真實DOM
let patch = init([attr, style, eventListeners])

// 使用 h() 渲染一個虛擬DOM
let vnode = h(
  'div#app',
  {
    // 自定義屬性
    attrs: {
      myattr: '我是自定義屬性',
    },
    // 行內樣式
    style: {
      fontSize: '29px',
      color: 'skyblue',
    },
    // 事件綁定
    on: {
      click: clickHandler,
    },
  },
  '我是內容'
)

// 點擊處理方法
function clickHandler() {
  // 拿到當前 DOM
  let elm = this.elm
  elm.style.color = 'red'
  elm.textContent = '我被點擊了'
}

// 獲取到 div#app
let app = document.querySelector('#app')

// patch 比較差別 ,而後添加到真實DOM 中
patch(app, vnode)

複製代碼

而後再 html 中引入

<body>
  <div id="app"></div>
  <script src="./js/module_test.js"></script>
  <script></script>
</body>
複製代碼

來看看效果

11

能夠看見的是 自定義屬性 ,行內樣式 ,點擊事件都被 h() 渲染出來了

上面的使用都簡單地過了一遍,那麼咱們就來看看 snabbdom 中的源碼吧

3.虛擬DOM 例子

說了這麼久的 h() 函數和 虛擬DOM 那麼 渲染出來的 虛擬DOM 是什麼樣呢

真實DOM 結構

<div class="container">
  <p>哈哈</p>
  <ul class="list">
    <li>1</li>
    <li>2</li>
  </ul>
</div>
複製代碼

轉爲爲 虛擬DOM 以後的結構

{ 
  // 選擇器
  "sel": "div",
  // 數據
  "data": {
    "class": { "container": true }
  },
  // DOM
  "elm": undefined,
  // 和 Vue :key 同樣是一種優化
  "key": undefined,
  // 子節點
  "children": [
    {
      "elm": undefined,
      "key": undefined,
      "sel": "p",
      "data": { "text": "哈哈" }
    },
    {
      "elm": undefined,
      "key": undefined,
      "sel": "ul",
      "data": {
        "class": { "list": true }
      },
      "children": [
        {
          "elm": undefined,
          "key": undefined,
          "sel": "li",
          "data": {
            "text": "1"
          },
          "children": undefined
        },
        {
          "elm": undefined,
          "key": undefined,
          "sel": "li",
          "data": {
            "text": "1"
          },
          "children": undefined
        }
      ]
    }
  ]
}

複製代碼

在以前提到的 snabbdompatch方法

就是對 新的虛擬DOM老的虛擬DOM 進行diff(精細化比較),找出最小量更新 是在虛擬DOM 比較

不可能把全部的 DOM 都拆掉 而後所有從新渲染

4.h 函數

在上面咱們體驗了虛擬DOM的使用 ,那麼咱們如今來實現一個 丐版的 snabbdom

h 函數在介紹下

snabbdom 咱們也使用了屢次的 h 函數,主要做用是建立 虛擬節點

snabbdom 使用 TS 編寫, 因此 h 函數中作了 方法重載 使用起來靈活

下面是 snabbdomh 函數,能夠看出 參數的有好幾種方式

export declare function h(sel: string): VNode;
export declare function h(sel: string, data: VNodeData): VNode;
export declare function h(sel: string, children: VNodeChildren): VNode;
export declare function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
複製代碼

實現 vnode 函數

在寫 h 函數以前 先實現 vnode 函數,vnode 函數要在 h 中使用, 其實這個 vnode 函數實現功能很是簡單 在 TS 裏面規定了不少類型,不過我這裏和以後都是 用 JS 去寫

/* vnode.js */

/** * 把傳入的 參數 做爲 對象返回 * @param {string} sel 選擇器 * @param {object} data 數據 * @param {array} children 子節點 * @param {string} text 文本 * @param {dom} elm DOM * @returns object */
export default function (sel, data, children, text, elm) {
  return { sel, data, children, text, elm }
}

複製代碼

實現簡易 h 函數

這裏寫的 h 函數 只實現主要功能,沒有實現重載,直接實現 3個 參數的 h 函數

/* h.js */

// 導入 vnode
import vnode from './vnode'

// 導出 h 方法
// 這裏就實現簡單3個參數 參數寫死
/** * * @param {string} a sel * @param {object} b data * @param {any} c 是子節點 能夠是文本,數組 */
export default function h(a, b, c) {
  // 先判斷是否有三個參數
  if (arguments.length < 3) throw new Error('請檢查參數個數')
  // 第三個參數有不肯定性 進行判斷
  // 1.第三個參數是文本節點
  if (typeof c === 'string' || typeof c === 'number') {
    // 調用 vnode 這直接傳 text 進去
    // 返回值 {sel,data,children,text,elm} 再返回出去
    return vnode(a, b, undefined, c, undefined)
  } // 2.第三個參數是數組 [h(),h()] [h(),text] 這些狀況
  else if (Array.isArray(c)) {
    // 然而 數組裏必須是 h() 函數
    // children 用收集返回結果
    let children = []
    // 先判斷裏面是否全是 h()執行完的返回結果 是的話添加到 chilren 裏
    for (let i = 0; i < c.length; i++) {
      // h() 的返回結果 是{} 並且 包含 sel
      if (!(typeof c[i] === 'object' && c[i].sel))
        throw new Error('第三個參數爲數組時只能傳遞 h() 函數')
      // 知足條件進行push [{sel,data,children,text,elm},{sel,data,children,text,elm}]
      children.push(c[i])
    }
    // 調用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  } // 3.第三個參數直接就是函數 返回的是 {sel,data,children,text,elm}
  else if (typeof c === 'object' && c.sel) {
    // 這個時候在 使用h()的時候 c = {sel,data,children,text,elm} 直接放入children
    let children = [c]
    // 調用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  }
}

複製代碼

是否是很簡單呢,他提及來也不是遞歸,像是一種嵌套,不斷地收集 {sel,data,children,text,elm}

chirldren 裏面再套 {sel,data,children,text,elm}

舉個例子

/* index.js */

import h from './my-snabbdom/h'

let vnode = h('div', {}, 
  h('ul', {}, [
    h('li', {}, '我是一個li'),
    h('li', {}, '我是一個li'),
    h('li', {}, '我是一個li'),
  ),
])
console.log(vnode)

複製代碼
<body>
  <div id="container"></div>
  <script src="/virtualdir/bundle.js"></script>
</body>
複製代碼

image-20210727204731661

OK,寫的 h 函數沒有問題,生成了虛擬DOM 樹,生成了虛擬 DOM,咱們以後 就會用的到

簡單說下流程吧

你們都知道js 函數執行,固然是先執行最裏面的 函數

  • 1.h('li', {}, '我是一個li')第一個執行 返回的 {sel,data,children,text,elm} 連續三個 li 都是這個

  • 2.接着就是 h('ul', {}, []) 進入到了第二個判斷是否爲數組,而後 把每一項 進行判斷是否對象 和 有sel 屬性,而後添加到 children 裏面又返回了出去 {sel,data,children,text,elm}

  • 3.第三就是執行 h('div', {},h()) 了, 第三個參數 直接是 h()函數 = {sel,data,children,text,elm} ,他的 children 把他用 [ ] 包起來

    再返回給 vnode

5.patch 函數

簡介

snabbdom 中咱們 經過 init() 返回了一個 patch 函數,經過 patch 進行吧比較兩個 虛擬 DOM 而後添加的 真實的 DOM 樹上,中間比較就是咱們等下要說的 diff

先來了解下 patch裏面作了什麼

image-20210728172052418

按照上面的流程咱們來寫個簡單的 patch

1.patch

先寫個sameVnode

用來對比兩個虛擬DOMkeysel

/* sameVnode.js */

/** * 判斷兩個虛擬節點是不是同一節點 * @param {vnode} vnode1 虛擬節點1 * @param {vnode} vnode2 虛擬節點2 * @returns boolean */
export default function sameVnode(vnode1, vnode2) {
  return (
    (vnode1.data ? vnode1.data.key : undefined) ===
      (vnode2.data ? vnode2.data.key : undefined) && vnode1.sel === vnode2.sel
  )
}

複製代碼

寫個基礎的patch

/* patch.js */

// 導入 vnode
import vnode from './vnode'


// 導出 patch
/** * * @param {vnode/DOM} oldVnode * @param {vnode} newVnode */
export default function patch(oldVnode, newVnode) {
  // 1.判斷oldVnode 是否爲虛擬 DOM 這裏判斷是否有 sel
  if (!oldVnode.sel) {
    // 轉爲虛擬DOM
    oldVnode = emptyNodeAt(oldVnode)
  }
  // 判斷 oldVnode 和 newVnode 是否爲同一虛擬節點
  // 經過 key 和 sel 進行判斷
  if (sameVnode(oldVnode, newVnode)) {
    // 是同一個虛擬節點 調用咱們寫的 patchVnode.js 中的方法
    ...
  } else {
    // 不是同一虛擬個節點 直接暴力拆掉老節點,換上新的節點
    ...
  }
  newVnode.elm = oldVnode.elm

  // 返回newVnode做爲 舊的虛擬節點
  return newVnode
}

/** * 轉爲 虛擬 DOM * @param {DOM} elm DOM節點 * @returns {object} */
function emptyNodeAt(elm) {
  // 把 sel 和 elm 傳入 vnode 並返回
  // 這裏主要選擇器給轉小寫返回vnode
  // 這裏功能作的簡陋,沒有去解析 # .
  // data 也能夠傳 ID 和 class
  return vnode(elm.tagName.toLowerCase(), undefined, undefined, undefined, elm)
}

複製代碼

如今要處理是不是 同一個虛擬節點的問題

2.createElm

先來處理不是同一個虛擬節點

處理這個咱們得去寫個 建立節點的方法 這裏就放到 createElm.js 中完成

/* createElm.js */

/** * 建立元素 * @param {vnode} vnode 要建立的節點 */
export default function createElm(vnode) {
  // 拿出 新建立的 vnode 中的 sel
  let node = document.createElement(vnode.sel)
  // 存在子節點
  // 子節點是文本
  if (
    vnode.text !== '' &&
    (vnode.children === undefined || vnode.children.length === 0)
  ) {
    // 直接添加文字到 node 中
    node.textContent = vnode.text

    // 子節點是數組
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    let children = vnode.children
    // 遍歷數組
    for (let i = 0; i < children.length; i++) {
      // 獲取到每個數組中的 子節點
      let ch = children[i]
      // 遞歸的方式 建立節點
      let chDom = createElm(ch)
      // 把子節點添加到 本身身上
      node.appendChild(chDom)
    }
  }
  // 更新vnode 中的 elm
  vnode.elm = node
  // 返回 DOM
  return node
}

複製代碼

上面的 createElm 就是使用了遞歸的方式去建立子節點 ,而後咱們就去 patch 中 具體的調用這個 建立節點的方法

/* patch.js */

// 導入 vnode createELm
import vnode from './vnode'
import createElm from './createElm'


// 導出 patch
/** * * @param {vnode/DOM} oldVnode * @param {vnode} newVnode */
export default function patch(oldVnode, newVnode) {
  // 1.判斷oldVnode 是否爲虛擬 DOM 這裏判斷是否有 sel
  if (!oldVnode.sel) {
    // 轉爲虛擬DOM
    oldVnode = emptyNodeAt(oldVnode)
  }
  // 判斷 oldVnode 和 newVnode 是否爲同一虛擬節點
  // 經過 key 和 sel 進行判斷
  if (sameVnode(oldVnode, newVnode)) {
    // 是同一個虛擬節點 調用咱們寫的 patchVnode.js 中的方法
    ...
  } else {
    // 不是同一虛擬個節點 直接暴力拆掉老節點,換上新的節點
    // 這裏經過 createElm 遞歸 轉爲 真實的 DOM 節點
    let newNode = createElm(newVnode)
    // 舊節點的父節點
    if (oldVnode.elm.parentNode) {
      let parentNode = oldVnode.elm.parentNode
      // 添加節點到真實的DOM 上
      parentNode.insertBefore(newNode, oldVnode.elm)
      // 刪除舊節點
      parentNode.removeChild(oldVnode.elm)
    }
  }
  newVnode.elm = oldVnode.elm
  return newVnode
}
...
}

複製代碼

在遞歸添加子節點 到了最後咱們在 patch 添加到 真實的 DOM 中,移除以前的老節點

寫到這裏了來試試 不一樣節點 是否真的添加

/* index.js */

import h from './my-snabbdom/h'
import patch from './my-snabbdom/patch'


let app = document.querySelector('#app')

let vnode = h('ul', {}, [
  h('li', {}, '我是一個li'),
  h('li', {}, [
    h('p', {}, '我是一個p'),
    h('p', {}, '我是一個p'),
    h('p', {}, '我是一個p'),
  ]),
  h('li', {}, '我是一個li'),
])


let oldVnode = patch(app, vnode)

複製代碼
<body>
  <div id="app">hellow</div>
  <script src="/virtualdir/bundle.js"></script>
</body>
複製代碼

image-20210728164308771

div#app 給替換了,而且成功替換

3.patchVnode

咱們如今來實現同一個虛擬 DOM 的處理

在 patchVnode 中

步驟都是按照 以前那個流程圖進行編寫,咱們把比較兩個相同的 虛擬 DOM 代碼寫在 patchVnode.js

在比較 兩個相同的虛擬節點分支 有好幾種狀況

/* patchVnode.js */

// 導入 vnode createELm
import createElm from './createElm'

/** * * @param {vnode} oldVnode 老的虛擬節點 * @param {vnode} newVnode 新的虛擬節點 * @returns */
// 對比同一個虛擬節點
export default function patchVnode(oldVnode, newVnode) {
  // 1.判斷是否相同對象
  console.log('同一個虛擬節點')
  if (oldVnode === newVnode) return
  // 2.判斷newVnode上有沒有text
  // 這裏爲啥不考慮 oldVnode呢,由於 newVnode有text說明就沒children
  if (newVnode.text && !newVnode.children) {
    // 判斷是text否相同
    if (oldVnode.text !== newVnode.text) {
      console.log('文字不相同')
      // 不相同就直接把 newVnode中text 給 elm.textContent
      oldVnode.elm.textContent = newVnode.text
    }
  } else {
    // 3.判斷oldVnode有children, 這個時候newVnode 沒有text可是有 children
    if (oldVnode.children) {
      ...這裏新舊節點都存在children 這裏要使用 updateChildren 下面進行實現
    } else {
      console.log('old沒有children,new有children')
      // oldVnode沒有 children ,newVnode 有children
      // 這個時候oldVnode 只有text 咱們把 newVnode 的children拿過來
      // 先清空 oldVnode 中text
      oldVnode.elm.innerHTML = ''
      // 遍歷 newVnode 中的 children
      let newChildren = newVnode.children
      for (let i = 0; i < newChildren.length; i++) {
        // 經過遞歸拿到了 newVnode 子節點
        let node = createElm(newChildren[i])
        // 添加到 oldVnode.elm 中
        oldVnode.elm.appendChild(node)
      }
    }
  }
}

複製代碼

按照流程圖進行編碼,如今要處理 newVnodeoldVnode 都存在 children 的狀況了

在這裏咱們要進行精細化比較 也就是咱們常常說的 diff

4.diff

常常聽到的 diff(精細化比較) ,那咱們先來了解下

diff四種優化策略

在這裏要使用 4 個指針,從1-4的順序來開始命中優化策略,命中一個,指針進行移動(新前和舊前向下移動,新後和舊後向上移動),沒有命中,就使用下一個策略,若是四個策略都沒有命中,只能靠循環來找

命中:兩個節點 selkey 同樣

  1. 新前與舊前
  2. 新後與舊後
  3. 新後與舊前
  4. 新前與舊後

先來講下新增的狀況

四種策略都是在 循環裏面執行

while(舊前<=舊後&&新前<=新後){
  ...
}
複製代碼

14

能夠看出 舊子節點 先循環完畢,那麼說明了新的子節點有須要 新增的 子節點

新前新後 的 節點 就是須要新增的字節

刪除的狀況1

19

這裏新子節點 先循環完畢說明 舊子節點有須要刪除的節點

刪除的狀況2

當咱們刪除多個,並且 4種策略都沒有知足,咱們得經過 while 循環 舊子節點 找到 新子節點須要尋找節點並標記爲 undefined 虛擬節點是 undefined實際上在 DOM已經把它移動了 ,舊前舊後 之間的節點就是須要刪除的節點

18

複雜狀況1

當觸發了 第四種 策略,這裏就須要移動節點了,舊後指向的節點(在虛擬節點標爲 undefined),實際把 新前 指向的節點 在DOM 中 移動到舊前以前

20

複雜狀況2

當觸發了 第三種 策略,這裏也須要移動節點了,舊前 指向的節點(在虛擬節點標爲 undefined),實際把 新後 指向的節點 在DOM 中 移動到舊後以後

21

注意幾個點 :

  • h('li',{key:'A'} : "A"}) 好比這其中的 key 是這個節點的惟一的標識
  • 它的存在是在告訴 diff ,在更改先後它們是同一個DOM節點。
  • 只有是同一個虛擬節點,才進行精細化比較,不然就是暴力刪除舊的、插入新的
  • 同一虛擬節點 不只要 key 相同並且要 選擇器相同也就是上面的 h() 函數建立的 虛擬節點 對象裏的 sel
  • 只進行同層比較,不會進行跨層比較

5.updateChildren

看了上面對於 diff 的介紹,不知道我畫的圖 演示清楚了沒,而後咱們接着繼續來完成 patchVnode

咱們得寫個 updateChildren 來進行精細化比較

這個文件就是 diff 算法的核心,咱們用來比較 oldVnodenewVnode 都存在 children 的狀況

這裏有點繞,註釋都寫了,請耐心觀看,流程就是按照 diff 的四種策略來寫,還要處理沒有命中的狀況

/* updateChilren.js */

// 導入 createElm patchVnode sameVnode
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'

// 導出 updateChildren
/** * * @param {dom} parentElm 父節點 * @param {array} oldCh 舊子節點 * @param {array} newCh 新子節點 */
export default function updateChildren(parentElm, oldCh, newCh) {
  // 下面先來定義一下以前講過的 diff 的幾個指針 和 指針指向的 節點
  // 舊前 和 新前
  let oldStartIdx = 0,
    newStartIdx = 0
  let oldEndIdx = oldCh.length - 1 //舊後
  let newEndIdx = newCh.length - 1 //新後
  let oldStartVnode = oldCh[0] //舊前 節點
  let oldEndVnode = oldCh[oldEndIdx] //舊後節點
  let newStartVnode = newCh[0] //新前節點
  let newEndVnode = newCh[newEndIdx] //新後節點
  let keyMap = null //用來作緩存
  // 寫循環條件
  while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
    console.log('---進入diff---')

    // 下面按照 diff 的4種策略來寫 這裏面還得調用 pathVnode
    // patchVnode 和 updateChildren 是互相調用的關係,不過這可不是死循環
    // 指針走完後就不調用了

    // 這一段都是爲了忽視咱們加過 undefined 節點,這些節點實際上已經移動了
    if (oldCh[oldStartIdx] == undefined) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldCh[oldEndIdx] == undefined) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (newCh[newStartIdx] == undefined) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newCh[newEndIdx] == undefined) {
      newEndVnode = newCh[--newEndIdx]
    }
    // 忽視了全部的 undefined 咱們這裏來 判斷四種diff優化策略
    // 1.新前 和 舊前
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      console.log('1命中')
      // 調用 patchVnode 對比兩個節點的 對象 文本 children
      patchVnode(oldStartVnode, newStartVnode)
      // 指針移動
      newStartVnode = newCh[++newStartIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    } // 2.新後 和 舊後
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      console.log('2命中')
      // 調用 patchVnode 對比兩個節點的 對象 文本 children
      patchVnode(oldEndVnode, newEndVnode)
      // 指針移動
      newEndVnode = newCh[--newEndIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } // 3.新後 和 舊前
    else if (sameVnode(oldStartVnode, newEndVnode)) {
      console.log('3命中')
      // 調用 patchVnode 對比兩個節點的 對象 文本 children
      patchVnode(oldStartVnode, newEndVnode)
      // 策略3是須要移動節點的 把舊前節點 移動到 舊後 以後
      // insertBefore 若是參照節點爲空,就插入到最後 和 appendChild同樣
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
      // 指針移動
      newEndVnode = newCh[--newEndIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    }
    // 4.新前 和 舊後
    else if (sameVnode(oldEndVnode, newStartVnode)) {
      console.log('4命中')
      // 調用 patchVnode 對比兩個節點的 對象 文本 children
      patchVnode(oldEndVnode, newStartVnode)
      // 策略4是也須要移動節點的 把舊後節點 移動到 舊前 以前
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      // 指針移動
      newStartVnode = newCh[++newStartIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } else {
      console.log('diff四種優化策略都沒命中')
      // 當四種策略都沒有命中
      // keyMap 爲緩存,這樣就不用每次都遍歷老對象
      if (!keyMap) {
        // 初始化 keyMap
        keyMap = {}
        // 從oldStartIdx到oldEndIdx進行遍歷
        for (let i = oldStartIdx; i < oldEndIdx; i++) {
          // 拿個每一個子對象 的 key
          const key = oldCh[i].data.key
          // 若是 key 不爲 undefined 添加到緩存中
          if (!key) keyMap[key] = i
        }
      }

      // 判斷當前項是否存在 keyMap 中 ,當前項時 新前(newStartVnode)
      let idInOld = keyMap[newStartIdx.data]
        ? keyMap[newStartIdx.data.key]
        : undefined

      // 存在的話就是移動操做
      if (idInOld) {
        console.log('移動節點')
        // 從 老子節點 取出要移動的項
        let moveElm = oldCh[idInOld]
        // 調用 patchVnode 進行對比 修改
        patchVnode(moveElm, newStartVnode)
        // 將這一項設置爲 undefined
        oldCh[idInOld] = undefined
        // 移動 節點 ,對於存在的節點使用 insertBefore移動
        // 移動的 舊前 以前 ,由於 舊前 與 舊後 之間的要被刪除
        parentElm.insertBefore(moveElm.elm, oldStartVnode.elm)
      } else {
        console.log('添加新節點')
        // 不存在就是要新增的項
        // 添加的節點仍是虛擬節點要經過 createElm 進行建立 DOM
        // 一樣添加到 舊前 以前
        parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
      }

      // 處理完上面的添加和移動 咱們要 新前 指針繼續向下走
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 咱們添加和刪除操做還沒作呢
  // 首先來完成添加操做 新前 和 新後 中間是否還存在節點
  if (newStartIdx <= newEndIdx) {
    console.log('進入添加剩餘節點')
    // 這是一個標識
    // let beforeFlag = oldCh[oldEndIdx + 1] ? oldCh[oldEndIdx + 1].elm : null
    let beforeFlag = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1] : null
    // new 裏面還有剩餘節點 遍歷添加
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // newCh裏面的子節點還須要 從虛擬DOM 轉爲 DOM
      parentElm.insertBefore(createElm(newCh[i]), beforeFlag)
    }
  } else if (oldStartIdx <= oldEndIdx) {
    console.log('進入刪除多餘節點')
    // old 裏面還有剩餘 節點 ,舊前 和 舊後 之間的節點須要刪除
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      // 刪除 剩餘節點以前 先判斷下是否存在
      if (oldCh[i].elm) parentElm.removeChild(oldCh[i].elm)
    }
  }
}

複製代碼

到了這裏咱們基本寫都完成了, h 函數 建立 虛擬 DOM , patch 比較 虛擬DOM 進行更新視圖

6.咱們來測試一下寫的

其實在寫代碼的時候就在不斷的調試。。。如今隨便測試幾個

1.代碼

html

<body>
  <button class="btn">策略3</button>
  <button class="btn">複雜</button>
  <button class="btn">刪除</button>
  <button class="btn">複雜</button>
  <button class="btn">複雜</button>
  <ul id="app">
    hellow
  </ul>

  <script src="/virtualdir/bundle.js"></script>
</body>
複製代碼

index.js

/* index.js */

import h from './my-snabbdom/h'
import patch from './my-snabbdom/patch'

let app = document.querySelector('#app')

let vnode = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'E' }, 'E'),
])

let oldVnode = patch(app, vnode)

let vnode2 = h('ul', {}, [
  h('li', { key: 'E' }, 'E'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'A' }, 'A'),
])
let vnode3 = h('ul', {}, [
  h('li', { key: 'E' }, 'E'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'K' }, 'K'),
])
let vnode4 = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
])
let vnode5 = h('ul', {}, [
  h('li', { key: 'E' }, 'E'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'V' }, 'V'),
])
let vnode6 = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'D' }, 'D'),
  h(
    'li',
    { key: 'E' },
    h('ul', {}, [
      h('li', { key: 'A' }, 'A'),
      h('li', { key: 'B' }, 'B'),
      h('li', { key: 'C' }, 'C'),
      h('li', { key: 'D' }, 'D'),
      h('li', { key: 'E' }, h('div', { key: 'R' }, 'R')),
    ])
  ),
])
let vnodeList = [vnode2, vnode3, vnode4, vnode5, vnode6]
let btn = document.querySelectorAll('.btn')
for (let i = 0; i < btn.length; i++) {
  btn[i].onclick = () => {
    patch(vnode, vnodeList[i])
  }
}
複製代碼

2.演示

策略3

22

複雜

23

刪除

24

複雜

25

複雜(這裏是簡單 。。)

26

7.結語

註釋我都寫了喔,你們能夠對照 我上面畫的圖不清楚能夠反覆耐心的看哈

若是看的話沒什麼感受,你們能夠本身動手寫寫,下面我會貼出全部的代碼

代碼一樣也放在 github

完整代碼:

h.js

/* h.js */

// 導入 vnode
import vnode from './vnode'

// 導出 h 方法
// 這裏就實現簡單3個參數 參數寫死
/** * * @param {string} a sel * @param {object} b data * @param {any} c 是子節點 能夠是文本,數組 */
export default function h(a, b, c) {
  // 先判斷是否有三個參數
  if (arguments.length < 3) throw new Error('請檢查參數個數')
  // 第三個參數有不肯定性 進行判斷
  // 1.第三個參數是文本節點
  if (typeof c === 'string' || typeof c === 'number') {
    // 調用 vnode 這直接傳 text 進去
    // 返回值 {sel,data,children,text,elm} 再返回出去
    return vnode(a, b, undefined, c, undefined)
  } // 2.第三個參數是數組 [h(),h()] [h(),text] 這些狀況
  else if (Array.isArray(c)) {
    // 然而 數組裏必須是 h() 函數
    // children 用收集返回結果
    let children = []
    // 先判斷裏面是否全是 h()執行完的返回結果 是的話添加到 chilren 裏
    for (let i = 0; i < c.length; i++) {
      // h() 的返回結果 是{} 並且 包含 sel
      if (!(typeof c[i] === 'object' && c[i].sel))
        throw new Error('第三個參數爲數組時只能傳遞 h() 函數')
      // 知足條件進行push [{sel,data,children,text,elm},{sel,data,children,text,elm}]
      children.push(c[i])
    }
    // 調用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  } // 3.第三個參數直接就是函數 返回的是 {sel,data,children,text,elm}
  else if (typeof c === 'object' && c.sel) {
    // 這個時候在 使用h()的時候 c = {sel,data,children,text,elm} 直接放入children
    let children = [c]
    // 調用 vnode 返回 {sel,data,children,text,elm} 再返回
    return vnode(a, b, children, undefined, undefined)
  }
}

複製代碼

patch.js

/* patch.js */

// 導入 vnode createELm patchVnode sameVnode.js
import vnode from './vnode'
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'

// 導出 patch
/** * * @param {vnode/DOM} oldVnode * @param {vnode} newVnode */
export default function patch(oldVnode, newVnode) {
  // 1.判斷oldVnode 是否爲虛擬 DOM 這裏判斷是否有 sel
  if (!oldVnode.sel) {
    // 轉爲虛擬DOM
    oldVnode = emptyNodeAt(oldVnode)
  }
  // 判斷 oldVnode 和 newVnode 是否爲同一虛擬節點
  // 經過 key 和 sel 進行判斷
  if (sameVnode(oldVnode, newVnode)) {
    // 是同一個虛擬節點 調用咱們寫的 patchVnode.js 中的方法
    patchVnode(oldVnode, newVnode)
  } else {
    // 不是同一虛擬個節點 直接暴力拆掉老節點,換上新的節點
    // 這裏經過 createElm 遞歸 轉爲 真實的 DOM 節點
    let newNode = createElm(newVnode)
    // 舊節點的父節點
    if (oldVnode.elm.parentNode) {
      let parentNode = oldVnode.elm.parentNode
      // 添加節點到真實的DOM 上
      parentNode.insertBefore(newNode, oldVnode.elm)
      // 刪除舊節點
      parentNode.removeChild(oldVnode.elm)
    }
  }
  newVnode.elm = oldVnode.elm
  // console.log(newVnode.elm)

  // 返回newVnode做爲 舊的虛擬節點
  return newVnode
}

/** * 轉爲 虛擬 DOM * @param {DOM} elm DOM節點 * @returns {object} */
function emptyNodeAt(elm) {
  // 把 sel 和 elm 傳入 vnode 並返回
  // 這裏主要選擇器給轉小寫返回vnode
  // 這裏功能作的簡陋,沒有去解析 # .
  // data 也能夠傳 ID 和 class
  return vnode(elm.tagName.toLowerCase(), undefined, undefined, undefined, elm)
}

複製代碼

createElm.js

/* createElm.js */

/** * 建立元素 * @param {vnode} vnode 要建立的節點 */
export default function createElm(vnode) {
  // 拿出 新建立的 vnode 中的 sel
  let node = document.createElement(vnode.sel)
  // 存在子節點
  // 子節點是文本
  if (
    vnode.text !== '' &&
    (vnode.children === undefined || vnode.children.length === 0)
  ) {
    // 直接添加文字到 node 中
    node.textContent = vnode.text
    // 子節點是數組
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    let children = vnode.children
    // 遍歷數組
    for (let i = 0; i < children.length; i++) {
      // 獲取到每個數組中的 子節點
      let ch = children[i]
      // 遞歸的方式 建立節點
      let chDom = createElm(ch)
      // 把子節點添加到 本身身上
      node.appendChild(chDom)
    }
  }
  // 更新vnode 中的 elm
  vnode.elm = node
  // 返回 DOM
  return node
}

複製代碼

vnode.js

/* vnode.js */

/** * 把傳入的 參數 做爲 對象返回 * @param {string} sel 選擇器 * @param {object} data 數據 * @param {array} children 子節點 * @param {string} text 文本 * @param {dom} elm DOM * @returns */
export default function (sel, data, children, text, elm) {
  return { sel, data, children, text, elm }
}

複製代碼

patchVnode.js

/* patchVnode.js */

// 導入 vnode createELm patchVnode updateChildren
import createElm from './createElm'
import updateChildren from './updateChildren'
/** * * @param {vnode} oldVnode 老的虛擬節點 * @param {vnode} newVnode 新的虛擬節點 * @returns */
// 對比同一個虛擬節點
export default function patchVnode(oldVnode, newVnode) {
  // 1.判斷是否相同對象
  // console.log('同一個虛擬節點')
  if (oldVnode === newVnode) return
  // 2.判斷newVnode上有沒有text
  // 這裏爲啥不考慮 oldVnode呢,由於 newVnode有text說明就沒children
  if (newVnode.text && !newVnode.children) {
    // 判斷是text否相同
    if (oldVnode.text !== newVnode.text) {
      console.log('文字不相同')
      // 不相同就直接把 newVnode中text 給 elm.textContent
      oldVnode.elm.textContent = newVnode.text
    }
  } else {
    // 3.判斷oldVnode有children, 這個時候newVnode 沒有text可是有 children
    if (oldVnode.children) {
      updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
    } else {
      console.log('old沒有children,new有children')
      // oldVnode沒有 children ,newVnode 有children
      // 這個時候oldVnode 只有text 咱們把 newVnode 的children拿過來
      // 先清空 oldVnode 中text
      oldVnode.elm.innerHTML = ''
      // 遍歷 newVnode 中的 children
      let newChildren = newVnode.children
      for (let i = 0; i < newChildren.length; i++) {
        // 經過遞歸拿到了 newVnode 子節點
        let node = createElm(newChildren[i])
        // 添加到 oldVnode.elm 中
        oldVnode.elm.appendChild(node)
      }
    }
  }
}

複製代碼

sameVnode.js

/* sameVnode.js */

/** * 判斷兩個虛擬節點是不是同一節點 * @param {vnode} vnode1 虛擬節點1 * @param {vnode} vnode2 虛擬節點2 * @returns boolean */
export default function sameVnode(vnode1, vnode2) {
  return (
    (vnode1.data ? vnode1.data.key : undefined) ===
      (vnode2.data ? vnode2.data.key : undefined) && vnode1.sel === vnode2.sel
  )
}

複製代碼

updateChildren.js

/* updateChilren.js */

// 導入 createElm patchVnode sameVnode
import createElm from './createElm'
import patchVnode from './patchVnode'
import sameVnode from './sameVnode'

// 導出 updateChildren
/** * * @param {dom} parentElm 父節點 * @param {array} oldCh 舊子節點 * @param {array} newCh 新子節點 */
export default function updateChildren(parentElm, oldCh, newCh) {
  // 下面先來定義一下以前講過的 diff 的幾個指針 和 指針指向的 節點
  // 舊前 和 新前
  let oldStartIdx = 0,
    newStartIdx = 0
  let oldEndIdx = oldCh.length - 1 //舊後
  let newEndIdx = newCh.length - 1 //新後
  let oldStartVnode = oldCh[0] //舊前 節點
  let oldEndVnode = oldCh[oldEndIdx] //舊後節點
  let newStartVnode = newCh[0] //新前節點
  let newEndVnode = newCh[newEndIdx] //新後節點
  let keyMap = null //用來作緩存
  // 寫循環條件
  while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
    console.log('---進入diff---')

    // 下面按照 diff 的4種策略來寫 這裏面還得調用 pathVnode
    // patchVnode 和 updateChildren 是互相調用的關係,不過這可不是死循環
    // 指針走完後就不調用了

    // 這一段都是爲了忽視咱們加過 undefined 節點,這些節點實際上已經移動了
    if (oldCh[oldStartIdx] == undefined) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldCh[oldEndIdx] == undefined) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (newCh[newStartIdx] == undefined) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newCh[newEndIdx] == undefined) {
      newEndVnode = newCh[--newEndIdx]
    }
    // 忽視了全部的 undefined 咱們這裏來 判斷四種diff優化策略
    // 1.新前 和 舊前
    else if (sameVnode(oldStartVnode, newStartVnode)) {
      console.log('1命中')
      // 調用 patchVnode 對比兩個節點的 對象 文本 children
      patchVnode(oldStartVnode, newStartVnode)
      // 指針移動
      newStartVnode = newCh[++newStartIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    } // 2.新後 和 舊後
    else if (sameVnode(oldEndVnode, newEndVnode)) {
      console.log('2命中')
      // 調用 patchVnode 對比兩個節點的 對象 文本 children
      patchVnode(oldEndVnode, newEndVnode)
      // 指針移動
      newEndVnode = newCh[--newEndIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } // 3.新後 和 舊前
    else if (sameVnode(oldStartVnode, newEndVnode)) {
      console.log('3命中')
      // 調用 patchVnode 對比兩個節點的 對象 文本 children
      patchVnode(oldStartVnode, newEndVnode)
      // 策略3是須要移動節點的 把舊前節點 移動到 舊後 以後
      // insertBefore 若是參照節點爲空,就插入到最後 和 appendChild同樣
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
      // 指針移動
      newEndVnode = newCh[--newEndIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    }
    // 4.新前 和 舊後
    else if (sameVnode(oldEndVnode, newStartVnode)) {
      console.log('4命中')
      // 調用 patchVnode 對比兩個節點的 對象 文本 children
      patchVnode(oldEndVnode, newStartVnode)
      // 策略4是也須要移動節點的 把舊後節點 移動到 舊前 以前
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      // 指針移動
      newStartVnode = newCh[++newStartIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } else {
      console.log('diff四種優化策略都沒命中')
      // 當四種策略都沒有命中
      // keyMap 爲緩存,這樣就不用每次都遍歷老對象
      if (!keyMap) {
        // 初始化 keyMap
        keyMap = {}
        // 從oldStartIdx到oldEndIdx進行遍歷
        for (let i = oldStartIdx; i < oldEndIdx; i++) {
          // 拿個每一個子對象 的 key
          const key = oldCh[i].data.key
          // 若是 key 不爲 undefined 添加到緩存中
          if (!key) keyMap[key] = i
        }
      }

      // 判斷當前項是否存在 keyMap 中 ,當前項時 新前(newStartVnode)
      let idInOld = keyMap[newStartIdx.data]
        ? keyMap[newStartIdx.data.key]
        : undefined

      // 存在的話就是移動操做
      if (idInOld) {
        console.log('移動節點')
        // 從 老子節點 取出要移動的項
        let moveElm = oldCh[idInOld]
        // 調用 patchVnode 進行對比 修改
        patchVnode(moveElm, newStartVnode)
        // 將這一項設置爲 undefined
        oldCh[idInOld] = undefined
        // 移動 節點 ,對於存在的節點使用 insertBefore移動
        // 移動的 舊前 以前 ,由於 舊前 與 舊後 之間的要被刪除
        parentElm.insertBefore(moveElm.elm, oldStartVnode.elm)
      } else {
        console.log('添加新節點')
        // 不存在就是要新增的項
        // 添加的節點仍是虛擬節點要經過 createElm 進行建立 DOM
        // 一樣添加到 舊前 以前
        parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
      }

      // 處理完上面的添加和移動 咱們要 新前 指針繼續向下走
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 咱們添加和刪除操做還沒作呢
  // 首先來完成添加操做 新前 和 新後 中間是否還存在節點
  if (newStartIdx <= newEndIdx) {
    console.log('進入添加剩餘節點')
    // 這是一個標識
    // let beforeFlag = oldCh[oldEndIdx + 1] ? oldCh[oldEndIdx + 1].elm : null
    let beforeFlag = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1] : null
    // new 裏面還有剩餘節點 遍歷添加
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // newCh裏面的子節點還須要 從虛擬DOM 轉爲 DOM
      parentElm.insertBefore(createElm(newCh[i]), beforeFlag)
    }
  } else if (oldStartIdx <= oldEndIdx) {
    console.log('進入刪除多餘節點')
    // old 裏面還有剩餘 節點 ,舊前 和 舊後 之間的節點須要刪除
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      // 刪除 剩餘節點以前 先判斷下是否存在
      if (oldCh[i].elm) parentElm.removeChild(oldCh[i].elm)
    }
  }
}

複製代碼
相關文章
相關標籤/搜索