深入淺出 - vue變化偵測原理
其實在一年前我已經寫過一篇關於 ,但是最近我翻開看看發現講的內容和我現在心裡想的有些不太一樣,所以我打算重新寫一篇更通俗易懂的文章。
我的目標是能讓讀者讀完我寫的文章能學到知識,有一部分文章標題都以深入淺出開頭,目的是把一個複雜的東西排除掉干擾學習的因素後剩下的核心原理透過很簡單的描述來讓讀者學習到知識。
關於vue的內部原理其實有很多個重要的部分,變化偵測,模板編譯,virtualDOM,整體執行流程等。
今天主要把變化偵測這部分單獨拿出來講一講。
如何偵測變化?
關於變化偵測首先要問一個問題,在 js 中,如何偵測一個物件的變化,其實這個問題還是比較簡單的,學過js的都能知道,js中有兩種方法可以偵測到變化,Object.defineProperty
和 ES6 的proxy
。
到目前為止vue還是用的 Object.defineProperty
,所以我們拿 Object.defineProperty
來舉例子說明這個原理。
這裡我想說的是,不管以後vue是否會用 proxy
重寫這部分,我講的是原理,並不是api,所以不論以後vue會怎樣改,這個原理是不會變的,哪怕vue用了其他完全不同的原理實現了變化偵測,但是本篇文章講的原理一樣可以實現變化偵測,原理這個東西是不會過時的。
之前我寫文章有一個毛病就是喜歡對著原始碼翻譯,結果過了半年一年人家原始碼改了,我寫的文章就一毛錢都不值了,而且對著原始碼翻譯還有一個缺點是對讀者的要求有點偏高,讀者如果沒看過原始碼或者看的和我不是一個版本,那根本就不知道我在說什麼。
好了不說廢話了,繼續講剛才的內容。
知道 Object.defineProperty
可以偵測到物件的變化,那麼我們瞬間可以寫出這樣的程式碼:
function defineReactive (data, key, val) { Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function () { return val }, set: function (newVal) { if(val === newVal){ return } val = newVal } }) }
寫一個函式封裝一下 Object.defineProperty
,畢竟 Object.defineProperty
的用法這麼複雜,封裝一下我只需要傳遞一個 data,和 key,val 就行了。
現在封裝好了之後每當 data
的 key
讀取資料 get
這個函式可以被觸發,設定資料的時候 set
這個函式可以被觸發,但是,,,,,,,,,,,,,,,,,,發現好像並沒什麼鳥用?
怎麼觀察?
現在我要問第二個問題,“怎麼觀察?”
思考一下,我們之所以要觀察一個資料,目的是為了當資料的屬性發生變化時,可以通知那些使用了這個 key
的地方。
舉個:
{{ key }}{{ key }}
模板中有兩處使用了 key
,所以當資料發生變化時,要把這兩處都通知到。
所以上面的問題,我的回答是,先收集依賴,把這些使用到 key
的地方先收集起來,然後等屬性發生變化時,把收集好的依賴迴圈觸發一遍就好了~
總結起來其實就一句話,getter中,收集依賴,setter中,觸發依賴。
依賴收集在哪?
現在我們已經有了很明確的目標,就是要在getter中收集依賴,那麼我們的依賴收集到哪裡去呢??
思考一下,首先想到的是每個 key
都有一個陣列,用來儲存當前 key
的依賴,假設依賴是一個函式存在 window.target
上,先把 defineReactive
稍微改造一下:
function defineReactive (data, key, val) { let dep = [] // 新增 Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function () { dep.push(window.target) // 新增 return val }, set: function (newVal) { if(val === newVal){ return } // 新增 for (let i = 0; i在
defineReactive
中新增了陣列 dep,用來儲存被收集的依賴。然後在觸發 set 觸發時,迴圈dep把收集到的依賴觸發。
但是這樣寫有點耦合,我們把依賴收集這部分程式碼封裝起來,寫成下面的樣子:
export default class Dep { static target: ?Watcher; id: number; subs: Array; constructor () { this.id = uid++ this.subs = [] } addSub (sub: Watcher) { this.subs.push(sub) } removeSub (sub: Watcher) { remove(this.subs, sub) } depend () { if (Dep.target) { this.addSub(Dep.target) } } notify () { // stabilize the subscriber list first const subs = this.subs.slice() for (let i = 0, l = subs.length; i 然後在改造一下
defineReactive
:function defineReactive (data, key, val) { let dep = new Dep() // 修改 Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function () { dep.depend() // 修改 return val }, set: function (newVal) { if(val === newVal){ return } dep.notify() // 新增 val = newVal } }) }這一次程式碼看起來清晰多了,順便回答一下上面問的問題,依賴收集到哪?收集到Dep中,Dep是專門用來儲存依賴的。
收集誰?
上面我們假裝
window.target
是需要被收集的依賴,細心的同學可能已經看到,上面的程式碼window.target
已經改成了Dep.target
,那Dep.target
是什麼?我們究竟要收集誰呢??
收集誰,換句話說是當屬性發生變化後,通知誰。
我們要通知那個使用到資料的地方,而使用這個資料的地方有很多,而且型別還不一樣,有可能是模板,有可能是使用者寫的一個 watch,所以這個時候我們需要抽象出一個能集中處理這些不同情況的類,然後我們在依賴收集的階段只收集這個封裝好的類的例項進來,通知也只通知它一個,然後它在負責通知其他地方,所以我們要抽象的這個東西需要先起一個好聽的名字,嗯,就叫它watcher吧~
所以現在可以回答上面的問題,收集誰??收集 Watcher。
什麼是Watcher?
watcher 是一箇中介的角色,資料發生變化通知給 watcher,然後watcher在通知給其他地方。
關於watcher我們先看一個經典的使用方式:
// keypathvm.$watch('a.b.c', function (newVal, oldVal) { // do something})這段程式碼表示當
data.a.b.c
這個屬性發生變化時,觸發第二個引數這個函式。思考一下怎麼實現這個功能呢?
好像只要把這個 watcher 例項新增到
data.a.b.c
這個屬性的 Dep 中去就行了,然後data.a.b.c
觸發時,會通知到watcher,然後watcher在執行引數中的這個回撥函式。好,思考完畢,開工,寫出如下程式碼:
class Watch { constructor (expOrFn, cb) { // 執行 this.getter() 就可以拿到 data.a.b.c this.getter = parsePath(expOrFn) this.cb = cb this.value = this.get() } get () { Dep.target = this value = this.getter.call(vm, vm) Dep.target = undefined } update () { const oldValue = this.value this.value = this.get() this.cb.call(this.vm, this.value, oldValue) } }這段程式碼可以把自己主動
push
到data.a.b.c
的 Dep 中去。因為我在
get
這個方法中,先把 Dep.traget 設定成了this
,也就是當前watcher例項,然後在讀一下data.a.b.c
的值。因為讀了
data.a.b.c
的值,所以肯定會觸發getter
。觸發了
getter
上面我們封裝的defineReactive
函式中有一段邏輯就會從Dep.target
裡讀一個依賴push
到Dep
中。所以就導致,我只要先在 Dep.target 賦一個
this
,然後我在讀一下值,去觸發一下getter
,就可以把this
主動push
到keypath
的依賴中,有沒有很神奇~依賴注入到
Dep
中去之後,當這個data.a.b.c
的值發生變化,就把所有的依賴迴圈觸發 update 方法,也就是上面程式碼中 update 那個方法。
update
方法會觸發引數中的回撥函式,將value 和 oldValue 傳到引數中。所以其實不管是使用者執行的
vm.$watch('a.b.c', (value, oldValue) => {})
還是模板中用到的data,都是透過 watcher 來通知自己是否需要發生變化的。遞迴偵測所有key
現在其實已經可以實現變化偵測的功能了,但是我們之前寫的程式碼只能偵測資料中的一個 key,所以我們要加工一下
defineReactive
這個函式:// 新增function walk (obj: Object) { const keys = Object.keys(obj) for (let i = 0; i這樣我們就可以透過執行
walk(data)
,把data
中的所有key
都加工成可以被偵測的,因為是一個遞迴的過程,所以key
中的value
如果是一個物件,那這個物件的所有key也會被偵測。Array怎麼進行變化偵測?
現在又發現了新的問題,
data
中不是所有的value
都是物件和基本型別,如果是一個陣列怎麼辦??陣列是沒有辦法透過Object.defineProperty
來偵測到行為的。vue 中對這個陣列問題的解決方案非常的簡單粗暴,我說說vue是如何實現的,大體上分三步:
第一步:先把原生
Array
的原型方法繼承下來。第二步:對繼承後的物件使用
Object.defineProperty
做一些攔截操作。第三步:把加工後可以被攔截的原型,賦值到需要被攔截的
Array
型別的資料的原型上。vue的實現
第一步:
const arrayProto = Array.prototype export const arrayMethods = Object.create(arrayProto)第二步:
;[ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ] .forEach(function (method) { // cache original method const original = arrayProto[method] Object.defineProperty(arrayMethods, method, { value: function mutator (...args) { console.log(methods) // 列印陣列方法 return original.apply(this, args) }, enumerable: false, writable: true, configurable: true }) })現在可以看到,每當被偵測的
array
執行方法運算元組時,我都可以知道他執行的方法是什麼,並且列印到console
中。現在我要對這個陣列方法型別進行判斷,如果運算元組的方法是 push unshift splice (這種可以新增陣列元素的方法),需要把新增的元素用上面封裝的
walk
來進行變化檢測。並且不論運算元組的是什麼方法,我都要觸發訊息,通知依賴列表中的依賴資料發生了變化。
那現在怎麼訪問依賴列表呢,可能我們需要把上面封裝的
walk
加工一下:// 工具函式function def (obj: Object, key: string, val: any, enumerable?: boolean) { Object.defineProperty(obj, key, { value: val, enumerable: !!enumerable, writable: true, configurable: true }) } export class Observer { value: any; dep: Dep; vmCount: number; // number of vms that has this object as root $data constructor (value: any) { this.value = value this.dep = new Dep() // 新增 this.vmCount = 0 def(value, '__ob__', this) // 新增 // 新增 if (Array.isArray(value)) { this.observeArray(value) } else { this.walk(value) } } /** * Walk through each property and convert them into * getter/setters. This method should only be called when * value type is Object. */ walk (obj: Object) { const keys = Object.keys(obj) for (let i = 0; i ) { for (let i = 0, l = items.length; i我們定義了一個
Observer
space######space類,他的職責是將data
轉換成可以被偵測到變化的data
,並且新增了對型別的判斷,如果是value
的型別是Array
迴圈 Array將每一個元素丟到 Observer 中。並且在 value 上做了一個標記
__ob__
,這樣我們就可以透過value
的__ob__
拿到Observer例項,然後使用__ob__
上的dep.notify()
就可以傳送通知啦。然後我們在改進一下Array原型的攔截器:
;[ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ] .forEach(function (method) { // cache original method const original = arrayProto[method] def(arrayMethods, method, function mutator (...args) { const result = original.apply(this, args) const ob = this.__ob__ let inserted switch (method) { case 'push': case 'unshift': inserted = args break case 'splice': inserted = args.slice(2) break } if (inserted) ob.observeArray(inserted) // notify change ob.dep.notify() return result }) })可以看到寫了一個
switch
對method
進行判斷,如果是push
,unshift
,splice
這種可以新增陣列元素的方法就使用ob.observeArray(inserted)
把新增的元素也丟到Observer
中去轉換成可以被偵測到變化的資料。在最後不論運算元組的方法是什麼,都會呼叫
ob.dep.notify()
去通知watcher
資料發生了改變。arrayMethods 是怎麼生效的?
現在我們有一個
arrayMenthods
是被加工後的Array.prototype
,那麼怎麼讓這個物件應用到Array
上面呢?思考一下,我們不能直接修改
Array.prototype
因為這樣會汙染全域性的Array,我們希望arrayMenthods
只對data
中的Array
生效。所以我們只需要把
arrayMenthods
賦值給value
的__proto__
上就好了。我們改造一下
Observer
:export class Observer { constructor (value: any) { this.value = value this.dep = new Dep() this.vmCount = 0 def(value, '__ob__', this) if (Array.isArray(value)) { value.__proto__ = arrayMethods // 新增 this.observeArray(value) } else { this.walk(value) } } }如果不能使用
__proto__
,就直接迴圈arrayMethods
把它身上的這些方法直接裝到value
身上好了。什麼情況不能使用
__proto__
我也不知道,各位大佬誰知道能否給我留個言?跪謝~所以我們的程式碼又要改造一下:
// can we use __proto__?const hasProto = '__proto__' in {} // 新增export class Observer { constructor (value: any) { this.value = value this.dep = new Dep() this.vmCount = 0 def(value, '__ob__', this) if (Array.isArray(value)) { // 修改 const augment = hasProto ? protoAugment : copyAugment augment(value, arrayMethods, arrayKeys) this.observeArray(value) } else { this.walk(value) } } } function protoAugment (target, src: Object, keys: any) { target.__proto__ = src } function copyAugment (target: Object, src: Object, keys: Array) { for (let i = 0, l = keys.length; i 關於Array的問題
關於vue對Array的攔截實現上面剛說完,正因為這種實現方式,其實有些陣列操作vue是攔截不到的,例如:
this.list[0] = 2修改陣列第一個元素的值,無法偵測到陣列的變化,所以並不會觸發
re-render
或watch
等。在例如:
this.list.length = 0清空陣列操作,無法偵測到陣列的變化,所以也不會觸發
re-render
或watch
等。因為vue的實現方式就決定了無法對上面舉得兩個例子做攔截,也就沒有辦法做到響應,ES6是有能力做到的,在ES6之前是無法做到模擬陣列的原生行為的,現在 ES6 的 Proxy 可以模擬陣列的原生行為,也可以透過 ES6 的繼承來繼承陣列原生行為,從而進行攔截。
總結
最後掏出vue官網上的一張圖,這張圖其實非常清晰,就是一個變化偵測的原理圖。
getter
到watcher
有一條線,上面寫著收集依賴,意思是說getter
裡收集watcher
,也就是說當資料發生get
動作時開始收集watcher
。
setter
到watcher
有一條線,寫著Notify
意思是說在setter
中觸發訊息,也就是當資料發生set
動作時,通知watcher
。
Watcher
到 ComponentRenderFunction 有一條線,寫著Trigger re-render
意思很明顯了。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2310/viewspace-2799874/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- Vue的變化偵測原理Vue
- 深入淺出 Angular 變更檢測Angular
- Vue陣列變化的偵測的學習Vue陣列
- 深入淺出Service外掛化原理
- 深入淺出 webpack(vue 專案優化)WebVue優化
- 深入淺出 Vue 系列 -- 資料劫持實現原理Vue
- 深入淺出Vue響應式原理(完整版)Vue
- 深入淺出,ARCore開發原理
- 深入淺出HTTPS工作原理HTTP
- 深入淺出 Viewport 設計原理View
- 深入淺出瀏覽器渲染原理瀏覽器
- Hive的原理—— 深入淺出學HiveHive
- [深入淺出Windows 10]佈局原理Windows
- 深入淺出Dotnet Core的專案結構變化
- 深入淺出VACUUM核心原理(中): index by passIndex
- 深入淺出FE(十四)深入淺出websocketWeb
- 深入淺出理解 Spark:環境部署與工作原理Spark
- Redis Sentinel-深入淺出原理和實戰Redis
- 深入淺出 PLT/GOT Hook與原理實踐GoHook
- 深入淺出一致性Hash原理
- 深入淺出MyBatis:MyBatis解析和執行原理MyBatis
- 熱修復——深入淺出原理與實現
- 通過幾個問題深入淺出VueVue
- 【深入淺出ES6】塊級變數變數
- 深入淺出 Java 中列舉的實現原理Java
- 聊一聊cc的變化偵測和hook實現Hook
- 【深入淺出ES6】模組化Modules
- 深入淺出Python字串格式化Python字串格式化
- 深入淺出JS - 變數提升(函式宣告提升)JS變數函式
- 淺入淺出VueVue
- 深入淺出——MVCMVC
- 深入淺出mongooseGo
- HTTP深入淺出HTTP
- 深入淺出IO
- 深入淺出 RabbitMQMQ
- 深入淺出PromisePromise
- ArrayList 深入淺出
- mysqldump 深入淺出MySql