原文連結原文寫於 2015-07-31,雖然時間比較久遠,但是對於我們理解虛擬 DOM 和 view 層之間的關係還是有很積極的作用的。
React 是 JavaScript 社群的新成員,儘管 JSX (在 JavaScript 中使用 HTML 語法)存在一定的爭議,但是對於虛擬 DOM 人們有不一樣的看法。
對於不熟悉的人來說,虛擬 DOM 可以描述為某個時刻真實DOM的簡單表示。其思想是:每次 UI 狀態發生更改時,重新建立一個虛擬 DOM,而不是直接使用命令式的語句更新真實 DOM ,底層庫將對應的更新對映到真實 DOM 上。
需要注意的是,更新操作並沒有替換整個 DOM 樹(例如使用 innerHTML 重新設定 HTML 字串),而是替換 DOM 節點中實際修改的部分(改變節點屬性、新增子節點)。這裡使用的是增量更新,通過比對新舊虛擬 DOM 來推斷更新的部分,然後將更新的部分通過補丁的方式更新到真實 DOM 中。
虛擬 DOM 因為高效的效能經常受到特別的關注。但是還有一項同樣重要的特性,虛擬 DOM 可以把 UI 表示為狀態函式的對映(PS. 也就是我們常說的 UI = render(state)
),這也使得編寫 web 應用有了新的形式。
在本文中,我們將研究虛擬 DOM 的概念如何引用到 web 應用中。我們將從簡單的例子開始,然後給出一個架構來編寫基於 Virtual DOM 的應用。
為此我們將選擇一個獨立的 JavaScript 虛擬 DOM 庫,因為我們希望依賴最小化。本文中,我們將使用 snabbdom(paldepind/snabbdom),但是你也可以使用其他類似的庫,比如 Matt Esch 的 virtual-dom
snabbdom簡易教程
snabbdom 是一個模組化的庫,所以,我們需要使用一個打包工具,比如 webpack。
首先,讓我們看看如何進行 snabbdom 的初始化。
import snabbdom from 'snabbdom';
const patch = snabbdom.init([ // 指定模組初始化 patch 方法
require('snabbdom/modules/class'), // 切換 class
require('snabbdom/modules/props'), // 設定 DOM 元素的屬性
require('snabbdom/modules/style'), // 處理元素的 style ,支援動畫
require('snabbdom/modules/eventlisteners'), // 事件處理
]);
上面的程式碼中,我們初始化了 snabbdom 模組並新增了一些擴充套件。在 snabbdom 中,切換 class、style還有 DOM 元素上的屬性設定和事件繫結都是給不同模組實現的。上面的例項,只使用了預設提供的模組。
核心模組只暴露了一個 patch
方法,它由 init 方法返回。我們使用它建立初始化的 DOM,之後也會使用它來進行 DOM 的更新。
下面是一個 Hello World 示例:
import h from 'snabbdom/h';
var vnode = h('div', {style: {fontWeight: 'bold'}}, 'Hello world');
patch(document.getElementById('placeholder'), vnode);
h
是一個建立虛擬 DOM 的輔助函式。我們將在文章後面介紹具體用法,現在只需要該函式的 3 個輸入引數:
- 一個 CSS 選擇器(jQuery 的選擇器),比如
div#id.class
。 - 一個可選的資料物件,它包含了虛擬節點的屬性(class、styles、events)。
- 一個字串或者虛擬 DOM 的陣列(用於表示該節點的children)。
第一次呼叫的時候,patch 方法需要一個 DOM 佔位符和一個初始的虛擬 DOM,然後它會根據虛擬 DOM 建立一個對應的真實 DO樹。在隨後的的呼叫中,我們為它提供新舊兩個虛擬 DOM,然後它通過 diff 演算法比對這兩個虛擬 DOM,並找出更新的部分對真實 DOM 進行必要的修改 ,使得真實的 DOM 樹為最新的虛擬 DOM 的對映。
為了快速上手,我在 GitHub 上建立了一個倉庫,其中包含了專案的必要內容。下面讓我們來克隆這個倉庫(yelouafi/snabbdom-starter),然後執行 npm install
安裝依賴。這個倉庫使用 Browserify 作為打包工具,檔案變更後使用 Watchify 自動重新構建,並且通過 Babel 將 ES6 的程式碼轉成相容性更好的 ES5。
下面執行如下程式碼:
npm run watch
這段程式碼將啟動 watchify 模組,它會在 app 資料夾內,建立一個瀏覽器能夠執行的包:build.js
。模組還將檢測我們的 js 程式碼是否發生改變,如果有修改,會自動的重新構建 build.js
。(如果你想手動構建,可以使用:npm run build
)
在瀏覽器中開啟 app/index.html
就能執行程式,這時候你會在螢幕上看到 “Hello World”。
這篇文中的所有案例都能在特定的分支上進行實現,我會在文中連結到每個分支,同時 README.md 檔案也包含了所有分支的連結。
動態檢視
本例的原始碼在 dynamic-view branch
為了突出虛擬 DOM 動態化的優勢,接下來會構建一個很簡單的時鐘。
首先修改 app/js/main.js
:
function view(currentDate) {
return h('div', 'Current date ' + currentDate);
}
var oldVnode = document.getElementById('placeholder');
setInterval( () => {
const newVnode = view(new Date());
oldVnode = patch(oldVnode, newVnode);
}, 1000);
通過單獨的函式 view
來生成虛擬 DOM,它接受一個狀態(當前日期)作為輸入。
該案例展示了虛擬 DOM 的經典使用方式,在不同的時刻構造出新的虛擬 DOM,然後將新舊虛擬 DOM 進行對比,並更新到真實 DOM 上。案例中,我們每秒都構造了一個虛擬 DOM,並用它來更新真實 DOM。
事件響應
本例的原始碼在 event-reactivity branch
下面的案例介紹了通過事件系統完成一個打招呼的應用程式:
function view(name) {
return h('div', [
h('input', {
props: { type: 'text', placeholder: 'Type your name' },
on : { input: update }
}),
h('hr'),
h('div', 'Hello ' + name)
]);
}
var oldVnode = document.getElementById('placeholder');
function update(event) {
const newVnode = view(event.target.value);
oldVnode = patch(oldVnode, newVnode);
}
oldVnode = patch(oldVnode, view(''));
在 snabbdom 中,我們使用 props 物件來設定元素的屬性,props 模組會對 props 物件進行處理。類似地,我們通過 on 物件進行元素的時間繫結,eventlistener 模組會對 on 物件進行處理。
上面的案例中,update 函式執行了與前面案例中 setInterval 類似的事情:從傳入的事件物件中提取出 input 的值,構造出一個新的虛擬 DOM,然後呼叫 patch ,用新的虛擬 DOM 樹更新真實 DOM。
複雜的應用程式
使用獨立的虛擬 DOM 庫的好處是,我們在構建自己的應用時,可以按照自己喜歡的方式來做。你可以使用 MVC 的設計模式,可以使用更現代化的資料流體系,比如 Flux。
在這篇文章中,我會介紹一種不太為人所知的架構模式,是我之前在 Elm(一種可編譯成 JavaScript 的 函式式語言)中使用過的。Elm 的開發者稱這種模式為 Elm Architecture,它的主要優點是允許我們將整個應用編寫為一組純函式。
主流程
讓我們回顧一下上個案例的主流程:
- 通過 view 函式構造出我們初始的虛擬 DOM,在 view 函式中,給 input 輸入框新增了一個 input 事件。
- 通過 patch 將虛擬 DOM 渲染到真實 DOM 中,並將 input 事件繫結到真實 DOM 上。
- 等待使用者輸入……
- 使用者輸入內容,觸發 input 事件,然後呼叫 update 函式
- 在 update 函式中,我們更新了狀態
- 我們傳入了新的狀態給 view 函式,並生成新的虛擬 DOM (與步驟 1 相同)
- 再次呼叫 patch,重複上述過程(與步驟 2 相同)
上面的過程可以描述成一個迴圈。如果去掉實現的一些細節,我們可以建立一個抽象的函式呼叫序列。
user
是使用者互動的抽象,我們得到的是函式呼叫的迴圈序列。注意,user
函式是非同步的,否則這將是一個無限的死迴圈。
讓我們將上述過程轉換為程式碼:
function main(initState, element, {view, update}) {
const newVnode = view(initState, event => {
const newState = update(initState, event);
main(newState, newVnode, {view, update});
});
patch(oldVnode, newVnode);
}
main
函式反映了上述的迴圈過程:給定一個初始狀態(initState),一個 DOM 節點和一個頂層元件(view + update),main
通過當前的狀態經過 view 函式構建出新的虛擬 DOM,然後通過補丁的方式更新到真實 DOM上。
傳遞給 view
函式的引數有兩個:首先是當前狀態,其次是事件處理的回撥函式,對生成的檢視中觸發的事件進行處理。回撥函式主要負責為應用程式構建一個新的狀態,並使用新的狀態重啟 UI 迴圈。
新狀態的構造委託給頂層元件的 update
函式,該函式是一個簡單的純函式:無論何時,給定當前狀態和當前程式的輸入(事件或行為),它都會為程式返回一個新的狀態。
要注意的是,除了 patch 方法會有副作用,主函式內不會有任何改變狀態行為發生。
main 函式有點類似於低階GUI框架的 main
事件迴圈,這裡的重點是收回對 UI 事件分發流程的控制: 在實際狀態下,DOM API通過採用觀察者模式強制我們進行事件驅動,但是我們不想在這裡使用觀察者模式,下面就會講到。
Elm 架構(Elm architecture)
基於 Elm-architecture 的程式中,是由一個個模組或者說元件構成的。每個元件都有兩個基本函式:update
和view
,以及一個特定的資料結構:元件擁有的 model
以及更新該 model
例項的 actions
。
-
update
是一個純函式,接受兩個引數:元件擁有的model
例項,表示當前的狀態(state),以及一個action
表示需要執行的更新操作。它將返回一個新的model
例項。 -
view
同樣接受兩個引數:當前model
例項和一個事件通道,它可以通過多種形式傳播資料,在我們的案例中,將使用一個簡單的回撥函式。該函式返回一個新的虛擬 DOM,該虛擬 DOM 將會渲染成真實 DOM。
如上所述,Elm architecture 擺脫了傳統的由事件進行驅動觀察者模式。相反該架構傾向於集中式的管理資料(比如 React/Flux),任何的事件行為都會有兩種方式:
- 冒泡到頂層元件;
- 通過元件樹的形式進行下發,在此階段,每個元件都可以選擇自己的處理方式,或者轉發給其他一個或所有子元件。
該架構的另一個關鍵點,就是將程式需要的整個狀態都儲存在一個物件中。樹中的每個元件都負責將它們擁有的狀態的一部分傳遞給子元件。
在我們的案例中,我們將使用與 Elm 網站相同的案例,因為它完美的展示了該模式。
案例一:計數器
本例的原始碼在 counter-1 branch
我們在 “counter.js” 中定義了 counter 元件:
const INC = Symbol('inc');
const DEC = Symbol('dec');
// model : Number
function view(count, handler) {
return h('div', [
h('button', {
on : { click: handler.bind(null, {type: INC}) }
}, '+'),
h('button', {
on : { click: handler.bind(null, {type: DEC}) }
}, '-'),
h('div', `Count : ${count}`),
]);
}
function update(count, action) {
return action.type === INC ? count + 1
: action.type === DEC ? count - 1
: count;
}
export default { view, update, actions : { INC, DEC } }
counter 元件由以下屬性組成:
- Model: 一個簡單的
Number
- View:為使用者提供兩個按鈕,使用者遞增、遞減計數器,以及顯示當前數字
- Update:接受兩個動作:INC / DEC,增加或減少計數器的值
首先要注意的是,view/update 都是純函式,除了輸入之外,他們不依賴任何外部環境。計數器元件本身不包括任何狀態或變數,它只會從給定的狀態構造出固定的檢視,以及通過給定的狀態更新檢視。由於其純粹性,計數器元件可以輕鬆的插入任何提供依賴(state 和 action)環境。
其次需要注意 handler.bind(null, action)
表示式,每次點選按鈕,事件監聽器都會觸發該函式。我們將原始的使用者事件轉換為一個有意義的操作(遞增或遞減),使用了 ES6 的 Symbol 型別,比原始的字串型別更好(避免了操作名稱衝突的問題),稍後我們還將看到更好的解決方案:使用 union 型別。
下面看看如何進行元件的測試,我們使用了 “tape” 測試庫:
import test from 'tape';
import { update, actions } from '../app/js/counter';
test('counter update function', (assert) => {
var count = 10;
count = update(count, {type: actions.INC});
assert.equal(count, 11);
count = update(count, {type: actions.DEC});
assert.equal(count, 10);
assert.end();
});
我們可以直接使用 babel-node 來進行測試
babel-node test/counterTest.js
案例二:兩個計數器
本例的原始碼在 counter-2 branch
我們將和 Elm 官方教程保持同步,增加計數器的數量,現在我們會有2個計數器。此外,還有一個“重置”按鈕,將兩個計數器同時重置為“0”;
首先,我們需要修改計數器元件,讓該元件支援重置操作。為此,我們將引入一個新函式 init
,其作用是為計數器構造一個新狀態 (count)。
function init() {
return 0;
}
init
在很多情況下都非常有用。例如,使用來自伺服器或本地儲存的資料初始化狀態。它通過 JavaScript 物件建立一個豐富的資料模型(例如,為一個 JavaScript 物件新增一些原型屬性或方法)。
init
與 update
有一些區別:後者執行一個更新操作,然後從一個狀態派生出新的狀態;但是前者是使用一些輸入值(比如:預設值、伺服器資料等等)構造一個狀態,輸入值是可選的,而且完全不管前一個狀態是什麼。
下面我們將通過一些程式碼管理兩個計數器,我們在 towCounters.js
中實現我們的程式碼。
首先,我們需要定義模型相關的操作型別:
//{ first : counter.model, second : counter.model }
const RESET = Symbol('reset');
const UPDATE_FIRST = Symbol('update first');
const UPDATE_SECOND = Symbol('update second');
該模型匯出兩個屬性:first 和 second 分別儲存兩個計數器的狀態。我們定義了三個操作型別:第一個用來將計數器重置為 0,另外兩個後面也會講到。
元件通過 init 方法建立 state。
function init() {
return { first: counter.init(), second: counter.init() };
}
view 函式負責展示這兩個計數器,併為使用者提供一個重置按鈕。
function view(model, handler) {
return h('div', [
h('button', {
on : { click: handler.bind(null, {type: RESET}) }
}, 'Reset'),
h('hr'),
counter.view(model.first, counterAction => handler({ type: UPDATE_FIRST, data: counterAction})),
h('hr'),
counter.view(model.second, counterAction => handler({ type: UPDATE_SECOND, data: counterAction})),
]);
}
我們給 view 方法傳遞了兩個引數:
- 每個檢視都會獲得父元件的部分狀態(model.first / model.second)
- 動態處理函式,它會傳遞到每個子節點的 view 。比如:第一個計數器觸發了一個動作,我們會將
UPDATE_FIRST
封裝在 action 中,當父類的 update 方法被呼叫時,我們會將計數器需要的 action(儲存在 data 屬性中)轉發到正確的計數器,並呼叫計數器的 update 方法。
下面看看 update 函式的實現,並匯出元件的所有屬性。
function update(model, action) {
return action.type === RESET ?
{
first : counter.init(),
second: counter.init()
}
: action.type === UPDATE_FIRST ?
{...model, first : counter.update(model.first, action.data) }
: action.type === UPDATE_SECOND ?
{...model, second : counter.update(model.second, action.data) }
: model;
}
export default { view, init, update, actions : { UPDATE_FIRST, UPDATE_SECOND, RESET } }
update 函式處理3個操作:
-
RESET
操作會呼叫 init 將每個計數器重置到預設狀態。 -
UPDATE_FIRST
和UPDATE_SECOND
,會封裝一個計數器需要 action。函式將封裝好的 action 連同其 state 轉發給相關的子計數器。
{...model, prop: val};
是 ES7 的物件擴充套件屬性(如object .assign),它總是返回一個新的物件。我們不修改引數中傳遞的 state ,而是始終返回一個相同屬性的新 state 物件,確保更新函式是一個純函式。
最後呼叫 main 方法,構造頂層元件:
main(
twoCounters.init(), // the initial state
document.getElementById('placeholder'),
twoCounters
);
“towCounters” 展示了經典的巢狀元件的使用模式:
- 元件通過類似於樹的層次結構進行組織。
- main 函式呼叫頂層元件的 view 方法,並將全域性的初始狀態和處理回撥(main handler)作為引數。
- 在檢視渲染的時候,父元件呼叫子元件的 view 函式,並將子元件相關的 state 傳給子元件。
- 檢視將使用者事件轉化為對程式更有意義的 actions。
- 從子元件觸發的操作會通過父元件向上傳遞,直到頂層元件。與 DOM 事件的冒泡不同,父元件不會在此階段進行操作,它能做的就是將相關資訊新增到 action 中。
- 在冒泡階段,父元件的 view 函式可以攔截子元件的 actions ,並擴充套件一些必要的資料。
- 該操作最終在主處理程式(main handler)中結束,主處理程式將通過呼叫頂部元件的 update 函式進行派發操作。
- 每個父元件的 update 函式負責將操作分派給其子元件的 update 函式。通常使用在冒泡階段新增了相關資訊的 action。
案例三:計數器列表
本例的原始碼在 counter-3 branch
讓我們繼續來看 Elm 的教程,我們將進一步擴充套件我們的示例,可以管理任意數量的計數器列表。此外還提供新增計數器和刪除計數器的按鈕。
“counter” 元件程式碼保持不變,我們將定義一個新元件 counterList
來管理計數器陣列。
我們先來定義模型,和一組關聯操作。
/*
model : {
counters: [{id: Number, counter: counter.model}],
nextID : Number
}
*/
const ADD = Symbol('add');
const UPDATE = Symbol('update counter');
const REMOVE = Symbol('remove');
const RESET = Symbol('reset');
元件的模型包括了兩個引數:
- 一個由物件(id,counter)組成的列表,id 屬性與前面例項的 first 和 second 屬性作用類似;它將標識每個計數器的唯一性。
-
nextID
用來維護一個做自動遞增的基數,每個新新增的計數器都會使用nextID + 1
來作為它的 ID。
接下來,我們定義 init
方法,它將構造一個預設的 state。
function init() {
return { nextID: 1, counters: [] };
}
下面定義一個 view 函式。
function view(model, handler) {
return h('div', [
h('button', {
on : { click: handler.bind(null, {type: ADD}) }
}, 'Add'),
h('button', {
on : { click: handler.bind(null, {type: RESET}) }
}, 'Reset'),
h('hr'),
h('div.counter-list', model.counters.map(item => counterItemView(item, handler)))
]);
}
檢視提供了兩個按鈕來觸發“新增”和“重置”操作。每個計數器的都通過 counterItemView
函式來生成虛擬 DOM。
function counterItemView(item, handler) {
return h('div.counter-item', {key: item.id }, [
h('button.remove', {
on : { click: e => handler({ type: REMOVE, id: item.id}) }
}, 'Remove'),
counter.view(item.counter, a => handler({type: UPDATE, id: item.id, data: a})),
h('hr')
]);
}
該函式新增了一個 remove 按鈕在檢視中,並引用了計數器的 id 新增到 remove 的 action 中。
接下來看看 update 函式。
const resetAction = {type: counter.actions.INIT, data: 0};
function update(model, action) {
return action.type === ADD ? addCounter(model)
: action.type === RESET ? resetCounters(model)
: action.type === REMOVE ? removeCounter(model, action.id)
: action.type === UPDATE ? updateCounter(model, action.id, action.data)
: model;
}
export default { view, update, actions : { ADD, RESET, REMOVE, UPDATE } }
該程式碼遵循上一個示例的相同的模式,使用冒泡階段儲存的 id 資訊,將子節點的 actions 轉發到頂層元件。下面是 update 的一個分支 “updateCounter” 。
function updateCounter(model, id, action) {
return {...model,
counters : model.counters.map(item =>
item.id !== id ?
item
: { ...item,
counter : counter.update(item.counter, action)
}
)
};
}
上面這種模式可以應用於任何樹結構巢狀的元件結構中,通過這種模式,我們讓整個應用程式的結構進行了統一。
在 actions 中使用 union 型別
在前面的示例中,我們使用 ES6 的 Symbols 型別來表示操作型別。在檢視內部,我們建立了帶有操作型別和附加資訊(id,子節點的 action)的物件。
在真實的場景中,我們必須將 action 的建立邏輯移動到一個單獨的工廠函式中(類似於React/Flux中的 Action Creators)。在這篇文章的剩餘部分,我將提出一個更符合 FP 精神的替代方案:union 型別。它是 FP 語言(如Haskell)中使用的 代數資料型別 的子集,您可以將它們看作具有更強大功能的列舉。
union型別可以為我們提供以下特性:
- 定義一個可描述所有可能的 actions 的型別。
- 為每個可能的值提供一個工廠函式。
- 提供一個可控的流來處理所有可能的變數。
union 型別在 JavaScript 中不是原生的,但是我們可以使用一個庫來模擬它。在我們的示例中,我們使用 union-type (github/union-type) ,這是 snabbdom 作者編寫的一個小而美的庫。
先讓我們安裝這個庫:
npm install --save union-type
下面我們來定義計數器的 actions:
import Type from 'union-type';
const Action = Type({
Increment : [],
Decrement : []
});
Type
是該庫匯出的唯一函式。我們使用它來定義 union 型別 Action
,其中包含兩個可能的 actions。
返回的 Action
具有一組工廠函式,用於建立所有可能的操作。
function view(count, handler) {
return h('div', [
h('button', {
on : { click: handler.bind(null, Action.Increment()) }
}, '+'),
h('button', {
on : { click: handler.bind(null, Action.Decrement()) }
}, '-'),
h('div', `Count : ${count}`),
]);
}
在 view 建立遞增和遞減兩種 action。update 函式展示了 uinon 如何對不同型別的 action 進行模式匹配。
function update(count, action) {
return Action.case({
Increment : () => count + 1,
Decrement : () => count - 1
}, action);
}
Action
具有一個 case
方法,該方法接受兩個引數:
- 一個物件(變數名和一個回撥函式)
- 要匹配的值
然後,case方法將提供的 action 與所有指定的變數名相匹配,並呼叫相應的處理函式。返回值是匹配的回撥函式的返回值。
類似地,我們看看如何定義 counterList
的 actions
const Action = Type({
Add : [],
Remove : [Number],
Reset : [],
Update : [Number, counter.Action],
});
Add
和Reset
是空陣列(即它們沒有任何欄位),Remove
只有一個欄位(計數器的 id)。最後,Update
操作有兩個欄位:計數器的 id 和計數器觸發時的 action。
與之前一樣,我們在 update 函式中進行模式匹配。
function update(model, action) {
return Action.case({
Add : () => addCounter(model),
Remove : id => removeCounter(model, id),
Reset : () => resetCounters(model),
Update : (id, action) => updateCounter(model, id, action)
}, action);
}
注意,Remove
和 Update
都會接受引數。如果匹配成功,case
方法將從 case 例項中提取欄位並將它們傳遞給對應的回撥函式。
所以典型的模式是:
- 將 actions 建模為union型別。
- 在 view 函式中,使用 union 型別提供的工廠函式建立 action (如果建立的邏輯更復雜,還可以將操作建立委託給單獨的函式)。
- 在 update 函式中,使用
case
方法來匹配 union 型別的可能值。
TodoMVC例子
在這個倉庫中(github/yelouafi/snabbdom-todomvc),使用本文提到的規範進行了 todoMVC 應用的實現。應用程式由2個模組組成:
-
task.js
定義一個呈現單個任務並更新其狀態的元件 -
todos.js
,它管理任務列表以及過濾和更新
總結
我們已經瞭解瞭如何使用小而美的虛 擬DOM 庫編寫應用程式。當我們不想被迫選擇使用React框架(尤其是 class),或者當我們需要一個小型 JavaScript 庫時,這將非常有用。
Elm architecture 提供了一個簡單的模式來編寫複雜的虛擬DOM應用,具有純函式的所有優點。這為我們的程式碼提供了一個簡單而規範的結構。使用標準的模式使得應用程式更容易維護,特別是在成員頻繁更改的團隊中。新成員可以快速掌握程式碼的總體架構。
由於完全用純函式實現的,我確信只要元件程式碼遵守其約定,更改元件就不會產生不良的副作用。
想檢視更多前端技術相關文章可以逛逛我的部落格:自然醒的部落格