基於虛擬DOM(Snabbdom)的迷你React
原文寫於 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 個輸入引數:
div#id.class
第一次呼叫的時候,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: 'Typeyour 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) { returnaction.type === INC ? count + 1 : action.type === DEC ? count - 1 : count; } export default { view, update, actions : { INC, DEC } }
counter 元件由以下屬性組成:
Number
首先要注意的是,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 方法傳遞了兩個引數:
UPDATE_FIRST
下面看看 update 函式的實現,並匯出元件的所有屬性。
function update(model, action) { returnaction.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) { returnaction.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) { returnAction.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 例項中提取欄位並將它們傳遞給對應的回撥函式。
所以典型的模式是:
case
TodoMVC例子
在這個倉庫中( github/yelouafi/snabbdom-todomvc ),使用本文提到的規範進行了 todoMVC 應用的實現。應用程式由2個模組組成:
task.js todos.js
總結
我們已經瞭解瞭如何使用小而美的虛 擬DOM 庫編寫應用程式。當我們不想被迫選擇使用React框架(尤其是 class),或者當我們需要一個小型 JavaScript 庫時,這將非常有用。
Elm architecture 提供了一個簡單的模式來編寫複雜的虛擬DOM應用,具有純函式的所有優點。這為我們的程式碼提供了一個簡單而規範的結構。使用標準的模式使得應用程式更容易維護,特別是在成員頻繁更改的團隊中。新成員可以快速掌握程式碼的總體架構。
由於完全用純函式實現的,我確信只要元件程式碼遵守其約定,更改元件就不會產生不良的副作用。
想檢視更多前端技術相關文章可以逛逛我的部落格: 自然醒的部落格