不誇張,這真的是本Vue.js寶藏書!360前端工程師Vue.js源碼解析
優秀源代碼背後的思想是永恆的、普適的。
這些年來,前端行業一直在飛速發展。行業的進步,導致對從業人員的要求不斷攀升。放眼未來,雖然僅僅會用某些框架還可以找到工作,但僅僅滿足於會用,一定無法走得更遠。隨著越來越多「聰明又勤奮」的人加入前端行列,能否洞悉前沿框架的設計和實現已經成為高級人才與普通人才的「分水嶺」。
本文將通過探究Vue.js渲染中變化偵測的實現原理,來解讀Github上最流行Web框架Vue.js源碼背後的思想,讓你親身體驗從「知其然」到「知其所以然」的蛻變!
變化偵測的實現原理
Vue.js 最獨特的特性之一是看起來並不顯眼的響應式系統。數據模型僅僅是普通的JavaScript 對象。而當你修改它們時,視圖會進行更新。這使得狀態管理非常簡單、直接。不過理解其工作原理同樣重要,這樣你可以迴避一些常見的問題。
——官方文檔
從狀態生成DOM,再輸出到用戶界面顯示的一整套流程叫作渲染,應用在運行時會不斷地進行重新渲染。而響應式系統賦予框架重新渲染的能力,其重要組成部分是變化偵測。變化偵測是響應式系統的核心,沒有它,就沒有重新渲染。框架在運行時,視圖也就無法隨著狀態的變化而變化。
簡單來說,變化偵測的作用是偵測數據的變化。當數據變化時,會通知視圖進行相應的更新。正如文檔中所說,深入理解變化偵測的工作原理,既可以幫助我們在開發應用時迴避一些很常見的問題,也可以在應用程序出問題時,快速調試並修復問題。
本文中,我們將針對變化偵測的實現原理做一個詳細介紹,並且會帶著你一步一步從0 到1實現一個變化偵測的邏輯。
什麼是變化偵測
Vue.js 會自動通過狀態生成DOM,並將其輸出到頁面上顯示出來,這個過程叫渲染。Vue.js的渲染過程是聲明式的,我們通過模板來描述狀態與DOM之間的映射關係。
通常,在運行時應用內部的狀態會不斷發生變化,此時需要不停地重新渲染。這時如何確定狀態中發生了什麼變化?
變化偵測就是用來解決這個問題的,它分為兩種類型:一種是「推」(push),另一種是「拉」(pull)。
Angular 和React 中的變化偵測都屬於「拉」,這就是說當狀態發生變化時,它不知道哪個狀態變了,只知道狀態有可能變了,然後會發送一個信號告訴框架,框架內部收到信號後,會進行一個暴力比對來找出哪些DOM 節點需要重新渲染。這在Angular 中是臟檢查的流程,在React中使用的是虛擬DOM。
而Vue.js 的變化偵測屬於「推」。當狀態發生變化時,Vue.js 立刻就知道了,而且在一定程度上知道哪些狀態變了。因此,它知道的信息更多,也就可以進行更細粒度的更新。
所謂更細粒度的更新,就是說:假如有一個狀態綁定著好多個依賴,每個依賴表示一個具體的DOM節點,那麼當這個狀態發生變化時,向這個狀態的所有依賴發送通知,讓它們進行DOM更新操作。相比較而言,「拉」的粒度是最粗的。
但是它也有一定的代價,因為粒度越細,每個狀態所綁定的依賴就越多,依賴追蹤在內存上的開銷就會越大。因此,從Vue.js 2.0 開始,它引入了虛擬DOM,將粒度調整為中等粒度,即一個狀態所綁定的依賴不再是具體的DOM 節點,而是一個組件。這樣狀態變化後,會通知到組件,組件內部再使用虛擬DOM 進行比對。這可以大大降低依賴數量,從而降低依賴追蹤所消耗的內存。
Vue.js 之所以能隨意調整粒度,本質上還要歸功於變化偵測。因為「推」類型的變化偵測可以隨意調整粒度。
如何追蹤變化
關於變化偵測,首先要問一個問題,在JavaScript(簡稱JS)中,如何偵測一個對象的變化?
其實這個問題還是比較簡單的。學過JavaScript 的人都知道,有兩種方法可以偵測到變化:使用Object.defineProperty 和ES6 的Proxy。
由於ES6 在瀏覽器中的支持度並不理想,到目前為止Vue.js 還是使用Object.define-Property 來實現的,所以文中也會使用它來介紹變化偵測的原理。
由於使用Object.defineProperty 來偵測變化會有很多缺陷,所以Vue.js 的作者尤雨溪說日後會使用Proxy 重寫這部分代碼。好在本文講的是原理和思想,所以即便以後用Proxy 重寫了這部分代碼,文中介紹的原理也不會變。
知道了Object.defineProperty 可以偵測到對象的變化,那麼我們可以寫出這樣的代碼:
01 function defineReactive (data, key, val) {
02 Object.defineProperty(data, key, {
03 enumerable: true,
04 configurable: true,
05 get: function () {
06 return val
07 },
08 set: function (newVal) {
09 if(val === newVal){
10 return
11 }
12 val = newVal
13 }
14 })
15 }
這裡的函數defineReactive 用來對Object.defineProperty 進行封裝。從函數的名字可以看出,其作用是定義一個響應式數據。也就是在這個函數中進行變化追蹤,封裝後只需要傳遞data、key 和val 就行了。
封裝好之後,每當從data 的key 中讀取數據時,get 函數被觸發;每當往data 的key 中設置數據時,set 函數被觸發。
如何收集依賴
如果只是把Object.defineProperty 進行封裝,那其實並沒什麼實際用處,真正有用的是收集依賴。
現在我要問第二個問題:如何收集依賴?
思考一下,我們之所以要觀察數據,其目的是當數據的屬性發生變化時,可以通知那些曾經使用了該數據的地方。
舉個例子:
01 <template>
02 <h1>{{ name }}</h1>
03 </template>
該模板中使用了數據name,所以當它發生變化時,要向使用了它的地方發送通知。
注意:在Vue.js 2.0 中,模板使用數據等同於組件使用數據,所以當數據發生變化時,會將通知發送到組件,然後組件內部再通過虛擬DOM重新渲染。
對於上面的問題,我的回答是,先收集依賴,即把用到數據name 的地方收集起來,然後等屬性發生變化時,把之前收集好的依賴循環觸發一遍就好了。
總結起來,其實就一句話,在getter 中收集依賴,在setter 中觸發依賴。
依賴收集在哪裡
現在我們已經有了很明確的目標,就是要在getter 中收集依賴,那麼要把依賴收集到哪裡去呢?
思考一下,首先想到的是每個key 都有一個數組,用來存儲當前key 的依賴。假設依賴是一個函數,保存在window.target 上,現在就可以把defineReactive 函數稍微改造一下:
01 function defineReactive (data, key, val) {
02 let dep = [] // 新增
03 Object.defineProperty(data, key, {
04 enumerable: true,
05 configurable: true,
06 get: function () {
07 dep.push(window.target) // 新增
08 return val
09 },
10 set: function (newVal) {
11 if(val === newVal){
12 return
13 }
14 // 新增
15 for (let i = 0; i < dep.length; i++) {
16 dep[i](newVal, val)
17 }
18 val = newVal
19 }
20 })
21 }
這裡我們新增了數組dep,用來存儲被收集的依賴。
然後在set 被觸發時,循環dep 以觸發收集到的依賴。
但是這樣寫有點耦合,我們把依賴收集的代碼封裝成一個Dep 類,它專門幫助我們管理依賴。使用這個類,我們可以收集依賴、刪除依賴或者向依賴發送通知等。其代碼如下:
01 export default class Dep {
02 constructor () {
03 this.subs = []
04 }
05
06 addSub (sub) {
07 this.subs.push(sub)
08 }
09
10 removeSub (sub) {
11 remove(this.subs, sub)
12 }
13
14 depend () {
15 if (window.target) {
16 this.addSub(window.target)
17 }
18 }
19
20 notify () {
21 const subs = this.subs.slice()
22 for (let i = 0, l = subs.length; i < l; i++) {
23 subs[i].update()
24 }
25 }
26 }
27
28 function remove (arr, item) {
29 if (arr.length) {
30 const index = arr.indexOf(item)
31 if (index > -1) {
32 return arr.splice(index, 1)
33 }
34 }
35 }
之後再改造一下defineReactive:
01 function defineReactive (data, key, val) {
02 let dep = new Dep() // 修改
03 Object.defineProperty(data, key, {
04 enumerable: true,
05 configurable: true,
06 get: function () {
07 dep.depend() // 修改
08 return val
09 },
10 set: function (newVal) {
11 if(val === newVal){
12 return
13 }
14 val = newVal
15 dep.notify() // 新增
16 }
17 })
18 }
此時代碼看起來清晰多了,這也順便回答了上面的問題,依賴收集到哪兒?收集到Dep 中。
依賴是誰
在上面的代碼中,我們收集的依賴是window.target,那麼它到底是什麼?我們究竟要收集誰呢?
收集誰,換句話說,就是當屬性發生變化後,通知誰。
我們要通知用到數據的地方,而使用這個數據的地方有很多,而且類型還不一樣,既有可能是模板,也有可能是用戶寫的一個watch,這時需要抽象出一個能集中處理這些情況的類。然後,我們在依賴收集階段只收集這個封裝好的類的實例進來,通知也只通知它一個。接著,它再負責通知其他地方。所以,我們要抽象的這個東西需要先起一個好聽的名字。嗯,就叫它Watcher 吧。
現在就可以回答上面的問題了,收集誰?Watcher!
什麼是Watcher
Watcher 是一個中介的角色,數據發生變化時通知它,然後它再通知其他地方。
關於Watcher,先看一個經典的使用方式:
01 // keypath
02 vm.$watch(a.b.c, function (newVal, oldVal) {
03 // 做點什麼
04 })
這段代碼表示當data.a.b.c 屬性發生變化時,觸發第二個參數中的函數。
思考一下,怎麼實現這個功能呢?好像只要把這個watcher 實例添加到data.a.b.c 屬性的Dep 中就行了。然後,當data.a.b.c 的值發生變化時,通知Watcher。接著,Watcher 再執行參數中的這個回調函數。
好,思考完畢,寫出如下代碼:
01 export default class Watcher {
02 constructor (vm, expOrFn, cb) {
03 this.vm = vm
04 // 執行this.getter(),就可以讀取data.a.b.c 的內容
05 this.getter = parsePath(expOrFn)
06 this.cb = cb
07 this.value = this.get()
08 }
09
10 get() {
11 window.target = this
12 let value = this.getter.call(this.vm, this.vm)
13 window.target = undefined
14 return value
15 }
16
17 update () {
18 const oldValue = this.value
19 this.value = this.get()
20 this.cb.call(this.vm, this.value, oldValue)
21 }
22 }
這段代碼可以把自己主動添加到data.a.b.c 的Dep 中去,是不是很神奇?
因為我在get 方法中先把window.target 設置成了this,也就是當前watcher 實例,然後再讀一下data.a.b.c 的值,這肯定會觸發getter。
觸發了getter,就會觸發收集依賴的邏輯。而關於收集依賴,上面已經介紹了,會從window.target 中讀取一個依賴並添加到Dep 中。
這就導致,只要先在window.target 賦一個this,然後再讀一下值,去觸發getter,就可以把this 主動添加到keypath 的Dep 中。有沒有很神奇的感覺啊?
依賴注入到Dep 中後,每當data.a.b.c 的值發生變化時,就會讓依賴列表中所有的依賴循環觸發update 方法,也就是Watcher 中的update 方法。而update 方法會執行參數中的回調函數,將value 和oldValue 傳到參數中。
所以,其實不管是用戶執行的vm.$watch(a.b.c, (value, oldValue) => {}),還是模板中用到的data,都是通過Watcher 來通知自己是否需要發生變化。
這裡有些小夥伴可能會好奇上面代碼中的parsePath 是怎麼讀取一個字元串的keypath 的,下面用一段代碼來介紹其實現原理:
01 /**
02 * 解析簡單路徑
03 */
04 const bailRE = /[^w.$]/
05 export function parsePath (path) {
06 if (bailRE.test(path)) {
07 return
08 }
09 const segments = path.split(.)
10 return function (obj) {
11 for (let i = 0; i < segments.length; i++) {
12 if (!obj) return
13 obj = obj[segments[i]]
14 }
15 return obj
16 }
17 }
可以看到,這其實並不複雜。先將keypath 用 . 分割成數組,然後循環數組一層一層去讀數據,最後拿到的obj 就是keypath 中想要讀的數據。
遞歸偵測所有key
現在,其實已經可以實現變化偵測的功能了,但是前面介紹的代碼只能偵測數據中的某一個屬性,我們希望把數據中的所有屬性(包括子屬性)都偵測到,所以要封裝一個Observer 類。這個類的作用是將一個數據內的所有屬性(包括子屬性)都轉換成getter/setter 的形式,然後去追蹤它們的變化:
01 /**
02 * Observer 類會附加到每一個被偵測的object 上。
03 * 一旦被附加上,Observer 會將object 的所有屬性轉換為getter/setter 的形式
04 * 來收集屬性的依賴,並且當屬性發生變化時會通知這些依賴
05 */
06 export class Observer {
07 constructor (value) {
08 this.value = value
09
10 if (!Array.isArray(value)) {
11 this.walk(value)
12 }
13 }
14
15 /**
16 * walk 會將每一個屬性都轉換成getter/setter 的形式來偵測變化
17 * 這個方法只有在數據類型為Object 時被調用
18 */
19 walk (obj) {
20 const keys = Object.keys(obj)
21 for (let i = 0; i < keys.length; i++) {
22 defineReactive(obj, keys[i], obj[keys[i]])
23 }
24 }
25 }
26
27 function defineReactive (data, key, val) {
28 // 新增,遞歸子屬性
29 if (typeof val === object) {
30 new Observer(val)
31 }
32 let dep = new Dep()
33 Object.defineProperty(data, key, {
34 enumerable: true,
35 configurable: true,
36 get: function () {
37 dep.depend()
38 return val
39 },
40 set: function (newVal) {
41 if(val === newVal){
42 return
43 }
44
45 val = newVal
46 dep.notify()
47 }
48 })
49 }
在上面的代碼中,我們定義了Observer 類,它用來將一個正常的object 轉換成被偵測的object。
然後判斷數據的類型,只有Object 類型的數據才會調用walk 將每一個屬性轉換成getter/setter 的形式來偵測變化。
最後,在defineReactive 中新增new Observer(val)來遞歸子屬性,這樣我們就可以把data 中的所有屬性(包括子屬性)都轉換成getter/setter 的形式來偵測變化。
當data 中的屬性發生變化時,與這個屬性對應的依賴就會接收到通知。
也就是說,只要我們將一個object 傳到Observer 中,那麼這個object 就會變成響應式的object。
關於Object 的問題
前面介紹了Object 類型數據的變化偵測原理,了解了數據的變化是通過getter/setter 來追蹤的。也正是由於這種追蹤方式,有些語法中即便是數據發生了變化,Vue.js 也追蹤不到。
比如,向object 添加屬性:
01 var vm = new Vue({
02 el: #el,
03 template: #demo-template,
04 methods: {
05 action () {
06 this.obj.name = berwin
07 }
08 },
09 data: {
10 obj: {}
11 }
12 })
在action 方法中,我們在obj 上面新增了name 屬性,Vue.js 無法偵測到這個變化,所以不會向依賴發送通知。
再比如,從obj 中刪除一個屬性:
01 var vm = new Vue({
02 el: #el,
03 template: #demo-template,
04 methods: {
05 action () {
06 delete this.obj.name
07 }
08 },
09 data: {
10 obj: {
11 name: berwin
12 }
13 }
14 })
在上面的代碼中,我們在action 方法中刪除了obj 中的name 屬性,而Vue.js 無法偵測到這個變化,所以不會向依賴發送通知。
Vue.js 通過Object.defineProperty 來將對象的key 轉換成getter/setter 的形式來追蹤變化,但getter/setter 只能追蹤一個數據是否被修改,無法追蹤新增屬性和刪除屬性,所以才會導致上面例子中提到的問題。
但這也是沒有辦法的事,因為在ES6 之前,JavaScript 沒有提供元編程的能力,無法偵測到一個新屬性被添加到了對象中,也無法偵測到一個屬性從對象中刪除了。為了解決這個問題,Vue.js 提供了兩個API——vm.$set 與vm.$delete,本文暫不介紹。
總結
變化偵測就是偵測數據的變化。當數據發生變化時,要能偵測到並發出通知。
Object 可以通過Object.defineProperty 將屬性轉換成getter/setter 的形式來追蹤變化。讀取數據時會觸發getter,修改數據時會觸發setter。
我們需要在getter 中收集有哪些依賴使用了數據。當setter 被觸發時,去通知getter 中收集的依賴數據發生了變化。
收集依賴需要為依賴找一個存儲依賴的地方,為此我們創建了Dep,它用來收集依賴、刪除依賴和向依賴發送消息等。
所謂的依賴,其實就是Watcher。只有Watcher 觸發的getter 才會收集依賴,哪個Watcher觸發了getter,就把哪個Watcher 收集到Dep 中。當數據發生變化時,會循環依賴列表,把所有的Watcher 都通知一遍。
Watcher 的原理是先把自己設置到全局唯一的指定位置(例如window.target),然後讀取數據。因為讀取了數據,所以會觸發這個數據的getter。接著,在getter 中就會從全局唯一的那個位置讀取當前正在讀取數據的Watcher,並把這個Watcher 收集到Dep 中去。通過這樣的方式,Watcher 可以主動去訂閱任意一個數據的變化。
此外,我們創建了Observer 類,它的作用是把一個object 中的所有數據(包括子數據)都轉換成響應式的,也就是它會偵測object 中所有數據(包括子數據)的變化。
由於在ES6 之前JavaScript 並沒有提供元編程的能力,所以在對象上新增屬性和刪除屬性都無法被追蹤到。
圖2-1 給出了Data、Observer、Dep 和Watcher 之間的關係。
![](http://i1.wp.com/pic1.zhimg.com/50/v2-8a722658aac3fd01f613431147b06c18_720w.webp)
Data 通過Observer 轉換成了getter/setter 的形式來追蹤變化。
當外界通過Watcher 讀取數據時,會觸發getter 從而將Watcher 添加到依賴中。
當數據發生了變化時,會觸發setter,從而向Dep 中的依賴(Watcher)發送通知。
Watcher 接收到通知後,會向外界發送通知,變化通知到外界後可能會觸發視圖更新,也有可能觸發用戶的某個回調函數等。
——本文節選自《深入淺出Vue.js》
![](http://i1.wp.com/pic3.zhimg.com/50/v2-7aaa8c768ceed89b38227631bfdbc4a2_720w.webp)
劉博文 著
360前端工程師精心打造!
360 奇舞團團長月影和《JavaScript高級程序設計》譯者李松峰作序推薦
同樣在360工作的《JavaScript高級程序設計(第3版)》譯者李松峰在圖靈待過幾年,很熟悉什麼樣的書會更暢銷,他早就跟博文說過:要想讓技術書暢銷,一是讀者定位必須是新手,因為新手人數眾多;二是要注重實用,書中的例子最好能立即照搬到項目上。
然而,這本書的讀者定位顯然不是新手,而且書中的源碼分析似乎也不能直接套用到項目上。其實這也是沒辦法的事,因為博文寫這本書的初衷就是把自己研究Vue.js 源碼的心得分享出來。
Vue.js 是一個優秀的前端框架。一個優秀的前端框架如果沒有一本優秀的解讀著作,確實是一大缺憾。應該說,本書正是一本優秀的Vue.js 源碼解讀專著。
全書從一個新穎的「入口點」——「變化偵測」切入,逐步過渡到「虛擬DOM」和「模板編譯」,最後展開分析Vue.js的整體架構。如果想讀懂這本書,讀者不僅要有一些Vue.js 的實際使用經驗,而且還要有一些編譯原理(比如AST)相關的知識儲備,這樣才能更輕鬆地理解模板解析、優化與代碼生成的原理。
本書最後幾章對Vue.js 的實例方法和全局API,以及生命周期、指令和過濾器的解讀,雖然借鑒了Vue.js 官方文檔,但作者更注重實現原理的分析,彌補了文檔的不足。
早一天讀到,早一天受益,僅此而已。
目錄
第1章 Vue.js簡介
第一篇 變化偵測
第2章 Object的變化偵測
第3章 Array的變化偵測
第4章 變化偵測相關的API實現原理
第二篇 虛擬DOM
第5章 虛擬DOM簡介
第6章 VNode
第7章 patch
第三篇 模板編譯原理
第8章 模板編譯
第9章 解析器
第10章 優化器
第11章 代碼生成器
第四篇 整體流程
第12章 架構設計與項目結構
第13章 實例方法與全局API的實現原理
第14章 生命周期
第15章 指令的奧秘
第16章 過濾器的奧秘
推薦閱讀: