拋開react,如何理解virtual dom和immutability

去年以來,React的出現爲前端框架設計和編程模式吹來了一陣春風。不少概念,不管是本來已有的、仍是由React首先提出的,都由於React的流行而倍受關注,成爲你們研究和學習的熱點。本篇分享主要就聚焦於這些概念中出現頻率較高的兩個:virtual dom(虛擬DOM)和data immutability(數據不變性)。但願經過幾段代碼和同窗們分享博主對於這兩個概念的思考和理解。javascript

文章分爲四個部分,由你們最爲熟悉的基於dom node的編程開始:
1. 基於模板和dom node的編程:回顧前端傳統的編程模式,簡單總結前端發展的趨勢和潮流
2. 面向immutable data model的編程:淺析在virtual dom出現以前,爲何基於immutability的編程不具有大規模流行的條件
3. 引入virtual dom,優化渲染性能:介紹virtual dom以及一些常見的性能優化技巧,給出性能比較的測試方法和結論
4. virtual dom和redux的整合:示範如何與redux整合css

 

1. 基於模板和dom node的編程html

基於模板和dom node的編程是前端開發中最爲傳統和深刻人心的開發方式。這種開發方式編碼簡單、模式靈活、學習曲線平滑,深受你們的喜好。模版層渲染既能夠在後端完成(如smarty、velocity、jade)也能夠在前端完成(如mustache,handlebars),而dom操做通常則會藉助於諸如jquery、yui之類封裝良好的類庫。本文爲了方便同窗們在純前端環境中進行實驗,將採用handlebars + jquery的技術選型。其中handlebars負責前端渲染,jquery負責事件監聽和dom操做。從本節開始,將使用不一樣的方式實現一個支持添加、刪除操做的列表,大體界面以下:前端

首先簡要分析一下代碼邏輯:
模板放在script標籤中,type設置爲text/template,用於稍後渲染;因爲須要頻繁添加、刪除dom元素,所以選用事件代理(delegation),以免頻繁處理添加、刪除監聽器的邏輯。html代碼:java

 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代碼:node

 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層分離和狀態維護。react

不管是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選擇了前者。jquery

 

2. 面向immutable data model的編程angularjs

然而在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

原文:http://www.cnblogs.com/front-end-ralph/p/5283586.html

相關文章
相關標籤/搜索