原文連接原文寫於 2015-07-31,雖然時間比較久遠,可是對於咱們理解虛擬 DOM 和 view 層之間的關係仍是有很積極的做用的。javascript
React 是 JavaScript 社區的新成員,儘管 JSX (在 JavaScript 中使用 HTML 語法)存在必定的爭議,可是對於虛擬 DOM 人們有不同的見解。html
對於不熟悉的人來講,虛擬 DOM 能夠描述爲某個時刻真實DOM的簡單表示。其思想是:每次 UI 狀態發生更改時,從新建立一個虛擬 DOM,而不是直接使用命令式的語句更新真實 DOM ,底層庫將對應的更新映射到真實 DOM 上。前端
須要注意的是,更新操做並無替換整個 DOM 樹(例如使用 innerHTML 從新設置 HTML 字符串),而是替換 DOM 節點中實際修改的部分(改變節點屬性、添加子節點)。這裏使用的是增量更新,經過比對新舊虛擬 DOM 來推斷更新的部分,而後將更新的部分經過補丁的方式更新到真實 DOM 中。java
虛擬 DOM 由於高效的性能常常受到特別的關注。可是還有一項一樣重要的特性,虛擬 DOM 能夠把 UI 表示爲狀態函數的映射(PS. 也就是咱們常說的 UI = render(state)
),這也使得編寫 web 應用有了新的形式。node
在本文中,咱們將研究虛擬 DOM 的概念如何引用到 web 應用中。咱們將從簡單的例子開始,而後給出一個架構來編寫基於 Virtual DOM 的應用。react
爲此咱們將選擇一個獨立的 JavaScript 虛擬 DOM 庫,由於咱們但願依賴最小化。本文中,咱們將使用 snabbdom(paldepind/snabbdom),可是你也可使用其餘相似的庫,好比 Matt Esch 的 virtual-domwebpack
snabbdom 是一個模塊化的庫,因此,咱們須要使用一個打包工具,好比 webpack。git
首先,讓咱們看看如何進行 snabbdom 的初始化。github
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 元素上的屬性設置和事件綁定都是給不一樣模塊實現的。上面的實例,只使用了默認提供的模塊。web
核心模塊只暴露了一個 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: '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,它的主要優勢是容許咱們將整個應用編寫爲一組純函數。
讓咱們回顧一下上個案例的主流程:
上面的過程能夠描述成一個循環。若是去掉實現的一些細節,咱們能夠創建一個抽象的函數調用序列。
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-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 組件由如下屬性組成:
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
封裝在 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」 展現了經典的嵌套組件的使用模式:
本例的源代碼在 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');
組件的模型包括了兩個參數:
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) } ) }; }
上面這種模式能夠應用於任何樹結構嵌套的組件結構中,經過這種模式,咱們讓整個應用程序的結構進行了統一。
在前面的示例中,咱們使用 ES6 的 Symbols 類型來表示操做類型。在視圖內部,咱們建立了帶有操做類型和附加信息(id,子節點的 action)的對象。
在真實的場景中,咱們必須將 action 的建立邏輯移動到一個單獨的工廠函數中(相似於React/Flux中的 Action Creators)。在這篇文章的剩餘部分,我將提出一個更符合 FP 精神的替代方案:union 類型。它是 FP 語言(如Haskell)中使用的 代數數據類型 的子集,您能夠將它們看做具備更強大功能的枚舉。
union類型能夠爲咱們提供如下特性:
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 實例中提取字段並將它們傳遞給對應的回調函數。
因此典型的模式是:
case
方法來匹配 union 類型的可能值。在這個倉庫中(github/yelouafi/snabbdom-todomvc),使用本文提到的規範進行了 todoMVC 應用的實現。應用程序由2個模塊組成:
task.js
定義一個呈現單個任務並更新其狀態的組件todos.js
,它管理任務列表以及過濾和更新咱們已經瞭解瞭如何使用小而美的虛 擬DOM 庫編寫應用程序。當咱們不想被迫選擇使用React框架(尤爲是 class),或者當咱們須要一個小型 JavaScript 庫時,這將很是有用。
Elm architecture 提供了一個簡單的模式來編寫複雜的虛擬DOM應用,具備純函數的全部優勢。這爲咱們的代碼提供了一個簡單而規範的結構。使用標準的模式使得應用程序更容易維護,特別是在成員頻繁更改的團隊中。新成員能夠快速掌握代碼的整體架構。
因爲徹底用純函數實現的,我確信只要組件代碼遵照其約定,更改組件就不會產生不良的反作用。
想查看更多前端技術相關文章能夠逛逛個人博客:天然醒的博客