文章結構:html
雖然React中的虛擬DOM很好用,可是這是一個無意插柳的結果。 前端
Chrome剛出來的時候,在Chrome裏跑Javascript很是快,給了其它瀏覽器很大壓力。而如今通過幾輪你追我趕,各主流瀏覽器的Javascript執行速度都很快了。java
在 https://julialang.org/benchmarks/ 這個網站上,咱們能夠看到,JavaScript語言已經很是快了,和C就是幾倍的關係,和java在同一個量級。因此說,單純的JavaScript仍是仍是很快的。node
1.2 Dom很慢react
當建立一個元素好比div,有如下幾項內容須要實現: HTML element、Element、GlobalEventHandler。簡單的說,就是插入一個Dom元素的時候,這個元素上自己或者繼承不少屬性如 width、height、offsetHeight、style、title,另外還須要註冊這個元素的諸多方法,好比onfucos、onclick等等。 這還只是一個元素,若是元素比較多的時候,還涉及到嵌套,那麼元素的屬性和方法等等就會不少,效率很低。git
好比,咱們在一個空白網頁的body中添加一個div元素,以下所示:github
這個元素會掛載默認的styles、獲得這個元素的computed屬性、註冊相應的Event Listener、DOM Breakpoints以及大量的properties,這些屬性、方法的註冊確定是須要h耗費大量時間的。web
尤爲是在js操做DOM的過程當中,不只有dom自己的繁重,js的操做也須要浪費時間,咱們認爲js和DOM之間有一座橋,若是你頻繁的在橋兩邊走動,顯然效率是很低的,若是你的JavaScript操做DOM的方式還很是不合理,那麼顯然就會更糟糕了。 算法
而 React的虛擬DOM就是解決這個問題的! 雖然它解決不了DOM自身的繁重,可是虛擬DOM能夠對JavaScript操做DOM這一部份內容進行優化。redux
好比說,如今你的list是這樣:
<ul> <li>0</li> <li>1</li> <li>2</li> <li>3</li> </ul>
你但願把它變成下面這樣:
<ul> <li>6</li> <li>7</li> <li>8</li> <li>9</li> <li>10</li> </ul>
一般的操做是什麼?
先把0, 1,2,3這些Element刪掉,而後加幾個新的Element 6,7,8,9,10進去,這裏面就有4次Element刪除,5次Element添加。共計9次DOM操做。
那React的虛擬DOM能夠怎麼作呢?
而React會把這兩個作一下Diff,而後發現其實不用刪除0,1,2,3,而是能夠直接改innerHTML,而後只須要添加一個Element(10)就好了,這樣就是4次innerHTML操做加1個Element添加。共計5此操做,這樣效率的提高是很是可觀的。
2.1 接口和設計
在React的設計中,是徹底不須要你來操做DOM的。咱們也能夠認爲,在React中根本就沒有DOM這個概念,有的只是Component。
當你寫好一個Component之後,Component會徹底負責UI,你不須要也不該該去也不可以指揮Component怎麼顯示,你只能告訴它你想要顯示一個香蕉仍是兩個梨。
隔離DOM並不只僅是由於DOM慢,而也是爲了把界面和業務徹底隔離,操做數據的只關心數據,操做界面的只關心界面。好比在websocket聊天室的建立房間時,咱們能夠首先Component寫好,而後當獲取到數據的時候,只要把數據放在redux中就好,而後Component就動把房間添加到頁面中去,而不是你先拿到數據,而後使用js操做DOM把數據顯示在頁面上。
即我提供一個Component,而後你只管給我數據,界面的事情徹底不用你操心,我保證會把界面變成你想要的樣子。因此說React的着力點就在於View層,即React專一於View層。你能夠把一個React的Component想象成一個Pure Function,只要你給的數據是[1, 2, 3],我保證顯示的是[1, 2, 3]。沒有什麼刪除一個Element,添加一個Element這樣的事情。NO。你要我顯示什麼就給我一個完整的列表。
另外,Flux雖說的是單向的Data Flow(redux也是),可是實際上就是單向的Observer,Store->View->Action->Store(箭頭是數據流向,實現上能夠理解爲View監聽Store,View直接trigger action,而後Store監聽Action)。
2.2 實現
那麼react如何實現呢? 最簡單的方法就是當數據變化時,我直接把原先的DOM卸載,而後把最新數據的DOM替換上去。 可是,虛擬DOM哪去了? 這樣作的效率顯然是極低的。
因此虛擬DOM就來救場了。
那麼虛擬DOM和DOM之間的關係是什麼呢?
首先,Virtual DOM並無徹底實現DOM,即虛擬DOM和真正地DOM是不同的,Virtual DOM最主要的仍是保留了Element之間的層次關係和一些基本屬性。由於真實DOM實在是太複雜,一個空的Element都複雜得能讓你崩潰,而且幾乎全部內容我根本不關心好嗎。因此Virtual DOM裏每個Element實際上只有幾個屬性,即最重要的,最爲有用的,而且沒有那麼多亂七八糟的引用,好比一些註冊的屬性和函數啊,這些都是默認的,建立虛擬DOM進行diff的過程當中你們都一致,是不須要進行比對的。因此哪怕是直接把Virtual DOM刪了,根據新傳進來的數據從新建立一個新的Virtual DOM出來都很是很是很是快。(每個component的render函數就是在作這個事情,給新的virtual dom提供input)。
因此,引入了Virtual DOM以後,React是這麼幹的:你給我一個數據,我根據這個數據生成一個全新的Virtual DOM,而後跟我上一次生成的Virtual DOM去 diff,獲得一個Patch,而後把這個Patch打到瀏覽器的DOM上去。完事。而且這裏的patch顯然不是完整的虛擬DOM,而是新的虛擬DOM和上一次的虛擬DOM通過diff後的差別化的部分。
假設在任意時候有,VirtualDom1 == DOM1 (組織結構相同, 顯然虛擬DOM和真實DOM是不可能徹底相等的,這裏的==是js中非徹底相等)。當有新數據來的時候,我生成VirtualDom2,而後去和VirtualDom1作diff,獲得一個Patch(差別化的結果)。而後將這個Patch去應用到DOM1上,獲得DOM2。若是一切正常,那麼有VirtualDom2 == DOM2(一樣是結構上的相等)。
這裏你能夠作一些小實驗,去破壞VirtualDom1 == DOM1這個假設(手動在DOM裏刪除一些Element,這時候VirtualDom裏的Element沒有被刪除,因此兩邊不同了)。
而後給新的數據,你會發現生成的界面就不是你想要的那個界面了。
最後,回到爲何Virtual Dom快這個問題上。
實際上是因爲每次生成virtual dom很快,diff生成patch也比較快,而在對DOM進行patch的時候,雖然DOM的變動比較慢,可是React可以根據Patch的內容,優化一部分DOM操做,好比以前的那個例子。
重點就在最後,哪怕是我生成了virtual dom(須要耗費時間),哪怕是我跑了diff(還須要花時間),可是我根據patch簡化了那些DOM操做省下來的時間依然很可觀(這個就是時間差的問題了,即節省下來的時間 > 生成 virtual dom的時間 + diff時間)。因此整體上來講,仍是比較快。
簡單發散一下思路,若是哪一天,DOM自己的已經操做很是很是很是快了,而且咱們手動對於DOM的操做都是精心設計優化事後的,那麼加上了VirtualDom還會快嗎?
固然不行了,畢竟你多作了這麼多額外的工做。
在上面一部分中,咱們已經簡單介紹了虛擬DOM的答題思路和好處,這裏咱們將經過本身寫一個虛擬DOM來加深對其的理解,有一些本身的思考。
維護狀態,更新視圖。
DOM是很慢的,若是咱們建立一個簡單的div,而後把他的全部的屬性都打印出來,你會看到:
var div = document.createElement('div'), str = ''; for (var key in div) { str = str + ' ' + key; } console.log(str);
var element = { tagName: 'ul', props: { id: 'list' }, children: { { tagName: 'li', props: { class: 'item' }, children: ['Item1'] }, { tagName: 'li', props: { class: 'item' }, children: ['Item1'] }, { tagName: 'li', props: { class: 'item' }, children: ['Item1'] } } }
如上所示,對於一個元素,咱們只須要一個JavaScript對象就能夠很容易的表示出來,這個對象中有三個屬性:
而上面的這個對象使用HTML表示就是:
<ul id='list'> <li class='item'>Item 1</li> <li class='item'>Item 2</li> <li class='item'>Item 3</li> </ul>
OK! 既然原來的DOM信息可使用JavaScript來表示,那麼反過來,咱們就能夠用這個JavaScript對象來構建一個真正的DOM樹。
因此以前所說的狀態變動的時候會重新構建這個JavaScript對象,而後呢,用新渲染的對象和舊的對象去對比, 記錄兩棵樹的差別,記錄下來的就是咱們須要改變的地方。 這就是所謂的虛擬DOM,包括下面的幾個步驟:
Virtual DOM的本質就是在JS和DOM之間作一個緩存,能夠類比CPU和硬盤,既然硬盤這麼慢,咱們就也在他們之間添加一個緩存; 既然DOM這麼慢,咱們就能夠在JS和DOM之間添加一個緩存。 CPU(JS)只操做內存(虛擬DOM),最後的時候在把變動寫入硬盤(DOM)。
一、 用JavaScript對象模擬DOM樹
用JavaScript對象來模擬一個DOM節點並不難,你只須要記錄他的節點類型(tagName)、屬性(props)、子節點(children)。
function Element(tagName, props, children) { this.tagName = tagName; this.props = props; this.children = children; }
module.exports = function (tagName, props, children) {
return new Element(tagName, props, children);
}
經過這個構造函數,咱們就能夠傳入標籤名、屬性以及子節點了,tagName能夠在咱們render的時候直接根據它來建立真實的元素,這裏的props使用一個對象傳入,能夠方便咱們遍歷。
基本使用方法以下:
var el = require('./element'); var ul = el('ul', {id: 'list'}, [ el('li', {class: 'item'}, ['item1']), el('li', {class: 'item'}, ['item2']), el('li', {class: 'item'}, ['item3']) ]);
然而,如今的ul只是JavaScript表示的一個DOM結構,頁面上並無這個結構,全部咱們能夠根據ul構建一個真正的<ul>:
Element.prototype.render = function () { // 根據tagName建立一個真實的元素 var el = document.createElement(this.tagName); // 獲得這個元素的屬性對象,方便咱們遍歷。 var props = this.props; for (var propName in props) { // 獲取到這個元素值 var propValue = props[propName]; // 經過setAttribute設置元素屬性。 el.setAttribute(propName, propValue); } // 注意: 這裏的children,咱們傳入的是一個數組,因此,children不存在時咱們用【】來替代。 var children = this.children || []; //遍歷children children.forEach(function (child) { var childEl = (child instanceof Element) ? child.render() : document.createTextNode(child); // 不管childEl是元素仍是文字節點,都須要添加到這個元素中。 el.appendChild(childEl); }); return el; }
因此,render方法會根據tagName構建一個真正的DOM節點,而後設置這個節點的屬性,最後遞歸的把本身的子節點也構建起來,因此只須要調用ul的render方法,經過document.body.appendChild就能夠掛載到真實的頁面了。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>div</title> </head> <body> <script> function Element(tagName, props, children) { this.tagName = tagName; this.props = props; this.children = children; } var ul = new Element('ul', {id: 'list'}, [ new Element('li', {class: 'item'}, ['item1']), new Element('li', {class: 'item'}, ['item2']), new Element('li', {class: 'item'}, ['item3']) ]); Element.prototype.render = function () { // 根據tagName建立一個真實的元素 var el = document.createElement(this.tagName); // 獲得這個元素的屬性對象,方便咱們遍歷。 var props = this.props; for (var propName in props) { // 獲取到這個元素值 var propValue = props[propName]; // 經過setAttribute設置元素屬性。 el.setAttribute(propName, propValue); } // 注意: 這裏的children,咱們傳入的是一個數組,因此,children不存在時咱們用【】來替代。 var children = this.children || []; //遍歷children children.forEach(function (child) { var childEl = (child instanceof Element) ? child.render() : document.createTextNode(child); // 不管childEl是元素仍是文字節點,都須要添加到這個元素中。 el.appendChild(childEl); }); return el; } var ulRoot = ul.render(); document.body.appendChild(ulRoot); </script> </body> </html>
上面的這段代碼,就能夠渲染出下面的結果了:
比較兩顆DOM數的差別是Virtual DOM算法中最爲核心的部分,這也就是所謂的Virtual DOM的diff算法。 兩個樹的徹底的diff算法是一個時間複雜度爲 O(n3) 的問題。 可是在前端中,你會不多跨層地移動DOM元素,因此真實的DOM算法會對同一個層級的元素進行對比。
上圖中,div只會和同一層級的div對比,第二層級的只會和第二層級對比。 這樣算法複雜度就能夠達到O(n)。
(1)深度遍歷優先,記錄差別
在實際的代碼中,會對新舊兩棵樹進行一個深度優先的遍歷,這樣每個節點就會有一個惟一的標記:
上面的這個遍歷過程就是深度優先,即深度徹底完成以後,再轉移位置。 在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的樹進行對比,若是有差別的話就記錄到一個對象裏面。
// diff函數,對比兩顆樹 function diff(oldTree, newTree) { // 當前的節點的標誌。由於在深度優先遍歷的過程當中,每一個節點都有一個index。 var index = 0; // 在遍歷到每一個節點的時候,都須要進行對比,找到差別,並記錄在下面的對象中。 var pathches = {}; // 開始進行深度優先遍歷 dfsWalk(oldTree, newTree, index, pathches); // 最終diff算法返回的是一個兩棵樹的差別。 return pathches; } // 對兩棵樹進行深度優先遍歷。 function dfsWalk(oldNode, newNode, index, pathches) { // 對比oldNode和newNode的不一樣,記錄下來 pathches[index] = [...]; diffChildren(oldNode.children, newNode.children, index, pathches); } // 遍歷子節點 function diffChildren(oldChildren, newChildren, index, pathches) { var leftNode = null; var currentNodeIndex = index; oldChildren.forEach(function (child, i) { var newChild = newChildren[i]; currentNodeIndex = (leftNode && leftNode.count) ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1 // 深度遍歷子節點 dfsWalk(child, newChild, currentNodeIndex, pathches); leftNode = child; }); }
例如,上面的div和新的div有差別,當前的標記是0, 那麼咱們可使用數組來存儲新舊節點的不一樣:
patches[0] = [{difference}, {difference}, ...]
同理使用patches[1]來記錄p,使用patches[3]來記錄ul,以此類推。
(2)差別類型
上面說的節點的差別指的是什麼呢? 對DOM操做可能會:
因此,咱們能夠定義下面的幾種類型:
var REPLACE = 0; var REORDER = 1; var PROPS = 2; var TEXT = 3;
對於節點替換,很簡單,判斷新舊節點的tagName是否是同樣的,若是不同的說明須要替換掉。 如div換成了section,就記錄下:
patches[0] = [{ type: REPALCE, node: newNode // el('section', props, children) }]
除此以外,若是給div新增了屬性id爲container,就記錄下:
pathches[0] = [ { type: REPLACE, node: newNode }, { type: PROPS, props: { id: 'container' } } ]
若是是文本節點發生了變化,那麼就記錄下:
pathches[2] = [ { type: TEXT, content: 'virtual DOM2' } ]
那麼若是咱們把div的子節點從新排序了呢? 好比p、ul、div的順序換成了div、p、ul,那麼這個該怎麼對比呢? 若是按照同級進行順序對比的話,他們就會被替換掉,如p和div的tagName不一樣,p就會被div所代替,最終,三個節點就都會被替換,這樣DOM開銷就會很是大,而其實是不須要替換節點的,只須要移動就能夠了, 咱們只須要知道怎麼去移動。這裏牽扯到了兩個列表的對比算法,以下。
假設如今能夠英文字母惟一地標識每個子節點:
a b c d e f g h i
如今對節點進行了刪除、插入、移動的操做。新增j
節點,刪除e
節點,移動h
節點:
a b c h d f g i j
如今知道了新舊的順序,求最小的插入、刪除操做(移動能夠當作是刪除和插入操做的結合)。這個問題抽象出來實際上是字符串的最小編輯距離問題(Edition Distance),最多見的解決算法是 Levenshtein Distance,經過動態規劃求解,時間複雜度爲 O(M * N)。可是咱們並不須要真的達到最小的操做,咱們只須要優化一些比較常見的移動狀況,犧牲必定DOM操做,讓算法時間複雜度達到線性的(O(max(M, N))。具體算法細節比較多,這裏不累述,有興趣能夠參考代碼。
patches[0] = [{ type: REORDER, moves: [{remove or insert}, {remove or insert}, ...] }]
可是要注意的是,由於tagName
是可重複的,不能用這個來進行對比。因此須要給子節點加上惟一標識key
,列表對比的時候,使用key
進行對比,這樣才能複用老的 DOM 樹上的節點。
這樣,咱們就能夠經過深度優先遍歷兩棵樹,每層的節點進行對比,記錄下每一個節點的差別了。完整 diff 算法代碼可見 diff.js。
由於步驟一所構建的 JavaScript 對象樹和render
出來真正的DOM樹的信息、結構是同樣的。因此咱們能夠對那棵DOM樹也進行深度優先的遍歷,遍歷的時候從步驟二生成的patches
對象中找出當前遍歷的節點差別,而後進行 DOM 操做。
function patch (node, patches) { var walker = {index: 0} dfsWalk(node, walker, patches) } function dfsWalk (node, walker, patches) { var currentPatches = patches[walker.index] // 從patches拿出當前節點的差別 var len = node.childNodes ? node.childNodes.length : 0 for (var i = 0; i < len; i++) { // 深度遍歷子節點 var child = node.childNodes[i] walker.index++ dfsWalk(child, walker, patches) } if (currentPatches) { applyPatches(node, currentPatches) // 對當前節點進行DOM操做 } }
applyPatches,根據不一樣類型的差別對當前節點進行 DOM 操做:
function applyPatches (node, currentPatches) { currentPatches.forEach(function (currentPatch) { switch (currentPatch.type) { case REPLACE: node.parentNode.replaceChild(currentPatch.node.render(), node) break case REORDER: reorderChildren(node, currentPatch.moves) break case PROPS: setProps(node, currentPatch.props) break case TEXT: node.textContent = currentPatch.content break default: throw new Error('Unknown patch type ' + currentPatch.type) } }) }
virtual DOM算法主要實現上面步驟的三個函數: element、diff、patch,而後就能夠實際的進行使用了。
// 1. 構建虛擬DOM var tree = el('div', {'id': 'container'}, [ el('h1', {style: 'color: blue'}, ['simple virtal dom']), el('p', ['Hello, virtual-dom']), el('ul', [el('li')]) ]) // 2. 經過虛擬DOM構建真正的DOM var root = tree.render() document.body.appendChild(root) // 3. 生成新的虛擬DOM var newTree = el('div', {'id': 'container'}, [ el('h1', {style: 'color: red'}, ['simple virtal dom']), el('p', ['Hello, virtual-dom']), el('ul', [el('li'), el('li')]) ]) // 4. 比較兩棵虛擬DOM樹的不一樣 var patches = diff(tree, newTree) // 5. 在真正的DOM元素上應用變動 patch(root, patches)
固然這是很是粗糙的實踐,實際中還須要處理事件監聽等;生成虛擬 DOM 的時候也能夠加入 JSX 語法。這些事情都作了的話,就能夠構造一個簡單的ReactJS了。