去年以來,React的出現為前端框架設計和程式設計模式吹來了一陣春風。很多概念,無論是原本已有的、還是由React首先提出的,都因為React的流行而倍受關注,成為大家研究和學習的熱點。本篇分享主要就聚焦於這些概念中出現頻率較高的兩個:virtual dom(虛擬DOM)和data immutability(資料不變性)。希望通過幾段程式碼和同學們分享博主對於這兩個概念的思考和理解。
文章分為四個部分,由大家最為熟悉的基於dom node的程式設計開始:
1. 基於模板和dom node的程式設計:回顧前端傳統的程式設計模式,簡單總結前端發展的趨勢和潮流
2. 面向immutable data model的程式設計:淺析在virtual dom出現之前,為什麼基於immutability的程式設計不具備大規模流行的條件
3. 引入virtual dom,優化渲染效能:介紹virtual dom以及一些常見的效能優化技巧,給出效能比較的測試方法和結論
4. virtual dom和redux的整合:示範如何與redux整合
1. 基於模板和dom node的程式設計
基於模板和dom node的程式設計是前端開發中最為傳統和深入人心的開發方式。這種開發方式編碼簡單、模式靈活、學習曲線平滑,深受大家的喜愛。模版層渲染既可以在後端完成(如smarty、velocity、jade)也可以在前端完成(如mustache,handlebars),而dom操作一般則會藉助於諸如jquery、yui之類封裝良好的類庫。本文為了方便同學們在純前端環境中進行實驗,將採用handlebars + jquery的技術選型。其中handlebars負責前端渲染,jquery負責事件監聽和dom操作。從本節開始,將使用不同的方式實現一個支援新增、刪除操作的列表,大致介面如下:
首先簡要分析一下程式碼邏輯:
模板放在script標籤中,type設定為text/template,用於稍後渲染;由於需要頻繁新增、刪除dom元素,因此選用事件代理(delegation),以避免頻繁處理新增、刪除監聽器的邏輯。html程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
<!doctype html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.2/css/bootstrap.min.css" rel="stylesheet" /> <style> .dbl-top-margin { margin-top: 20px; } .float-right { float: right; } </style> <script class="template" type="text/template"> <ul class="list-group dbl-top-margin"> {{#items}} <li class="list-group-item"> <span>{{name}}</span> <button data-index="{{@index}}" class="item-remove btn btn-danger btn-sm float-right">刪除</button> </li> {{/items}} </ul> <div class="dbl-top-margin"> <input placeholder="新增新專案" type="text" class="form-control item-name" /> <button class="dbl-top-margin btn btn-primary col-xs-12 item-add">新增</button> </div> </script> </head> <body> <div class="container"></div> <script src="bundle.js"></script> </body> </html> |
javascript程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
var $ = require('jquery'); var Handlebars = require('handlebars'); var template = $('.template').text(); // 用一組div標籤將template包裹起來 template = ['<div>', template, '</div>'].join(''); // 初次渲染模板時所用到的資料 var model = { items: [{ name: '專案1' }, { name: '專案2' }, { name: '專案3' }] }; // Handlebars.compile方法返回編譯後的模組方法,呼叫這個模板方法並傳入資料,即可得到渲染後的模板 var html = Handlebars.compile(template)(model); var $container = $('.container'); $container.html(html); var $ul = $container.find('ul'); var $itemName = $container.find('.item-name'); var liTemplate = '' + '<li class="list-group-item">' + '<span>{{name}}</span>' + '<button class="item-remove btn btn-danger btn-sm float-right">刪除</button>' + '</li>'; $container.delegate('.item-remove', 'click', function (e) { var $li = $(e.target).parents('li'); $li.remove(); }); $container.delegate('.item-add', 'click', function () { var name = $itemName.val(); // 清空輸入框 $itemName.val(''); // 渲染新專案並插入 $ul.append(Handlebars.compile(liTemplate)({ name: name })); }); |
雖然編碼起來簡單易行,但是這種傳統的開發模式弊端也比較明顯,尤其是在前端專案規模不斷擴大,複雜度不斷提升的今天。比如,由於dom操作和資料操作夾雜在一起,很難將view層從業務程式碼中剝離開來;由於沒有集中維護內部狀態,當事件監聽器增多,尤其當監聽器間有相互觸發的關係時,程式除錯變得困難;沒有通行的模組化、元件化的解決方案;儘管可以在一定程度上進行優化,但相同內容的模板往往有冗餘,甚至同時存在於前、後端,比如上面的liTemplate就是一個例子…… 雖然問題較多,但經常更新自己知識儲備的同學一般都有一套結合自己工作經驗的處理辦法,能力和篇幅所限,本文也無法涉及方方面面的知識。後文提到的virtual dom和immutability主要涉及到這裡的兩個問題:view層分離和狀態維護。
無論是mvc還是mvvm,將view層從業務程式碼中更好地分離出來一直是多年以來前端社群努力和前進的方向。將view層分離的具體手段很多,大都和model和view model的使用有關。react之前的先行者如backbone、angularjs注重於model的設計而並沒有在狀態維護上下太多的功夫,舉個例子,對angularjs中的NgModelController有開發經驗的同學可能就會抱怨這種用於同步view和model的機制過於複雜。react在面對這一問題時,也許是由於有了angularjs的前車之鑑,並沒有嘗試要做出一套更復雜的機制,而是將flux推薦給大家,鼓勵使用immutable model。immutable model使得設計良好的系統中幾乎可以不再考慮內部狀態(state)的維護問題,也無需太多地擔憂view和model的同步。一旦有操作(action)發生,一個新的model被建立,與之繫結的view層也隨即被重新渲染,整個過程清晰明瞭,秩序井然。要麼讓程式碼簡單到明顯沒有錯誤,要麼讓程式碼複雜到沒有明顯錯誤,react選擇了前者。
2. 面向immutable data model的程式設計
然而在react出現之前,immutable data model的確沒有流行起來,這是為什麼呢?博主先和大家分享一種樸素的基於immutability的程式設計模式,再回過頭來分析具體原因。接著使用上例中的handlebars + jquery的技術選型,但思路有一些略微的變化:實現一個接收model引數的render方法,render方法中呼叫handlebars編譯後的方法來渲染html,並直接使用innerHTML寫入容器;相應的事件監聽器中不再直接對DOM進行操作,而是生成一個新的model物件,並呼叫render方法;由於innerHTML頻繁更新,基於和上例中相似的原因,我們使用事件代理來完成事件監聽;由於本例中的model結構簡單,暫時不引入immutablejs之類的類庫,而是使用jquery的extend方法來深度複製物件。
下面來看程式碼實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
var $ = require('jquery'); var Handlebars = require('handlebars'); var template = $('.template').text(); // 用一組div標籤將template包裹起來 template = ['<div>', template, '</div>'].join(''); template = Handlebars.compile(template); var model = { items: [{ name: 'item-1' }, { name: 'item-2' }] }; var $container = $('.container'); function render(model) { $container[0].innerHTML = template(model); } $container.delegate('.item-remove', 'click', function (e) { var index = $(e.target).attr('data-index'); index = parseInt(index, 10); model = $.extend(true, {}, model); model.items.splice(index, 1); render(model); }); $container.delegate('.item-add', 'click', function () { var name = $('.item-name').val(); model = $.extend(true, {}, model); model.items.push({ name: name }); render(model); }); render(model); |
上面的程式碼中有兩個地方容易成為效能瓶頸,剛好這兩處都在一個語句中:$container[0].innerHTML = template(model); 模板渲染是比較耗時的字串操作,當然經過了handlebars的編譯,效能上基本可以接受;但直接從容器根部寫入innerHTML則是明顯的效能殺手:明明只需要新增或刪除一個li,瀏覽器卻重新渲染了整個view層。說到這裡,在react出現之前immutability沒有流行起來的原因應該也就比較清晰了。下節會簡單提到效能比較,這裡先賣一個關子,這一步看似簡單,但博主初次嘗試卻沒有得到理想中的實驗結果。言歸正傳,按照正常的思路,為了優化innerHTML帶來的效能損耗,直接渲染看來是不可取了,下一步應該就是比較已經存在的dom結構和新傳入的model所將要渲染出的dom結構,只對有修改的部分進行更新操作。思路雖然很自然,但是要和dom樹進行比較,也很難避免繁重的dom操作。那可不可以對dom樹進行快取呢?比如,相較於getAttribute之類原生的dom方法,節點的屬性值其實可以被以某種資料結構快取下來,用於提高diff的速度。巨集觀上說,virtual dom出現的目的就是快取dom樹,並在他們之間進行同步。當然快取的實現形式已經比較具體,不再是普通的map、list或者set,而是virtual dom tree。下一節中將介紹如何用hyperscript——一款簡單的開源框架——來構建virtual dom tree。
3. 引入virtual dom,優化渲染效能
既然要構建virtual dom tree,那之前通過handlebars渲染的方式就不能再使用了,因為handlebars的渲染結果是字串,而被快取起來的dom節點並不是以字串的形式存在的。這一節中,我們先對技術選型進行一些更新:jquery + hyperscript。jquery繼續負責事件邏輯(其實hyperscript中也有事件監聽的機制,但是既然我們的jquery事件監聽邏輯已經寫好了,這裡就先沿用了。如果有需要,後面可以使用hyperscript再重構一遍)hyperscript負責view層邏輯,包括virtual dom tree的維護和實際dom tree的更新。當然,更新dom tree這一步對開發者是透明的,我們不需要自己去呼叫原生的dom方法。
hyperscript的使用非常簡單,記住下面一個api就可以開始工作了:
h(tag, attrs?, [text?, Elements?,…])
第一個引數是節點型別,比如div、input、button等,第二個可選的引數是屬性,比如value,type等,第三個可選的引數是子節點(或子節點陣列)。使用這個api對view層進行重構:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function generateTree(model) { return h('div', [ h('ul.list-group.dbl-top-margin', model.items.map(function (item, index) { return h('li.list-group-item', [ item.name, h('button.item-remove.btn.btn-danger.btn-sm.float-right', { value: item.name }, '刪除') ]); })), h('div.dbl-top-margin', [ h('input.form-control.item-name', { placeholder: '新增新專案', type: 'text' }), h('button.dbl-top-margin.btn.btn-primary.col-xs-12.item-add', '新增') ]) ]) } |
最外層是一個div節點,作為容器包裹內部元素。div的第一個子節點是ul,ul中通過遍歷model.items生成li,每個li裡有item.name和一個刪除按鈕。div的第二個子節點是用於輸入新專案的文字框和一個“新增”按鈕。當然,每次生成新的virtual tree的效能是比較低下的:雖然避免了大量dom操作,但是卻將時間消耗在了virtual tree的構建上。一個典型的例子是,如果items中的專案沒有改變,我們其實可以把它們快取起來。博主暫時還沒有機會深入研究react的實現,但有過react開發經驗的同學應該對陣列中不出現key時而報的warn並不陌生。這裡應該就是react效能優化中比較重要的一個點。利用類似的思路,對generateTree函式進行適當的優化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
var hyperItems = {}; var hyperFooter = h('div.dbl-top-margin', [ h('input.form-control.item-name', { placeholder: '新增新專案', type: 'text' }), h('button.dbl-top-margin.btn.btn-primary.col-xs-12.item-add', '新增') ]); function generateTree(model) { return h('div', [ h('ul.list-group.dbl-top-margin', model.items.map(function (item, index) { hyperItems[item.name] = hyperItems[item.name] || h('li.list-group-item', [ item.name, h('button.item-remove.btn.btn-danger.btn-sm.float-right', { value: item.name }, '刪除') ]); return hyperItems[item.name]; })), hyperFooter ]) } |
除了一開始提到了陣列項的快取之外,由於新增新專案的部分是不會改變的,因此我們先建立好hyperFooter例項,每次需要生成virtual tree的時候直接呼叫就好了。hyperscript的部分介紹清楚了,接下來就來看程式碼實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
var $ = require('jquery'); var h = require('virtual-dom/h'); var diff = require('virtual-dom/diff'); var patch = require('virtual-dom/patch'); var createElement = require('virtual-dom/create-element'); var model = { items: [] }; var $container = $('.container'); var hyperItems = {}; var hyperFooter = h('div.dbl-top-margin', [ h('input.form-control.item-name', { placeholder: '新增新專案', type: 'text' }), h('button.dbl-top-margin.btn.btn-primary.col-xs-12.item-add', '新增') ]); function generateTree(model) { return h('div', [ h('ul.list-group.dbl-top-margin', model.items.map(function (item, index) { hyperItems[item.name] = hyperItems[item.name] || h('li.list-group-item', [ item.name, h('button.item-remove.btn.btn-danger.btn-sm.float-right', { value: item.name }, '刪除') ]); return hyperItems[item.name]; })), hyperFooter ]) } var root; var tree; function render(model) { var newTree = generateTree(model); if (!root) { tree = newTree; root = createElement(tree); $container.append(root); return; } var patches = diff(tree, newTree); root = patch(root, patches) tree = newTree; } $container.delegate('.item-remove', 'click', function (e) { var value = $(e.target).val(); model = $.extend(true, {}, model); for (var i = 0; i < model.items.length; i++) { if (model.items[i].name === value) { model.items.splice(i, 1); break; } } render(model); }); $container.delegate('.item-add', 'click', function () { var name = $('.item-name').val(); model.items.push({ name: name }); render(model); }); render(model); |
上面的程式碼中需要說明的是render函式的實現:每次呼叫render時,先使用傳入的model物件生成一棵virtual dom tree,此時如果是第一次渲染(root為空),則利用這棵virtual dom tree構建真實的dom tree,並將其放入到容器中;如果不是第一次渲染,則比較已經存在的virtual dom tree和新構建的virtual dom tree,獲取到不同的部分,儲存到patches變數中,再呼叫patch方法實際更新dom tree。
這樣的實現是不是就沒有效能問題呢?還需要實驗資料來證明。細心的同學可能會注意到,在上一節中提到了在效能比較的環節博主曾經踩了一個坑。雖然幾天以前博主就開始準備這篇博文,但是由於觀點無法得到實驗資料的佐證,一度難以繼續。接下來就來一起回顧這個坑。一開始的時候博主使用了類似下面的辦法來考察直接使用innerHTML和利用virtual dom在連續反覆渲染上的效能表現:
1 2 3 4 5 6 7 8 9 |
var time = Date.now(); for (var i = 0; i < 100; i++) { model = $.extend(true, {}, model); model.items.push({ name: 'item-' + n }); render(model); } console.log(Date.now() - time); |
期待的結果自然應該是virtual dom的耗時低於innerHTML,但實際情況卻大相徑庭——innerHTML的表現遠勝virtual dom,這讓博主一度開始懷疑起自己的人生觀。為什麼不能使用這樣的方式來驗證效能呢:寫入innerHTML並不一定會引發一次渲染,如果寫入的時間間隔短於瀏覽器ui執行緒的響應時間,之前寫入的結果可能未來得及渲染就被忽略掉,也就是我們常說的掉幀。有兩個常用的知識可以從側面印證這一點:1是requestAnimationFrame的使用,2是為什麼從reflow,repaint的角度來看,利用innerHTML可以優化程式效能。問題找到了,並且既然我們想實實在在的測試兩種渲染方式帶來的效能差異,這裡就應該處理掉由遊覽器自身repaint機制而造成的干擾因素:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
var start; var timeConsumed = 0; function renderTest(n) { if (n === 0) { console.log(timeConsumed); return; } model = $.extend(true, {}, model); model.items.push({ name: 'item-' + n }); start = Date.now(); render(model); timeConsumed += Date.now() - start; requestAnimationFrame(renderTest.bind(undefined, n - 1)); }; renderTest(100); |
通過呼叫requestAnimationFrame,保證每一次對innerHTML的寫入都被瀏覽器真實渲染了出來;再對每次渲染的時間進行累加,這樣的結果就比較準確了。不出乎意料,直接使用innerHTML的方式的耗時在300ms左右;而使用virtual dom的方式耗時大概只有1/3。即使優化的程度還比較低,但是virtual dom在效能上的確有明顯的提升。
4. virtual dom和redux的整合
基於immutability的程式和redux的整合是非常自然的一件事:將產生新model的邏輯集中起來,便於程式碼維護、模組化、測試和業務邏輯解耦。本例中,需要兩個action,分別對應新增和刪除操作;reducer生成新的state,並根據action的型別對state進行操作;最後,將render方法繫結在store上即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
var $ = require('jquery'); var h = require('virtual-dom/h'); var diff = require('virtual-dom/diff'); var patch = require('virtual-dom/patch'); var createElement = require('virtual-dom/create-element'); var redux = require('redux'); var $container = $('.container'); var hyperItems = {}; var hyperFooter = h('div.dbl-top-margin', [ h('input.form-control.item-name', { placeholder: '新增新專案', type: 'text' }), h('button.dbl-top-margin.btn.btn-primary.col-xs-12.item-add', '新增') ]); function generateTree(model) { return h('div', [ h('ul.list-group.dbl-top-margin', model.items.map(function (item, index) { hyperItems[item.name] = hyperItems[item.name] || h('li.list-group-item', [ item.name, h('button.item-remove.btn.btn-danger.btn-sm.float-right', { value: item.name }, '刪除') ]); return hyperItems[item.name]; })), hyperFooter ]) } var root; var tree; function render(model) { var newTree = generateTree(model); if (!root) { tree = newTree; root = createElement(tree); $container.append(root); return; } var patches = diff(tree, newTree); root = patch(root, patches) tree = newTree; } $container.delegate('.item-remove', 'click', function (e) { var name = $(e.target).val(); store.dispatch(removeItem(name)); }); $container.delegate('.item-add', 'click', function () { var name = $('.item-name').val(); store.dispatch(addItem(name)); }); // action types var ADD_ITEM = 'ADD_ITEM'; var REMOVE_ITEM = 'REMOVE_ITEM' // action creators function addItem(name) { return { type: ADD_ITEM, name: name }; } function removeItem(name) { return { type: REMOVE_ITEM, name: name }; } // reducers var listApp = function (state, action) { // deep copy當前state,類似於前面的model state = $.extend(true, {}, state); switch (action.type) { case ADD_ITEM: (function () { state.items.push({ name: action.name }) })(); break; case REMOVE_ITEM: (function () { var items = state.items; for (var i = 0; i < items.length; i++) { if (items[i].name === action.name) { items.splice(i, 1); break; } } })(); break; } // 總是返回一個新的state return state; }; var initState = { items: [] }; // store var store = redux.createStore(listApp, initState); render(initState); // 監聽store變化 store.subscribe(function () { render(store.getState()); }); |
可以說,由react構建的龐大生態有一半的功勞要歸功於virtual dom。如果沒有virtual dom,基於immutable data的程式設計模式由於效能原因就難以在前端進行推廣,flux/redux這類依賴於immutability的框架也就無用武之地了。
博主在本文中的程式碼僅供研究學習,有相關需要的同學建議直接使用react,無論是相容性、效能優化還是社群建設都已經比較到位了 :)