父組件明明使用了v-model,子組件竟然可以不用定義props和emit拋出事件,快來看看吧

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

使用defineModel時,為什麼子組件內沒有任何關於props的定義和emit事件觸發的代碼?修改defineModel返回值會修改父組件上綁定的變數,這是否破壞了vue的單向數據流呢? ...


前言

vue3.4增加了defineModel巨集函數,在子組件內修改了defineModel的返回值,父組件上v-model綁定的變數就會被更新。大家都知道v-model:modelValue@update:modelValue的語法糖,但是你知道為什麼我們在子組件內沒有寫任何關於props的定義和emit事件觸發的代碼嗎?還有在template渲染中defineModel的返回值等於父組件v-model綁定的變數值,那麼這個返回值是否就是名為modelValue的props呢?直接修改defineModel的返回值就會修改父組件上面綁定的變數,那麼這個行為是否相當於子組件直接修改了父組件的變數值,破壞了vue的單向數據流呢?

先說答案

defineModel巨集函數經過編譯後會給vue組件對象上面增加modelValue的props選項和update:modelValue的emits選項,執行defineModel巨集函數的代碼會變成執行useModel函數,如下圖:
convert

經過編譯後defineModel巨集函數已經變成了useModel函數,而useModel函數的返回值是一個ref對象。註意這個是ref對象不是props,所以我們才可以在組件內直接修改defineModel的返回值。當我們對這個ref對象進行“讀操作”時,會像Proxy一樣被攔截到ref對象的get方法。在get方法中會返回本地維護localValue變數,localValue變數依靠watchSyncEffectlocalValue變數始終和父組件傳遞的modelValueprops值一致。

對返回值進行“寫操作”會被攔截到ref對象的set方法中,在set方法中會將最新值同步到本地維護localValue變數,調用vue實例上的emit方法拋出update:modelValue事件給父組件,由父組件去更新父組件中v-model綁定的變數。如下圖:
useModel

所以在子組件內無需寫任何關於props的定義和emit事件觸發的代碼,因為在編譯defineModel巨集函數的時候已經幫我們生成了modelValue的props選項。在對返回的ref變數進行寫操作時會觸發set方法,在set方法中會調用vue實例上的emit方法拋出update:modelValue事件給父組件。

defineModel巨集函數的返回值是一個ref變數,而不是一個props。所以我們可以直接修改defineModel巨集函數的返回值,父組件綁定的變數之所以會改變是因為在底層會拋出update:modelValue事件給父組件,由父組件去更新綁定的變數,這一行為當然滿足vue的單向數據流。

什麼是vue的單向數據流

vue的單向數據流是指,通過props將父組件的變數傳遞給子組件,在子組件中是沒有許可權去修改父組件傳遞過來的變數。只能通過emit拋出事件給父組件,讓父組件在事件回調中去修改props傳遞的變數,然後通過props將更新後的變數傳遞給子組件。在這一過程中數據的流動是單向的,由父組件傳遞給子組件,只有父組件有數據的更改權,子組件不可直接更改數據。
single-progress

一個defineModel的例子

我在前面的 一文搞懂 Vue3 defineModel 雙向綁定:告別繁瑣代碼!文章中已經講過了defineModel的各種用法,在這篇文章中我們就不多餘贅述了。我們直接來看一個簡單的defineModel的例子。

下麵這個是父組件的代碼:

<template>
  <CommonChild v-model="inputValue" />
  <p>input value is: {{ inputValue }}</p>
</template>

<script setup lang="ts">
import { ref } from "vue";
import CommonChild from "./child.vue";

const inputValue = ref();
</script>

父組件的代碼很簡單,使用v-model指令將inputValue變數傳遞給子組件。然後在父組件上使用p標簽渲染出inputValue變數的值。

我們接下來看子組件的代碼:

<template>
  <input v-model="model" />
  <button @click="handelReset">reset</button>
</template>

<script setup lang="ts">
const model = defineModel();

function handelReset() {
  model.value = "init";
}
</script>

子組件內的代碼也很簡單,將defineModel的返回值賦值給model變數。然後使用v-model指令將model變數綁定到子組件的input輸入框上面。並且還在按鈕的click事件時使用model.value = "init"將綁定的值重置為init字元串。請註意在子組件中我們沒有任何定義props的代碼,也沒有拋出emit事件的代碼。而是通過defineModel巨集函數的返回值來接收父組件傳過來的名為modelValue的prop,並且在子組件中是直接通過給defineModel巨集函數的返回值進行賦值來修改父組件綁定的inputValue變數的值。

defineModel編譯後的樣子

要回答前面提的幾個問題,我們還是得從編譯後的子組件代碼說起。下麵這個是經過簡化編譯後的子組件代碼:

import {
  defineComponent as _defineComponent,
  useModel as _useModel
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";

const _sfc_main = _defineComponent({
  __name: "child",
  props: {
    modelValue: {},
    modelModifiers: {},
  },
  emits: ["update:modelValue"],
  setup(__props) {
    const model = _useModel(__props, "modelValue");
    function handelReset() {
      model.value = "init";
    }
    const __returned__ = { model, handelReset };
    return __returned__;
  },
});

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    // ... 省略
  );
}
_sfc_main.render = _sfc_render;
export default _sfc_main;

從上面我們可以看到編譯後主要有_sfc_main_sfc_render這兩塊,其中_sfc_renderrender函數,不是我們這篇文章關註的重點。我們來主要看_sfc_main對象,看這個對象的樣子有name、props、emits、setup屬性,我想你也能夠猜出來他就是vue的組件對象。從組件對象中我們可以看到已經有了一個modelValueprops屬性,還有使用emits選項聲明瞭update:modelValue事件。我們在源代碼中沒有任何地方有定義propsemits選項,很明顯這兩個是通過編譯defineModel巨集函數而來的。

我們接著來看裡面的setup函數,可以看到經過編譯後的setup函數中代碼和我們的源代碼很相似。只有defineModel不在了,取而代之的是一個useModel函數。

// 編譯前的代碼
const model = defineModel();

// 編譯後的代碼
const model = _useModel(__props, "modelValue");

還是同樣的套路,在瀏覽器的sources面板上面找到編譯後的js文件,然後給這個useModel打個斷點。至於如何找到編譯後的js文件我們在前面的文章中已經講了很多遍了,這裡就不贅述了。刷新瀏覽器我們看到斷點已經走到了使用useModel函數的地方,我們這裡給useModel函數傳了兩個參數。第一個參數為子組件接收的props對象,第二個參數是寫死的字元串modelValue。進入到useModel函數內部,簡化後的useModel函數是這樣的:

function useModel(props, name) {
  const i = getCurrentInstance();
  const res = customRef((track2, trigger2) => {
    watchSyncEffect(() => {
      // 省略
    });
  });
  return res;
}

從上面的代碼中我們可以看到useModel中使用到的函數沒有一個是vue內部源碼專用的函數,全都是調用的vue暴露出來的API。這意味著我們可以參考defineModel的實現源碼,也就是useModel函數,然後根據自己實際情況改良一個適合自己項目的defineModel函數。

我們先來簡單介紹一下useModel函數中使用到的API,分別是getCurrentInstancecustomRefwatchSyncEffect,這三個API都是從vue中import導入的。

getCurrentInstance函數

首先來看看getCurrentInstance函數,他的作用是返回當前的vue實例。為什麼要調用這個函數呢?因為在setup中this是拿不到vue實例的,後面對值進行寫操作時會調用vue實例上面的emit方法拋出update事件。

watchSyncEffect函數

接著我們來看watchSyncEffect函數,這個API大家平時應該比較熟悉了。他的作用是立即運行一個函數,同時響應式地追蹤其依賴,併在依賴更改時立即重新執行這個函數。

比如下麵這段代碼,會立即執行console,當count變數的值改變後,也會立即執行console。

const count = ref(0)

watchSyncEffect(() => console.log(count.value))
// -> 輸出 0

customRef函數

最後我們來看customRef函數,他是useModel函數的核心。這個函數小伙伴們應該用的比較少,我們這篇文章只簡單講講他的用法即可。如果小伙伴們對customRef函數感興趣可以留言或者給我發消息,關註的小伙伴們多了我後面會安排一篇文章來專門講customRef函數。官方的解釋為:

創建一個自定義的 ref,顯式聲明對其依賴追蹤和更新觸發的控制方式。customRef() 預期接收一個工廠函數作為參數,這個工廠函數接受 track 和 trigger 兩個函數作為參數,並返回一個帶有 get 和 set 方法的對象。

這句話的意思是customRef函數的返回值是一個ref對象。當我們對返回值ref對象進行“讀操作”時,會被攔截到ref對象的get方法中。當我們對返回值ref對象進行“寫操作”時,會被攔截到ref對象的set方法中。和Promise相似同樣接收一個工廠函數作為參數,Promise的工廠函數是接收的resolvereject兩個函數作為參數,customRef的工廠函數是接收的tracktrigger兩個函數作為參數。track用於手動進行依賴收集,trigger函數用於手動進行依賴觸發。

我們知道vue的響應式原理是由依賴收集和依賴觸發的方式實現的,比如我們在template中使用一個ref變數。當template被編譯為render函數後,在瀏覽器中執行render函數時,就會對ref變數進行讀操作。讀操作會被攔截到Proxy的get方法中,由於此時在執行render函數,所以當前的依賴就是render函數。在get方法中會進行依賴收集,將當前的render函數作為依賴收集起來。註意這裡的依賴收集是vue內部自動完成的,在我們的代碼中無需手動去進行依賴收集。

當我們對ref變數進行寫操作時,此時會被攔截到Proxy的set方法,在set方法中會將收集到的依賴依次取出來執行,我們前面收集的依賴是render函數。所以render函數就會重新執行,執行render函數生成虛擬DOM,再生成真實DOM,這樣瀏覽器中渲染的就是最新的ref變數的值。同樣這裡依賴觸發也是在vue內部自動完成的,在我們的代碼中無需手動去觸發依賴。

搞清楚了依賴收集和依賴觸發現在來講tracktrigger兩個函數你應該就能很容易理解了,tracktrigger兩個函數可以讓我們手動控制什麼時候進行依賴收集和依賴觸發。執行track函數就會手動收集依賴,執行trigger函數就會手動觸發依賴,進行頁面刷新。在defineModel這個場景中track手動收集的依賴就是render函數,trigger手動觸發會導致render函數重新執行,進而完成頁面刷新。

useModel函數

現在我們可以來看useModel函數了,簡化後的代碼如下:

function useModel(props, name) {
  const i = getCurrentInstance();

  const res = customRef((track2, trigger2) => {
    let localValue;
    watchSyncEffect(() => {
      const propValue = props[name];
      if (hasChanged(localValue, propValue)) {
        localValue = propValue;
        trigger2();
      }
    });
    return {
      get() {
        track2();
        return localValue;
      },
      set(value) {
        if (hasChanged(value, localValue)) {
          localValue = value;
          trigger2();
        }
        i.emit(`update:${name}`, value);
      },
    };
  });
  return res;
}

從上面我們可以看到useModel函數的代碼其實很簡單,useModel的返回值就是customRef函數的返回值,也就是一個ref變數對象。我們看到返回值對象中有getset方法,還有在customRef函數中使用了watchSyncEffect函數。

get方法

在前面的demo中,我們在子組件的template中使用v-modeldefineModel的返回值綁定到一個input輸入框中。代碼如下:

<input v-model="model" />

在第一次執行render函數時會對model變數進行讀操作,而model變數是defineModel巨集函數的返回值。編譯後我們看到defineModel巨集函數變成了useModel函數。所以對model變數進行讀操作,其實就是對useModel函數的返回值進行讀操作。我們看到useModel函數的返回值是一個自定義ref,在自定義ref中有get和set方法,當對自定義ref進行讀操作時會被攔截到ref對象中的get方法。這裡在get方法中會手動執行track2方法進行依賴收集。因為此時是在執行render函數,所以收集到的依賴就是render函數,然後將本地維護的localValue的值進行攔截返回。

set方法

在我們前面的demo中,子組件reset按鈕的click事件中會對defineModel的返回值model變數進行寫操作,代碼如下:

function handelReset() {
  model.value = "init";
}

和對model變數“讀操作”同理,對model變數進行“寫操作”也會被攔截到返回值ref對象的set方法中。在set方法中會先判斷新的值和本地維護的localValue的值比起來是否有修改。如果有修改那就將更新後的值同步更新到本地維護的localValue變數,這樣就保證了本地維護的localValue始終是最新的值。然後執行trigger2函數手動觸發收集的依賴,在前面get的時候收集的依賴是render函數,所以這裡觸發依賴會重新執行render函數,然後將最新的值渲染到瀏覽器上面。

在set方法中接著會調用vue實例上面的emit方法進行拋出事件,代碼如下:

i.emit(`update:${name}`, value)

這裡的i就是getCurrentInstance函數的返回值。前面我們講過了getCurrentInstance函數的返回值是當前vue實例,所以這裡就是調用vue實例上面的emit方法向父組件拋出事件。這裡的name也就是調用useModel函數時傳入的第二個參數,我們來回憶一下前面是怎樣調用useModel函數的 ,代碼如下:

const model = _useModel(__props, "modelValue")

傳入的第一個參數為當前的props對象,第二個參數是寫死的字元串"modelValue"。那這裡調用emit拋出的事件就是update:modelValue,傳遞的參數為最新的value的值。這就是為什麼不需要在子組件中使用使用emit拋出事件,因為在defineModel巨集函數編譯成的useModel函數中已經幫我們使用emit拋出事件了。

watchSyncEffect函數

我們接著來看子組件中怎麼接收父組件傳遞過來的props呢,答案就在watchSyncEffect函數中。回憶一下前面講過的useModel函數中的watchSyncEffect代碼如下:

function useModel(props, name) {
  const res = customRef((track2, trigger2) => {
    let localValue;
    watchSyncEffect(() => {
      const propValue = props[name];
      if (hasChanged(localValue, propValue)) {
        localValue = propValue;
        trigger2();
      }
    });
    return {
     // ...省略
    };
  });
  return res;
}

這個name也就是調用useModel函數時傳過來的第二個參數,我們前面已經講過了是一個寫死的字元串"modelValue"。那這裡的const propValue = props[name]就是取父組件傳遞過來的名為modelValueprop,我們知道v-model就是:modelValue的語法糖,所以這個propValue就是取的是父組件v-model綁定的變數值。如果本地維護的localValue變數的值不等於父組件傳遞過來的值,那麼就將本地維護的localValue變數更新,讓localValue變數始終和父組件傳遞過來的值一樣。並且觸發依賴重新執行子組件的render函數,將子組件的最新變數的值更新到瀏覽器中。為什麼要調用trigger2函數呢?原因是可以在子組件的template中渲染defineModel函數的返回值,也就是父組件傳遞過來的prop變數。如果父組件傳遞過來的prop變數值改變後不重新調用trigger2函數以重新執行render函數,那麼子組件中的渲染的變數值就一直都是舊的值了。因為這個是在watchSyncEffect內執行的,所以每次父組件傳過來的props值變化後都會再執行一次,讓本地維護的localValue變數的值始終等於父組件傳遞過來的值,並且子組件頁面上也始終渲染的是最新的變數值。

這就是為什麼在子組件中沒有任何props定義了,因為在defineModel巨集函數編譯後會給vue組件對象塞一個modelValue的prop,並且在useModel函數中會維護一個名為localValue的本地變數接收父組件傳遞過來的props.modelValue,並且讓localValue變數和props.modelValue的值始終保持一致。

總結

現在我們可以回答前面提的幾個問題了:

  • 使用defineModel巨集函數後,為什麼我們在子組件內沒有寫任何關於props定義的代碼?

    答案是本地會維護一個localValue變數接收父組件傳遞過來的名為modelValue的props。調用defineModel函數的代碼經過編譯後會變成一個調用useModel函數的代碼,useModel函數的返回值是一個ref對象。當我們對defineModel的返回值進行“讀操作”時,類似於Proxyget方法一樣會對讀操作進行攔截到返回值ref對象的get方法中。而get方法的返回值為本地維護的localValue變數,在watchSyncEffect的回調中將父組件傳遞過來的名為modelValue的props賦值給本地維護的localValue變數。並且由於是在watchSyncEffect中,所以每次props改變都會執行這個回調,所以本地維護的localValue變數始終是等於父組件傳遞過來的modelValue。也正是因為defineModel巨集函數的返回值是一個ref對象而不是一個prop,所以我們可以在子組件內直接將defineModel的返回值使用v-model綁定到子組件input輸入框上面。

  • 使用defineModel巨集函數後,為什麼我們在子組件內沒有寫任何關於emit事件觸發的代碼?

    答案是因為調用defineModel函數的代碼經過編譯後會變成一個調用useModel函數的代碼,useModel函數的返回值是一個ref對象。當我們直接修改defineModel的返回值,也就是修改useModel函數的返回值。類似於Proxyset方法一樣會對寫行為進行攔截到ref對象中的set方法中。在set方法中會手動觸發依賴,render函數就會重新執行,瀏覽器上就會渲染最新的變數值。然後調用vue實例上的emit方法,向父組件拋出update:modelValue事件。並且將最新的值隨著事件一起傳遞給父組件,由父組件在update:modelValue事件回調中將父組件中v-model綁定的變數更新為最新值。

  • template渲染中defineModel的返回值等於父組件v-model綁定的變數值,那麼這個返回值是否就是名為modelValue的props呢?

    從第一個回答中我們知道defineModel的返回值不是props,而是一個ref對象。

  • 直接修改defineModel的返回值就會修改父組件上面綁定的變數,那麼這個行為是否相當於子組件直接修改了父組件的變數值,破壞了vue的單向數據流呢?

    修改defineModel的返回值,就會更新父組件中v-model綁定的變數值。看著就像是子組件中直接修改了父組件的變數值,從錶面上看著像是打破了vue的單向數據流。實則並不是那樣的,雖然我們在代碼中沒有寫過emit拋出事件的代碼,但是在defineModel函數編譯成的useModel函數中已經幫我們使用emit拋出事件了。所以並沒有打破vue的單向數據流

關註公眾號:前端歐陽,解鎖我更多vue乾貨文章。還可以加我微信,私信我想看哪些vue原理文章,我會根據大家的反饋進行創作。
qrcode
wxcode


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

-Advertisement-
Play Games
更多相關文章
  • 一、CustomDialog CustomDialog組件是一種自定義對話框,可以通過開發人員根據特定的要求定製內容和佈局。它允許開發人員創建一個完全可定製的對話框,可以顯示任何類型的內容,例如文本、圖像、表單和按鈕。 CustomDialog通常用於在執行任務之前向用戶提供額外的信息或輸入選項 ...
  • 一、TextInput/TextArea TextInput和TextArea組件通常用於收集用戶輸入的文本數據。 TextInput組件通常用於單行文本的輸入,它允許用戶通過一個游標來輸入文字,並支持多種樣式和佈局選項來提高用戶體驗。例如,在用戶輸入錯誤時可以顯示錯誤消息或在用戶輸入時自動完成 ...
  • Android 音視頻開發 - VideoView 本篇文章主要介紹下Android 中的VideoView. 1: VideoView簡介 VideoView是一個用於播放視頻的視圖組件,可以方便地在應用程式中播放本地或網路上的視頻文件。 VideoView可以直接在佈局文件中使用,也可以在代碼中 ...
  • Android音視頻開發 - MediaMetadataRetriever 相關 MediaMetadataRetriever 是android中用於從媒體文件中提取元數據新的類. 可以獲取音頻,視頻和圖像文件的各種信息,如時長,標題,封面等. 1:初始化對象 private MediaMetada ...
  • 關鍵詞:Android Framework 動態庫 動態鏈接 Binder 1、事件起因 Android Studio一次更新後發現install App,設備就重啟了,跑了一遍開機動畫但不是從開機第一屏開始重啟,tombstones內容查看發現是surfaceflinger掛在libbinder. ...
  • XPath(XML Path Language)是XSLT標準的主要組成部分。它用於在XML文檔中瀏覽元素和屬性,提供了一種強大的定位和選擇節點的方式。 XPath的基本特點 代表XML路徑語言: XPath是一種用於在XML文檔中導航和選擇節點的語言。 路徑樣式語法: XPath使用路徑表達式的“ ...
  • 一、是什麼 WebSocket,是一種網路傳輸協議,位於OSI模型的應用層。可在單個TCP連接上進行全雙工通信,能更好的節省伺服器資源和帶寬並達到實時通迅 客戶端和伺服器只需要完成一次握手,兩者之間就可以創建持久性的連接,併進行雙向數據傳輸 從上圖可見,websocket伺服器與客戶端通過握手連接, ...
  • 1. Module Module是NestJS 的基本組織單位。 模塊系統基於 Node.js 的 CommonJS 模塊系統,但提供了更高級別的抽象和組織方式。通過使用模塊,你可以將應用程式拆分成多個獨立且可復用的部分,每個模塊都負責實現特定的功能或業務邏輯。 模塊可以封裝相關的代碼、配置和依賴關 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...