前言
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
函式,如下圖:
經過編譯後defineModel
宏函式已經變成了useModel
函式,而useModel
函式的返回值是一個ref物件。注意這個是ref物件不是props,所以我們才可以在元件內直接修改defineModel
的返回值。當我們對這個ref
物件進行“讀操作”時,會像Proxy
一樣被攔截到ref
物件的get方法。在get方法中會返回本地維護localValue
變數,localValue
變數依靠watchSyncEffect
讓localValue
變數始終和父元件傳遞的modelValue
的props
值一致。
對返回值進行“寫操作”會被攔截到ref
物件的set方法中,在set方法中會將最新值同步到本地維護localValue
變數,呼叫vue例項上的emit方法丟擲update:modelValue
事件給父元件,由父元件去更新父元件中v-model
繫結的變數。如下圖:
所以在子元件內無需寫任何關於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將更新後的變數傳遞給子元件。在這一過程中資料的流動是單向的,由父元件傳遞給子元件,只有父元件有資料的更改權,子元件不可直接更改資料。
一個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_render
為render
函式,不是我們這篇文章關注的重點。我們來主要看_sfc_main
物件,看這個物件的樣子有name、props、emits、setup屬性,我想你也能夠猜出來他就是vue的元件物件。從元件物件中我們可以看到已經有了一個modelValue
的props
屬性,還有使用emits
選項宣告瞭update:modelValue
事件。我們在原始碼中沒有任何地方有定義props
和emits
選項,很明顯這兩個是透過編譯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,分別是getCurrentInstance
、customRef
、watchSyncEffect
,這三個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
的工廠函式是接收的resolve
和reject
兩個函式作為引數,customRef
的工廠函式是接收的track
和trigger
兩個函式作為引數。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內部自動完成的,在我們的程式碼中無需手動去觸發依賴。
搞清楚了依賴收集和依賴觸發現在來講track
和trigger
兩個函式你應該就能很容易理解了,track
和trigger
兩個函式可以讓我們手動控制什麼時候進行依賴收集和依賴觸發。執行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
變數物件。我們看到返回值物件中有get
和set
方法,還有在customRef
函式中使用了watchSyncEffect
函式。
get
方法
在前面的demo中,我們在子元件的template中使用v-model
將defineModel
的返回值繫結到一個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]
就是取父元件傳遞過來的名為modelValue
的prop
,我們知道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
的返回值進行“讀操作”時,類似於Proxy
的get
方法一樣會對讀操作進行攔截到返回值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
函式的返回值。類似於Proxy
的set
方法一樣會對寫行為進行攔截到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
原理文章,我會根據大家的反饋進行創作。