Skip to main content

Virtual DOM | 框架基礎探索

此筆記非原創,大部分內容參考 Leo Chiu 文章 Virtual DOM | 為了瞭解原理,那就來實作一個簡易 Virtual DOM 吧! 撰寫。

此筆記於 2022/01/05 撰寫。

為什麼要理解 Virtual DOM ?​

  1. 操作 Virtual DOM 及直接對 DOM 操作的優缺點。
  2. 更了解 Vue、React 等框架如何利用 Virtual DOM 達到效能優化。
  3. 更了解 MVVM 架構中 ViewModel 的核心原理,知道資料改變是如何觸發畫面改變的。
  4. 幫助了解框架進行 Virtual DOM 比對原理,在框架中撰寫程式時,手感會更扎實。

Virtual DOM 概念​

Virtual DOM 是以 JavaScript 物件模擬特定 DOM Tree,不直接操作真實 DOM,而是使用操作模擬 DOM Tree,等待操作結束後,再將變更更新回真實 DOM 上,達到效能優化目的。

( 圖片來源 : https://medium.com/手寫筆記/build-a-simple-virtual-dom-5cf12ccf379f )

info

利用 Virtual DOM 的操作,可以達到僅更新有修改變動的 DOM 優化渲染效能,不會整個 DOM Tree 都重新渲染。


JS 模擬 Virtual DOM 物件 :​

const vNode = {
tagName: 'div',
attrs: {
class: 'container',
style: 'display: flex;'
},
children: [
tagName: 'a',
attrs: {
href: 'https://www.google.com',
class: 'link',
style: 'text-decoration: none;'
},
children: [...],
],
}

以往我們使用 innerHTML 來操作更新畫面,因為簡單暴力 (?)。

但 innerHTML 有很多潛在的問題,例如 :

  1. 選定的 DOM 使用 innerHTML,DOM 的所有子元素通通會重新渲染一遍,造成瀏覽器效能負擔。
  2. innerHTML 潛藏 XSS 攻擊風險。
  3. 一不小心還會用迴圈多次渲染 DOM Tree,造成沒必要的效能浪費。

所以使用 Virtual DOM 都沒有缺點 ?​

程式選擇議題通常是一體兩面,有好有壞,選擇了一個方式通常會伴隨優勢和缺點。

Virtual DOM 是依靠記憶體空間存放物件,因此操作 Virtual DOM 也會提高網頁記憶體的使用量,另外 Virtual DOM 的寫法通常也比較複雜不像單純操作 DOM 來的方便直覺。


操作 DOM 的方式​

1. document.createElement(tagName)​

可以產生指定標籤名稱的 HTML 元素。

let div = document.createElement('div') // <div></div>

2. document.createTextNode(textContent)​

產生一個文字節點

let text = document.createTextNode('Hello world !!') // #text
console.log(text.textContent) // Hello world !!

3. ParentNode.appendChild(childNode)​

將一個節點附加到指定父節點 ( ParentNode ) 的子節點列表末尾處,如果將被附加的節點已存在 DOM Tree 中,appendChild() 只會將該節點移動至新的位置,不需事先移除。

const app = document.querySelector('div#app')
let p = document.createElement('p')
app.appendChild(p)

此時的 HTML :

<div id="app">
<p></p>
</div>

4. el.setAttrribute(name, value)​

設置 el 上某個屬性值,如果屬性已經存在,則更新該值;否則添加新的屬性名稱與值。

let img = document.querySelector('.img') // <img class="img">
img.setAttribute('src', './img.png') // <img class="img" src="./img.png">
img.setAttribute('alt', 'Image') // <img class="img" src="./img.png" alt="Image">

5. el.removeAttribute(name)​

將 el 上某個屬性移除。

img.removeAttribute('alt') // <img class="img" src="./img.png">

6. ChildNode.replaceWith(target)​

將 target ( Node 物件 或 DOMString ) 替換該節點 ( ChildNode ) 的父節點下當前的子節點。

const app = document.querySelector('div#app')
let p = document.createElement('p')
app.appendChild(p)

let span = document.createElement('span')
p.replaceWith(span)

此時的 HTML :

<div id="app">
<span></span>
</div>

7. el.remove()​

將 el 從它所屬的 DOM Tree 中刪除。

let el = document.querySelector('p.text')
el.remove()

其他 DOM 操作​

1. document.createDocumentFragment()​

屬於 DOM 的節點 ( Node ),實際渲染到 DOM 上會將 fragment 裡面所有子節點渲染出來,而 fragment 本身會被這些子節點取代,可以把 fragment 當作是裝這些子節點的箱子,箱子只是一個包裝而已。

MDN 連結

2. Node.cloneNode()​

用來複製一個節點 ( Node )。

MDN 連結

範例 :​

// 頁碼功能 - 建立 HTML 模板 ( 傳陣列資料 )
function createHTML(elData) {
let fragment = document.createDocumentFragment()
let el_li = document.createElement('li')
let el_a = document.createElement('a')
elData.forEach(el => {
el_li = el_li.cloneNode(false)
el_a = el_a.cloneNode(false)
el_li.setAttribute('class', 'filterPage__pagination-item flex-center')
el_a.setAttribute('href', 'javascript:;')
el_a.setAttribute('class', el['className'])
el_a.setAttribute('data-page', el['content'])
el_a.innerHTML = el['content']
el_li.appendChild(el_a)
fragment.appendChild(el_li)
})
return fragment // <li><a>...</a></li>
}

Virtual DOM 原理​


( 圖片來源 : https://medium.com/手寫筆記/build-a-simple-virtual-dom-5cf12ccf379f )

1. 建立描述 DOM 的物件 - vNode - vNode.js、createElement.js​

const vNode = {
tagName: 'div',
attrs: {
id: 'app',
class: 'container',
},
children: [
//...
],
}

上面是非常簡易描述 DOM 的物件,透過 tagName 定義 HTML 標籤名稱,attrs 為包含 HTML 標籤上所有屬性的物件,children 為該 HTML 元素底下所有子元素。


2. 將 vNode 轉換成實際的 Node - render.js​

也就是將描述 DOM 的物件 ( 如上 ),轉換成這樣的過程 :

<div
id="app"
class="container"
>
<!-- children... -->
</div>

需要注意的是,這邊只是個過程,畫面此時還並沒有轉換後的結果,最後須回傳結果,交由其他程式處理。


3. 把 Node 掛載到真實 DOM 上 - mount.js​

顧名思義,將 vNode 轉換後回傳的結果掛載到畫面上呈現 ( replaceWith() )


Virtual DOM 運作過程​

Vue 以資料驅動做基礎,當資料發生改變,畫面上有連動到資料的 DOM 也會跟著改變。

透過 diff ( diff.js ) 演算法比較 新的 Virtual DOM 和 舊的 Virtual DOM,再改變真實 DOM,這邊我們有兩種方式做資料的更新 :

1. 定時器 Timer 每秒執行演算法比較 : setInterval(..., 1000)​

vDOM_process.png
( 圖片來源 : https://medium.com/手寫筆記/build-a-simple-virtual-dom-5cf12ccf379f )

2. 觀察者模式 Observer Pattern : Proxy​

vDOM_proxy.png
( 圖片來源 : https://medium.com/手寫筆記/build-a-simple-virtual-dom-5cf12ccf379f )


開始實作​

💡 情境:使用 Virtual DOM 每秒隨機產生 0 ~ 9 張指定的圖片。

主程式 - vNode.js​

將上面 Virtual DOM 原理所需要用到的功能匯入主程式 :

import createElement from './createElement.js'
import render from './render.js'
import mount from './mount.js'
import diff from './diff.js'

先介紹主程式部分,稍後會來看看這些功能都是如何使用 JS 達成的。

建立一個會 回傳描述 DOM 物件 的函式 createVApp(),這個函式需要傳入隨機 0 ~ 10 的 count

const createVApp = function (count) {
// 產生最外層 id="app" 的 div 容器,所有更動都會掛載到這個 app 容器上
return createElement('div', {
attrs: {
id: 'app',
'data-count': count,
},
children: [
// 產生一個容器 div
createElement('div', {
attrs: {
class: 'container',
style: 'display: flex; flex-direction: column; align-items: center;',
},
children: [
// 產生一個放文字的 div 容器
createElement('div', {
attrs: {
style: 'padding: 12px 0; font-size: 20px;',
},
children: [
// 產生一個描述圖片數量的文字
String(`Current count: ${count}`),
],
}),
// 產生一個圖片 div 容器
createElement('div', {
attrs: {
class: 'imgContainer',
},
children: [
// 產生 count 數量的 img DOM 物件
...Array.from({ length: count }, () =>
createElement('img', {
attrs: {
src: './cat.jpg',
alt: 'cat meme',
style: 'width: 100px; margin: 4px 4px;',
},
})
),
],
}),
],
}),
],
})
}

初始化 Virtual DOM → Real DOM

let count = 0 // 預設 count 數量
let vApp = createVApp(count) // 建立描述 DOM 物件
const $app = render(vApp) // 將 DOM 物件轉換成實際的 Node
let $rootEl = mount($app, document.getElementById('app')) // 將實際 Node 掛載到畫面上

使用 setInterval 當計時器,每秒更新 count 隨機數,並呼叫 createVApp(count) 賦值到新變數上 vNewApp,並使用 diff 及透過 diff 回傳的 patch 函式更新 Node。

setInterval(() => {
count = Math.floor(Math.random() * 10) // 每秒隨機 0 ~ 10 數字
const vNewApp = createVApp(count) // 新的描述 DOM 物件
const patch = diff(vApp, vNewApp) // diff 回傳的函式用變數 patch 接住
$rootEl = patch($rootEl) // 使用 patch 函式更新 Node
// console.log($rootEl) // $rootEl 為實際 Node,是可以直接操作的
vApp = vNewApp // 更新描述 DOM 物件
}, 1000)

主程式完整程式碼​

// ----- vNode.js -----
import createElement from './createElement.js'
import render from './render.js'
import mount from './mount.js'
import diff from './diff.js'

const createVApp = function (count) {
// 產生最外層 id="app" 的 div 容器,所有更動都會掛載到這個 app 容器上
return createElement('div', {
attrs: {
id: 'app',
'data-count': count,
},
children: [
// 產生一個容器 div
createElement('div', {
attrs: {
class: 'container',
style: 'display: flex; flex-direction: column; align-items: center;',
},
children: [
// 產生一個放文字的 div 容器
createElement('div', {
attrs: {
style: 'padding: 12px 0; font-size: 20px;',
},
children: [
// 產生一個描述圖片數量的文字
String(`Current count: ${count}`),
],
}),
// 產生一個圖片 div 容器
createElement('div', {
attrs: {
class: 'imgContainer',
},
children: [
// 產生 count 數量的 img DOM 物件
...Array.from({ length: count }, () =>
createElement('img', {
attrs: {
src: './cat.jpg',
alt: 'cat meme',
style: 'width: 100px; margin: 4px 4px;',
},
})
),
],
}),
],
}),
],
})
}

let count = 0 // 預設 count 數量
let vApp = createVApp(count) // 建立描述 DOM 物件
const $app = render(vApp) // 將 DOM 物件轉換成實際的 Node
let $rootEl = mount($app, document.getElementById('app')) // 將實際 Node 掛載到畫面上

setInterval(() => {
count = Math.floor(Math.random() * 10) // 每秒隨機 0 ~ 10 數字
const vNewApp = createVApp(count) // 新的描述 DOM 物件
const patch = diff(vApp, vNewApp) // diff 回傳的函式用變數 patch 接住
$rootEl = patch($rootEl) // 使用 patch 函式更新 Node
// console.log($rootEl) // $rootEl 為實際 Node,是可以直接操作的
vApp = vNewApp // 更新描述 DOM 物件
}, 1000)

建立描述 DOM 物件 - createElement.js​

在主程式 vNode.js 有多次呼叫 createElement(),透過傳入參數建立描述 DOM 物件。

const createElement = function (tagName, { attrs = {}, children = [] } = {}) {
return {
tagName,
attrs,
children,
}
}

export default createElement

比較要注意的是,HTML 標籤屬性 attrs 是物件,而 HTML 標籤如果還有子元素,是傳入 children 陣列裡面,方便我們後續使用迴圈或遞迴操作,另外也要在參數上給予預設值 ( 空物件、空陣列 )。


將 vNode 轉換成實際的 Node - render.js​

主要就是負責把 createElement() 產生的 DOM 物件轉換成實際的 Node。

const render = function (vNode) {
// console.log(vNode)
if (typeof vNode === 'string') {
return document.createTextNode(vNode)
}
return renderElement(vNode)
}

// 不是單純字串
const renderElement = function ({ tagName, attrs, children }) {
const $el = document.createElement(tagName)
// HTML 標籤屬性設定
for (const key in attrs) {
$el.setAttribute(key, attrs[key])
}
// HTML 標籤的子元素遞迴設定
children.forEach(child => {
const $child = render(child)
$el.appendChild($child)
})
// console.log('e.target: ', $el)
return $el
}

export default render

撰寫 HTML 標籤的時候,子元素不只有 tag,也有字串 String,因此我們要在轉換成真實 Node 的時候,把遇到字串的狀況考量進去。

在 render(vNode) 這個 function 中,如果 vNode 傳入的節點是字串,就回傳 document.createTextNode(vNode) 這個方法;如果不是字串,就回傳 renderElement(vNode) 這個 function。

在 renderElement(vNode) 這個 function 中,因為可以確定是傳入 DOM 描述物件而非字串,所以我們使用傳入的 tagName 產生新節點,透過 for in 和 setAttribute() 設定屬性,且因為我們子元素儲存在 children 陣列,使用遞迴呼叫 render() 的方式訪問所有子元素的子元素,和 appendChild() 將所有子元素掛到當前節點 ( tagName,也就是 $el ) 上,並回傳,此時的 $el 已經成功轉換成實際的 Node,接下來只要把這個 Node 掛載到畫面上就好了。


把 Node 掛載到真實 DOM 上 - mount.js​

功能非常單純,使用前面介紹的 replaceWith() 功能替換成新的 DOM Tree。

export default function ($node, $target) {
$target.replaceWith($node)
return $node
}

在主程式初始化,我們使用 :

let $rootEl = mount($app, document.getElementById('app'))

把產生的 $app 這個 DOM Tree,掛載 ( 替換 ) 到 <div id=”app”></div> 上。


Diff 演算法​

要把一棵樹 ( DOM Tree )轉換成另一棵樹,並且要使用最少的步驟,其時間複雜度是 O(n³)。問題在於如果節點有 1000 個,所以計算量就來到 10 億次,計算量將會讓 CPU 造成不小的開銷。

因此為了解決這個問題,React 與 Vue 將時間複雜度降低至 O(n),總歸來說,就是只處理同一層級的節點,不進行跨節點的比較,如果遇到節點不一樣的情況,就砍掉重新建立節點。

而此篇文章使用的 Diff 算法的時間複雜度也是 O(n),只處理同一層級的節點。

以上截至 : Virtual DOM | 為了瞭解原理,那就來實作一個簡易 Virtual DOM 吧!


O(1) 陣列讀取

直接讀取陣列中某個位置的值,共花費 1 個步驟。

const ary = [1, 2, 3, 4, 5]
console.log(ary[2]) // 3

O(n) 簡易搜尋

從 index: 0 開始搜尋到 n 個位置直到獲取目標 ( 共花 n 個步驟 )。

const ary = [1, 2, 3, 4, 5]
ary.forEach(item => {
if (item === 3) console.log(item) // 3
})

O(log n) 二分搜尋法

終極密碼概念,也可以參考此 Codepen :



Diff 演算法主要傳入兩個參數,分別是 vOldNode 舊的 DOM 物件,和 vNewNode 新的 DOM 物件,這兩個參數都是描述 DOM 的物件。

函式需要考慮四種情況 :

  1. 如果 vNewNode 為 undefined,代表原先的 vOldNode 這個節點是即將要被刪除的節點,所以要使用 remove() 的方式移除實際 Node 節點。
  2. 舊節點 或 新節點可能會是字串,如果兩者不一樣,則新節點文字取代舊節點文字,反之則不變。
  3. 舊節點 與 新節點標籤若不一樣,則新節點取代舊節點。
  4. 舊節點 與 新節點標籤若一樣,繼續比較新舊節點的屬性及子節點。( 額外用函式處理 )

需要注意的是,diff 函式都是回傳 一個函式,在主程式 vNode.js 中使用 patch 變數接住回傳的函式,再呼叫傳入舊的實際 Node,並賦值回去 ( $rootEl ),達到更新 DOM ( 畫面 ) 的效果。

const patch = diff(vApp, vNewApp) // diff 回傳的函式用變數 patch 接住
$rootEl = patch($rootEl) // 更新 DOM

diff 主要函式

// vOldNode、vNewNode 是 createElement() 回傳給 createVApp 的 DOM 物件
const diff = function (vOldNode, vNewNode) {
// 傳進來的新節點如果是 undefined,代表該節點是要被刪除的節點,return undefined
if (vNewNode === undefined) {
return $node => {
$node.remove()
return undefined
}
}

// 如果傳進來的節點是字串
if (typeof vOldNode === 'string' || typeof vNewNode === 'string') {
// 如果字串有改變,就回傳掛載函式 (舊字串 => 新字串)
if (vOldNode !== vNewNode) {
return $node => {
const $newNode = render(vNewNode)
return mount($newNode, $node)
}
// 如果字串沒有改變,就回傳一樣的節點
} else {
return $node => $node
}
}

// 如果新舊節點標籤不一樣,則新節點取代舊節點
if (vOldNode.tagName !== vNewNode.tagName) {
return $node => {
const $newNode = render(vNewNode)
return mount($newNode, $node)
}
}

// 新舊節點標籤一樣,繼續比較新舊節點的屬性及子節點
const patchAttrs = diffAttrs(vOldNode.attrs, vNewNode.attrs)
const patchChildren = diffChildren(vOldNode.children, vNewNode.children)

return $node => {
patchAttrs($node)
patchChildren($node)
return $node
}
}

比較新舊節點屬性 diffAttrs(oldAttrs, newAttrs)

我們需要在標籤上 設定屬性 與 刪除屬性 兩種功能,這邊把需要變動的屬性使用 patches 陣列儲存函式。

const diffAttrs = function (oldAttrs, newAttrs) {
// 儲存屬性變動的 patch 函式
const patches = []

// 新增 "設定新屬性的函式" 到 patches 陣列
for (const key in newAttrs) {
patches.push($node => {
$node.setAttribute(key, newAttrs[key])
return $node
})
}

// 如果 "沒有" 舊屬性在新屬性上,就新增 "移除舊屬性函式" 到 patches 陣列
for (const key in oldAttrs) {
if (!(key in newAttrs)) {
patches.push($node => {
$node.removeAttribute(key)
return $node
})
}
}

// 回傳批次更新函式,這個函式會執行在 patches 陣列裡每個函式
return $node => {
patches.forEach(patch => {
patch($node)
})
}
}

比較新舊節點的子節點 diffChildren(oldVChildren, newVChildren)

與比較新舊節點的函式概念相近,一樣將需要更新的子節點 push 進 childPatches 及 additionalPatches 陣列。

const diffChildren = function (oldVChildren, newVChildren) {
// 舊節點在新節點上是否需要更新或刪除 (run diff)
// 建立 childPatches 函式陣列,若 newVChildren[i] 為 undefined,回傳 $node.remove() 的函式
const childPatches = []
oldVChildren.forEach((oldVChild, i) => {
childPatches.push(diff(oldVChild, newVChildren[i]))
})

// 插入新節點時,建立 additionalPatches 函式陣列
const additionalPatches = []
newVChildren.slice(oldVChildren.length).forEach(additionalVChild => {
additionalPatches.push($node => {
$node.appendChild(render(additionalVChild))
return $node
})
})

// 回傳 patch 函式
return $node => {
// 若刪除子節點,for loop 的 i 可能會超過 $node.childNodes 的長度,所以從最後一個 childNode 開始處理。
for (let i = childPatches.length - 1; i >= 0; i--) {
const $child = $node.childNodes[i]
const patch = childPatches[i]
patch($child)
}

// 插入子節點
additionalPatches.forEach(patch => {
patch($node)
})

return $node
}
}

diff 完整程式碼​

import mount from './mount.js'
import render from './render.js'

let diffCount = 0
let diffAttrsCount = 0
let diffChildrenCount = 0

// 這邊都是回傳 patch 函式
// vOldNode、vNewNode 是 createElement() 回傳給 createVApp 的 DOM 物件
const diff = function (vOldNode, vNewNode) {
// 傳進來的新節點如果是 undefined,代表該節點是要被刪除的節點,return undefined
if (vNewNode === undefined) {
return $node => {
$node.remove()
return undefined
}
}

// 如果傳進來的節點是字串
if (typeof vOldNode === 'string' || typeof vNewNode === 'string') {
// 如果字串有改變,就回傳掛載函式 (舊字串 => 新字串)
if (vOldNode !== vNewNode) {
return $node => {
const $newNode = render(vNewNode)
return mount($newNode, $node)
}
// 如果字串沒有改變,就回傳一樣的節點
} else {
return $node => $node
}
}

// 如果新舊節點標籤不一樣,則新節點取代舊節點
if (vOldNode.tagName !== vNewNode.tagName) {
return $node => {
const $newNode = render(vNewNode)
return mount($newNode, $node)
}
}

// 新舊節點標籤一樣,繼續比較新舊節點的屬性及子節點
const patchAttrs = diffAttrs(vOldNode.attrs, vNewNode.attrs)
const patchChildren = diffChildren(vOldNode.children, vNewNode.children)

return $node => {
patchAttrs($node)
patchChildren($node)
return $node
}
}

// 回傳批次更新 attrs 的函式
const diffAttrs = function (oldAttrs, newAttrs) {
// 儲存屬性變動的 patch 函式
const patches = []

// 新增 "設定新屬性的函式" 到 patches 陣列
for (const key in newAttrs) {
patches.push($node => {
$node.setAttribute(key, newAttrs[key])
return $node
})
}

// 如果 "沒有" 舊屬性在新屬性上,就新增 "移除舊屬性函式" 到 patches 陣列
for (const key in oldAttrs) {
if (!(key in newAttrs)) {
patches.push($node => {
$node.removeAttribute(key)
return $node
})
}
}

// 回傳批次更新函式,這個函式會執行在 patches 陣列裡每個函式
return $node => {
patches.forEach(patch => {
patch($node)
})
}
}

// 比較新舊節點是否需要更新
const diffChildren = function (oldVChildren, newVChildren) {
diffChildrenCount += 1
// 舊節點在新節點上是否需要更新或刪除 (run diff)
// 建立 childPatches 函式陣列,若 newVChildren[i] 為 undefined,回傳 $node.remove() 的函式
const childPatches = []
oldVChildren.forEach((oldVChild, i) => {
childPatches.push(diff(oldVChild, newVChildren[i]))
})

// 插入新節點時,建立 additionalPatches 函式陣列
const additionalPatches = []
newVChildren.slice(oldVChildren.length).forEach(additionalVChild => {
additionalPatches.push($node => {
$node.appendChild(render(additionalVChild))
return $node
})
})

// 回傳 patch 函式
return $node => {
// 若刪除子節點,for loop 的 i 可能會超過 $node.childNodes 的長度,所以從最後一個 childNode 開始處理。
for (let i = childPatches.length - 1; i >= 0; i--) {
const $child = $node.childNodes[i]
const patch = childPatches[i]
patch($child)
}

// 插入子節點
additionalPatches.forEach(patch => {
patch($node)
})

return $node
}
}

export default diff

GitHub 完整程式碼

Virtual DOM - Demo Page


Reference​