圖解Vue 響應式流程及原理
閱讀本文能夠幫助你什麼?
- 在學習vue源碼的時候發現組件化過程很繞?
- 在響應式過程中
Observer
、Dep
、Watcher
三大對象傻傻分不清? - 搞不清楚對象、數組依賴收集、派發更新的流程?
dep
、watcher
互調造成混亂? - 學瞭一遍好像懂瞭又好像不全懂的感覺?而且缺乏大體流程概念?
- 或者像我一樣,有段時間沒看vue源碼好像有點遺忘?但是想快速回顧卻無從下手?
本文主要分為1. 組件化;2. 響應式原理;3. 彩蛋(computed和watch)進行講解。本文調試源碼的vue版本是v2.6.14。整篇將采用源碼講解 + 流程圖的方式詳細還原整個Vue響應式原理的全過程。你可以瞭解到Dep.target
、pushTarget
、popTarget
;響應式中的三大Watcher;Dep
、Wathcer
多對多的,互相收集的關系。
這篇是進階的 Vue 響應式源碼解析,文章比較長,內容比較深,大傢可以先mark後看。看不懂的不要強行看,可以先看看其他作者的偏簡單一點的源碼解析文章,然後好好消化。等過段時間再回來看這篇,相信你由淺入深後再看本文,一定會有意想不到的收獲~
一、組件化流程
在講解整個響應式原理之前,先介紹一下Vue中另一個比較核心的概念——組件化,個人認為這也是學習響應式的前置核心。搞懂組件化,響應式學習如虎添翼!
1. 整個new Vue階段做瞭什麼?
- 執行init操作。包括且不限制
initLifecycle
、initState
等 - 執行mount。進行元素掛載
- compiler步驟在runtime-only版本中沒有。
- compiler步驟對template屬性進行編譯,生成render函數。
- 一般在項目中是在
.vue
文件開發,通過vue-loader處理生成render函數。
執行render。生成vnode
<div id="app">{{ message }}</div>
render (h) { return h('div', { attrs: { id: 'app' }, }, this.message) }
- render例子,如下
- 對應手寫的render函數
- patch。新舊vnode經過diff後,渲染到真實dom上
2. 普通dom元素如何渲染到頁面?
- 執行
$mount
。- 實際執行
mountComponent
- 這裡會實例化一個Watcher
- Watcher中會執行
get
方法,觸發updateComponent
- 實際執行
- 執行
updateComponent
。執行vm._update(vm._render(), hydrating)
- 執行
vm.render()
。- render其實調用
createElment
(h
函數) - 根據tag的不同,生成組件、原生VNode並返回
- render其實調用
- 執行
vm.update()
。createElm()
到createChildren()
遞歸調用 - 將VNode轉化為真實的dom,並且最終渲染到頁面
3. 組件如何渲染到頁面?
這裡以如下代碼案例講解更加清晰~沒錯,就是這麼熟悉!就是一個初始化的Vue項目
// mian.js import Vue from 'vue' import App from './App.vue' new Vue({ render: h => h(App), }).$mount('#app')
// App.vue <template> <div id="app"> <p>{{ msg }}</p> </div> </template> <script> export default { name: 'App', data () { return { msg: 'hello world' } } } </script>
主要講解組件跟普通元素的不同之處,主要有2點:
如何生成VNode——創建組件VNodecreateComponent
如何patch——組件new Vue到patch流程createComponent
$vnode:占位符vnode。最終渲染vnode掛載的地方。所有的組件通過遞歸調用createComponent直至不再存在組件VNode,最終都會轉化成普通的dom。
{ tag: 'vue-component-1-App', componentInstance: {組件實例}, componentOptions: {Ctor, ..., } }
_vnode:渲染vnode。
{ tag: 'div', { "attrs": { "id": "app" } }, // 對應占位符vnode: $vnode parent: { tag: 'vue-component-1-App', componentInstance: {組件實例}, componentOptions: {Ctor, ..., } }, children: [ // 對應p標簽 { tag: 'p', // 對應p標簽內的文本節點{{ msg }} children: [{ text: 'hello world' }] }, { // 如果還有組件VNode其實也是一樣的 tag: 'vue-component-2-xxx' } ] }
(註意:這一步對應上圖render流程的紫色塊的展開!!!)
區分普通元素VNode
- 普通VNode:tag是html的保留標簽,如
tag: 'div'
- 組件VNode:tag是以
vue-component
開頭,如tag: 'vue-component-1-App'
(註意:這一步對應上圖patch流程的紫色塊的展開!!!)
4. Vue組件化簡化流程
相信你看完細粒度的Vue組件化過程可能已經暈頭轉向瞭,這裡會用一個簡化版的流程圖進行回顧,加深理解
二、響應式流程
案例代碼
// 案例 export default { name: 'App', data () { return { msg: 'hello world', arr = [1, 2, 3] } } }
1. 依賴收集
這裡會從Observer、Dep、Watcher三個對象進行講解,分 object
、array
兩種依賴收集方式。
- 一定要註意!數組 的依賴收集 跟 對象的屬性 是不一樣的。對象屬性經過深度遍歷後,最終就是以一個基本類型的數據為單位收集依賴,但是數組仍然是一個引用類型。
- 如果這裡不懂,先想一個問題: 我們用
this.msg = 'xxx'
能觸發setter
派發更新,但是我們修改數組並不是用this.arr = xxx
,而是用this.arr.push(xxx)
等修改數組的方法。很顯然,這時候並不是通過觸發arr
的setter
去派發更新的。那是怎麼做的呢?先帶著這個問題繼續往下看吧!
三個核心對象:Observer
(藍)、Dep
(綠)、Watcher
(紫)
依賴收集準備階段——Observer、Dep的實例化
// 以下是initData調用的方法講解,排列遵循調用順序 function observe (value, asRootData) { if (!isObject(value)) return // 非對象則不處理 // 實例化Observer對象 var ob; ob = new Observer(value); return ob } function Observer (value) { this.value = value; // 保存當前的data this.dep = new Dep(); // 實例化dep,數組進行依賴收集的dep(對應案例中的arr) def(value, '__ob__', this); if (Array.isArray(value)) { if (hasProto) { // 這裡會改寫數組原型。__proto__指向重寫數組方法的對象 protoAugment(value, arrayMethods); } else { copyAugment(value, arrayMethods, arrayKeys); } this.observeArray(value); } else { this.walk(value); } } // 遍歷數組元素,執行對每一項調用observe,也就是說數組中有對象會轉成響應式對象 Observer.prototype.observeArray = function observeArray (items) { for (var i = 0, l = items.length; i < l; i++) { observe(items[i]); } } // 遍歷對象的全部屬性,調用defineReactive Observer.prototype.walk = function walk (obj) { var keys = Object.keys(obj); // 如案例代碼,這裡的 keys = ['msg', 'arr'] for (var i = 0; i < keys.length; i++) { defineReactive(obj, keys[i]); } }
function defineReactive (obj, key, val) { // 產生一個閉包dep var dep = new Dep(); // 如果val是object類型,遞歸調用observe,案例代碼中的arr會走這個邏輯 var childOb = !shallow && observe(val); Object.defineProperty(obj, key, { get: function reactiveGetter () { // 求value的值 var value = getter ? getter.call(obj) : val; if (Dep.target) { // Dep.target就是當前的Watcher // 這裡是閉包dep dep.depend(); if (childOb) { // 案例代碼中arr會走到這個邏輯 childOb.dep.depend(); // 這裡是Observer裡的dep,數組arr在此依賴收集 if (Array.isArray(value)) { dependArray(value); } } } return value }, set: function reactiveSetter (newVal) { // 下文派發更新裡進行講解 } }); }
註意 對象 、 數組 的不同處理方式。這裡以 核心代碼 + 圖 進行講解
接下來核心分析 defineReactive
做瞭什麼。註意 childOb
,這是數組進行依賴收集的地方(也就是為什麼我們 this.arr.push(4)
能找到 Watcher
進行派發更新)
依賴收集觸發階段——Wather實例化、訪問數據、觸發依賴收集
// new Wathcer核心 function Watcher (vm, expOrFn, cb, options, isRenderWatcher) { if (typeof expOrFn === 'function') { // 渲染watcher中,這裡傳入的expOrFn是updateComponent = vm.update(vm.render()) // this.getter等價於vm.update(vm.render()) this.getter = expOrFn; } else { ... } // 這裡進行判斷,lazy為true時(計算屬性)則什麼都不執行,否則執行get this.value = this.lazy ? undefined : this.get(); // 本次為渲染Watcher,執行get,繼續往下看~ } // Watcher的get方法 Watcher.prototype.get = function get () { // 這裡很關鍵,pushTarget就是把當前的Wather賦值給“Dep.target” pushTarget(this); var value; var vm = this.vm; try { // 1. 這裡調用getter,也就是執行vm.update(vm.render()) // 2. 執行vm.render函數就會訪問到響應式數據,觸發get進行依賴收集 // 3. 此時的Dep.target為當前的渲染Watcher,數據就可以理所應當的把Watcher加入自己的subs中 // 4. 所以此時,Watcher就能監測到數據變化,實現響應式 value = this.getter.call(vm, vm); } catch (e) { ... } finally { popTarget(); /* * cleanupDeps是個優化操作,會移除Watcher對本次render沒被使用的數據的觀測 * 效果:處於v-if為false中的響應式數據改變不會觸發Watcher的update * 感興趣的可以自己去debugger調試,這裡就不展開瞭 */ this.cleanupDeps(); } return value }
Dep.target相關講解
- targetStack:棧結構,用來保存
Watcher
- pushTarget:往
targetStack
中push
當前的Watcher
(排在前一個Watcher的後面),並把Dep.target
賦值給當前Watcher
- popTarget:先把
targetStack
最後一個元素彈出(.pop),再把Dep.target
賦值給最後一個Watcher
(也就是還原瞭前一個Watcher) - 通過上述實現,vue保證瞭
全局唯一的Watcher
,準確賦值在Dep.target
中
細節太多繞暈瞭?來個整體流程,從宏觀角度再過一遍(computed部分可看完彩蛋後再回來重溫一下)
2. 派發更新
派發更新區分對象屬性、數組方法進行講解
如果想要深入瞭解組件的異步更新,戳這裡,瞭解Vue組件異步更新之nextTick。本文隻針對派發更新流程,不會對異步更新DOM進行展開講解~
這裡可以先想一下,以下操作會發生什麼?
this.msg = 'new val'
this.arr.push(4)
是的,毫無疑問都會先觸發他們之中的get
,那再觸發什麼呢?我們接下來看
對象屬性修改觸發set,派發更新。this.msg = 'new val'
... Object.defineProperty (obj, key, { get () {...}, set: function reactiveSetter (newVal) { var value = getter ? getter.call(obj) : val; // 判斷新值相比舊值是否已經改變 if (newVal === value || (newVal !== newVal && value !== value)) { return } // 如果新值是引用類型,則將其轉化為響應式 childOb = !shallow && observe(newVal); // 這裡通知dep的所有watcher進行更新 dep.notify(); } } ...
數組調用方法。this.arr.push(4)
// 數組方法改寫是在 Observer 方法中 function Observer () { if (hasProto) { // 用案例講解,也就是this.arr.__proto__ = arrayMethods protoAugment(value, arrayMethods); } } // 以下是數組方法重寫的實現 var arrayProto = Array.prototype; // 保存真實數組的原型 var arrayMethods = Object.create(arrayProto); // 以真數組為原型創建對象 // 可以看成:arrayMethods.__proto__ = Array.prototype var methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ]; // 一個裝飾器模型,重寫7個數組方法 methodsToPatch.forEach(function (method) { // 保存原生的數組方法 var original = arrayProto[method]; // 劫持arrayMethods對象中的數組方法 def(arrayMethods, method, function mutator () { var args = [], len = arguments.length; while ( len-- ) args[ len ] = arguments[ len ]; var result = original.apply(this, args); var ob = this.__ob__; // 當我門調用this.arr.push(),這裡就能到數組對象的ob實例 var inserted; switch (method) { case 'push': case 'unshift': inserted = args; break case 'splice': inserted = args.slice(2); break } if (inserted) { ob.observeArray(inserted); } // 由於數組對象在new Observer中實例化瞭一個dep,並通過childOb邏輯收集瞭依賴,這裡就能在ob實例中拿到dep屬性 ob.dep.notify(); return result }); })
- 這裡可以聯合數組的依賴收集再看一遍,你就恍然大悟瞭。為什麼 對象的屬性 、數組 的依賴收集方式不一樣
整個new Vue階段、到依賴收集、派發更新的全部流程就到這裡結束瞭。可以縱觀流程圖看出,Vue應用就是一個個Vue組件組成的,雖然整個組件化、響應式流程很多,但核心的路徑一旦走通,你就會恍然大悟。
三、彩蛋篇
1. computed依賴收集
- 案例代碼
<template> <div id="app"> {{ name }} </div> </template> <script> export default { name: 'App', computed: { name () { return this.firstName + this.secondName } }, data () { return { firstName: 'jing', secondName: 'boran' } } } </script>
- 我們先看流程圖。圖有點大~大傢可以放大看看,每個核心步驟都附有文字說明
根據案例概括一下,加深理解
// 訪問computed時觸發get的核心代碼 function createComputedGetter (key) { return function computedGetter () { var watcher = this._computedWatchers && this._computedWatchers[key]; if (watcher) { if (watcher.dirty) { // dirty第一次為true watcher.evaluate(); // 這裡是對computed進行求值,對computed watcher執行依賴收集 } if (Dep.target) { watcher.depend(); // 這裡是對渲染Watcher進行依賴收集 } return watcher.value } } }
computed中的name
其實就是一個computed Watcher,這個Watcher在init
階段生成
當App組件render的階段,render函數會訪問到模版中的{{ name }}
,則會觸發computed的求值,也就是執行上面代碼computedGetter()
。執行watcher.evaluate()
。也就是執行wathcer.get
。上文依賴收集的第3點:依賴收集觸發階段有對get方法進行講解,忘瞭的可以上去回顧一下執行watcher.depend()
Watcher.prototype.depend = function depend () { var i = this.deps.length; while (i--) { // 也就是調用Dep.depend => Watcher.addDep => dep.addSub this.deps[i].depend(); } }
// this.firstName和this.secondName的dep.subs dep.subs: [name的computed watcher, App組件的渲染Watcher]
代碼中判斷watcher.dirty
標志是什麼?有什麼用?
隻有computed的值發生改變(也就是其依賴的數據改變),watcher.dirty
才會被設為true
隻有watcher.dirty
為true
才會對computed進行 求值 或 重新求值
總結:也就是組件每次render,如果computed的值沒改變,直接返回value值(是不需要重新計算的),這也是computed的一個特點
- 首先
pushTarget
把Dep.target
從App組件的渲染Watcher改為name的computed Watcher - 其次執行cb:
function() { return this.firstName + this.secondName }
- 執行cb的過程中,必然會訪問到
firstName
、secondName
,這時候就是我們熟悉的依賴收集階段瞭。firstName、secondName都會把name這個computed watcher收集到自己的dep.subs[]
中 - 最後
popTarget
把name的computed Watcher彈出棧,並恢復Dep.target
為當前App組件的渲染Watcher - 遍歷computed watcher的deps。其實就是firstName、secondName實例的Dep
dep.depend
也就是調用watcher.addDep
(把Dep收集進watcher.deps中),再由watcher.appDep調用dep.addSub
(把Watcher收集進dep.subs中)- 這樣一來,就完成瞭firstName、secondName對App組件的渲染watcher進行收集
- 結果如下。響應式數據中會存在兩個Watcher
- 至於為什麼響應式數據要收集2個watcher?下文computed派發更新會講解
講到這裡,我以自己的理解講解下文章開頭引言的問題:為什麼Watcher、Dep多對多且相互收集? 這可能也是大傢閱讀Vue源碼中一直存在的一個疑惑(包括我自己剛開始讀也是這樣)
對的,當然是為瞭computed中的響應式數據收集渲染Watcher啦!!!
還有!!! 還記得前文中依賴收集的第3點——依賴收集觸發階段的代碼講解中我寫瞭很多註釋的cleanupDeps
嗎?
// 此時flag為true,也就是說msg2沒有渲染在頁面中 <div v-if="flag">{{ msg1 }}</div> <div v-else>{{ msg2 }}</div> <button @click=() => { this.msg2 = 'change' }>changeMsg2</button>
function cleanupDeps () { var i = this.deps.length; while (i--) { // 這裡對watcher所觀測的響應式數據的dep進行遍歷 // 對的,這樣一來,是不是watcher中的deps就發揮作用瞭呢? var dep = this.deps[i]; if (!this.newDepIds.has(dep.id)) { // 這裡對當前渲染中沒有訪問到的響應式數據進行依賴移除 dep.removeSub(this); } } ... }
cleanupDeps
的作用就是清除掉當前沒有使用到的響應式數據。怎麼清除?我們往下看- 首先看個案例回答個問題,代碼如下。當flag為true時,
msg2
並沒有渲染在頁面中,那麼此時我們點擊按鈕修改msg2
的值會不會、或者應不應該觸發這個組件的重新渲染呢? - 答案肯定是不會、不應該。所以:
cleanupDeps
就是為此而存在的 - 那
cleanupDeps
是怎麼工作的呢?接著看下面代碼 - 到此,你是否已經懂得瞭watcher中為什麼要收集自己觀測的響應式數據對應的dep呢?
2. computed派發更新
派發相對來說比較簡單瞭~跟響應式的派發更新基本一致,繼續以案例來講解吧!
當我們修改firstName會發生什麼?this.firstName = 'change'
首先觸發firstName的set,最終會調用dep.notify()
。firstName的dep.subs中有2個watcher,分別執行對應watcher的notify
Watcher.prototype.update = function update () { if (this.lazy) { this.dirty = true; // computed會走到這裡,然後就結束瞭 } else if (this.sync) { this.run(); } else { queueWatcher(this); // 渲染watcher會走到這裡 } }
computed watcher:將dirty屬性置為true。
渲染watcher會執行派發更新流程(如本文響應式流程——2.派發更新一致)
nextTick階段執行flushSchedulerQueue
,則會執行watcher.run()
watcher.run會執行watcher.get方法,也就是重新執行render、update的流程
執行render又會訪問到name的computed,從而又會執行computedGetter
此時的watcher.dirty在本步驟3已經置為true,又會執行watcher.evaluate()
進行computed的求值,執行watcher.depend()
……後續的流程就是派發更新的流程瞭~
3. user Watcher依賴收集
user Watcher的依賴收集相比computed會簡單一點,這裡不會贅述太多,隻說核心區別,還有watch的常用配置immediate
、deep
、sync
user Watcher在init階段會執行一次watcher.get()
,在這裡會訪問我們watch的響應式數據,從而進行依賴收集。回顧下computed,computed在這個階段什麼也沒做。
// 沒錯,又是這段熟悉的代碼 this.value = this.lazy ? undefined : this.get(); // user Watcher和渲染 Watcher都在new Watcher階段執行get()
如果userWatcher設置的immediate: true
,則會在new Watcher後主動觸發一次cb的執行
Vue.prototype.$watch = function (expOrFn, cb, options) { ... var watcher = new Watcher(vm, expOrFn, cb, options); if (options.immediate) { // immediate則會執行我們傳入的callback try { cb.call(vm, watcher.value); } catch (error) { } } return function unwatchFn () { watcher.teardown(); } };
deep
邏輯很簡單,大概講下:深度遍歷這個對象,訪問到該對象的所有屬性,以此來觸發所有屬性的getter。這樣,所有屬性都會把當前的user Watcher收集到自己的dep中。因此,深層的屬性值修改(觸發set派發更新能通知到user Watcher),watch自然就能監測到數據改變~感興趣的同學可以自己去看看源碼中traverse
的實現。
sync
。當前tick執行,以此能先於渲染Wathcer執行。不設置同步的watcher都會放到nextTick中執行。
Watcher.prototype.update = function update () { if (this.lazy) { this.dirty = true; // 計算屬性 } else if (this.sync) { this.run(); // 同步的user Wathcer } else { queueWatcher(this); // 普通user Watcher和渲染Watcher } }
總體來說,Vue的源碼其實是比較好上手的,整體代碼流程非常的清晰。但是想要深入某一塊邏輯,最好結合流程圖加debugger方式親自上手實踐。畢竟真正搞懂一門框架的源碼並非易事,我也是通過不斷debugger調試,一遍遍走核心流程,才能較好的學習理解vue的實現原理~
寫在最後,這篇文章也算是自己的一個知識沉淀吧,畢竟很早之前就學習過Vue的源碼瞭,但是也一直沒做筆記。現在回顧一下,發現很多都有點忘瞭,但是缺乏一個快速記憶、回顧的筆記。如果要直接硬磕源碼重新記憶,還是比較費時費力的~作為知識分享,希望可以幫助到想學習源碼,想要進階的你,大傢彼此共勉,一同進步!
以上就是圖解Vue 響應式原理的詳細內容,更多關於Vue 響應式的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- Vue Computed底層原理深入探究
- Vue中避免濫用this去讀取data中數據
- 手動實現vue2.0的雙向數據綁定原理詳解
- Vue2 Observer實例dep和閉包中dep區別詳解
- 實現一個簡單得數據響應系統