去年以來,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 <!doctype html> 2 <html> 3 <head> 4 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> 5 <link href="https://cdn.bootcss.com/bootstrap/4.0.0-alpha.2/css/bootstrap.min.css" rel="stylesheet" /> 6 <style> 7 .dbl-top-margin { 8 margin-top: 20px; 9 } 10 .float-right { 11 float: right; 12 } 13 </style> 14 <script class="template" type="text/template"> 15 <ul class="list-group dbl-top-margin"> 16 {{#items}} 17 <li class="list-group-item"> 18 <span>{{name}}</span> 19 <button data-index="{{@index}}" class="item-remove btn btn-danger btn-sm float-right">刪除</button> 20 </li> 21 {{/items}} 22 </ul> 23 <div class="dbl-top-margin"> 24 <input placeholder="添加新項目" type="text" class="form-control item-name" /> 25 <button class="dbl-top-margin btn btn-primary col-xs-12 item-add">添加</button> 26 </div> 27 </script> 28 </head> 29 <body> 30 <div class="container"></div> 31 <script src="bundle.js"></script> 32 </body> 33 </html>
javascript代碼:
1 var $ = require('jquery'); 2 var Handlebars = require('handlebars'); 3 4 var template = $('.template').text(); 5 6 // 用一組div標簽將template包裹起來 7 template = ['<div>', template, '</div>'].join(''); 8 9 // 初次渲染模板時所用到的數據 10 var model = { 11 items: [{ 12 name: '項目1' 13 }, { 14 name: '項目2' 15 }, { 16 name: '項目3' 17 }] 18 }; 19 20 // Handlebars.compile方法返回編譯后的模塊方法,調用這個模板方法並傳入數據,即可得到渲染后的模板 21 var html = Handlebars.compile(template)(model); 22 23 24 var $container = $('.container'); 25 $container.html(html); 26 27 var $ul = $container.find('ul'); 28 var $itemName = $container.find('.item-name'); 29 30 var liTemplate = '' 31 + '<li class="list-group-item">' 32 + '<span>{{name}}</span>' 33 + '<button class="item-remove btn btn-danger btn-sm float-right">刪除</button>' 34 + '</li>'; 35 36 $container.delegate('.item-remove', 'click', function (e) { 37 var $li = $(e.target).parents('li'); 38 $li.remove(); 39 }); 40 41 $container.delegate('.item-add', 'click', function () { 42 var name = $itemName.val(); 43 // 清空輸入框 44 $itemName.val(''); 45 // 渲染新項目並插入 46 $ul.append(Handlebars.compile(liTemplate)({ 47 name: name 48 })); 49 });
雖然編碼起來簡單易行,但是這種傳統的開發模式弊端也比較明顯,尤其是在前端項目規模不斷擴大,復雜度不斷提升的今天。比如,由於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 var $ = require('jquery'); 2 var Handlebars = require('handlebars'); 3 var template = $('.template').text(); 4 5 // 用一組div標簽將template包裹起來 6 template = ['<div>', template, '</div>'].join(''); 7 template = Handlebars.compile(template); 8 9 var model = { 10 items: [{ 11 name: 'item-1' 12 }, { 13 name: 'item-2' 14 }] 15 }; 16 17 var $container = $('.container'); 18 19 20 function render(model) { 21 $container[0].innerHTML = template(model); 22 } 23 24 25 $container.delegate('.item-remove', 'click', function (e) { 26 var index = $(e.target).attr('data-index'); 27 index = parseInt(index, 10); 28 model = $.extend(true, {}, model); 29 model.items.splice(index, 1); 30 render(model); 31 }); 32 33 $container.delegate('.item-add', 'click', function () { 34 var name = $('.item-name').val(); 35 model = $.extend(true, {}, model); 36 model.items.push({ 37 name: name 38 }); 39 render(model); 40 }); 41 42 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 function generateTree(model) { 2 return h('div', [ 3 h('ul.list-group.dbl-top-margin', model.items.map(function (item, index) { 4 return h('li.list-group-item', [ 5 item.name, 6 h('button.item-remove.btn.btn-danger.btn-sm.float-right', { 7 value: item.name 8 }, '刪除') 9 ]); 10 })), 11 h('div.dbl-top-margin', [ 12 h('input.form-control.item-name', { 13 placeholder: '添加新項目', 14 type: 'text' 15 }), 16 h('button.dbl-top-margin.btn.btn-primary.col-xs-12.item-add', '添加') 17 ]) 18 ]) 19 }
最外層是一個div節點,作為容器包裹內部元素。div的第一個子節點是ul,ul中通過遍歷model.items生成li,每個li里有item.name和一個刪除按鈕。div的第二個子節點是用於輸入新項目的文本框和一個“添加”按鈕。當然,每次生成新的virtual tree的性能是比較低下的:雖然避免了大量dom操作,但是卻將時間消耗在了virtual tree的構建上。一個典型的例子是,如果items中的項目沒有改變,我們其實可以把它們緩存起來。博主暫時還沒有機會深入研究react的實現,但有過react開發經驗的同學應該對數組中不出現key時而報的warn並不陌生。這里應該就是react性能優化中比較重要的一個點。利用類似的思路,對generateTree函數進行適當的優化:
1 var hyperItems = {}; 2 var hyperFooter = h('div.dbl-top-margin', [ 3 h('input.form-control.item-name', { 4 placeholder: '添加新項目', 5 type: 'text' 6 }), 7 h('button.dbl-top-margin.btn.btn-primary.col-xs-12.item-add', '添加') 8 ]); 9 function generateTree(model) { 10 return h('div', [ 11 h('ul.list-group.dbl-top-margin', model.items.map(function (item, index) { 12 hyperItems[item.name] = hyperItems[item.name] || h('li.list-group-item', [ 13 item.name, 14 h('button.item-remove.btn.btn-danger.btn-sm.float-right', { 15 value: item.name 16 }, '刪除') 17 ]); 18 return hyperItems[item.name]; 19 })), 20 hyperFooter 21 ]) 22 }
除了一開始提到了數組項的緩存之外,由於添加新項目的部分是不會改變的,因此我們先創建好hyperFooter實例,每次需要生成virtual tree的時候直接調用就好了。hyperscript的部分介紹清楚了,接下來就來看代碼實現:
1 var $ = require('jquery'); 2 var h = require('virtual-dom/h'); 3 var diff = require('virtual-dom/diff'); 4 var patch = require('virtual-dom/patch'); 5 var createElement = require('virtual-dom/create-element'); 6 7 var model = { 8 items: [] 9 }; 10 11 var $container = $('.container'); 12 13 var hyperItems = {}; 14 15 var hyperFooter = h('div.dbl-top-margin', [ 16 h('input.form-control.item-name', { 17 placeholder: '添加新項目', 18 type: 'text' 19 }), 20 h('button.dbl-top-margin.btn.btn-primary.col-xs-12.item-add', '添加') 21 ]); 22 23 function generateTree(model) { 24 return h('div', [ 25 h('ul.list-group.dbl-top-margin', model.items.map(function (item, index) { 26 hyperItems[item.name] = hyperItems[item.name] || h('li.list-group-item', [ 27 item.name, 28 h('button.item-remove.btn.btn-danger.btn-sm.float-right', { 29 value: item.name 30 }, '刪除') 31 ]); 32 return hyperItems[item.name]; 33 })), 34 hyperFooter 35 ]) 36 } 37 38 var root; 39 var tree; 40 function render(model) { 41 var newTree = generateTree(model); 42 if (!root) { 43 tree = newTree; 44 root = createElement(tree); 45 $container.append(root); 46 return; 47 } 48 var patches = diff(tree, newTree); 49 root = patch(root, patches) 50 tree = newTree; 51 } 52 53 $container.delegate('.item-remove', 'click', function (e) { 54 var value = $(e.target).val(); 55 model = $.extend(true, {}, model); 56 for (var i = 0; i < model.items.length; i++) { 57 if (model.items[i].name === value) { 58 model.items.splice(i, 1); 59 break; 60 } 61 } 62 render(model); 63 }); 64 65 $container.delegate('.item-add', 'click', function () { 66 var name = $('.item-name').val(); 67 model.items.push({ 68 name: name 69 }); 70 render(model); 71 }); 72 73 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 var time = Date.now(); 2 for (var i = 0; i < 100; i++) { 3 model = $.extend(true, {}, model); 4 model.items.push({ 5 name: 'item-' + n 6 }); 7 render(model); 8 } 9 console.log(Date.now() - time);
期待的結果自然應該是virtual dom的耗時低於innerHTML,但實際情況卻大相徑庭——innerHTML的表現遠勝virtual dom,這讓博主一度開始懷疑起自己的人生觀。為什么不能使用這樣的方式來驗證性能呢:寫入innerHTML並不一定會引發一次渲染,如果寫入的時間間隔短於瀏覽器ui線程的響應時間,之前寫入的結果可能未來得及渲染就被忽略掉,也就是我們常說的掉幀。有兩個常用的知識可以從側面印證這一點:1是requestAnimationFrame的使用,2是為什么從reflow,repaint的角度來看,利用innerHTML可以優化程序性能。問題找到了,並且既然我們想實實在在的測試兩種渲染方式帶來的性能差異,這里就應該處理掉由游覽器自身repaint機制而造成的干擾因素:
1 var start; 2 var timeConsumed = 0; 3 function renderTest(n) { 4 if (n === 0) { 5 console.log(timeConsumed); 6 return; 7 } 8 model = $.extend(true, {}, model); 9 model.items.push({ 10 name: 'item-' + n 11 }); 12 start = Date.now(); 13 render(model); 14 timeConsumed += Date.now() - start; 15 requestAnimationFrame(renderTest.bind(undefined, n - 1)); 16 }; 17 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 var $ = require('jquery'); 2 var h = require('virtual-dom/h'); 3 var diff = require('virtual-dom/diff'); 4 var patch = require('virtual-dom/patch'); 5 var createElement = require('virtual-dom/create-element'); 6 var redux = require('redux'); 7 8 var $container = $('.container'); 9 10 var hyperItems = {}; 11 12 var hyperFooter = h('div.dbl-top-margin', [ 13 h('input.form-control.item-name', { 14 placeholder: '添加新項目', 15 type: 'text' 16 }), 17 h('button.dbl-top-margin.btn.btn-primary.col-xs-12.item-add', '添加') 18 ]); 19 20 function generateTree(model) { 21 return h('div', [ 22 h('ul.list-group.dbl-top-margin', model.items.map(function (item, index) { 23 hyperItems[item.name] = hyperItems[item.name] || h('li.list-group-item', [ 24 item.name, 25 h('button.item-remove.btn.btn-danger.btn-sm.float-right', { 26 value: item.name 27 }, '刪除') 28 ]); 29 return hyperItems[item.name]; 30 })), 31 hyperFooter 32 ]) 33 } 34 35 var root; 36 var tree; 37 function render(model) { 38 var newTree = generateTree(model); 39 if (!root) { 40 tree = newTree; 41 root = createElement(tree); 42 $container.append(root); 43 return; 44 } 45 var patches = diff(tree, newTree); 46 root = patch(root, patches) 47 tree = newTree; 48 } 49 50 $container.delegate('.item-remove', 'click', function (e) { 51 var name = $(e.target).val(); 52 store.dispatch(removeItem(name)); 53 }); 54 55 $container.delegate('.item-add', 'click', function () { 56 var name = $('.item-name').val(); 57 store.dispatch(addItem(name)); 58 }); 59 60 61 // action types 62 var ADD_ITEM = 'ADD_ITEM'; 63 var REMOVE_ITEM = 'REMOVE_ITEM' 64 65 // action creators 66 function addItem(name) { 67 return { 68 type: ADD_ITEM, 69 name: name 70 }; 71 } 72 73 function removeItem(name) { 74 return { 75 type: REMOVE_ITEM, 76 name: name 77 }; 78 } 79 80 // reducers 81 var listApp = function (state, action) { 82 // deep copy當前state,類似於前面的model 83 state = $.extend(true, {}, state); 84 85 switch (action.type) { 86 case ADD_ITEM: 87 (function () { 88 state.items.push({ 89 name: action.name 90 }) 91 })(); 92 break; 93 case REMOVE_ITEM: 94 (function () { 95 var items = state.items; 96 for (var i = 0; i < items.length; i++) { 97 if (items[i].name === action.name) { 98 items.splice(i, 1); 99 break; 100 } 101 } 102 })(); 103 break; 104 } 105 106 // 總是返回一個新的state 107 return state; 108 }; 109 110 111 var initState = { 112 items: [] 113 }; 114 115 // store 116 var store = redux.createStore(listApp, initState); 117 118 render(initState); 119 120 // 監聽store變化 121 store.subscribe(function () { 122 render(store.getState()); 123 });
可以說,由react構建的龐大生態有一半的功勞要歸功於virtual dom。如果沒有virtual dom,基於immutable data的編程模式由於性能原因就難以在前端進行推廣,flux/redux這類依賴於immutability的框架也就無用武之地了。
博主在本文中的代碼僅供研究學習,有相關需要的同學建議直接使用react,無論是兼容性、性能優化還是社區建設都已經比較到位了 :)
作者:ralph_zhu
時間:2016-03-16 15:00