70%的人都答錯了的面試題,vue3的ref是如何實現響應式的?

来源:https://www.cnblogs.com/heavenYJJ/p/18328847
-Advertisement-
Play Games

最近在我的vue源碼交流群有位面試官分享了一道他的面試題:vue3的ref是如何實現響應式的?下麵有不少小伙伴回答的是Proxy,其實這些小伙伴只回答對了一半。 ...


前言

最近在我的vue源碼交流群有位面試官分享了一道他的面試題:vue3的ref是如何實現響應式的?下麵有不少小伙伴回答的是Proxy其實這些小伙伴只回答對了一半
wx

  • 當ref接收的是一個對象時確實是依靠Proxy去實現響應式的。

  • 但是ref還可以接收 stringnumber 或 boolean 這樣的原始類型,當是原始類型時,響應式就不是依靠Proxy去實現的,而是在value屬性的gettersetter方法中去實現的響應式。

本文將通過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文件。如下圖:
index

然後我們就可以在瀏覽器中給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。如下圖:
set

從上圖中可以看到新的值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攔截。

getset攔截的代碼就在mutableHandlers對象中。

Proxysetget攔截

在源碼中使用搜一下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進行使用,在Proxyget攔截中使用Reflect.get,在Proxyset攔截中使用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.valueproxy對象的value屬性給刪除了。而使用RefImpl類的方式去實現就不能使用delete的方法去將value屬性給刪除了。

總結

這篇文章我們講了ref是如何實現響應式的,主要分為兩種情況:ref接收的是number這種原始類型、ref接收的是對象這種非原始類型。

  • 當ref接收的是number這種原始類型時是依靠RefImpl類的value屬性的gettersetter方法中去實現的響應式。

    當我們對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.valueproxy對象的value屬性給刪除了。而使用RefImpl類的方式去實現就不能使用delete的方法去將value屬性給刪除了。

關註公眾號:【前端歐陽】,給自己一個進階vue的機會


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 原文:Jetpack Compose學習(12)——Material Theme的主題色切換-Stars-One的雜貨小窩 閑著無事研究了下Jetpack Compose M3 主題切換效果 本系列以往文章請查看此分類鏈接Jetpack compose學習 如何生成主題 首先,我們需要知道的是,M3 ...
  • RunLoop用於管理事件的迴圈處理機制。運行迴圈在應用程式的主線程中自動啟動,負責監聽和分發各種事件,包括用戶交互(如觸摸事件)、定時器事件、選擇器調用和其他非同步回調。 運行迴圈的作用 運行迴圈的主要作用包括: 處理輸入事件:運行迴圈監聽用戶的輸入,如觸摸、點擊和滑動事件,並將它們分發到適當的處理 ...
  • 在實際開發中,經常需要對tableView或者collectionView執行更新數據源的操作,reloadData是一個常見的方法。這是 UITableView 和 UICollectionView 提供的一個方法,用於重新載入視圖的所有可見行(或項)。 當調用 reloadData 方法時,UI ...
  • AndroidStudio卸載 如果已經安裝:首先先卸載AndroidStudio,最好是使用軟體管理軟體進行卸載(遮這樣卸載的比較乾凈)。 找到你的C:\Users\Administrator.gradle,並且刪除掉【如果出現文件正在占用,你需要重啟電腦,否則刪不掉】 java安裝 我們安卓開 ...
  • 前言 今天複習了一些前端演算法題,寫到一兩道比較有意思的題:重建二叉樹、反向輸出鏈表每個節點 題目 重建二叉樹: 輸入某二叉樹的前序遍歷和中序遍歷的結果,請重建出該二叉樹。假設輸入的前序遍歷和中序遍歷的結果中都不含重覆的數字。例如輸入前序遍歷序列 {1,2,4,7,3,5,6,8} 和中序遍歷序列 { ...
  • title: Nuxt.js必讀:輕鬆掌握運行時配置與 useRuntimeConfig date: 2024/7/29 updated: 2024/7/29 author: cmdragon excerpt: 本文詳細介紹了Nuxt.js中的運行時配置功能,包括定義和使用運行時配置的方法,以及如何 ...
  • ‍ 寫在開頭 點贊 + 收藏 學會 axios的兩種調用方式 經常調介面的同學一定非常熟悉aixos下麵的兩種使用方式: axios(config) // 配置式請求 axios({ method: 'post', url: '/user/12345', }); axios.po ...
  • 我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:琉易 liuxianyu.cn 這一篇是系列文章: 搭建自動化 Web 頁面性能檢測系統 —— 設計篇 搭建自動化 Web 頁面性能檢測系統 —— 實現篇 作 ...
一周排行
    -Advertisement-
    Play Games
  • 示例項目結構 在 Visual Studio 中創建一個 WinForms 應用程式後,項目結構如下所示: MyWinFormsApp/ │ ├───Properties/ │ └───Settings.settings │ ├───bin/ │ ├───Debug/ │ └───Release/ ...
  • [STAThread] 特性用於需要與 COM 組件交互的應用程式,尤其是依賴單線程模型(如 Windows Forms 應用程式)的組件。在 STA 模式下,線程擁有自己的消息迴圈,這對於處理用戶界面和某些 COM 組件是必要的。 [STAThread] static void Main(stri ...
  • 在WinForm中使用全局異常捕獲處理 在WinForm應用程式中,全局異常捕獲是確保程式穩定性的關鍵。通過在Program類的Main方法中設置全局異常處理,可以有效地捕獲並處理未預見的異常,從而避免程式崩潰。 註冊全局異常事件 [STAThread] static void Main() { / ...
  • 前言 給大家推薦一款開源的 Winform 控制項庫,可以幫助我們開發更加美觀、漂亮的 WinForm 界面。 項目介紹 SunnyUI.NET 是一個基於 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 開源控制項庫,同時也提供了工具類庫、擴展 ...
  • 說明 該文章是屬於OverallAuth2.0系列文章,每周更新一篇該系列文章(從0到1完成系統開發)。 該系統文章,我會儘量說的非常詳細,做到不管新手、老手都能看懂。 說明:OverallAuth2.0 是一個簡單、易懂、功能強大的許可權+可視化流程管理系統。 有興趣的朋友,請關註我吧(*^▽^*) ...
  • 一、下載安裝 1.下載git 必須先下載並安裝git,再TortoiseGit下載安裝 git安裝參考教程:https://blog.csdn.net/mukes/article/details/115693833 2.TortoiseGit下載與安裝 TortoiseGit,Git客戶端,32/6 ...
  • 前言 在項目開發過程中,理解數據結構和演算法如同掌握蓋房子的秘訣。演算法不僅能幫助我們編寫高效、優質的代碼,還能解決項目中遇到的各種難題。 給大家推薦一個支持C#的開源免費、新手友好的數據結構與演算法入門教程:Hello演算法。 項目介紹 《Hello Algo》是一本開源免費、新手友好的數據結構與演算法入門 ...
  • 1.生成單個Proto.bat內容 @rem Copyright 2016, Google Inc. @rem All rights reserved. @rem @rem Redistribution and use in source and binary forms, with or with ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他的窗體程式在客戶這邊出現了卡死,讓我幫忙看下怎麼回事?dump也生成了,既然有dump了那就上 windbg 分析吧。 二:WinDbg 分析 1. 為什麼會卡死 窗體程式的卡死,入口門檻很低,後續往下分析就不一定了,不管怎麼說先用 !clrsta ...
  • 前言 人工智慧時代,人臉識別技術已成為安全驗證、身份識別和用戶交互的關鍵工具。 給大家推薦一款.NET 開源提供了強大的人臉識別 API,工具不僅易於集成,還具備高效處理能力。 本文將介紹一款如何利用這些API,為我們的項目添加智能識別的亮點。 項目介紹 GitHub 上擁有 1.2k 星標的 C# ...