最近在我的vue源碼交流群有位面試官分享了一道他的面試題:vue3的ref是如何實現響應式的?下麵有不少小伙伴回答的是Proxy,其實這些小伙伴只回答對了一半。 ...
前言
最近在我的vue源碼交流群
有位面試官分享了一道他的面試題:vue3的ref是如何實現響應式的?下麵有不少小伙伴回答的是Proxy
,其實這些小伙伴只回答對了一半。
-
當ref接收的是一個對象時確實是依靠
Proxy
去實現響應式的。 -
但是ref還可以接收
string
、number
或boolean
這樣的原始類型,當是原始類型時,響應式就不是依靠Proxy
去實現的,而是在value
屬性的getter
和setter
方法中去實現的響應式。
本文將通過debug的方式帶你搞清楚當ref接收的是對象和原始類型時,分別是如何實現響應式的。註:本文中使用的vue版本為3.4.19
。
關註公眾號:【前端歐陽】,給自己一個進階vue的機會
看個demo
還是老套路,我們來搞個demo,index.vue
文件代碼如下:
<template>
<div>
<p>count的值為:{{ count }}</p>
<p>user.count的值為:{{ user.count }}</p>
<button @click="count++">count++</button>
<button @click="user.count++">user.count++</button>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
const user = ref({
count: 0,
});
</script>
在上面的demo中我們有兩個ref變數,count
變數接收的是原始類型,他的值是數字0。
count
變數渲染在template的p標簽中,並且在button的click事件中會count++
。
user
變數接收的是對象,對象有個count
屬性。
同樣user.count
也渲染在另外一個p標簽上,並且在另外一個button的click事件中會user.count++
。
接下來我將通過debug的方式帶你搞清楚,分別點擊count++
和user.count++
按鈕時是如何實現響應式的。
開始打斷點
第一步從哪裡開始下手打斷點呢?
既然是要搞清楚ref是如何實現響應式的,那麼當然是給ref打斷點吖,所以我們的第一個斷點是打在const count = ref(0);
代碼處。這行代碼是運行時代碼,是跑在瀏覽器中的。
要在瀏覽器中打斷點,需要在瀏覽器的source面板中打開index.vue
文件,然後才能給代碼打上斷點。
那麼第二個問題來了,如何在source面板中找到我們這裡的index.vue
文件呢?
很簡單,像是在vscode中一樣使用command+p
(windows中應該是control+p)就可以喚起一個輸入框。在輸入框裡面輸入index.vue
,然後點擊回車就可以在source面板中打開index.vue
文件。如下圖:
然後我們就可以在瀏覽器中給const count = ref(0);
處打上斷點了。
RefImpl
類
刷新頁面此時斷點將會停留在const count = ref(0);
代碼處,讓斷點走進ref
函數中。在我們這個場景中簡化後的ref
函數代碼如下:
function ref(value) {
return createRef(value, false);
}
可以看到在ref
函數中實際是直接調用了createRef
函數。
接著將斷點走進createRef
函數,在我們這個場景中簡化後的createRef
函數代碼如下:
function createRef(rawValue, shallow) {
return new RefImpl(rawValue, shallow);
}
從上面的代碼可以看到實際是調用RefImpl
類new了一個對象,傳入的第一個參數是rawValue
,也就是ref綁定的變數值,這個值可以是原始類型,也可以是對象、數組等。
接著將斷點走進RefImpl
類中,在我們這個場景中簡化後的RefImpl
類代碼如下:
class RefImpl {
private _value: T
private _rawValue: T
constructor(value) {
this._rawValue = toRaw(value);
this._value = toReactive(value);
}
get value() {
trackRefValue(this);
return this._value;
}
set value(newVal) {
newVal = toRaw(newVal);
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal;
this._value = toReactive(newVal);
triggerRefValue(this, 4, newVal);
}
}
}
從上面的代碼可以看到RefImpl
類由三部分組成:constructor
構造函數、value
屬性的getter
方法、value
屬性的setter
方法。
RefImpl
類的constructor
構造函數
constructor
構造函數中的代碼很簡單,如下:
constructor(value) {
this._rawValue = toRaw(value);
this._value = toReactive(value);
}
在構造函數中首先會將toRaw(value)
的值賦值給_rawValue
屬性中,這個toRaw
函數是vue暴露出來的一個API,他的作用是根據一個 Vue 創建的代理返回其原始對象。因為ref
函數不光能夠接受普通的對象和原始類型,而且還能接受一個ref對象,所以這裡需要使用toRaw(value)
拿到原始值存到_rawValue
屬性中。
接著在構造函數中會執行toReactive(value)
函數,將其執行結果賦值給_value
屬性。toReactive
函數看名字你應該也猜出來了,如果接收的value是原始類型,那麼就直接返回value。如果接收的value不是原始類型(比如對象),那麼就返回一個value轉換後的響應式對象。這個toReactive
函數我們在下麵會講。
_rawValue
屬性和_value
屬性都是RefImpl
類的私有屬性,用於在RefImpl
類中使用的,而暴露出去的也只有value
屬性。
經過constructor
構造函數的處理後,分別給兩個私有屬性賦值了:
-
_rawValue
中存的是ref綁定的值的原始值。 -
如果ref綁定的是原始類型,比如數字0,那麼
_value
屬性中存的就是數字0。如果ref綁定的是一個對象,那麼
_value
屬性中存的就是綁定的對象轉換後的響應式對象。
RefImpl
類的value
屬性的getter
方法
我們接著來看value
屬性的getter
方法,代碼如下:
get value() {
trackRefValue(this);
return this._value;
}
當我們對ref的value屬性進行讀操作時就會走到getter
方法中。
我們知道template經過編譯後會變成render函數,執行render函數會生成虛擬DOM,然後由虛擬DOM生成真實DOM。
在執行render函數期間會對count
變數進行讀操作,所以此時會觸發count
變數的value
屬性對應的getter
方法。
在getter
方法中會調用trackRefValue
函數進行依賴收集,由於此時是在執行render函數期間,所以收集的依賴就是render函數。
最後在getter
方法中會return返回_value
私有屬性。
RefImpl
類的value
屬性的setter
方法
我們接著來看value
屬性的setter
方法,代碼如下:
set value(newVal) {
newVal = toRaw(newVal);
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal;
this._value = toReactive(newVal);
triggerRefValue(this, 4, newVal);
}
}
當我們對ref的value的屬性進行寫操作時就會走到setter
方法中,比如點擊count++
按鈕,就會對count
的值進行+1
,觸發寫操作走到setter
方法中。
給setter
方法打個斷點,點擊count++
按鈕,此時斷點將會走到setter
方法中。初始化count
的值為0,此時點擊按鈕後新的count
值為1,所以在setter
方法中接收的newVal
的值為1。如下圖:
從上圖中可以看到新的值newVal
的值為1,舊的值this._rawValue
的值為0。然後使用if (hasChanged(newVal, this._rawValue))
判斷新的值和舊的值是否相等,hasChanged
的代碼也很簡單,如下:
const hasChanged = (value, oldValue) => !Object.is(value, oldValue);
Object.is
方法大家平時可能用的比較少,作用也是判斷兩個值是否相等。和==
的區別為Object.is
不會進行強制轉換,其他的區別大家可以參看mdn上的文檔。
使用hasChanged
函數判斷到新的值和舊的值不相等時就會走到if語句裡面,首先會執行this._rawValue = newVal
將私有屬性_rawValue
的值更新為最新值。接著就是執行this._value = toReactive(newVal)
將私有屬性_value
的值更新為最新值。
最後就是執行triggerRefValue
函數觸發收集的依賴,前面我們講過了在執行render函數期間由於對count
變數進行讀操作。觸發了getter
方法,在getter
方法中將render函數作為依賴進行收集了。
所以此時執行triggerRefValue
函數時會將收集的依賴全部取出來執行一遍,由於render函數也是被收集的依賴,所以render函數會重新執行。重新執行render函數時從count
變數中取出的值就是新值1,接著就是生成虛擬DOM,然後將虛擬DOM掛載到真實DOM上,最終在頁面上count
變數綁定的值已經更新為1了。
看到這裡你是不是以為關於ref實現響應式已經完啦?
我們來看demo中的第二個例子,user
對象,回顧一下在template和script中關於user
對象的代碼如下:
<template>
<div>
<p>user.count的值為:{{ user.count }}</p>
<button @click="user.count++">user.count++</button>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const user = ref({
count: 0,
});
</script>
在button按鈕的click事件中執行的是:user.count++
,前面我們講過了對ref的value屬性進行寫操作會走到setter
方法中。但是我們這裡ref綁定的是一個對象,點擊按鈕時也不是對user.value
屬性進行寫操作,而是對user.value.count
屬性進行寫操作。所以在這裡點擊按鈕不會走到setter
方法中,當然也不會重新執行收集的依賴。
那麼當ref綁定的是對象時,我們改變對象的某個屬性時又是怎麼做到響應式更新的呢?
這種情況就要用到Proxy
了,還記得我們前面講過的RefImpl
類的constructor
構造函數嗎?代碼如下:
class RefImpl {
private _value: T
private _rawValue: T
constructor(value) {
this._rawValue = toRaw(value);
this._value = toReactive(value);
}
}
其實就是這個toReactive
函數在起作用。
Proxy
實現響應式
還是同樣的套路,這次我們給綁定對象的名為user
的ref打個斷點,刷新頁面代碼停留在斷點中。還是和前面的流程一樣最終斷點走到RefImpl
類的構造函數中,當代碼執行到this._value = toReactive(value)
時將斷點走進toReactive
函數。代碼如下:
const toReactive = (value) => (isObject(value) ? reactive(value) : value);
在toReactive
函數中判斷瞭如果當前的value
是對象,就返回reactive(value)
,否則就直接返回value。這個reactive
函數你應該很熟悉,他會返回一個對象的響應式代理。因為reactive
不接收number這種原始類型,所以這裡才會判斷value
是否是對象。
我們接著將斷點走進reactive
函數,看看他是如何返回一個響應式對象的,在我們這個場景中簡化後的reactive
函數代碼如下:
function reactive(target) {
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap
);
}
從上面的代碼可以看到在reactive
函數中是直接返回了createReactiveObject
函數的調用,第三個參數是mutableHandlers
。從名字你可能猜到了,他是一個Proxy對象的處理器對象,後面會講。
接著將斷點走進createReactiveObject
函數,在我們這個場景中簡化後的代碼如下:
function createReactiveObject(
target,
isReadonly2,
baseHandlers,
collectionHandlers,
proxyMap
) {
const proxy = new Proxy(target, baseHandlers);
return proxy;
}
在上面的代碼中我們終於看到了大名鼎鼎的Proxy
了,這裡new了一個Proxy
對象。new的時候傳入的第一個參數是target
,這個target
就是我們一路傳進來的ref綁定的對象。第二個參數為baseHandlers
,是一個Proxy對象的處理器對象。這個baseHandlers
是調用createReactiveObject
時傳入的第三個參數,也就是我們前面講過的mutableHandlers
對象。
在這裡最終將Proxy代理的對象進行返回,我們這個demo中ref綁定的是一個名為user
的對象,經過前面講過函數的層層return後,user.value
的值就是這裡return返回的proxy
對象。
當我們對user.value
響應式對象的屬性進行讀操作時,就會觸發這裡Proxy的get攔截。
當我們對user.value
響應式對象的屬性進行寫操作時,就會觸發這裡Proxy的set攔截。
get
和set
攔截的代碼就在mutableHandlers
對象中。
Proxy
的set
和get
攔截
在源碼中使用搜一下mutableHandlers
對象,看到他的代碼是這樣的,如下:
const mutableHandlers = new MutableReactiveHandler();
從上面的代碼可以看到mutableHandlers
對象是使用MutableReactiveHandler
類new出來的一個對象。
我們接著來看MutableReactiveHandler
類,在我們這個場景中簡化後的代碼如下:
class MutableReactiveHandler extends BaseReactiveHandler {
set(target, key, value, receiver) {
let oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (target === toRaw(receiver)) {
if (hasChanged(value, oldValue)) {
trigger(target, "set", key, value, oldValue);
}
}
return result;
}
}
在上面的代碼中我們看到了set
攔截了,但是沒有看到get
攔截。
MutableReactiveHandler
類是繼承了BaseReactiveHandler
類,我們來看看BaseReactiveHandler
類,在我們這個場景中簡化後的BaseReactiveHandler
類代碼如下:
class BaseReactiveHandler {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
track(target, "get", key);
return res;
}
}
在BaseReactiveHandler
類中我們找到了get
攔截,當我們對Proxy代理返回的對象的屬性進行讀操作時就會走到get
攔截中。
前面講過了經過層層return後user.value
的值就是這裡的proxy
響應式對象,而我們在template中使用user.count
將其渲染到p標簽上,在template中讀取user.count
,實際就是在讀取user.value.count
的值。
同樣的template經過編譯後會變成render函數,執行render函數會生成虛擬DOM,然後將虛擬DOM轉換為真實DOM渲染到瀏覽器上。在執行render函數期間會對user.value.count
進行讀操作,所以會觸發BaseReactiveHandler
這裡的get
攔截。
在get
攔截中會執行track(target, "get", key)
函數,執行後會將當前render函數作為依賴進行收集。到這裡依賴收集的部分講完啦,剩下的就是依賴觸發的部分。
我們接著來看MutableReactiveHandler
,他是繼承了BaseReactiveHandler
。在BaseReactiveHandler
中有個get
攔截,而在MutableReactiveHandler
中有個set
攔截。
當我們點擊user.count++
按鈕時,會對user.value.count
進行寫操作。由於對count
屬性進行了寫操作,所以就會走到set
攔截中,set
攔截代碼如下:
class MutableReactiveHandler extends BaseReactiveHandler {
set(target, key, value, receiver) {
let oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (target === toRaw(receiver)) {
if (hasChanged(value, oldValue)) {
trigger(target, "set", key, value, oldValue);
}
}
return result;
}
}
我們先來看看set
攔截接收的4個參數,第一個參數為target
,也就是我們proxy代理前的原始對象。第二個參數為key
,進行寫操作的屬性,在我們這裡key
的值就是字元串count
。第三個參數是新的屬性值。
第四個參數receiver
一般情況下是Proxy返回的代理響應式對象。這裡為什麼會說是一般是呢?看一下MDN上面的解釋你應該就能明白了:
假設有一段代碼執行
obj.name = "jen"
,obj
不是一個 proxy,且自身不含name
屬性,但是它的原型鏈上有一個 proxy,那麼,那個 proxy 的set()
處理器會被調用,而此時,obj
會作為 receiver 參數傳進來。
接著來看set
攔截函數中的內容,首先let oldValue = target[key]
拿到舊的屬性值,然後使用Reflect.set(target, key, value, receiver)
在Proxy
中一般都是搭配Reflect
進行使用,在Proxy
的get
攔截中使用Reflect.get
,在Proxy
的set
攔截中使用Reflect.set
。
這樣做有幾個好處,在set攔截中我們要return一個布爾值表示屬性賦值是否成功。如果使用傳統的obj[key] = value
的形式我們是不知道賦值是否成功的,而使用Reflect.set
會返回一個結果表示給對象的屬性賦值是否成功。在set攔截中直接將Reflect.set
的結果進行return即可。
還有一個好處是如果不搭配使用可能會出現this
指向不對的問題。
前面我們講過了receiver
可能不是Proxy返回的代理響應式對象,所以這裡需要使用if (target === toRaw(receiver))
進行判斷。
接著就是使用if (hasChanged(value, oldValue))
進行判斷新的值和舊的值是否相等,如果不相等就執行trigger(target, "set", key, value, oldValue)
。
這個trigger
函數就是用於依賴觸發,會將收集的依賴全部取出來執行一遍,由於render函數也是被收集的依賴,所以render函數會重新執行。重新執行render函數時從user.value.count
屬性中取出的值就是新值1,接著就是生成虛擬DOM,然後將虛擬DOM掛載到真實DOM上,最終在頁面上user.value.count
屬性綁定的值已經更新為1了。
這就是當ref綁定的是一個對象時,是如何使用Proxy去實現響應式的過程。
看到這裡有的小伙伴可能會有一個疑問,為什麼ref使用RefImpl
類去實現,而不是統一使用Proxy
去代理一個擁有value
屬性的普通對象呢?比如下麵這種:
const proxy = new Proxy(
{
value: target,
},
baseHandlers
);
如果是上面這樣做那麼就不需要使用RefImpl
類了,全部統一成Proxy去使用響應式了。
但是上面的做法有個問題,就是使用者可以使用delete proxy.value
將proxy
對象的value
屬性給刪除了。而使用RefImpl
類的方式去實現就不能使用delete
的方法去將value
屬性給刪除了。
總結
這篇文章我們講了ref
是如何實現響應式的,主要分為兩種情況:ref接收的是number這種原始類型、ref接收的是對象這種非原始類型。
-
當ref接收的是number這種原始類型時是依靠
RefImpl
類的value
屬性的getter
和setter
方法中去實現的響應式。當我們對ref的value屬性進行讀操作時會觸發value的
getter
方法進行依賴收集。當我們對ref的value屬性進行寫操作時會進行依賴觸發,重新執行render函數,達到響應式的目的。
-
當ref接收的是對象這種非原始類型時,會調用
reactive
方法將ref的value屬性轉換成一個由Proxy
實現的響應式對象。當我們對ref的value屬性對象的某個屬性進行讀操作時會觸發
Proxy
的get攔截進行依賴收集。當我們對ref的value屬性對象的某個屬性進行寫操作時會觸發
Proxy
的set攔截進行依賴觸發,然後重新執行render函數,達到響應式的目的。
最後我們講了為什麼ref不統一使用Proxy
去代理一個有value
屬性的普通對象去實現響應式,而是要多搞個RefImpl
類。
因為如果使用Proxy
去代理的有value屬性的普通的對象,可以使用delete proxy.value
將proxy
對象的value
屬性給刪除了。而使用RefImpl
類的方式去實現就不能使用delete
的方法去將value
屬性給刪除了。
關註公眾號:【前端歐陽】,給自己一個進階vue的機會