目錄
React起源於Facebook的內部項目,用來架設Instagram(照片交友)網站。在2013年5月開源。React的設計思想極其獨特,屬於革命性創新,性能出衆,代碼邏輯卻非常簡單,它可能是將來Web開發的主流工具。
前端三大主流框架(https://2018.stateofjs.com/front-end-frameworks/overview/):
Angular.js:較早。學習曲線比較陡,Ng1學起來比較麻煩,Ng2~5進行了一系列的改革(1和2像是兩套框架),提供了組件化開發的概念,支持使用TypeScript。
Vue.js:最火(關注的人多)。中國人開發。
React.js:最流行(用的多)。設計很優秀。
React與Vue:
組件化:Vue通過.vue文件創建對應的組件,template、script、style;React沒有組件模板文件,一切都是以JS來表現。
開發團隊:Vue以尤雨溪爲主導的開源小團隊;React是由FaceBook前端官方團隊,技術實力比較雄厚。
社區方面:Vue是近兩年才火起來的;React誕生較早,社區強大,常見的問題、最優解決方案、文檔、博客較全面。
移動APP開發:Weex目前只是一個小玩具,並沒有很成功的大案例;ReactNative提供了無縫遷移到移動App的開發體驗,用的最多。
模塊化:從代碼的角度,把一些可複用的代碼抽離爲單個的模塊,便於項目的維護和開發。
組件化:從UI界面的角度,把一些可複用的UI元素抽離爲單獨的組件,便於項目的維護和開發。
一、虛擬DOM和Diff算法
1、爲什麼需要虛擬DOM
瀏覽器的工作流程幾乎是相似的。How Browsers Work
(1)創建DOM樹:瀏覽器收到HTML文件後,渲染引擎就會對其進行解析並創建一個DOM樹節點,這些節點與HTML元素具有一對一的關係。
(2)創建渲染(Render)樹:解析來自外部CSS文件和內聯的樣式,樣式信息和DOM樹中的節點用於創建渲染樹。在WebKit中,DOM樹中的所有節點都有attach方法,它接收計算出的樣式信息並返回一個渲染對象。
(3)佈局(也稱迴流):渲染樹中的每個節點都有屏幕座標,使其出現在屏幕上的確切位置。
(4)繪畫:繪製渲染對象。遍歷渲染樹並調用每個節點的paint()方法,在屏幕上顯示內容。
虛擬Dom快,有兩個前提:(1)Javascript很快。Julia Micro-Benchmarks可看到Javascript跟Java基本是一個量級,是C語言的幾倍。(2)DOM很慢。用document.createElement()創建一個空元素時,有以下東西要實現HTMLElement的屬性和方法 Element的屬性和方法 GlobalEventHandlers的屬性和方法,還有不少嵌套引用,DOM設計得太複雜。原生及很多框架在調用DOM的API的時候做得不好,導致整個過程更加慢。每次更改DOM時,所有創建DOM樹至渲染對象都將重做,假設一個接一個地修改了30個節點,可能30次重新計算佈局、重新渲染等。
目的是爲了實現頁面元素的高效更新。
2、什麼是虛擬DOM
虛擬DOM是對DOM的“雙緩衝”應用,可以在脫機DOM樹中執行每個更改,之後將所有更改存儲爲真正的DOM,佈局渲染到頁面上(類似硬盤和緩存),只執行一次,減少了計算的數量。虛擬DOM自動化和抽象DOM片段的管理,不必手動執行,不必手動跟蹤哪部分已改變需要刷新。通過放棄對自身的DOM操作,允許代碼的不同組件或部分請求DOM修改而不必相互交互,避免在修改DOM的所有部分之間進行同步。
React虛擬DOM的運作是透明的,根本沒有DOM這個概念。只需提供component和數據,無需擔心性能問題,由虛擬DOM來確保只對界面上真正變化的部分進行實際的DOM操作。
虛擬DOM並沒有完全實現DOM,只保留了Element之間的層次關係和一些基本屬性。創建新的虛擬DOM時速度很快,每個component的render函數就是給新的虛擬dom提供input。(1)用JavaScript對象表示DOM樹的結構,構建一個真正的DOM樹,插到文檔當中;(2)當狀態變更時,重新構造一棵新的JS對象樹,將新舊樹進行比較,記錄差異;(3)將差異應用到步驟1所構建的真正的DOM樹上,實現視圖更新。
例子如下:通常是先把0, 1, 2元素刪除,再加3, 4, 5, 6新元素,有3次刪除元素和4次添加元素。而React會把這兩個虛擬DOM樹做Diff,只修改innerHTML和添加一個元素6。
<ul>
<li>0</li>
<li>1</li>
<li>2</li>
</ul>
<!-- 更改後 -->
<ul>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
</ul>
JS對象只需要記錄DOM樹的節點類型、屬性和子節點。DOM樹和JavaScript對象可以互相轉換。
<!-- HTML寫法 -->
<ul id='list'>
<li class='item'>Item 1</li>
<li class='item'>Item 2</li>
<li class='item'>Item 3</li>
</ul>
// DOM樹的結構、屬性信息用JavaScript對象表示
var element={
tagName:'ul', // 節點標籤名
props:{ // DOM的屬性,用一個對象存儲鍵值對
id:'list'
},
children:[ // 該節點的子節點
{tagName:'li',props:{class:'item'},children:["Item 1"]},
{tagName:'li',props:{class:'item'},children:["Item 2"]},
{tagName:'li',props:{class:'item'},children:["Item 3"]},
]
}
3、用JS對象模擬DOM樹
(1)構建JS對象
// element.js
function Element(tagName, props, children) {
if (!(this instanceof Element)) {
return new Element(tagName, props, children);
}
this.tagName = tagName;
this.props = props || {};
this.children = children || [];
this.key = props ? props.key : undefined;
let count = 0;
this.children.forEach((child) => {
if (child instanceof Element) {
count += child.count;
}
count++;
});
this.count = count;
}
module.exports = function (tagName, props, children) {
return new Element(tagName, props, children)
}
var el = require('./element')
// 目前ul只是一個JavaScript對象表示的DOM結構
var ul = el('ul', {id: 'list'}, [
el('li', {class: 'item'}, ['Item 1']),
el('li', {class: 'item'}, ['Item 2']),
el('li', {class: 'item'}, ['Item 3'])
])
(2)根據JS對象構建真正的DOM樹
render方法根據tagName構建一個真正的DOM節點,設置這個節點的屬性,遞歸地把子節點也構建起來。
// 根據JS對象構建真正的DOM樹
Element.prototype.render = function () {
var el = document.createElement(this.tagName) // 根據tagName構建
var props = this.props
for (var propName in props) { // 設置節點的DOM屬性
var propValue = props[propName]
el.setAttribute(propName, propValue)
}
var children = this.children || []
children.forEach(function (child) {
var childEl = (child instanceof Element)
? child.render() // 如果子節點也是虛擬DOM,遞歸構建DOM節點
: document.createTextNode(child) // 如果字符串,只構建文本節點
el.appendChild(childEl)
})
return el
}
(3)將真正的DOM樹渲染至頁面中
var ulRoot = ul.render()
document.body.appendChild(ulRoot)
4、Diff算法比較兩棵虛擬DOM樹的差異
新舊虛擬DOM樹需要進行Diff算法分析,找到差異。 標準的Diff算法複雜度需要O(n^3),Facebook工程師結合Web界面的特點做出兩個簡單的假設,使得Diff算法複雜度直接降低到O(n)。算法上的優化是React整個界面Render的基礎。(1)兩個相同組件產生類似的DOM結構,不同的組件產生不同的DOM結構;(2)對於同一層次的一組子節點,可以通過唯一的id進行區分。
(1)逐層進行節點比較
tree diff:逐層對比;
component diff:進行Tree Diff時,每一層中,組件級別的對比;
element diff:進行組件對比時,如果兩個組件類型相同,則需要進行元素級別的對比。
在React中比較兩個虛擬DOM節點,分爲兩種情況:
節點類型不同 。當在樹中的同一位置前後輸出了不同類型的節點,直接刪除之前的節點,創建並插入新的節點。注意,該刪除的節點的子節點也會被完全刪除,它們也不會用於後面的比較。同樣的邏輯也被用在React組件的比較,這正是應用了第一個假設。
節點類型相同,但是屬性不同。對屬性進行重設從而實現節點的轉換。虛擬DOM的style屬性稍有不同,其值並不是一個簡單字符串而必須爲一個對象。
在React中,樹的算法非常簡單,兩棵樹只會對同一層次的節點進行比較,即同一個父節點下的所有子節點。當發現節點已經不存在,則該節點及其子節點會被完全刪除掉,不會用於進一步的比較。這樣只需要對樹進行一次遍歷,便能完成整個DOM樹的比較。
這一假設至今爲止沒有導致嚴重的性能問題。在實現自己的組件時,保持穩定的DOM結構有助於性能的提升,例如可以通過CSS隱藏或顯示某些節點,而不是真的移除或添加DOM節點。
例如下面的DOM結構轉換:A節點被整個移動到D節點下,DOM Diff操作如下。
深度優先遍歷,記錄差異,每個節點都會有一個唯一的標記。patches是一個對象,用來記錄每個節點的差異。
// diff 函數,對比兩棵樹
function diff (oldTree, newTree) {
var index = 0 // 當前節點的標誌
var patches = {} // 用來記錄每個節點差異的對象
dfsWalk(oldTree, newTree, index, patches)
return patches
}
// 對兩棵樹進行深度優先遍歷
function dfsWalk (oldNode, newNode, index, patches) {
// 對比oldNode和newNode的不同,記錄下來
patches[index] = [...]
diffChildren(oldNode.children, newNode.children, index, patches)
}
// 遍歷子節點
function diffChildren (oldChildren, newChildren, index, patches) {
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, patches) // 深度遍歷子節點
leftNode = child
})
}
差異類型如下。
var REPLACE = 0 // 替換掉原來的節點
var REORDER = 1 // 移動、刪除、新增子節點
var PROPS = 2 // 修改了節點的屬性
var TEXT = 3 // 對於文本節點,文本內容改變
{
0: [{ type: REPALCE, node: newNode }, { type: PROPS, props: { id: "container" }}],
2: [{ type: TEXT, content: "Virtual DOM2" }]
}
(2)列表節點的比較
React在遇到列表時卻又找不到key時提示警告,通常意味着潛在的性能問題。對於列表節點提供唯一的key屬性可以幫助React定位到正確的節點進行比較,從而大幅減少DOM操作次數,提高了性能。
列表節點的操作通常包括添加、刪除和排序。(列表節點不只由v-for生成,指的是同一級的節點)
假設用英文字母唯一地標識每一個子節點,舊的節點順序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}, ...] }]
5、將差異應用到真正的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)
}
})
}
6、總結
Virtual DOM算法主要實現三個函數:element, diff, patch,之後就可以進行使用。
下面是最簡單的實踐,實際中還需要處理事件監聽、加入JSX語法等,完整代碼見simple-virtual-dom。
// 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)