框架在任何一種語言編程范疇中都扮演了舉足輕重的地位,前端尤是如此。目前流行的前端框架三駕馬車:Angular、React 和 Vue,它們各有特點和受眾,都值得開發(fā)者認(rèn)真思考和學(xué)習(xí)。那么我們在精力有限的情況下,如何做到「觸類旁通」、如何提取框架共性、提高學(xué)習(xí)和應(yīng)用效率呢?
我們這一講就來剖析這些框架的特點和本質(zhì),介紹如何學(xué)習(xí)并使用這些框架,進而了解前端框架的真諦。
相關(guān)知識點如下:

我把現(xiàn)代框架的關(guān)鍵詞進行提煉,掌握這些關(guān)鍵詞,是我們學(xué)習(xí)的重要環(huán)節(jié)。這些關(guān)鍵詞有:雙向綁定、依賴收集、發(fā)布訂閱模式、MVVM / MVC、虛擬 DOM、虛擬 DOM diff、模版編譯等。
響應(yīng)式框架基本原理
我們不再贅述響應(yīng)式或數(shù)據(jù)雙向綁定的基本概念,這里直接思考其行為:直觀上,數(shù)據(jù)在變化時,不再需要開發(fā)者去手動更新視圖,而視圖會根據(jù)變化的數(shù)據(jù)「自動」進行更新。想完成這個過程,我們需要:
- 收集視圖依賴了哪些數(shù)據(jù)
- 感知被依賴數(shù)據(jù)的變化
- 數(shù)據(jù)變化時,自動「通知」需要更新的視圖部分,并進行更新
道理很簡單,這個思考過程換成對應(yīng)的技術(shù)概念就是:
- 依賴收集
- 數(shù)據(jù)劫持 / 數(shù)據(jù)代理
- 發(fā)布訂閱模式
接下來,我們一步步拆解。
數(shù)據(jù)劫持與代理
感知數(shù)據(jù)變化的方法很直接,就是進行數(shù)據(jù)劫持或數(shù)據(jù)代理。我們往往通過 Object.defineProperty 實現(xiàn)。這個方法可以定義數(shù)據(jù)的 getter 和 setter,具體用法不再贅述。下面來看一個場景:
let data = {
stage: 'GitChat',
course: {
title: '前端開發(fā)進階',
author: 'Lucas',
publishTime: '2018 年 5 月'
}
}
Object.keys(data).forEach(key => {
let currentValue = data[key]
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get() {
console.log(`getting ${key} value now, getting value is:`, currentValue)
return currentValue
},
set(newValue) {
currentValue = newValue
console.log(`setting ${key} value now, setting value is`, currentValue)
}
})
})
data.course
// getting course value now, getting value is:
// {title: "前端開發(fā)進階", author: "Lucas", publishTime: "2018 年 5 月"}
data.course = '前端開發(fā)進階 2'
// setting course value now, setting value is 前端開發(fā)進階 2
但是這種實現(xiàn)有一個問題,例如:
data.course.title = '前端開發(fā)進階 2'
// getting course value now, getting value is:
// {title: "前端開發(fā)進階", author: "Lucas", publishTime: "2018 年 5 月"}
只會有 getting course value now, getting value is: {title: "前端開發(fā)進階", author: "Lucas", publishTime: "2018 年 5 月"} 的輸出,這是因為我們嘗試讀取了 data.course 信息。但是修改 data.course.title 的信息并沒有打印出來。
出現(xiàn)這個問題的原因是因為我們的實現(xiàn)代碼只進行了一層 Object.defineProperty,或者說只對 data 的第一層屬性進行了 Object.defineProperty,對于嵌套的引用類型數(shù)據(jù)結(jié)構(gòu):data.course,我們同樣應(yīng)該進行攔截。
為了達到深層攔截的目的,將 Object.defineProperty 的邏輯抽象為 observe 函數(shù),并改用遞歸實現(xiàn):
let data = {
stage: 'GitChat',
course: {
title: '前端開發(fā)進階',
author: 'Lucas',
publishTime: '2018 年 5 月'
}
}
const observe = data => {
if (!data || typeof data !== 'object') {
return
}
Object.keys(data).forEach(key => {
let currentValue = data[key]
observe(currentValue)
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get() {
console.log(`getting ${key} value now, getting value is:`, currentValue)
return currentValue
},
set(newValue) {
currentValue = newValue
console.log(`setting ${key} value now, setting value is`, currentValue)
}
})
})
}
observe(data)
這樣一來,就實現(xiàn)了深層數(shù)據(jù)攔截:
data.course.title = '前端開發(fā)進階 2'
// getting course value now, getting value is: {// ...}
// setting title value now, setting value is 前端開發(fā)進階 2
請注意,我們在 set 代理中,并沒有對 newValue 再次遞歸進行
observe(newValue)。也就是說,如果賦值是一個引用類型:
data.course.title = {
title: '前端開發(fā)進階 2'
}
無法實現(xiàn)對 data.course.title 數(shù)據(jù)的觀察。這里為了簡化學(xué)習(xí)成本,默認(rèn)修改的數(shù)值符合語義,都是基本類型。
在嘗試對 data.course.title 賦值時,首先會讀取 data.course,因此輸出:getting course value now, getting value is: {// ...},賦值后,觸發(fā) data.course.title 的 setter,輸出:setting title value now, setting value is 前端開發(fā)進階 2。
因此我們總結(jié)出:對數(shù)據(jù)進行攔截并不復(fù)雜,這也是很多框架實現(xiàn)的第一步。
監(jiān)聽數(shù)組變化
如果上述數(shù)據(jù)中某一項變?yōu)閿?shù)組:
let data = {
stage: 'GitChat',
course: {
title: '前端開發(fā)進階',
author: ['Lucas', 'Ronaldo'],
publishTime: '2018 年 5 月'
}
}
const observe = data => {
if (!data || typeof data !== 'object') {
return
}
Object.keys(data).forEach(key => {
let currentValue = data[key]
observe(currentValue)
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get() {
console.log(`getting ${key} value now, getting value is:`, currentValue)
return currentValue
},
set(newValue) {
currentValue = newValue
console.log(`setting ${key} value now, setting value is`, currentValue)
}
})
})
}
observe(data)
data.course.author.push('Messi')
// getting course value now, getting value is: {//...}
// getting author value now, getting value is: (2) [(...), (...)]
我們只監(jiān)聽到了 data.course 以及 data.course.author 的讀取,而數(shù)組 push 行為并沒有被攔截。這是因為 Array.prototype 上掛載的方法并不能觸發(fā) data.course.author 屬性值的 setter,由于這并不屬于做賦值操作,而是 push API 調(diào)用操作。然而對于框架實現(xiàn)來說,這顯然是不滿足要求的,當(dāng)數(shù)組變化時我們應(yīng)該也有所感知。
Vue 同樣存在這樣的問題,它的解決方法是:將數(shù)組的常用方法進行重寫,進而覆蓋掉原生的數(shù)組方法,重寫之后的數(shù)組方法需要能夠被攔截。
實現(xiàn)邏輯如下:
const arrExtend = Object.create(Array.prototype)
const arrMethods = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
arrMethods.forEach(method => {
const oldMethod = Array.prototype[method]
const newMethod = function(...args) {
oldMethod.apply(this, args)
console.log(`${method} 方法被執(zhí)行了`)
}
arrExtend[method] = newMethod
})
對于數(shù)組原生的 7 個方法:
- push
- pop
- shift
- unshift
- splice
- sort
- reverse
行重寫,核心操作還是調(diào)用原生方法:oldMethod.apply(this, args),除此之外可以在調(diào)用 oldMethod.apply(this, args) 前后加入我們需要的任何邏輯。示例代碼中加入了一行 console.log。使用時:
Array.prototype = Object.assign(Array.prototype, arrExtend)
let array = [1, 2, 3]
array.push(4)
// push 方法被執(zhí)行了
對應(yīng)我們的代碼:
const arrExtend = Object.create(Array.prototype)
const arrMethods = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
arrMethods.forEach(method => {
const oldMethod = Array.prototype[method]
const newMethod = function(...args) {
oldMethod.apply(this, args)
console.log(`${method} 方法被執(zhí)行了`)
}
arrExtend[method] = newMethod
})
Array.prototype = Object.assign(Array.prototype, arrExtend)
let data = {
stage: 'GitChat',
course: {
title: '前端開發(fā)進階',
author: ['Lucas', 'Ronaldo'],
publishTime: '2018 年 5 月'
}
}
const observe = data => {
if (!data || typeof data !== 'object') {
return
}
Object.keys(data).forEach(key => {
let currentValue = data[key]
observe(currentValue)
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get() {
console.log(`getting ${key} value now, getting value is:`, currentValue)
return currentValue
},
set(newValue) {
currentValue = newValue
console.log(`setting ${key} value now, setting value is`, currentValue)
}
})
})
}
observe(data)
data.course.author.push('Messi')
// getting course value now, getting value is: {//...}
// getting author value now, getting value is: (2) [(...), (...)]
// push 方法被執(zhí)行了
將會輸出:
這種 monkey patch 本質(zhì)是重寫原生方法,這天生不是很安全,也很不優(yōu)雅,能有更好的實現(xiàn)嗎?
答案是有的,使用 ES Next 的新特性——Proxy,之前也介紹過,它可以完成對數(shù)據(jù)的代理。
那么這兩種方式有何區(qū)別呢?請繼續(xù)閱讀。
Object.defineProperty VS Proxy
我們首先嘗試使用 Proxy 來完成代碼重構(gòu):
let data = {
stage: 'GitChat',
course: {
title: '前端開發(fā)進階',
author: ['Lucas'],
publishTime: '2018 年 5 月',
},
};
const observe = (data) => {
if (
!data ||
Object.prototype.toString.call(data) !== '[object Object]'
) {
return;
}
Object.keys(data).forEach((key) => {
let currentValue = data[key];
// 事實上 proxy 也可以對函數(shù)類型進行代理。
// 這里只對承載數(shù)據(jù)類型的 object 進行處理,讀者了解即可。
if (typeof currentValue === 'object') {
observe(currentValue);
data[key] = new Proxy(currentValue, {
set(target, property, value, receiver) {
// 因為數(shù)組的 push 會引起 length 屬性的變化,
// 所以 push 之后會觸發(fā)兩次 set 操作,
// 我們只需要保留一次即可,property 為 length 時,忽略
if (property !== 'length') {
console.log(
`setting ${key} value now, setting value is`,
currentValue
);
}
return Reflect.set(target, property, value, receiver);
},
});
} else {
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get() {
console.log(
`getting ${key} value now, getting value is:`,
currentValue
);
return currentValue;
},
set(newValue) {
currentValue = newValue;
console.log(
`setting ${key} value now, setting value is`,
currentValue
);
},
});
}
});
};
observe(data);
此時對數(shù)組進行操作:
data.course.author.push('messi')
// setting author value now, setting value is ["Lucas"]
已經(jīng)符合我們的需求了。注意這里在使用 Proxy 進行代理時,并沒有對 getter 進行代理,因此上述代碼的輸出結(jié)果并不像之前使用 Object.defineProperty 那樣也會有 getting value 輸出。
整體實現(xiàn)并不難理解,需要讀者了解最基本的 Proxy 知識。簡單總結(jié)一下,對于數(shù)據(jù)鍵值為基本類型的情況,我們使用 Object.defineProperty;對于鍵值為對象類型的情況,繼續(xù)遞歸調(diào)用 observe 方法,并通過 Proxy 返回的新對象對 data[key] 重新賦值,這個新值的 getter 和 setter 已經(jīng)被添加了代理。
了解了 Proxy 實現(xiàn)之后,我們對 Proxy 實現(xiàn)數(shù)據(jù)代理和 Object.defineProperty 實現(xiàn)數(shù)據(jù)攔截進行對比,會發(fā)現(xiàn):
- Object.defineProperty 不能監(jiān)聽數(shù)組的變化,需要進行數(shù)組方法的重寫
- Object.defineProperty 必須遍歷對象的每個屬性,且對于嵌套結(jié)構(gòu)需要深層遍歷
- Proxy 的代理是針對整個對象的,而不是對象的某個屬性,因此不同于Object.defineProperty 的必須遍歷對象每個屬性,Proxy 只需要做一層代理就可以監(jiān)聽同級結(jié)構(gòu)下的所有屬性變化,當(dāng)然對于深層結(jié)構(gòu),遞歸還是需要進行的
- Proxy 支持代理數(shù)組的變化
- Proxy 的第二個參數(shù)除了 set 和 get 以外,可以有 13 種攔截方法,比起 Object.defineProperty() 更加強大,這里不再一一列舉
- Proxy 性能將會被底層持續(xù)優(yōu)化,而 Object.defineProperty 已經(jīng)不再是優(yōu)化重點
模版編譯原理介紹
到此,我們了解了如何監(jiān)聽數(shù)據(jù)的變化,那么下一步呢?以類 Vue 框架為例,我們看看一個典型的用法:
{{stage}} 平臺課程:{{course.title}}
{{course.title}} 是 {{course.author}} 發(fā)布的課程
發(fā)布時間為 {{course.publishTime}}
let vue = new Vue({
ele: '#app',
data: {
stage: 'GitChat',
course: {
title: '前端開發(fā)進階',
author: 'Lucas',
publishTime: '2018 年 5 月',
},
},
});
其中模版變量使用了 {{}} 的表達方式輸出模版變量。最終輸出的 HTML 內(nèi)容應(yīng)該被合適的數(shù)據(jù)進行填充替換,因此還需要一步編譯過程,該過程任何框架或類庫中都是相通的,比如 React 中的 JSX,也是編譯為 React.createElement,并在生成虛擬 DOM 時進行數(shù)據(jù)填充。
我們這里簡化過程,將模版內(nèi)容:
{{stage}} 平臺課程:{{course.title}}
{{course.title}} 是 {{course.author}} 發(fā)布的課程
發(fā)布時間為 {{course.publishTime}}
輸出為真實 HTML 即可。
模版編譯實現(xiàn)
一提到這樣的「模版編譯」過程,很多開發(fā)者都會想到詞法分析,也許都會感到頭大。其實原理很簡單,就是使用正則 + 遍歷,有時也需要一些算法知識,我們來看現(xiàn)在的場景,只需要對 #app 節(jié)點下內(nèi)容進行替換,通過正則識別出模版變量,獲取對應(yīng)的數(shù)據(jù)即可:
compile(document.querySelector('#app'), data);
function compile(el, data) {
let fragment = document.createDocumentFragment();
while ((child = el.firstChild)) {
fragment.appendChild(child);
}
// 對 el 里面的內(nèi)容進行替換
function replace(fragment) {
Array.from(fragment.childNodes).forEach((node) => {
let textContent = node.textContent;
let reg = /\{\{(.*?)\}\}/g;
if (node.nodeType === 3 && reg.test(textContent)) {
const nodeTextContent = node.textContent;
const replaceText = () => {
node.textContent = nodeTextContent.replace(
reg,
(matched, placeholder) => {
return placeholder.split('.').reduce((prev, key) => {
return prev[key];
}, data);
}
);
};
replaceText();
}
// 如果還有子節(jié)點,繼續(xù)遞歸 replace
if (node.childNodes && node.childNodes.length) {
replace(node);
}
});
}
replace(fragment);
el.appendChild(fragment);
return el;
}
代碼分析:我們使用 fragment 變量儲存生成的真實 HTML 節(jié)點內(nèi)容。通過 replace 方法對 {{變量}} 進行數(shù)據(jù)替換,同時 {{變量}} 的表達只會出現(xiàn)在 nodeType === 3 的文本類型節(jié)點中,因此對于符合 node.nodeType === 3 && reg.test(textContent) 條件的情況,進行數(shù)據(jù)獲取和填充。我們借助字符串 replace 方法第二個參數(shù)進行一次性替換,此時對于形如 {{data.course.title}} 的深層數(shù)據(jù),通過 reduce 方法,獲得正確的值。
因為 DOM 結(jié)構(gòu)可能是多層的,所以對存在子節(jié)點的節(jié)點,依然使用遞歸進行 replace 替換。
這個編譯過程比較簡單,沒有考慮到邊界情況,只是單純完成模版變量到真實 DOM 的轉(zhuǎn)換,讀者只需體會簡單道理即可。
雙向綁定實現(xiàn)
上述實現(xiàn)是單向的,數(shù)據(jù)變化引起了視圖變化,那么如果頁面中存在一個輸入框,如何觸發(fā)數(shù)據(jù)變化呢?比如:
<input v-model="inputData"/>
我們需要在模版編譯中,對于存在 v-model 屬性的 node 進行事件監(jiān)聽,在輸入框輸入時,改變 v-model 屬性值對應(yīng)的數(shù)據(jù)即可(這里為 inputData),增加 compile 中的 replace 方法邏輯,對于 node.nodeType === 1 的 DOM 類型,偽代碼如下:
function replace(el, data) {
// 省略...
if (node.nodeType === 1) {
let attributesArray = node.attributes
Array.from(attributesArray).forEach(attr => {
let attributeName = attr.name
let attributeValue = attr.value
if (name.includes('v-')) {
node.value = data[attributeValue]
}
node.addEventListener('input', e => {
let newVal = e.target.value
data[attributeValue] = newVal
// ...
// 更改數(shù)據(jù)源,觸發(fā) setter
// ...
})
})
}
if (node.childNodes && node.childNodes.length) {
replace(node)
}
}
發(fā)布訂閱模式簡單應(yīng)用
作為前端開發(fā)人員,我們對于所謂的「事件驅(qū)動」理念——即「事件發(fā)布訂閱模式(Pub/Sub 模式)」一定再熟悉不過了。這種模式在 JavaScript 里面有與生俱來的基因:我們可以認(rèn)為 JavaScript 本身就是事件驅(qū)動型語言,比如,應(yīng)用中對一個 button 進行了事件綁定,用戶點擊之后就會觸發(fā)按鈕上面的 click 事件。這是因為此時有特定程序正在監(jiān)聽這個事件,隨之觸發(fā)了相關(guān)的處理程序。
這個模式的一個好處之一在于能夠解耦,實現(xiàn)「高內(nèi)聚、低耦合」的理念。這種模式對于我們框架的設(shè)計同樣也不可或缺。請思考:通過前面內(nèi)容的學(xué)習(xí),我們了解了如何監(jiān)聽數(shù)據(jù)的變化。如果最終想實現(xiàn)響應(yīng)式 MVVM,或所謂的雙向綁定,那么還需要根據(jù)這個數(shù)據(jù)變化作出相應(yīng)的視圖更新。這個邏輯和我們在頁面中對 button 綁定事件處理函數(shù)是多么相近。
那么這樣一個「熟悉的」模式應(yīng)該怎么實現(xiàn)呢,又該如何在框架中具體應(yīng)用呢?看代碼:
class Notify {
constructor() {
this.subscribers = [];
}
add(handler) {
this.subscribers.push(handler);
}
emit() {
this.subscribers.forEach((subscriber) => subscriber());
}
}
使用:
let notify = new Notify();
notify.add(() => {
console.log('emit here');
});
notify.emit();
// emit here
這就是一個簡單實現(xiàn)的「事件發(fā)布訂閱模式」,當(dāng)然代碼只是啟發(fā)思路,真實應(yīng)用還比較「粗糙」,沒有進行事件名設(shè)置,APIs 也并不豐富,但完全能夠說明問題了。其實讀者翻看 Vue 源碼,也能了解 Vue 中的發(fā)布訂閱模式很簡單。
MVVM 融會貫通
回顧一下前面的基本內(nèi)容:數(shù)據(jù)攔截和代理、發(fā)布訂閱模式、模版編譯,那么如何根據(jù)這些概念實現(xiàn)一個 MVVM 框架呢?其實不管是 Vue 還是其他類庫或框架,其解決思想都是建立在前文所述概念之上的。
我們來進行串聯(lián),整個過程是:首先對數(shù)據(jù)進行深度攔截或代理,對每一個屬性的 getter 和 setter 進行「加工」,該「加工」具體做些什么后面馬上會有說明。在模版初次編譯時,解析指令(如 v-model),并進行依賴收集({{變量}}),訂閱數(shù)據(jù)的變化。
這里的依賴收集過程具體指:當(dāng)調(diào)用 compiler 中的 replace 方法時,我們會讀取數(shù)據(jù)進行模版變量的替換,這時候「讀取數(shù)據(jù)時」需要做一個標(biāo)記,用來表示「我依賴這一項數(shù)據(jù)」,因此我要訂閱這個屬性值的變化。Vue 中定義一個 Watcher 類來表示觀察訂閱依賴。這就實現(xiàn)了整套流程,換個思路再復(fù)述一遍:我們知道模版編譯過程中會讀取數(shù)據(jù),進而觸發(fā)數(shù)據(jù)源屬性值的 getter,因此上面所說的數(shù)據(jù)代理的「加工」就是在數(shù)據(jù)監(jiān)聽的 getter 中記錄這個依賴,同時在 setter 觸發(fā)數(shù)據(jù)變化時,執(zhí)行依賴對應(yīng)的相關(guān)操作,最終觸發(fā)模版中數(shù)據(jù)的變化。
我們抽象成流程圖來理解:

這也是 Vue 框架(類庫)的基本架構(gòu)圖。由此看出,Vue 的實現(xiàn),或者大部分 MVVM 的實現(xiàn),就是我們本節(jié)課程介紹的概念組合應(yīng)用。
關(guān)于框架的對比剖析,更多話題我們留在《第 4-7 課:從框架和類庫,我們該學(xué)到什么》一課中介紹。
揭秘虛擬 DOM
我們來看現(xiàn)代框架中另一個重頭戲——虛擬 DOM。虛擬 DOM 這個概念其實并沒有那么新,甚至在前端三大框架問世之前,虛擬 DOM 就已經(jīng)存在了,只不過 React 創(chuàng)造性的應(yīng)用了虛擬 DOM,為前端發(fā)展帶來了變革。Vue 2.0 也很快跟進,使得虛擬 DOM 徹底成為現(xiàn)代框架的重要基因。簡單來說,虛擬 DOM 就是用數(shù)據(jù)結(jié)構(gòu)表示 DOM 結(jié)構(gòu),它并沒有真實 append 到 DOM 上,因此稱之為「虛擬」。
應(yīng)用虛擬 DOM 的收益也很直觀:操作數(shù)據(jù)結(jié)構(gòu)遠比和瀏覽器交互去操作 DOM 快很多。請讀者準(zhǔn)確理解這句話:操作數(shù)據(jù)結(jié)構(gòu)是指改變對象(虛擬 DOM),這個過程比修改真實 DOM 快很多。但虛擬 DOM 也最終是要掛載到瀏覽器上成為真實 DOM 節(jié)點,因此使用虛擬 DOM 并不能使得操作 DOM 的數(shù)量減少,但能夠精確地獲取最小的、最必要的操作 DOM 的集合。
這樣一來,我們抽象表示 DOM,每次通過 DOM diff 計算出視圖前后更新的最小差異,再去把最小差異應(yīng)用到真實 DOM 上的做法,無疑更為可靠,性能更有保障。
那我們該如何表示虛擬 DOM 呢?又該如何產(chǎn)出虛擬 DOM 呢?
直觀上我們看這樣一段 DOM 結(jié)構(gòu):
<ul id="chapterList">
<li class="chapter">chapter1</li>
<li class="chapter">chapter2</li>
<li class="chapter">chapter3</li>
</ul>
如果用 JavaScript 來表示,我們采用對象結(jié)構(gòu):
const chapterListVirtualDom = {
tagName: 'ul',
attributes: {
id: 'chapterList'
},
children: [
{ tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter1'] },
{ tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter2'] },
{ tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter3'] },
]
}
很好理解:tagName 表示虛擬 DOM 對應(yīng)的真實 DOM 標(biāo)簽類型;attributes 是一個對象,表示真實 DOM 節(jié)點上所有的屬性;children 對應(yīng)真實 DOM 的 childNodes,其中 childNodes 每一項又是類似的結(jié)構(gòu)。
我們來實現(xiàn)一個虛擬 DOM 生成類,用于生產(chǎn)虛擬 DOM:
class Element {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName
this.attributes = attributes
this.children = children
}
}
function element(tagName, attributes, children) {
return new Element(tagName, attributes, children)
}
上述虛擬 DOM 就可以這樣生成:
const chapterListVirtualDom = element('ul', { id: 'list' }, [
element('li', { class: 'chapter' }, ['chapter1']),
element('li', { class: 'chapter' }, ['chapter2']),
element('li', { class: 'chapter' }, ['chapter3'])
])
如圖:

是不是很簡單?我們繼續(xù)完成虛擬 DOM 向真實 DOM 節(jié)點的生成。首先實現(xiàn)一個 setAttribute 方法,后續(xù)的代碼都將使用 setAttribute 方法來對 DOM 節(jié)點進行屬性設(shè)置。
const setAttribute = (node, key, value) => {
switch (key) {
case 'style':
node.style.cssText = value
break
case 'value':
let tagName = node.tagName || ''
tagName = tagName.toLowerCase()
if (
tagName === 'input' || tagName === 'textarea'
) {
node.value = value
} else {
// 如果節(jié)點不是 input 或者 textarea, 則使用 setAttribute 去設(shè)置屬性
node.setAttribute(key, value)
}
break
default:
node.setAttribute(key, value)
break
}
}
Element 類中加入 render 原型方法,該方法的目的是根據(jù)虛擬 DOM 生成真實 DOM 片段:
class Element {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName
this.attributes = attributes
this.children = children
}
render () {
let element = document.createElement(this.tagName)
let attributes = this.attributes
for (let key in attributes) {
setAttribute(element, key, attributes[key])
}
let children = this.children
children.forEach(child => {
let childElement = child instanceof Element
? child.render() // 若 child 也是虛擬節(jié)點,遞歸進行
: document.createTextNode(child) // 若是字符串,直接創(chuàng)建文本節(jié)點
element.appendChild(childElement)
})
return element
}
}
function element (tagName, attributes, children) {
return new Element(tagName, attributes, children)
}
實現(xiàn)也不困難,我們借助工具方法:setAttribute 進行屬性的創(chuàng)建;對 children 每一項類型進行判斷,如果是 Element 實例,進行遞歸調(diào)用 child 的 render 方法;直到遇見文本節(jié)點類型,進行內(nèi)容渲染。
有了真實的 DOM 節(jié)點片段,我們趁熱打鐵,將真實的 DOM 節(jié)點渲染到瀏覽器上,實現(xiàn) renderDOM 方法:
const renderDom = (element, target) => {
target.appendChild(element)
}
執(zhí)行代碼:
const setAttribute = (node, key, value) => {
switch (key) {
case 'style':
node.style.cssText = value
break
case 'value':
let tagName = node.tagName || ''
tagName = tagName.toLowerCase()
if (
tagName === 'input' || tagName === 'textarea'
) {
node.value = value
} else {
// 如果節(jié)點不是 input 或者 textarea,則使用 setAttribute 去設(shè)置屬性
node.setAttribute(key, value)
}
break
default:
node.setAttribute(key, value)
break
}
}
class Element {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName
this.attributes = attributes
this.children = children
}
render () {
let element = document.createElement(this.tagName)
let attributes = this.attributes
for (let key in attributes) {
setAttribute(element, key, attributes[key])
}
let children = this.children
children.forEach(child => {
let childElement = child instanceof Element
? child.render() // 若 child 也是虛擬節(jié)點,遞歸進行
: document.createTextNode(child) // 若是字符串,直接創(chuàng)建文本節(jié)點
element.appendChild(childElement)
})
return element
}
}
function element (tagName, attributes, children) {
return new Element(tagName, attributes, children)
}
const renderDom = (element, target) => {
target.appendChild(element)
}
const chapterListVirtualDom = element('ul', { id: 'list' }, [
element('li', { class: 'chapter' }, ['chapter1']),
element('li', { class: 'chapter' }, ['chapter2']),
element('li', { class: 'chapter' }, ['chapter3'])
])
const dom = chapterListVirtualDom.render()
renderDom(dom, document.body)
得到如圖:

虛擬 DOM diff
有了上述基礎(chǔ),我們可以產(chǎn)出一份虛擬 DOM,并渲染在瀏覽器中。當(dāng)用戶在特定操作后,會產(chǎn)出新的一份虛擬 DOM,如何得出前后兩份虛擬 DOM 的差異,并交給瀏覽器需要更新的結(jié)果呢?這就涉及到 DOM diff 的過程。
直觀上,因為虛擬 DOM 是個樹形結(jié)構(gòu),所以我們需要對兩份虛擬 DOM 進行遞歸比較,將變化存儲在一個變量 patches 中:
const diff = (oldVirtualDom, newVirtualDom) => {
let patches = {}
// 遞歸樹,比較后的結(jié)果放到 patches
walkToDiff(oldVirtualDom, newVirtualDom, 0, patches)
// 返回 diff 結(jié)果
return patches
}
walkToDiff 前兩個參數(shù)是兩個需要比較的虛擬 DOM 對象;第三個參數(shù)記錄 nodeIndex,在刪除節(jié)點時使用,初始為 0;第四個參數(shù)是一個閉包變量,記錄 diff 結(jié)果:
let initialIndex = 0
const walkToDiff = (oldVirtualDom, newVirtualDom, index, patches) => {
let diffResult = []
// 如果 newVirtualDom 不存在,說明該節(jié)點被移除,
// 我們將 type 為 REMOVE 的對象推進 diffResult 變量,并記錄 index
if (!newVirtualDom) {
diffResult.push({
type: 'REMOVE',
index
})
}
// 如果新舊節(jié)點都是文本節(jié)點,是字符串
else if (typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') {
// 比較文本是否相同,如果不同則記錄新的結(jié)果
if (oldVirtualDom !== newVirtualDom) {
diffResult.push({
type: 'MODIFY_TEXT',
data: newVirtualDom,
index
})
}
}
// 如果新舊節(jié)點類型相同
else if (oldVirtualDom.tagName === newVirtualDom.tagName) {
// 比較屬性是否相同
let diffAttributeResult = {}
for (let key in oldVirtualDom) {
if (oldVirtualDom[key] !== newVirtualDom[key]) {
diffAttributeResult[key] = newVirtualDom[key]
}
}
for (let key in newVirtualDom) {
// 舊節(jié)點不存在的新屬性
if (!oldVirtualDom.hasOwnProperty(key)) {
diffAttributeResult[key] = newVirtualDom[key]
}
}
if (Object.keys(diffAttributeResult).length > 0) {
diffResult.push({
type: 'MODIFY_ATTRIBUTES',
diffAttributeResult
})
}
// 如果有子節(jié)點,遍歷子節(jié)點
oldVirtualDom.children.forEach((child, index) => {
walkToDiff(child, newVirtualDom.children[index], ++initialIndex, patches)
})
}
// else 說明節(jié)點類型不同,被直接替換了,我們直接將新的結(jié)果 push
else {
diffResult.push({
type: 'REPLACE',
newVirtualDom
})
}
if (!oldVirtualDom) {
diffResult.push({
type: 'REPLACE',
newVirtualDom
})
}
if (diffResult.length) {
patches[index] = diffResult
}
}
我們最后將所有代碼放在一起:
const setAttribute = (node, key, value) => {
switch (key) {
case 'style':
node.style.cssText = value
break
case 'value':
let tagName = node.tagName || ''
tagName = tagName.toLowerCase()
if (
tagName === 'input' || tagName === 'textarea'
) {
node.value = value
} else {
// 如果節(jié)點不是 input 或者 textarea,則使用 setAttribute 去設(shè)置屬性
node.setAttribute(key, value)
}
break
default:
node.setAttribute(key, value)
break
}
}
class Element {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName
this.attributes = attributes
this.children = children
}
render () {
let element = document.createElement(this.tagName)
let attributes = this.attributes
for (let key in attributes) {
setAttribute(element, key, attributes[key])
}
let children = this.children
children.forEach(child => {
let childElement = child instanceof Element
? child.render() // 若 child 也是虛擬節(jié)點,遞歸進行
: document.createTextNode(child) // 若是字符串,直接創(chuàng)建文本節(jié)點
element.appendChild(childElement)
})
return element
}
}
function element (tagName, attributes, children) {
return new Element(tagName, attributes, children)
}
const renderDom = (element, target) => {
target.appendChild(element)
}
const diff = (oldVirtualDom, newVirtualDom) => {
let patches = {}
// 遞歸樹 比較后的結(jié)果放到 patches
walkToDiff(oldVirtualDom, newVirtualDom, 0, patches)
return patches
}
let initialIndex = 0
const walkToDiff = (oldVirtualDom, newVirtualDom, index, patches) => {
let diffResult = []
// 如果 newVirtualDom 不存在,說明該節(jié)點被移除,
// 我們將 type 為 REMOVE 的對象推進 diffResult 變量,并記錄 index
if (!newVirtualDom) {
diffResult.push({
type: 'REMOVE',
index
})
}
// 如果新舊節(jié)點都是文本節(jié)點,是字符串
else if (typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') {
// 比較文本是否相同,如果不同則記錄新的結(jié)果
if (oldVirtualDom !== newVirtualDom) {
diffResult.push({
type: 'MODIFY_TEXT',
data: newVirtualDom,
index
})
}
}
// 如果新舊節(jié)點類型相同
else if (oldVirtualDom.tagName === newVirtualDom.tagName) {
// 比較屬性是否相同
let diffAttributeResult = {}
for (let key in oldVirtualDom) {
if (oldVirtualDom[key] !== newVirtualDom[key]) {
diffAttributeResult[key] = newVirtualDom[key]
}
}
for (let key in newVirtualDom) {
// 舊節(jié)點不存在的新屬性
if (!oldVirtualDom.hasOwnProperty(key)) {
diffAttributeResult[key] = newVirtualDom[key]
}
}
if (Object.keys(diffAttributeResult).length > 0) {
diffResult.push({
type: 'MODIFY_ATTRIBUTES',
diffAttributeResult
})
}
// 如果有子節(jié)點,遍歷子節(jié)點
oldVirtualDom.children.forEach((child, index) => {
walkToDiff(child, newVirtualDom.children[index], ++initialIndex, patches)
})
}
// else 說明節(jié)點類型不同,被直接替換了,我們直接將新的結(jié)果 push
else {
diffResult.push({
type: 'REPLACE',
newVirtualDom
})
}
if (!oldVirtualDom) {
diffResult.push({
type: 'REPLACE',
newVirtualDom
})
}
if (diffResult.length) {
patches[index] = diffResult
}
}
我們對 diff 進行測試:
const chapterListVirtualDom = element('ul', { id: 'list' }, [
element('li', { class: 'chapter' }, ['chapter1']),
element('li', { class: 'chapter' }, ['chapter2']),
element('li', { class: 'chapter' }, ['chapter3'])
])
const chapterListVirtualDom1 = element('ul', { id: 'list2' }, [
element('li', { class: 'chapter2' }, ['chapter4']),
element('li', { class: 'chapter2' }, ['chapter5']),
element('li', { class: 'chapter2' }, ['chapter6'])
])
diff(chapterListVirtualDom, chapterListVirtualDom1)
得到如圖 diff 數(shù)組:

最小化差異應(yīng)用
大功告成之前,我們來看看都做了哪些事情:通過 Element class 生成了虛擬 DOM,通過 diff 方法對任意兩個虛擬 DOM 進行比對,得到差異。那么這個差異如何更新到現(xiàn)有的 DOM 節(jié)點中呢?看上去需要一個 patch 方法來完成:
const patch = (node, patches) => {
let walker = { index: 0 }
walk(node, walker, patches)
}
patch 方法接受一個真實的 DOM 節(jié)點,它是現(xiàn)有的瀏覽器中需要進行更新的 DOM 節(jié)點,同時接受一個最小化差異集合,該集合對接 diff 方法返回的結(jié)果。在 patch 方法內(nèi)部,我們調(diào)用了 walk 函數(shù):
const walk = (node, walker, patches) => {
let currentPatch = patches[walker.index]
let childNodes = node.childNodes
childNodes.forEach(child => {
walker.index++
walk(child, walker, patches)
})
if (currentPatch) {
doPatch(node, currentPatch)
}
}
walk 進行自身遞歸,對于當(dāng)前節(jié)點的差異調(diào)用 doPatch 方法進行更新:
const doPatch = (node, patches) => {
patches.forEach(patch => {
switch (patch.type) {
case 'MODIFY_ATTRIBUTES':
const attributes = patch.diffAttributeResult.attributes
for (let key in attributes) {
if (node.nodeType !== 1) return
const value = attributes[key]
if (value) {
setAttribute(node, key, value)
} else {
node.removeAttribute(key)
}
}
break
case 'MODIFY_TEXT':
node.textContent = patch.data
break
case 'REPLACE':
let newNode = (patch.newNode instanceof Element)
? render(patch.newNode)
: document.createTextNode(patch.newNode)
node.parentNode.replaceChild(newNode, node)
break
case 'REMOVE':
node.parentNode.removeChild(node)
break
default:
break
}
})
}
doPatch 對四種類型的 diff 進行處理,最終進行測試:
var element = chapterListVirtualDom.render()
renderDom(element, document.body)
const patches = diff(chapterListVirtualDom, chapterListVirtualDom1)
patch(element, patches)
全部代碼放在一起:
const setAttribute = (node, key, value) => {
switch (key) {
case 'style':
node.style.cssText = value
break
case 'value':
let tagName = node.tagName || ''
tagName = tagName.toLowerCase()
if (
tagName === 'input' || tagName === 'textarea'
) {
node.value = value
} else {
// 如果節(jié)點不是 input 或者 textarea, 則使用 setAttribute 去設(shè)置屬性
node.setAttribute(key, value)
}
break
default:
node.setAttribute(key, value)
break
}
}
class Element {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName
this.attributes = attributes
this.children = children
}
render () {
let element = document.createElement(this.tagName)
let attributes = this.attributes
for (let key in attributes) {
setAttribute(element, key, attributes[key])
}
let children = this.children
children.forEach(child => {
let childElement = child instanceof Element
? child.render() // 若 child 也是虛擬節(jié)點,遞歸進行
: document.createTextNode(child) // 若是字符串,直接創(chuàng)建文本節(jié)點
element.appendChild(childElement)
})
return element
}
}
function element (tagName, attributes, children) {
return new Element(tagName, attributes, children)
}
const renderDom = (element, target) => {
target.appendChild(element)
}
const diff = (oldVirtualDom, newVirtualDom) => {
let patches = {}
// 遞歸樹 比較后的結(jié)果放到 patches
walkToDiff(oldVirtualDom, newVirtualDom, 0, patches)
return patches
}
let initialIndex = 0
const walkToDiff = (oldVirtualDom, newVirtualDom, index, patches) => {
let diffResult = []
// 如果 newVirtualDom 不存在,說明該節(jié)點被移除,
// 我們將 type 為 REMOVE 的對象推進 diffResult 變量,并記錄 index
if (!newVirtualDom) {
diffResult.push({
type: 'REMOVE',
index
})
}
// 如果新舊節(jié)點都是文本節(jié)點,是字符串
else if (typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') {
// 比較文本是否相同,如果不同則記錄新的結(jié)果
if (oldVirtualDom !== newVirtualDom) {
diffResult.push({
type: 'MODIFY_TEXT',
data: newVirtualDom,
index
})
}
}
// 如果新舊節(jié)點類型相同
else if (oldVirtualDom.tagName === newVirtualDom.tagName) {
// 比較屬性是否相同
let diffAttributeResult = {}
for (let key in oldVirtualDom) {
if (oldVirtualDom[key] !== newVirtualDom[key]) {
diffAttributeResult[key] = newVirtualDom[key]
}
}
for (let key in newVirtualDom) {
// 舊節(jié)點不存在的新屬性
if (!oldVirtualDom.hasOwnProperty(key)) {
diffAttributeResult[key] = newVirtualDom[key]
}
}
if (Object.keys(diffAttributeResult).length > 0) {
diffResult.push({
type: 'MODIFY_ATTRIBUTES',
diffAttributeResult
})
}
// 如果有子節(jié)點,遍歷子節(jié)點
oldVirtualDom.children.forEach((child, index) => {
walkToDiff(child, newVirtualDom.children[index], ++initialIndex, patches)
})
}
// else 說明節(jié)點類型不同,被直接替換了,我們直接將新的結(jié)果 push
else {
diffResult.push({
type: 'REPLACE',
newVirtualDom
})
}
if (!oldVirtualDom) {
diffResult.push({
type: 'REPLACE',
newVirtualDom
})
}
if (diffResult.length) {
patches[index] = diffResult
}
}
const chapterListVirtualDom = element('ul', { id: 'list' }, [
element('li', { class: 'chapter' }, ['chapter1']),
element('li', { class: 'chapter' }, ['chapter2']),
element('li', { class: 'chapter' }, ['chapter3'])
])
const chapterListVirtualDom1 = element('ul', { id: 'list2' }, [
element('li', { class: 'chapter2' }, ['chapter4']),
element('li', { class: 'chapter2' }, ['chapter5']),
element('li', { class: 'chapter2' }, ['chapter6'])
])
const patch = (node, patches) => {
let walker = { index: 0 }
walk(node, walker, patches)
}
const walk = (node, walker, patches) => {
let currentPatch = patches[walker.index]
let childNodes = node.childNodes
childNodes.forEach(child => {
walker.index++
walk(child, walker, patches)
})
if (currentPatch) {
doPatch(node, currentPatch)
}
}
const doPatch = (node, patches) => {
patches.forEach(patch => {
switch (patch.type) {
case 'MODIFY_ATTRIBUTES':
const attributes = patch.diffAttributeResult.attributes
for (let key in attributes) {
if (node.nodeType !== 1) return
const value = attributes[key]
if (value) {
setAttribute(node, key, value)
} else {
node.removeAttribute(key)
}
}
break
case 'MODIFY_TEXT':
node.textContent = patch.data
break
case 'REPLACE':
let newNode = (patch.newNode instanceof Element)
? render(patch.newNode)
: document.createTextNode(patch.newNode)
node.parentNode.replaceChild(newNode, node)
break
case 'REMOVE':
node.parentNode.removeChild(node)
break
default:
break
}
})
}
先執(zhí)行:
var element = chapterListVirtualDom.render()
renderDom(element, document.body)
再執(zhí)行:
const patches = diff(chapterListVirtualDom, chapterListVirtualDom1)
patch(element, patches)
生成結(jié)果符合預(yù)期。
短短不到兩百行代碼,就實現(xiàn)了虛擬 DOM 思想的全部流程。當(dāng)然其中還有一些優(yōu)化手段,一些邊界情況并沒有進行特別處理,但是我們?nèi)シ匆恍┲奶摂M DOM 庫:snabbdom、etch 等,其實現(xiàn)思想和上述教例完全一致。
總結(jié)
現(xiàn)代框架無疑極大程度上解放了前端生產(chǎn)力,其設(shè)計思想相互借鑒,存在非常多的共性。本講我們通過分析前端框架中的共性,梳理概念原理,希望達到「任何一種框架變得不再神秘」的目的。掌握了這些基本思想,我們不僅能觸類旁通,更快地上手框架,更能學(xué)習(xí)進階,吸取優(yōu)秀框架的精華。