本文你將學到
所謂的響應式資料的概念,其實最主要的目的就是為資料繫結執行函數,當資料發生變動的時候,再次觸發函數的執行。(學習視訊分享:)
例如我們有一個物件data
,我們想讓它變成一個響應式資料,當data
的資料發生變化時,自動執行effect
函數,使nextVal
變數的值也進行變化:
// 定義一個物件 let data = { name: 'pino', age: 18 } let nextVal // 待繫結函數 function effect() { nextVal = data.age + 1 } data.age++
上面的例子中我們將data
中的age
的值進行變化,但是effect
函數並沒有執行,因為現在effect
函數與data
這個物件不能說是沒啥聯絡,簡直就是半毛錢的關係都沒有。
那麼怎麼才能使這兩個毫不相關的函數與物件之間產生關聯呢?
因為一個物件最好可以繫結多個函數,所以有沒有可能我們為data
這個物件定義一個空間,每當data
的值進行變化的時候就會執行這個空間裡的函數?
答案是有的。
js在原生提供了一個用於操作物件的比較底層的api:Object.defineProperty()
,它賦予了我們對一個物件的讀取和攔截的操作。
Object.defineProperty()
方法直接在一個物件上定義一個新屬性,或者修改一個已經存在的屬性, 並返回這個物件。
Object.defineProperty(obj, prop, descriptor)
引數
obj
需要定義屬性的物件。 prop
需被定義或修改的屬性名。 descriptor
(描述符) 需被定義或修改的屬性的描述符。
其中descriptor
接受一個物件,物件中可以定義以下的屬性描述符,使用屬性描述符對一個物件進行攔截和控制:
value
——當試圖獲取屬性時所返回的值。
writable
——該屬性是否可寫。
enumerable
——該屬性在for in迴圈中是否會被列舉。
configurable
——該屬性是否可被刪除。
set()
——該屬性的更新操作所呼叫的函數。
get()
——獲取屬性值時所呼叫的函數。
另外,資料描述符(其中屬性為: enumerable
, configurable
, value
, writable
)與存取描述符(其中屬性為 enumerable
, configurable
, set()
, get()
)之間是有互斥關係的。在定義了 set()
和 get()
之後,描述符會認為存取操作已被 定義了,其中再定義 value
和 writable
會引起錯誤。
let obj = { name: "小花" } Object.defineProperty(obj, 'name', { // 屬性讀取時進行攔截 get() { return '小明'; }, // 屬性設定時攔截 set(newValue) { obj.name = newValue; }, enumerable: true, configurable: true });
上面的例子中就已經完成對一個物件的最基本的攔截,這也是vue2.x
中對物件監聽的方式,但是由於Object.defineProperty()
中存在一些問題,例如:
一次只能對一個屬性進行監聽,需要遍歷來對所有屬性監聽
對於物件的新增屬性,需要手動監聽
對於陣列通過push
、unshift
方法增加的元素,也無法監聽
那麼vue3
版本中是如何對一個物件進行攔截的呢?答案是es6
中的Proxy
。
由於本文主要是vue3
版本的響應式的實現,如果想要深入瞭解Object.defineProperty()
,請移步:MDN Object.defineProperty
proxy
是es6
版本出現的一種對物件的操作方式,Proxy
可以理解成,在目標物件之前架設一層「攔截」,外界對該物件的存取,都必須先通過這層攔截,因此提供了一種機制,可以對外界的存取進行過濾和改寫。Proxy
這個詞的原意是代理,用在這裡表示由它來「代理」某些操作,可以譯為「代理器」。
通過proxy
我們可以實現對一個物件的讀取,設定等等操作進行攔截,而且直接對物件進行整體攔截,內部提供了多達13種攔截方式。
get(target, propKey, receiver) :攔截物件屬性的讀取,比如 proxy.foo
和 proxy['foo']
。
set(target, propKey, value, receiver) :攔截物件屬性的設定,比如 proxy.foo = v
或 proxy['foo'] = v
,返回一個布林值。
has(target, propKey) :攔截 propKey in proxy
的操作,返回一個布林值。
deleteProperty(target, propKey) :攔截 delete proxy[propKey]
的操作,返回一個布林值。
ownKeys(target) :攔截 Object.getOwnPropertyNames(proxy)
、 Object.getOwnPropertySymbols(proxy)
、 Object.keys(proxy)
、 for...in
迴圈,返回一個陣列。該方法返回目標物件所有自身的屬性的屬性名,而 Object.keys()
的返回結果僅包括目標物件自身的可遍歷屬性。
getOwnPropertyDescriptor(target, propKey) :攔截 Object.getOwnPropertyDescriptor(proxy, propKey)
,返回屬性的描述物件。
defineProperty(target, propKey, propDesc) :攔截 Object.defineProperty(proxy, propKey, propDesc)
、 Object.defineProperties(proxy, propDescs)
,返回一個布林值。
preventExtensions(target) :攔截 Object.preventExtensions(proxy)
,返回一個布林值。
getPrototypeOf(target) :攔截 Object.getPrototypeOf(proxy)
,返回一個物件。
isExtensible(target) :攔截 Object.isExtensible(proxy)
,返回一個布林值。
setPrototypeOf(target, proto) :攔截 Object.setPrototypeOf(proxy, proto)
,返回一個布林值。如果目標物件是函數,那麼還有兩種額外操作可以攔截。
apply(target, object, args) :攔截 Proxy (代理) 範例作為函數呼叫的操作,比如 proxy(...args)
、 proxy.call(object, ...args)
、 proxy.apply(...)
。
construct(target, args) :攔截 Proxy (代理) 範例作為建構函式呼叫的操作,比如 new proxy(...args)
。
如果想要詳細瞭解proxy
,請移步:es6.ruanyifeng.com/#docs/proxy…
let obj = { name: "小花" } // 只使用get和set進行演示 let obj2 = new Proxy(obj, { // 讀取攔截 get: function (target, propKey) { return target[propKey] }, // 設定攔截 set: function (target, propKey, value) { // 此處的value為使用者設定的新值 target[propKey] = value } });
有了proxy
,我們就可以根據之前的思路實現一個基本的響應式功能了,我們的思路是這樣的:在物件被讀取時把函數收集到一個「倉庫」,在物件的值被設定時觸發倉庫中的函數。
由此我們可以寫出一個最基本的響應式功能:
// 定義一個「倉庫」,用於儲存觸發函數 let store = new Set() // 使用proxy進行代理 let data_proxy = new Proxy(data, { // 攔截讀取操作 get(target, key) { // 收集依賴函數 store.add(effect) return target[key] }, // 攔截設定操作 set(target, key, newVal) { target[key] = newVal // 取出所有的依賴函數,執行 store.forEach(fn => fn()) } })
我們建立了一個用於儲存依賴函數的「倉庫」,它是Set
型別,然後使用proxy
對物件data
進行代理,設定了set
和get
攔截函數,用於攔截讀取和設定操作,當讀取屬性時,將依賴函數effect
儲存到「倉庫」中,當設定屬性值時,將依賴函數從「倉庫」中取出並重新執行。
還有一個小問題,怎麼觸發物件的讀取操作呢?我們可以直接呼叫一次effect
函數,如果在effect
函數中存在需要收集的屬性,那麼執行一次effect
函數也是比較符合常理的。
// 定義一個物件 let data = { name: 'pino', age: 18 } let nextVal // 待繫結函數 function effect() { // 依賴函數在這裡被收集 // 當呼叫data.age時,effect函數被收集到「倉庫」中 nextVal = data.age + 1 console.log(nextVal) } // 執行依賴函數 effect() // 19 setTimeout(()=>{ // 使用proxy進行代理後,使用代理後的物件名 // 觸發設定操作,此時會取出effect函數進行執行 data_proxy.age++ // 2秒後輸出 20 }, 2000)
一開始會執行一次effect
,然後函數兩秒鐘後會執行代理物件設定操作,再次執行effect
函數,輸出20。
此時整個響應式流程的功能是這樣的:
階段一,在屬性被讀取時,為物件屬性收集依賴函數:
階段二,當屬性發生改變時,再次觸發依賴函數
這樣就實現了一個最基本的響應式的功能。
問題一
其實上面實現的功能還有很大的缺陷,首先最明顯的問題是,我們把effect
函數給固定了,如果使用者使用的依賴函數不叫effect
怎麼辦,顯然我們的功能就不能正常執行了。
所以先來進行第一步的優化:抽離出一個公共方法,依賴函數由使用者來傳遞引數。
我們使用effect
函數來接受使用者傳遞的依賴函數:
// effect接受一個函數,把這個匿名函數當作依賴函數 function effect(fn) { // 執行依賴函數 fn() } // 使用 effect(()=>{ nextVal = data.age + 1 console.log(nextVal) })
但是effect
函數內部只是執行了,在get
函數中怎麼能知道使用者傳遞的依賴函數是什麼呢,這兩個操作並不在一個函數內啊?其實可以使用一個全域性變數activeEffect
來儲存當前正在處理的依賴函數。
修改後的effect
函數是這樣的:
let activeEffect // 新增 function effect(fn) { // 儲存到全域性變數activeEffect activeEffect = fn // 新增 // 執行依賴函數 fn() } // 而在get內部只需要�收集activeEffect即可 get(target, key) { store.add(activeEffect) return target[key] },
呼叫effect
函數傳遞一個匿名函數作為依賴函數,當執行時,首先會把匿名函數賦值給全域性變數activeEffect
,然後觸發屬性的讀取操作,進而觸發get
攔截,將全域性變數activeEffect
進行收集。
問題二
從上面我們定義的物件可以看到,我們的物件data
中有兩個屬性,上面的例子中我們只給age
建立了響應式連線,那麼如果我現在也想給name
建立響應式連線怎麼辦呢?那好說,那我們直接向「倉庫」中繼續新增依賴函數不就行了嗎。
其實這會帶來很嚴重的問題,由於 「倉庫」並沒有與被操作的目標屬性之間建立聯絡,而上面我們的實現只是將整個「倉庫」遍歷了一遍,所以無論哪個屬性被觸發,都會將「倉庫」中所有的依賴函數都取出來執行一遍,因為整個執行程式中可能有很多物件及屬性都設定了響應式聯絡,這將會帶來很大的效能浪費。所謂牽一髮而動全身,這種結果顯然不是我們想要的。
let data = { name: 'pino', age: 18 }
所以我們要重新設計一下「倉庫」的資料結構,目的就是為了可以在屬性這個粒度下和「倉庫」建立明確的聯絡。
就拿我們上面進行操作的物件來說,存在著兩層的結構,有兩個角色,物件data
以及屬性name``age
let data = { name: 'pino', age: 18 }
他們的關係是這樣的:
data -> name -> effectFn // 如果兩個屬性讀取了同一個依賴函數 data -> name -> effectFn -> age -> effectFn // 如果兩個屬性讀取了不同的依賴函數 data -> name -> effectFn -> age -> effectFn1 // 如果是兩個不同的物件 data -> name -> effectFn -> age -> effectFn1 data2 -> addr -> effectFn
接下來我們實現一下程式碼,為了方便呼叫,將設定響應式資料的操作封裝為一個函數reactive
:
let newObj = new Proxy(obj, { // 讀取攔截 get: function (target, propKey) { }, // 設定攔截 set: function (target, propKey, value) { } }); // 封裝為 function reactive(obj) { return new Proxy(obj, { // 讀取攔截 get: function (target, propKey) { }, // 設定攔截 set: function (target, propKey, value) { } }); }
function reactive(obj) { return new Proxy(obj, { get(target, key) { // 收集依賴 track(target, key) return target[key] }, set(target, key, newVal) { target[key] = newVal // 觸發依賴 trigger(target, key) } }) } function track(target, key) { // 如果沒有依賴函數,則不需要進行收集。直接return if (!activeEffect) return // 獲取target,也就是物件名,對應上面例子中的data let depsMap = store.get(target) if (!depsMap) { store.set(target, (depsMap = new Map())) } // 獲取物件中的key值,對應上面例子中的name或age let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } // 收集依賴函數 deps.add(activeEffect) } function trigger(target, key) { // 取出物件對應的Map let depsMap = store.get(target) if(!depsMap) return // 取出key所對應的Set let deps = depsMap.get(key) // 執行依賴函數 deps && deps.forEach(fn => fn()); }
我們將讀取操作封裝為了函數track
,觸發依賴函數的動作封裝為了trigger
方便呼叫,現在的整個「倉庫」結構是這樣的:
可能有人會問了,為什麼設定「倉庫」要使用WeakMap
呢,我使用一個普通物件來建立不行嗎? -
WeakMap
結構與 Map
結構類似,也是用於生成鍵值對的集合。
WeakMap
與 Map
的區別有兩點。
首先, WeakMap
只接受物件作為鍵名( null
除外),不接受其他型別的值作為鍵名。
const map = new WeakMap(); map.set(1, 2) // TypeError: 1 is not an object! map.set(Symbol(), 2) // TypeError: Invalid value used as weak map key map.set(null, 2) // TypeError: Invalid value used as weak map key
上面程式碼中,如果將數值 1
和 Symbol
值作為 WeakMap 的鍵名,都會報錯。
其次, WeakMap
的鍵名所指向的物件,不計入垃圾回收機制。
WeakMap
的設計目的在於,有時我們想在某個物件上面存放一些資料,但是這會形成對於這個物件的參照。請看下面的例子。
const e1 = document.getElementById('foo'); const e2 = document.getElementById('bar'); const arr = [ [e1, 'foo 元素'], [e2, 'bar 元素'], ];
上面程式碼中, e1
和 e2
是兩個物件,我們通過 arr
陣列對這兩個物件新增一些文字說明。這就形成了 arr
對 e1
和 e2
的參照。
一旦不再需要這兩個物件,我們就必須手動刪除這個參照,否則垃圾回收機制就不會釋放 e1
和 e2
佔用的記憶體。
// 不需要 e1 和 e2 的時候 // 必須手動刪除參照 arr [0] = null; arr [1] = null;
上面這樣的寫法顯然很不方便。一旦忘了寫,就會造成記憶體洩露。
它的鍵名所參照的物件都是弱參照,即垃圾回收機制不將該參照考慮在內。因此,只要所參照的物件的其他參照都被清除,垃圾回收機制就會釋放該物件所佔用的記憶體。也就是說,一旦不再需要,WeakMap
裡面的鍵名物件和所對應的鍵值對會自動消失,不用手動刪除參照。
如果我們上文中target
物件沒有任何參照了,那麼說明使用者已經不需要用到它了,這時垃圾回收器會自動執行回收,而如果使用Map
來進行收集,那麼即使其他地方的程式碼已經對target
沒有任何參照,這個target
也不會被回收。
在vue3中的實現方式和我們的基本實現還有一點不同就是在vue3中是使用Reflect
來運算元據的,例如:
function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { track(target, key) // 使用Reflect.get操作讀取資料 return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { trigger(target, key) // 使用Reflect.set來操作觸發資料 Reflect.set(target, key, value, receiver) } }) }
那麼為什麼要使用Reflect
來運算元據呢,像之前一樣直接操作原物件不行嗎,我們先來看一下一種特殊的情況:
const obj = { foo: 1, get bar() { return this.foo } }
在effect
依賴函數中通過代理物件p存取bar屬性:
effect(()=>{ console.log(p.bar) // 1 })
可以分析一下這個過程發生了什麼,當effect
函數被呼叫時,會讀取p.bar
屬性,他發現p.bar
屬性是一個存取器屬性,因此會執行getter
函數,由於在getter
函數中通過this.foo
讀取了foo
屬性的值,因此我們會認為副作用函數與屬性foo
之間也會建立聯絡,當修改p.foo
的值的時候因該也能夠觸發響應,使依賴函數重新執行才對,然而當修改p.foo
的時候,並沒有觸發依賴函數:
p.foo++
實際上問題就出在bar
屬性中的存取器函數getter
上:
get bar() { // 這個this究竟指向誰? return this.foo }
當通過代理物件p存取p.bar
,這回觸發代理物件的get
攔截函數執行:
const p = new Proxt(obj, { get(target, key) { track(target, key) return target[key] } })
可以看到在get
的攔截函數中,通過target[key]
返回屬性值,其中target
是原始物件obj
,而key
就是字串'bar'
,所以target[key]
就相當於obj.bar
。因此當我們使用p.bar
存取bar
屬性時,他的getter
函數內的this
其實指向原始物件obj
,這說明我們最終存取的是obj.foo
。所以在依賴函數內部通過原始物件存取他的某個屬性是不會建立響應聯絡的:
effect(()=>{ // obj是原始資料,不是代理物件,不會建立響應聯絡 obj.foo })
那麼怎麼解決這個問題呢,這時候就需要用到 Reflect
出場了。
先來看一下Reflect
是啥:
Reflect
函數的功能就是提供了存取一個物件屬性的預設行為,例如下面兩個操作是等價的:
const obj = { foo: 1 } // 直接讀取 console.log(obj.foo) //1 // 使用Reflect.get讀取 console.log(Reflect.get(obj, 'foo')) // 1
實際上Reflect.get
函數還能接受第三個函數,即制定接受者receiver
,可以把它理解為函數呼叫過程中的this
:
const obj = { foo: 1 } console.log(Reflect.get(obj, 'foo', { foo: 2 })) // 輸出的是 2 而不是 1
在這段程式碼中,指定了第三個引數receiver為一個物件{ foo: 2 }
,這是讀取到的值時receiver
物件的foo
屬性。
而我們上文中的問題的解決方法就是在操作物件資料的時候通過Reflect
的方法來傳遞第三個引數receiver
,它代表誰在讀取屬性:
const p = new Proxt(obj, { // 讀取屬性接收receiver get(target, key, receiver) { track(target, key) // 使用Reflect.get返回讀取到的屬性值 return Reflect.get(target, key, receiver) } })
當使用代理物件p
存取bar
屬性時,那麼receiver
就是p,可以把它理解為函數呼叫中的this
。
所以我們改造一下reactive
函數的實現:
function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { track(target, key) return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { trigger(target, key) Reflect.set(target, key, value, receiver) } }) }
擴充套件
Proxy -> get()
get
方法用於攔截某個屬性的讀取操作,可以接受三個引數,依次為目標物件、屬性名和 proxy
(代理) 範例本身(嚴格地說,是操作行為所針對的物件),其中最後一個引數可選。
Reflect.get(target, name, receiver)
Reflect.get
方法查詢並返回 target
物件的 name
屬性,如果沒有該屬性,則返回 undefined
。
var myObject = { foo: 1, bar: 2, get baz() { return this.foo + this.bar; }, } Reflect.get(myObject, 'foo') // 1 Reflect.get(myObject, 'bar') // 2 Reflect.get(myObject, 'baz') // 3
如果 name
屬性部署了讀取函數( getter ),則讀取函數的 this
繫結 receiver
。
var myObject = { foo: 1, bar: 2, get baz() { return this.foo + this.bar; }, }; var myReceiverObject = { foo: 4, bar: 4, }; Reflect.get(myObject, 'baz', myReceiverObject) // 8
如果第一個引數不是物件, Reflect.get
方法會報錯。
Reflect.get(1, 'foo') // 報錯 Reflect.get(false, 'foo') // 報錯
Reflect.set(target, name, value, receiver)
Reflect.set
方法設定 target
物件的 name
屬性等於 value
。
var myObject = { foo: 1, set bar(value) { return this.foo = value; }, } myObject.foo // 1 Reflect.set(myObject, 'foo', 2); myObject.foo // 2 Reflect.set(myObject, 'bar', 3) myObject.foo // 3
如果 name
屬性設定了賦值函數,則賦值函數的 this
繫結 receiver
。
var myObject = { foo: 4, set bar(value) { return this.foo = value; }, }; var myReceiverObject = { foo: 0, }; Reflect.set(myObject, 'bar', 1, myReceiverObject); myObject.foo // 4 myReceiverObject.foo // 1
注意,如果 Proxy
物件和 Reflect
物件聯合使用,前者攔截賦值操作,後者完成賦值的預設行為,而且傳入了 receiver
,那麼 Reflect.set
會觸發 Proxy.defineProperty
攔截。
let p = { a: 'a' }; let handler = { set(target, key, value, receiver) { console.log('set'); Reflect.set(target, key, value, receiver) }, defineProperty(target, key, attribute) { console.log('defineProperty'); Reflect.defineProperty(target, key, attribute); } }; let obj = new Proxy(p, handler); obj.a = 'A'; // set // defineProperty
上面程式碼中, Proxy.set
攔截裡面使用了 Reflect.set
,而且傳入了 receiver
,導致觸發 Proxy.defineProperty
攔截。這是因為 Proxy.set
的 receiver
引數總是指向當前的 Proxy
範例(即上例的 obj
),而 Reflect.set
一旦傳入 receiver
,就會將屬性賦值到 receiver
上面(即 obj
),導致觸發 defineProperty
攔截。如果 Reflect.set
沒有傳入 receiver
,那麼就不會觸發 defineProperty
攔截。
let p = { a: 'a' }; let handler = { set(target, key, value, receiver) { console.log('set'); Reflect.set(target, key, value) }, defineProperty(target, key, attribute) { console.log('defineProperty'); Reflect.defineProperty(target, key, attribute); } }; let obj = new Proxy(p, handler); obj.a = 'A'; // set
如果第一個引數不是物件, Reflect.set
會報錯。
Reflect.set(1, 'foo', {}) // 報錯 Reflect.set(false, 'foo', {}) // 報錯
到這裡,一個非常基本的響應式的功能就完成了,整體程式碼如下:
// 定義倉庫 let store = new WeakMap() // 定義當前處理的依賴函數 let activeEffect function effect(fn) { // 將操作包裝為一個函數 const effectFn = ()=> { activeEffect = effectFn fn() } effectFn() } function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { // 收集依賴 track(target, key) return Reflect.get(target, key, receiver) }, set(target, key, newVal, receiver) { // 觸發依賴 trigger(target, key) Reflect.set(target, key, newVal, receiver) } }) } function track(target, key) { // 如果沒有依賴函數,則不需要進行收集。直接return if (!activeEffect) return // 獲取target,也就是物件名 let depsMap = store.get(target) if (!depsMap) { store.set(target, (depsMap = new Map())) } // 獲取物件中的key值 let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } // 收集依賴函數 deps.add(activeEffect) } function trigger(target, key) { // 取出物件對應的Map let depsMap = store.get(target) if (!depsMap) return // 取出key所對應的Set const effects = depsMap.get(key) // 執行依賴函數 // 為避免汙染,建立一個新的Set來進行執行依賴函數 let effectsToRun = new Set() effects && effects.forEach(effectFn => { effectsToRun.add(effectFn) }) effectsToRun.forEach(effect => effect()) }
在日常的工作中,effect
函數並不是單獨存在的,比如在vue的渲染函數中,各個元件之間互相巢狀,那麼他們在元件中所使用的effect
是必然會發生巢狀的:
effect(function effectFn1() { effect(function effectFn1() { // ... }) })
當元件中發生巢狀時,此時的渲染函數:
effect(()=>{ Father.render() //巢狀子元件 effect(()=>{ Son.render() }) })
但是此時我們實現的effect
並沒有這個能力,執行下面這段程式碼,並不會出現意料之中的行為:
const data = { foo: 'pino', bar: '在幹啥' } // 建立代理物件 const obj = reactive(data) let p1, p2; // 設定obj.foo的依賴函數 effect(function effect1(){ console.log('effect1執行'); // 巢狀,obj.bar的依賴函數 effect(function effect2(){ p2 = obj.bar console.log('effect2執行') }) p1 = obj.foo })
在這段程式碼中,定義了代理物件obj
,裡面有兩個屬性foo
和bar
,然後定義了收集foo
的依賴函數,在依賴函數的內部又定義了bar
的依賴函數。
在理想狀態下,我們希望依賴函數與屬性之間的關係如下:
obj -> foo -> effect1 -> bar -> effect2
當修改obj.foo
的值的時候,會觸發effect1
函數執行,由於effect2
函數在effect
函數內部,所以effect2
函數也會執行,而當修改obj.bar
時,只會觸發effect2
函數。接下來修改一下obj.foo
:
const data = { foo: 'pino', bar: '在幹啥' } // 建立代理物件 const obj = reactive(data) let p1, p2; // 設定obj.foo的依賴函數 effect(function effect1(){ console.log('effect1執行'); // 巢狀,obj.bar的依賴函數 effect(function effect2(){ p2 = obj.bar console.log('effect2執行') }) p1 = obj.foo }) // 修改obj.foo的值 obj.foo = '前來買瓜'
看一下執行結果:
可以看到effect2
函數竟然執行了兩次?按照之前的分析,當obj.foo
被修改後,應當觸發effect1
這個依賴函數,但是為什麼會effect2
會被再次執行呢?
來看一下我們effect
函數的實現:
function effect(fn) { // 將依賴函數進行包裝 const effectFn = ()=> { activeEffect = effectFn fn() } effectFn() }
其實在這裡就已經很容易看出問題了,在接受使用者傳遞過來的值時,我們直接將activeEffect
這個全域性變數進行了覆蓋!所以在內部執行完後,activeEffect
這個變數就已經是effect2
函數了,而且永遠不會再次變為effect1
,此時再進行收集依賴函數時,永遠收集的都是effect2
函數。
那麼如何解決這種問題呢,這種情況可以借鑑棧結構來進行處理,棧結構是一種後進先出的結構,在依賴函數執行時,將當前的依賴函數壓入棧中,等待依賴函數執行完畢後將其從棧中彈出,始終activeEffect
指向棧頂的依賴函數。
// 增加effect呼叫棧 const effectStack = [] // 新增 function effect(fn) { let effectFn = function () { activeEffect = effectFn // 入棧 effectStack.push(effectFn) // 新增 // 執行函數的時候進行get收集 fn() // 收集完畢後彈出 effectStack.pop() // 新增 // 始終指向棧頂 activeEffect = effectStack[effectStack.length - 1] // 新增 } effectFn() }
此時兩個屬性所對應的依賴函數便不會發生錯亂了。
如果現在將effect
函數中傳遞的依賴函數改一下:
// 定義一個物件 let data = { name: 'pino', age: 18 } // 將data更改為響應式物件 let obj = reactive(data) effect(() => { obj.age++ })
在這段程式碼中,我們將代理物件obj
的age
屬性執行自增操作,但是執行這段程式碼,卻發現竟然棧溢位了?這是怎麼回事呢?
其實在effect
中處理依賴函數時,obj.age++
的操作其實可以看做是這樣的:
effect(()=>{ // 等式右邊的操作是先執行了一次讀取操作 obj.age = obj.age + 1 })
這段程式碼的執行流程是這樣的:首先讀取obj.foo
的值,這會觸發track
函數進行收集操作,也就是將當前的依賴函數收集到「倉庫」中,接著將其加1後再賦值給obj.foo
,此時會觸發trigger
操作,即把「倉庫」中的依賴函數取出並執行。但是此時該依賴函數正在執行中,還沒有執行完就要再次開始下一次的執行。就會導致無限的遞迴呼叫自己。
解決這個問題,其實只需要在觸發函數執行時,判斷當前取出的依賴函數是否等於activeEffect
,就可以避免重複執行同一個依賴函數。
function trigger(target, key) { // 取出物件對應的Map let depsMap = store.get(target) if (!depsMap) return // 取出key所對應的Set const effects = depsMap.get(key) // // 執行依賴函數 // 因為刪除又新增都在同一個deps中,所以會產生無限執行 let effectsToRun = new Set() effects && effects.forEach(effectFn => { // 如果trigger出發執行的副作用函數與當前正在執行的副作用函數相同,則不觸發執行 if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) effectsToRun.forEach(effect => effect()) }
computed
是vue3中的計算屬性,它可以根據傳入的引數進行響應式的處理:
const plusOne = computed(() => count.value + 1)
根據computed
的用法,我們可以知道它的幾個特點:
懶執行,值變化時才會觸發
快取功能,如果值沒有變化,就會返回上一次的執行結果 在實現這兩個核心功能之前,我們先來改造一下之前實現的effect
函數。
怎麼能使effect
函數變成懶執行呢,比如計算屬性的這種功能,我們不想要他立即執行,而是希望在它需要的時候才執行。
這時候我們可以在effect
函數中傳遞第二個引數,一個物件,用來設定一些額外的功能。
function effect(fn, options = {}) { // 修改 let effectFn = function () { activeEffect = effectFn effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } // 只有當非lazy的時候才直接執行 if(!options.lazy) { effectFn() } // 將依賴函陣列為返回值進行返回 return effectFn // 新增 }
這時,如果傳遞了lazy
屬性,那麼該effect
將不會立即執行,需要手動進行執行:
const effectFn = effect(()=>{ console.log(obj.foo) }, { lazy: true }) // 手動執行 effectFn()
但是如果我們想要獲取手動執行後的值呢,這時只需要在effect
函數中將其返回即可。
function effect(fn, options = {}) { let effectFn = function () { activeEffect = effectFn effectStack.push(effectFn) // 儲存返回值 const res = fn() // 新增 effectStack.pop() activeEffect = effectStack[effectStack.length - 1] return res // 新增 } // 只有當非lazy的時候才直接執行 if(!options.lazy) { effectFn() } // 將依賴函陣列為返回值進行返回 return effectFn }
接下來開始實現computed
函數:
function computed(getter) { // 建立一個可手動呼叫的依賴函數 const effectFn = effect(getter, { lazy: true }) // 當物件被存取的時候才呼叫依賴函數 const obj = { get value() { return effectFn() } } return obj }
但是此時還做不到對值進行快取和對比,增加兩個變數,一個儲存執行的值,另一個為一個開關,表示「是否可以重新執行依賴函數」:
function computed(getter) { // 定義value儲存執行結果 // isRun表示是否需要執行依賴函數 let value, isRun = true; // 新增 const effectFn = effect(getter, { lazy: true }) const obj = { get value() { // 增加判斷,isRun為true時才會重新執行 if(isRun) { // 新增 // 儲存執行結果 value = effectFn() // 新增 // 執行完畢後再次重置執行開關 isRun = false // 新增 } return value } } return obj }
但是上面的實現還有一個問題,就是好像isRun
執行一次後好像永遠都不會變成true
了,我們的本意是在資料發生變動的時候需要再次觸發依賴函數,也就是將isRun變為true,實現這種效果,需要我們為options
再傳遞一個函數,用於使用者自定義的排程執行。
function effect(fn, options = {}) { let effectFn = function () { activeEffect = effectFn effectStack.push(effectFn) const res = fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] return res } // 掛載使用者自定義的排程執行器 effectFn.options = options // 新增 if(!options.lazy) { effectFn() } return effectFn }
接下來需要修改一下trigger
如果傳遞了scheduler
這個函數,那麼只執行scheduler
這個函數而不執行依賴函數:
function trigger(target, key) { let depsMap = store.get(target) if (!depsMap) return const effects = depsMap.get(key) let effectsToRun = new Set() effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) effectsToRun.forEach(effect => { // 如果存在排程器scheduler,那麼直接呼叫該排程器,並將依賴函數進行傳遞 if(effectFn.options.scheduler) { // 新增 effectFn.options.scheduler(effect) // 新增 } else { effect() } }) }
那麼在computed
中就可以實現重置執行開關isRun
的操作了:
function computed(getter) { // 定義value儲存執行結果 // isRun表示是否需要執行依賴函數 let value, isRun = true; // 新增 const effectFn = effect(getter, { lazy: true, scheduler() { if(!isRun) { isRun = true } } }) const obj = { get value() { // 增加判斷,isRun為true時才會重新執行 if(isRun) { // 新增 // 儲存執行結果 value = effectFn() // 新增 // 執行完畢後再次重置執行開關 isRun = false // 新增 } return value } } return obj }
當computed
傳入的依賴函數中的值發生改變時,會觸發響應式物件的trigger
函數,而計算屬性建立響應式物件時傳入了scheduler
,所以當資料改變時,只會執行scheduler
函數,在scheduler
函數內我們將執行開關重置為true
,再下次存取資料觸發get
函數時,就會重新執行依賴函數。這也就實現了當資料發生改變時,會再次觸發依賴函數的功能了。
為了避免計算屬性被另外一個依賴函數呼叫而失去響應,我們還需要為計算屬性單獨進行繫結響應式的功能,形成一個effect
巢狀。
function computed(getter) { let value, isRun = true; const effectFn = effect(getter, { lazy: true, scheduler() { if(!isRun) { isRun = true // 當計算屬性依賴的響應式資料發生變化時,手動呼叫trigger函數觸發響應 trigger(obj, 'value') // 新增 } } }) const obj = { get value() { if(isRun) { value = effectFn() isRun = false } // 當讀取value時,手動呼叫track函數進行追蹤 track(obj, 'value') return value } } return obj }
先來看一下watch
函數的用法,它的用法也非常簡單:
watch(obj, ()=>{ console.log(改變了) }) // 修改資料,觸發watch函數 obj.age++
watch
接受兩個引數,第一個引數為繫結的響應式資料,第二個引數為依賴函數,我們依然可以沿用之前的思路來進行處理,利用effect
以及scheduler
來改變觸發執行時機。
function watch(source, fn) { effect( // 遞迴讀取物件中的每一項,變為響應式資料,繫結依賴函數 ()=> bindData(source), { scheduler() { // 當資料發生改變時,呼叫依賴函數 fn() } } ) } // readData儲存已讀取過的資料,防止重複讀取 function bindData(value, readData = new Set()) { // 此處只考慮物件的情況,如果值已被讀取/值不存在/值不為物件,那麼直接返回 if(typeof value !== 'object' || value == null || readData.has(value)) return // 儲存已讀取物件 readData.add(value) // 遍歷物件 for(const key in value) { // 遞迴進行讀取 bindData(value[key], readData) } return value }
watch
函數還有另外一種用法,就是除了接收物件,還可以接受一個getter
函數,例如:
watch( ()=> obj.age, ()=> { console.log('改變了') } )
這種情況下只需要將使用者傳入的getter
將我們自定義的bindData
替代即可:
function watch(source, fn) { let getter = typeof source === 'function' ? source : (()=> bindData(source)) effect( // 執行getter ()=> getter(), { scheduler() { // 當資料發生改變時,呼叫依賴函數 fn() } } ) }
其實watch
函數還有一個很重要的功能:就是在使用者傳遞的依賴函數中可以獲取新值和舊值,但是我們目前還做不到這一點。實現這個功能我們可以設定前文中的lazy
屬性來實現。
來回顧一下lazy
屬性:設定了lazy
之後一開始不會執行依賴函數,手動執行時會返回執行結果:
function watch(source, fn) { let getter = typeof source === 'function' ? source : (()=> bindData(source)) // 定義新值與舊值 let newVal, oldVal; // 新增 const effectFn = effect( // 執行getter ()=> getter(), { lazy: true, scheduler() { // 在scheduler重新執行依賴函數,得到新值 newVal = effectFn() // 新增 fn(newVal, oldVal) // 新增 // 執行完畢後更新舊值 oldVal = newVal // 新增 } } ) // 手動呼叫依賴函數,取得舊值 oldVal = effectFn() // 新增 }
此外,watch
函數還有一個功能,就是可以自定義執行時機,比如immediate
屬性,他會在建立時立即執行一次:
watch(obj, ()=>{ console.log('改變了') }, { immediate: true })
我們可以把scheduler
封裝為一個函數,以便在不同的時機去呼叫他:
function watch(source, fn, options = {}) { let getter = typeof source === 'function' ? source : (()=> bindData(source)) let newVal, oldVal; const run = () => { // 新增 newVal = effectFn() fn(newVal, oldVal) oldVal = newVal } const effectFn = effect( ()=> getter(), { lazy: true, // 使用run來執行依賴函數 scheduler: run // 修改 } ) // 當immediate為true時,立即執行一次依賴函數 if(options.immediate) { // 新增 run() // 新增 } else { oldVal = effectFn() } }
watch
函數還支援其他的執行呼叫時機,這裡只實現了immediate
。
深響應和淺響應的區別:
const obj = reatcive({ foo: { bar: 1} }) effect(()=>{ console.log(obj.foo.bar) }) // 修改obj.foo.bar的值,並不能觸發響應 obj.foo.bar = 2
因為之前實現的攔截,無論對於什麼型別的資料都是直接進行返回的,如果實現深響應,那麼首先應該判斷是否為物件型別的值,如果是物件型別的值,應當遞迴呼叫reactive
方法進行轉換。
// 接收第二個引數,標記為是否為淺響應 function createReactive(obj, isShallow = false) { return new Proxy(obj, { get(target, key, receiver) { // 存取raw時,返回原物件 if(key === 'raw') return target track(target, key) const res = Reflect.get(target, key, receiver) // 如果是淺響應,直接返回值 if(isShallow) { return res } // 判斷res是否為物件並且不為null,迴圈呼叫reatcive if(typeof res === 'object' && res !== null) { return reatcive(res) } return res }, // ...省略其他 })
將建立響應式物件的方法抽離出去,通過傳遞isShallow
引數來決定是否建立深響應/淺響應物件。
// 深響應 function reactive(obj) { return createReactive(obj) } // 淺響應 function shallowReactive(obj) { return createReactive(obj, true) }
有時候我們並不需要對值進行修改,也就是需要值為唯讀的,這個操作也分為深唯讀和淺唯讀,首先需要在createReactive
函數中增加一個引數isReadOnly
,代表是否為唯讀屬性。
// 淺唯讀 function shallowReadOnly(obj) { return createReactive(obj, true, true) } // 深唯讀 function readOnly(obj) { return createReactive(obj, false, true) }
set(target, key, newValue, receiver) { // 是否為唯讀屬性,如果是則列印警告資訊並直接返回 if(isReadOnly) { console.log(`屬性${key}是唯讀的`) return false } const oldVal = target[key] const type = Object.prototype.hasOwnProperty.call(target, key) ? triggerType.SET : triggerType.ADD const res = Reflect.set(target, key, newValue, receiver) if (target === receiver.raw) { if (oldVal !== newValue && (oldVal === oldVal || newValue === newValue)) { trigger(target, key, type) } } return res }
如果為唯讀屬性,那麼也不需要為其建立響應聯絡
如果為唯讀屬性,那麼在進行深層次遍歷的時候,需要呼叫readOnly
函數對值進行包裝
function createReactive(obj, isShallow = false, isReadOnly = false) { return new Proxy(obj, { get(target, key, receiver) { // 存取raw時,返回原物件 if (key === 'raw') return target //只有在非唯讀的時候才需要建立響應聯絡 if(!isReadOnly) { track(target, key) } const res = Reflect.get(target, key, receiver) // 如果是淺響應,直接返回值 if (isShallow) { return res } // 判斷res是否為物件並且不為null,迴圈呼叫creative if (typeof res === 'object' && res !== null) { // 如果資料為唯讀,則呼叫readOnly對值進行包裝 return isReadOnly ? readOnly(res) : creative(res) } return res }, }) }
如果運算元組時,設定的索引值大於陣列當前的長度,那麼要更新陣列的length
屬性,所以當通過索引設定元素值時,可能會隱式的修改length
的屬性值,因此再j進行觸發響應時,也應該觸發與length
屬性相關聯的副作用函數重新執行。
const arr = reactive(['foo']) // 陣列原來的長度為1 effect(()=>{ console.log(arr.length) //1 }) // 設定索引為1的值,會導致陣列長度變為2 arr[1] = 'bar'
在判斷操作型別時,新增對陣列型別的判斷,如果代理目標是陣列,那麼對於操作型別的判斷作出處理:
如果設定的索引值小於陣列的長度,就視為SET
操作,因為他不會改變陣列長度,如果設定的索引值大於當前陣列的長度,那麼應該被視為ADD
操作。
// 定義常數,便於修改 const triggerType = { ADD: 'add', SET: 'set' } set(target, key, newValue, receiver) { if(isReadOnly) { console.log(`屬性${key}是唯讀的`) return false } const oldVal = target[key] // 如果目標物件是陣列,檢測被設定的索引值是否小於陣列長度 const type = Array.isArray(target) && (Number(key) > target.length ? triggerType.ADD : triggerType.SET) const res = Reflect.set(target, key, newValue, receiver) trigger(target, key, type) return res },
function trigger(target, key, type) { const depsMap = store.get(target) if (!depsMap) return const effects = depsMap.get(key) let effectsToRun = new Set() effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) // 當操作型別是ADD並且目標物件時陣列時,應該取出執行那些與 length 屬性相關的副作用函數 if(Array.isArray(target) && type === triggerType.ADD) { // 取出與length相關的副作用函數 const lengthEffects = deps.get('length') lengthEffects && lengthEffects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) } effectsToRun.forEach(effect => { if (effectFn.options.scheduler) { effectFn.options.scheduler(effect) } else { effect() } }) }
還有一點:其實修改陣列的length
屬性也會隱式的影響陣列元素:
const arr = reactive(['foo']) effect(()=>{ // 存取陣列的第0個元素 console.log(arrr[0]) // foo }) // 將陣列的長度修改為0,導致第0個元素被刪除,因此應該觸發響應 arr.length = 0
如上所示,在副作用函數內部存取了第0個元素,然後將陣列的length
屬性修改為0,這回隱式的影響陣列元素,及所有的元素都會被刪除,所以應該觸發副作用函數重新執行。
然而並非所有的對length
屬性值的修改都會影響陣列中的已有元素,如果設定的length
屬性為100,這並不會影響第0個元素,當修改屬性值時,只有那些索引值大於等於新的length
屬性值的元素才需要觸發響應。
呼叫trigger
函數時傳入新值:
set(target, key, newValue, receiver) { if(isReadOnly) { console.log(`屬性${key}是唯讀的`) return false } const oldVal = target[key] // 如果目標物件是陣列,檢測被設定的索引值是否小於陣列長度 const type = Array.isArray(target) && (Number(key) > target.length ? triggerType.ADD : triggerType.SET) const res = Reflect.set(target, key, newValue, receiver) // 將新的值進行傳遞,及觸發響應的新值 trigger(target, key, type, newValue) // 新增 return res }
判斷新的下標值與需要操作的新的下標值進行判斷,因為陣列的key
為下標,所以副作用函數蒐集器是以下標作為key
值的,當length
發生變動時,只需要將新值與每個下標的key
判斷,大於等於新的length
值的需要重新執行副作用函數。
如上圖所示,Map
為根據陣列的key
,也就是id
組成的Map
結構,他們的每一個key
都對應一個Set
,用於儲存這個key
下面的所有的依賴函數。
當length
屬性發生變動時,應當取出所有key
值大於等於length
值的所有依賴函數進行執行。
function trigger(target, key, type, newValue) { const depsMap = store.get(target) if (!depsMap) return const effects = depsMap.get(key) let effectsToRun = new Set() effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) // 如果操作目標是陣列,並且修改了陣列的length屬性 if(Array.isArray(target) && key === 'length') { // 對於索引值大於或等於新的length元素 // 需要把所有相關聯的副作用函數取出並新增到effectToRun中待執行 depsMap.forEach((effects, key)=>{ // key 與 newValue均為陣列下標,因為陣列中key為index if(key >= newValue) { effects.forEach(effectFn=>{ if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) } }) } // ...省略 }
本文的實現陣列這種資料結構只考慮了針對長度發生變化的情況。
由於Proxy的代理目標是非原始值,所以沒有任何手段去攔截對原始值的操作:
let str = 'hi' // 無法攔截對值的修改 str = 'pino'
解決方法是:使用一個非原始值去包裹原始值:
function ref(val) { // 建立一個物件對原始值進行包裹 const wrapper = { value: val } // 使用reactive函數將包裹物件程式設計響應式資料並返回 return reactive(wrapper) }
如何判斷是使用者傳入的物件還是包裹物件呢?
const ref1 = ref(1) const ref2 = reactive({ value: 1 })
只需要在包裹物件內部定義一個不可列舉且不可寫的屬性:
function ref(val) { // 建立一個物件對原始值進行包裹 const wrapper = { value: val } // 定義一個屬性值__v_isRef,值為true,代表是包裹物件 Object.defineProperty(wrapper, '_isRef', { value: true }) // 使用reactive函數將包裹物件程式設計響應式資料並返回 return reactive(wrapper) }
在使用...解構賦值時會導致響應式丟失:
const obj = reactive({ foo: 1, bar: 2 }) // 將響應式資料展開到一個新的物件newObj const newObj = { ...obj } // 此時相當於: const newObj = { foo: 1, bar: 2 } effect(()=>{ //在副作用函數中通過新物件newObj讀取foo屬性值 console.log(newObj.foo) }) // obj,foo並不會觸發響應 obj.foo = 100
首先建立一個響應式物件obj,然後使用展開運運算元得到一個新物件newObj
,他是一個普通物件,不具有響應式的能力,所以修改obj.foo
的值不會觸發副作用函數重新更新。
解決方法:
const newObj = { foo: { // 用於返回其原始的響應式物件 get value() { return obj.foo } }, bar: { get value() { return obj.bar } } }
將單個值包裝為一個物件,相當於存取該屬性的時候會得到該屬性的getter
,在getter
中返回原始的響應式物件。
相當於解構存取newObj.foo
=== obj.foo
。
{ get value() { return obj.foo } }
function toRefs(obj) { let res = {} // 處理整個物件時,將屬性依次進行遍歷,呼叫toRef進行轉化 for(let key in obj) { res[key] = toRef(obj, key) } return res } function toRef(obj, key) { const wrapper = { // 允許讀取值 get value() { return obj[key] }, // 允許設定值 set value(val) { obj[key] = val } } // 標誌為ref物件 Object.defineProperty(wrapper, '_isRef', { value: true }) return wrapper }
使用toRefs
處理整個物件,在toRefs
這個函數中迴圈處理了物件所包含的所有屬性。
const newObj = { ...toRefs(obj) }
當設定value
屬性值的時候,最終設定的是響應式資料的同名屬性值。
一個基本的vue3
響應式就完成了,但是本文所實現的依然是閹割版本,有很多情況都沒有進行考慮,還有好多功能沒有實現,比如:攔截 Map
,Set
,陣列的其他問題,物件的其他問題,其他api的實現,但是上面的實現已經足夠讓你理解vue3響應式原理實現的核心了,這裡還有很多其他的資料需要推薦,比如阮一峰老師的es6教學,對於vue3底層原理的實現,許多知識依然是需要回顧和複習,檢視原始底層的實現,再比如霍春陽老師的《vue.js的設計與實現》這本書,這本書目前我也只看完了一半,但是截止到目前我認為這本書對於學習vue3
的原理是非常深入淺出,鞭辟入裡的,本文的許多例子也是借鑑了這本書。
最後當然是需要取讀一讀原始碼,不過在讀原始碼之前能夠先了解一下實現的核心原理,再去看原始碼是事半功倍的。希望大家都能早日學透原始碼,面試的時候能夠對答如流,工作中遇到的問題也能從原理層面去理解和更好地解決!
目前我也在實現一個mini-vue
,截止到目前只實現了響應式部分,而且與本文的實現方式有所不同,後續還會繼續實現編譯和虛擬DOM部分,歡迎star!
k-vue:
https://github.com/konvyi/k-vue
如果想學習《vue.js的設計與實現》這本書這本書,那麼請關注下面這個連結作為參考,裡面包含了根據具體的問題的功能進行拆分實現,同樣也只實現了響應式的部分!
vue3-analysis:
https://github.com/konvyi/vue3-analysis
(學習視訊分享:、)