🎨 什麼是 Virtual DOM?
📖 定義
Virtual DOM 是實際 DOM 的輕量級複製品,是一個僅存在於記憶體中的 JavaScript 物件。像 React 這樣的函式庫會使用 Virtual DOM 來先虛擬處理 UI 的變更,然後只對實際 DOM 應用最小的變更。透過這種方式,大幅提升網頁應用程式的效能。
🎯 透過比喻理解
建築設計圖比喻
將 Virtual DOM 比喻成建築設計圖:
實際建築 (Real DOM)
├─ 建築成本高昂
├─ 修改耗時
└─ 拆除重建困難
設計圖 (Virtual DOM)
├─ 可在紙上自由修改
├─ 快速且便宜
├─ 可比較多種方案
└─ 只將最終方案反映到實際建築
流程:
1. 在設計圖上多次修改 (Virtual DOM)
2. 找出變更的部分
3. 僅將最小變更應用到實際建築 (Real DOM)
如此可節省建築成本和時間!
文字處理器比喻
記事本 (原生 JavaScript)
- 每次輸入立即儲存
- 緩慢且效率低
- 重複檔案讀寫
文字處理器 (React + Virtual DOM)
- 在記憶體中編輯
- 追蹤變更
- 只在按下儲存時寫入檔案
- 快速且高效
Virtual DOM = 記憶體緩衝區
Real DOM = 儲存在硬碟
遊戲渲染比喻
遊戲開發中:
直接在螢幕上繪製 (直接操作 Real DOM)
- 出現閃爍
- 效能下降
- 難以部分更新
雙重緩衝 (Virtual DOM 方式)
1. 在背景緩衝區繪製圖像
2. 完成後替換前景螢幕
3. 平滑渲染
4. 僅更新變更的像素
Virtual DOM 扮演與雙重緩衝相同的角色!
⚙️ 運作原理
1. 什麼是 DOM?
// DOM (Document Object Model)
// HTML 可以被 JavaScript 操作的介面
<!DOCTYPE html>
<html>
<body>
<div id="root">
<h1>Hello</h1>
<p>World</p>
</div>
</body>
</html>
// DOM 樹
document
└─ html
└─ body
└─ div#root
├─ h1 ("Hello")
└─ p ("World")
// 使用 JavaScript 操作 DOM
document.getElementById('root').innerHTML = '<h1>Changed!</h1>';
2. Real DOM 的問題點
// ❌ 低效的 Real DOM 操作
// 問題 1:每次都全部重新渲染
for (let i = 0; i < 1000; i++) {
const div = document.createElement('div');
div.textContent = `Item ${i}`;
document.body.appendChild(div); // 操作 DOM 1000 次!
}
// 問題 2:重新計算佈局 (Reflow)
element.style.width = '100px'; // 發生 Reflow
element.style.height = '100px'; // 發生 Reflow
element.style.margin = '10px'; // 發生 Reflow
// 問題 3:複雜的 UI 更新
// 即使只有一個字元改變,也要重新建立整個元件
<div>
<Header />
<Content /> ← 只有這個變更
<Footer />
</div>
// 需要重新建立全部!
// Real DOM 操作緩慢的原因:
// 1. 瀏覽器渲染引擎和 JavaScript 引擎之間的通訊
// 2. Reflow(重新計算佈局)
// 3. Repaint(重新繪製螢幕)
3. Virtual DOM 作業流程
// ✅ Virtual DOM 的有效處理
// 第 1 階段:初始渲染
const initialVirtualDOM = {
type: 'div',
props: { id: 'app' },
children: [
{ type: 'h1', props: {}, children: ['Count: 0'] },
{ type: 'button', props: {}, children: ['Click'] }
]
};
// 反映到 Real DOM
<div id="app">
<h1>Count: 0</h1>
<button>Click</button>
</div>
// 第 2 階段:狀態變更 (count = 1)
const newVirtualDOM = {
type: 'div',
props: { id: 'app' },
children: [
{ type: 'h1', props: {}, children: ['Count: 1'] }, // ← 已變更
{ type: 'button', props: {}, children: ['Click'] }
]
};
// 第 3 階段:Diffing(計算差異)
const diff = {
path: ['div', 'h1', 'text'],
oldValue: 'Count: 0',
newValue: 'Count: 1'
};
// 第 4 階段:Reconciliation(調和)
// 只對 Real DOM 應用最小變更
document.querySelector('h1').textContent = 'Count: 1';
// div 和 button 保持不變!
4. Diffing 演算法
// React 的 Diffing 演算法原理
// 規則 1:不同類型的元素會建立不同的樹
// 之前
<div>
<Counter />
</div>
// 之後
<span>
<Counter />
</span>
// 結果:將 div 完全替換為 span
// Counter 也會被卸載後重新掛載
// 規則 2:使用 key 追蹤清單項目
// ❌ 沒有 key
<ul>
<li>A</li>
<li>B</li>
</ul>
// 在最前方新增 C
<ul>
<li>C</li> // 重新建立全部
<li>A</li>
<li>B</li>
</ul>
// ✅ 使用 key
<ul>
<li key="a">A</li>
<li key="b">B</li>
</ul>
// 在最前方新增 C
<ul>
<li key="c">C</li> // 只新增 C
<li key="a">A</li> // 保持不變
<li key="b">B</li> // 保持不變
</ul>
// 規則 3:遞迴比較子節點
function diff(oldNode, newNode) {
// 1. 如果類型不同,則替換
if (oldNode.type !== newNode.type) {
return { action: 'REPLACE', newNode };
}
// 2. 比較屬性
const propsDiff = diffProps(oldNode.props, newNode.props);
// 3. 比較子節點(遞迴)
const childrenDiff = diffChildren(
oldNode.children,
newNode.children
);
return { propsDiff, childrenDiff };
}
5. Reconciliation(調和)
// Reconciliation:將 Virtual DOM 反映到 Real DOM
// 階段 1:渲染階段(非同步)
// - 比較 Virtual DOM
// - 計算變更
// - 可中斷(React 18 Fiber)
// 階段 2:提交階段(同步)
// - 更新 Real DOM
// - 執行生命週期方法
// - 不可中斷
// 範例:更新清單
const oldList = [
{ id: 1, name: 'A' },
{ id: 2, name: 'B' },
{ id: 3, name: 'C' }
];
const newList = [
{ id: 1, name: 'A' },
{ id: 3, name: 'C-updated' }, // 修改
{ id: 4, name: 'D' } // 新增
// id: 2 刪除
];
// Reconciliation 結果:
// - id:1 保持不變
// - id:2 移除
// - id:3 只更新文字
// - id:4 新增
// 最小化實際 DOM 操作:
// remove(id:2)
// updateText(id:3, 'C-updated')
// append(id:4)
💡 實際範例
基本 Virtual DOM 實現
// 簡單的 Virtual DOM 實現(教學用)
// 1. 建立 Virtual DOM 節點
function createElement(type, props = {}, ...children) {
return {
type,
props,
children: children.flat()
};
}
// 不使用 JSX
const vdom = createElement(
'div',
{ id: 'app', className: 'container' },
createElement('h1', {}, 'Hello Virtual DOM'),
createElement('p', {}, 'This is a paragraph')
);
console.log(vdom);
// {
// type: 'div',
// props: { id: 'app', className: 'container' },
// children: [
// { type: 'h1', props: {}, children: ['Hello Virtual DOM'] },
// { type: 'p', props: {}, children: ['This is a paragraph'] }
// ]
// }
// 2. 將 Virtual DOM 轉換為 Real DOM
function render(vnode) {
// 文字節點
if (typeof vnode === 'string' || typeof vnode === 'number') {
return document.createTextNode(vnode);
}
// 元素節點
const element = document.createElement(vnode.type);
// 應用屬性
Object.entries(vnode.props || {}).forEach(([key, value]) => {
if (key === 'className') {
element.setAttribute('class', value);
} else if (key.startsWith('on')) {
// 事件監聽器
const event = key.substring(2).toLowerCase();
element.addEventListener(event, value);
} else {
element.setAttribute(key, value);
}
});
// 遞迴渲染子節點
(vnode.children || []).forEach(child => {
element.appendChild(render(child));
});
return element;
}
// 使用
const domElement = render(vdom);
document.body.appendChild(domElement);
Diffing 演算法實現
// 3. Diffing 演算法(簡化版)
function diff(oldVNode, newVNode) {
// 1. 如果新節點 不存在,則移除
if (!newVNode) {
return { type: 'REMOVE' };
}
// 2. 如果舊節點不存在,則新增
if (!oldVNode) {
return { type: 'CREATE', newVNode };
}
// 3. 如果類型不同,則替換
if (
typeof oldVNode !== typeof newVNode ||
(typeof oldVNode === 'string' && oldVNode !== newVNode) ||
oldVNode.type !== newVNode.type
) {
return { type: 'REPLACE', newVNode };
}
// 4. 如果是相同類型,則更新
if (newVNode.type) {
return {
type: 'UPDATE',
props: diffProps(oldVNode.props, newVNode.props),
children: diffChildren(oldVNode.children, newVNode.children)
};
}
// 5. 無變更
return { type: 'NONE' };
}
// 比較屬性
function diffProps(oldProps = {}, newProps = {}) {
const patches = [];
```javascript
// 變更或新增的屬性
Object.keys(newProps).forEach(key => {
if (oldProps[key] !== newProps[key]) {
patches.push({ type: 'SET_PROP', key, value: newProps[key] });
}
});
// 移除的屬性
Object.keys(oldProps).forEach(key => {
if (!(key in newProps)) {
patches.push({ type: 'REMOVE_PROP', key });
}
});
return patches;
}
// 比較子節點
function diffChildren(oldChildren = [], newChildren = []) {
const patches = [];
const maxLength = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLength; i++) {
patches.push(diff(oldChildren[i], newChildren[i]));
}
return patches;
}
應用 Patch
// 4. 將 Patch 應用到 Real DOM
function patch(parent, patches, index = 0) {
if (!patches) return;
const element = parent.childNodes[index];
switch (patches.type) {
case 'CREATE':
parent.appendChild(render(patches.newVNode));
break;
case 'REMOVE':
parent.removeChild(element);
break;
case 'REPLACE':
parent.replaceChild(render(patches.newVNode), element);
break;
case 'UPDATE':
// 更新屬性
patches.props.forEach(propPatch => {
if (propPatch.type === 'SET_PROP') {
element.setAttribute(propPatch.key, propPatch.value);
} else if (propPatch.type === 'REMOVE_PROP') {
element.removeAttribute(propPatch.key);
}
});
// 遞迴更新子節點
patches.children.forEach((childPatch, i) => {
patch(element, childPatch, i);
});
break;
}
}
React 中使用 Virtual DOM
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// React 自動處理 Virtual DOM
return (
<div className="counter">
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>
遞增
</button>
<button onClick={() => setCount(count - 1)}>
遞減
</button>
</div>
);
}
// 內部運作:
// 1. count 變更 → setState 呼叫
// 2. React 建立新的 Virtual DOM
// 3. 與先前的 Virtual DOM 比較(Diffing)
// 4. 偵測到 <h1> 文字已變更
// 5. 只更新 Real DOM 的文字節點
// 6. div 和 button 保持不變
// 性能比較:
// ❌ 原生 JS:替換整個 div.innerHTML
// ✅ React:只更新 <h1> 的文字
複雜列表範例
import React, { useState } from 'react';
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '運動', done: false },
{ id: 2, text: '學習', done: false },
{ id: 3, text: '打掃', done: false }
]);
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div>
<h1>待辦事項清單</h1>
<ul>
{todos.map(todo => (
<li
key={todo.id} // key 對於 Diffing 優化是必要的!
style={{
textDecoration: todo.done ? 'line-through' : 'none',
color: todo.done ? '#888' : '#000'
}}
>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => deleteTodo(todo.id)}>刪除</button>
</li>
))}
</ul>
</div>
);
}
// Virtual DOM 的效率:
// 勾選一個 todo 時
// - ❌ 不重新生成整個清單 (X)
// - ✅ 只變更該 <li> 的樣式 (O)
// key 的重要性:
// 有 key 時:
// - 項目移動/刪除時更有效率
// - 可重複使用 DOM
// - 保持元件狀態
// 沒有 key 時:
// - 重新生成整個清單
// - 效能降低
// - 可能遺失元件狀態
效能最佳化範例
import React, { useState, useMemo, memo } from 'react';
// 使用 memo 對元件進行記憶化
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }) {
console.log(`TodoItem ${todo.id} 渲染`);
return (
<li>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>刪除</button>
</li>
);
});
function OptimizedTodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: '運動', done: false },
{ id: 2, text: '學習', done: false },
{ id: 3, text: '打掃', done: false }
]);
// 使用 useMemo 快取計算結果
const activeTodos = useMemo(() => {
console.log('計算活躍的待辦事項');
return todos.filter(todo => !todo.done);
}, [todos]);
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div>
<h1>