文章結構:
- React中的虛擬DOM是什么?
- 虛擬DOM的簡單實現(diff算法)
- 虛擬DOM的內部工作原理
- React中的虛擬DOM與Vue中的虛擬DOM比較
React中的虛擬DOM是什么?
雖然React中的虛擬DOM很好用,但是這是一個無心插柳的結果。
React的核心思想:一個Component拯救世界,忘掉煩惱,從此不再操心界面。
1. Virtual Dom快,有兩個前提
1.1 Javascript很快
Chrome剛出來的時候,在Chrome里跑Javascript非常快,給了其它瀏覽器很大壓力。而現在經過幾輪你追我趕,各主流瀏覽器的Javascript執行速度都很快了。
在 https://julialang.org/benchmarks/ 這個網站上,我們可以看到,JavaScript語言已經非常快了,和C就是幾倍的關系,和java在同一個量級。所以說,單純的JavaScript還是還是很快的。
1.2 Dom很慢
當創建一個元素比如div,有以下幾項內容需要實現: HTML element、Element、GlobalEventHandler。簡單的說,就是插入一個Dom元素的時候,這個元素上本身或者繼承很多屬性如 width、height、offsetHeight、style、title,另外還需要注冊這個元素的諸多方法,比如onfucos、onclick等等。 這還只是一個元素,如果元素比較多的時候,還涉及到嵌套,那么元素的屬性和方法等等就會很多,效率很低。
比如,我們在一個空白網頁的body中添加一個div元素,如下所示:
這個元素會掛載默認的styles、得到這個元素的computed屬性、注冊相應的Event Listener、DOM Breakpoints以及大量的properties,這些屬性、方法的注冊肯定是需要h耗費大量時間的。
尤其是在js操作DOM的過程中,不僅有dom本身的繁重,js的操作也需要浪費時間,我們認為js和DOM之間有一座橋,如果你頻繁的在橋兩邊走動,顯然效率是很低的,**如果你的JavaScript操作DOM的方式還非常不合理,那么顯然就會更糟糕了。 **
而 React的虛擬DOM就是解決這個問題的! 雖然它解決不了DOM自身的繁重,但是虛擬DOM可以對JavaScript操作DOM這一部分內容進行優化。
比如說,現在你的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、 關于React
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還會快嗎?
當然不行了,畢竟你多做了這么多額外的工作。
但是那一天會來到嗎?
誒,大不了到時候不用Virtual DOM。
注: 此部分內容整理自:https://www.zhihu.com/question/29504639/answer/44680878
虛擬DOM的簡單實現(diff算法)
目錄
- 1 前言
- 2 對前端應用狀態管理思考
- 3 Virtual DOM 算法
- 4 算法實現
- 4.1 步驟一:用JS對象模擬DOM樹
- 4.2 步驟二:比較兩棵虛擬DOM樹的差異
- 4.3 步驟三:把差異應用到真正的DOM樹上
- 5 結語
前言
在上面一部分中,我們已經簡單介紹了虛擬DOM的答題思路和好處,這里我們將通過自己寫一個虛擬DOM來加深對其的理解,有一些自己的思考。
對前端應用狀態管理思考
維護狀態,更新視圖。
虛擬DOM算法
DOM是很慢的,如果我們創建一個簡單的div,然后把他的所有的屬性都打印出來,你會看到:
var div = document.createElement('div'),
str = '';
for (var key in div) {
str = str + ' ' + key;
}
console.log(str);
可以看到,這些屬性還是非常驚人的,包括樣式的修飾特性、一般的特性、方法等等,如果我們打印出其長度,可以得到驚人的227個。
而這僅僅是一層,真正的DOM元素是非常龐大的,這是因為標準就是這么設計的,而且操作他們的時候你要小心翼翼,輕微的觸碰就有可能導致頁面發生重排,這是殺死性能的罪魁禍首。
而相對于DOM對象,原生的JavaScript對象處理起來更快,而且更簡單,DOM樹上的結構信息我們都可以使用JavaScript對象很容易的表示出來。
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對象就可以很容易的表示出來,這個對象中有三個屬性:
- tagName: 用來表示這個元素的標簽名。
- props: 用來表示這元素所包含的屬性。
- children: 用來表示這元素的children。
而上面的這個對象使用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,包括下面的幾個步驟:
- 用JavaScript對象來表示DOM樹的結構; 然后用這個樹構建一個真正的DOM樹,插入到文檔中。
- 當狀態變更的時候,重新構造一個新的對象樹,然后用這個新的樹和舊的樹作對比,記錄兩個樹的差異。
- 把2所記錄的差異應用在步驟一所構建的真正的DOM樹上,視圖就更新了。
Virtual DOM的本質就是在JS和DOM之間做一個緩存,可以類比CPU和硬盤,既然硬盤這么慢,我們就也在他們之間添加一個緩存; 既然DOM這么慢,我們就可以在JS和DOM之間添加一個緩存。 CPU(JS)只操作內存(虛擬DOM),最后的時候在把變更寫入硬盤(DOM)。
算法實現
1、 用JavaScript對象模擬DOM樹
用JavaScript對象來模擬一個DOM節點并不難,你只需要記錄他的節點類型(tagName)、屬性(props)、子節點(children)。
element.js
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>
上面的這段代碼,就可以渲染出下面的結果了:
2、比較兩顆虛擬DOM樹的差異
比較兩顆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操作可能會:
- 替換原來的節點,如把上面的div換成了section。
- 移動、刪除、新增子節點, 例如上面div的子節點,把p和ul順序互換。
- 修改了節點的屬性。
- 對于文本節點,文本內容可能會改變。 例如修改上面的文本內容2內容為Virtual DOM2.
所以,我們可以定義下面的幾種類型:
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開銷就會非常大,而實際上是不需要替換節點的,只需要移動就可以了, 我們只需要知道怎么去移動。這里牽扯到了兩個列表的對比算法,如下。
(3)列表對比算法
假設現在可以英文字母唯一地標識每一個子節點:
舊的節點順序:
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。
3、把差異引用到真正的DOM樹上
因為步驟一所構建的 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)
}
})
}
5、結語
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了。