前言
還是上一篇面試官:來說說vue3是怎麼處理內建的v-for、v-model等指令? 文章的那個粉絲,面試官接著問了他另外一個v-model的問題。
-
面試官:vue3的v-model都用過吧,來講講。
-
粉絲:v-model其實就是一個語法糖,在編譯時v-model會被編譯成
:modelValue
屬性和@update:modelValue
事件。一般在子元件中定義一個名為modelValue
的props來接收父元件v-model傳遞的值,然後當子元件表單的值變化時再使用@update:modelValue
丟擲事件給父元件,由父元件來更新v-model繫結的變數。 -
面試官:你說的這個是在元件上面使用v-model,原生input上面也支援v-model,你來說說原生input上面使用v-model以及和元件上面使用v-model有什麼區別?
-
粉絲:啊,兩個不是一樣的嗎?都是
:modelValue
屬性和@update:modelValue
事件的語法糖吖。 -
面試官:原生input標籤接收的是value屬性,監聽的是input或者change事件。你說v-model會編譯成
:modelValue
屬性,但是input標籤只接收value屬性,那你傳的modelValue
屬性input標籤怎麼接收的?同理你說v-model會編譯成監聽@update:modelValue
事件,但是input標籤只監聽input或者change事件,那你傳監聽的@update:modelValue
事件又是怎麼觸發的呢?
在之前的 面試官:只知道v-model是modelValue語法糖,那你可以走了 文章中我已經講過了在元件中怎麼將v-model編譯成:modelValue
屬性和@update:modelValue
事件,今天我們就來講講在原生input上面使用v-model和在元件上面使用有什麼區別?
先說答案
來看看我畫個這個流程圖,如下:
根據上面的流程圖,我們知道了在元件上面使用v-model和原生input上面使用v-model區別主要有三點:
-
元件上面的v-model編譯後會生成
modelValue
屬性和@update:modelValue
事件。而在原生input上面使用v-model編譯後不會生成
modelValue
屬性,只會生成onUpdate:modelValue
回撥函式和vModelText
自定義指令。(在 面試官:只知道v-model是modelValue語法糖,那你可以走了 文章中我們已經講過了@update:modelValue
事件其實等價於onUpdate:modelValue
回撥函式) -
在元件上面使用v-model,是由子元件中定義一個名為
modelValue
的props來接收父元件使用v-model繫結的變數,然後使用這個modelValue
繫結到子元件的表單中。在原生input上面使用v-model,是由編譯後生成的
vModelText
自定義指令在mounted
和beforeUpdate
鉤子函式中去將v-model繫結的變數值更新到原生input輸入框的value屬性,以保證v-model繫結的變數值和input輸入框中的值始終一致。 -
在元件上面使用v-model,是由子元件使用emit丟擲
@update:modelValue
事件,在@update:modelValue
的事件處理函式中去更新v-model繫結的變數。而在原生input上面使用v-model,是由編譯後生成的
vModelText
自定義指令在created
鉤子函式中去監聽原生input標籤的input或者change事件。在事件回撥函式中去手動呼叫onUpdate:modelValue
回撥函式,然後在回撥函式中去更新v-model繫結的變數。
看個例子
下面這個是我寫的一個demo,程式碼如下:
<template>
<input v-model="msg" />
<p>input value is: {{ msg }}</p>
</template>
<script setup lang="ts">
import { ref } from "vue";
const msg = ref();
</script>
上面的例子很簡單,在原生input標籤上面使用v-model繫結了msg
變數。我們接下來看看編譯後的js程式碼是什麼樣的,那麼問題來了怎麼找到編譯後的js程式碼呢?
其實很簡單直接在network上面找到你的那個vue檔案就行了,比如我這裡的檔案是index.vue
,那我只需要在network上面找叫index.vue
的檔案就行了。但是需要注意一下network上面有兩個index.vue
的js請求,分別是template模組+script模組編譯後的js檔案,和style模組編譯後的js檔案。
那怎麼區分這兩個index.vue
檔案呢?很簡單,透過query就可以區分。由style模組編譯後的js檔案的URL中有type=style的query,如下圖所示:
接下來我們來看看編譯後的index.vue
,簡化的程式碼如下:
import {
Fragment as _Fragment,
createElementBlock as _createElementBlock,
createElementVNode as _createElementVNode,
defineComponent as _defineComponent,
openBlock as _openBlock,
toDisplayString as _toDisplayString,
vModelText as _vModelText,
withDirectives as _withDirectives,
ref,
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";
const _sfc_main = _defineComponent({
__name: "index",
setup(__props, { expose: __expose }) {
__expose();
const msg = ref();
const __returned__ = { msg };
return __returned__;
},
});
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock(
_Fragment,
null,
[
_withDirectives(
_createElementVNode(
"input",
{
"onUpdate:modelValue":
_cache[0] || (_cache[0] = ($event) => ($setup.msg = $event)),
},
null,
512
),
[[_vModelText, $setup.msg]]
),
_createElementVNode(
"p",
null,
"input value is: " + _toDisplayString($setup.msg),
1
),
],
64
)
);
}
_sfc_main.render = _sfc_render;
export default _sfc_main;
從上面的程式碼中我們可以看到編譯後的js程式碼主要分為兩塊。
第一塊是_sfc_main
元件物件,裡面有name屬性和setup方法。一個vue元件其實就是一個物件,這裡的_sfc_main
物件就是一個vue元件物件。
我們接著來看第二塊_sfc_render
,從名字我想你應該已經猜到了他是一個render函式。執行這個_sfc_render
函式就會生成虛擬DOM,然後再由虛擬DOM生成瀏覽器上面的真實DOM。我們接下來主要看看這個render函式。
render函式
這個render
函式前面會呼叫openBlock
函式和createElementBlock
函式。他的作用是在編譯時儘可能的提取多的關鍵資訊,可以減少執行時比較新舊虛擬DOM帶來的效能開銷。我們這篇文章不關注這點,所以就不細講了。
來看看裡層的陣列,陣列中有兩項。分別是withDirectives
函式和createElementVNode
函式,陣列中的這兩個函式分別對應的就是template中的input標籤和p標籤。我們主要來關注input標籤,也就是withDirectives
函式。
withDirectives
函式
這個withDirectives
是否覺得有點眼熟?他是vue提供的一個進階API,我們平時寫業務基本不會用到他。作用是給vnode(虛擬DOM)增加自定義指令。
接收兩個引數,第一個引數為需要新增指令的vnode,第二個引數是由自定義指令組成的二維陣列。二維陣列的第一層是表示有哪些自定義指令,第二層表示的是指令名稱、繫結值、引數、修飾符。第二層的結構為: [Directive, value, argument, modifiers]
。如果不需要,可以省略陣列的尾元素。
舉個例子:
import { h, withDirectives } from 'vue'
// 一個自定義指令
const pin = {
mounted() {
/* ... */
},
updated() {
/* ... */
}
}
// <div v-pin:top.animate="200"></div>
const vnode = withDirectives(h('div'), [
[pin, 200, 'top', { animate: true }]
])
上面這個例子定義了一個pin
的自定義指令,呼叫h
函式生成vnode傳給withDirectives
函式的第一個引數。第二個引數自定義指令陣列,我們這裡只傳了一個pin
自定義指令。來看看[Directive, value, argument, modifiers]
。
-
第一個
Directive
欄位:“指令名稱”對應的就是pin
自定義指令。 -
第二個
value
欄位:“指令值”對應的就是200。 -
第三個欄位
argument
欄位:“引數”對應的就是top
引數。 -
第四個欄位
modifiers
欄位:“修飾符”對應的就是animate
修飾符。
所以上面的withDirectives
函式實際就是對應的<div v-pin:top.animate="200"></div>
createElementVNode
函式
看見這個函式名字我想你應該也猜到了,作用是建立vnode(虛擬dom)。這個函式和vue提供的 h函式差不多,底層呼叫的都是一個名為createBaseVNode
的函式。接收的第一個引數既可以是一個字串 (用於原生元素) 也可以是一個 Vue 元件定義。接收的第二個引數是要傳遞的 prop,第三個引數是子節點。
舉個例子:
createElementVNode("input", {
value: 12,
})
上面這個例子建立了一個input的vnode,輸入框中的值為12
搞清楚了withDirectives
函式和createElementVNode
函式的作用,我們回過頭來看之前對應input標籤的程式碼你應該就很容易理解了。程式碼如下:
_withDirectives(
_createElementVNode(
"input",
{
"onUpdate:modelValue":
_cache[0] || (_cache[0] = ($event) => ($setup.msg = $event)),
},
null,
512
),
[[_vModelText, $setup.msg]]
)
呼叫withDirectives
函式,傳入兩個引數。第一個引數為呼叫createElementVNode
函式生成input的vnode。第二個引數為傳入的自定義指令組成的陣列,很明顯這裡的二維陣列的第一層只有一項,說明只傳入了一個自定義指令。
回憶一下前面說的二維陣列中的第二層的結構: [Directive, value, argument, modifiers]
,第一個欄位Directive
表示這裡傳入了一個名為vModelText
的自定義指令,第二個欄位value
表示給vModelText
指令繫結的值為$setup.msg
。我們在 Vue 3 的 setup語法糖到底是什麼東西?文章中已經講過了,這裡的$setup.msg
實際就是指向的是setup中定義的名為msg
的ref變數。
我們再來看裡面的createElementVNode
函式,建立一個input的vnode。傳入了一個名為onUpdate:modelValue
的props屬性,屬性值是一個經過快取的回撥函式。
為什麼需要快取呢?因為每次更新頁面都會執行一次render函式,每次執行render函式都會呼叫一次createElementVNode
函式。如果不快取那不就變成了每次更新頁面都會生成一個onUpdate:modelValue
的回撥函式。這裡的回撥函式也很簡單,接收一個$event
變數。這個$event
變數就是輸入框中輸入的值,然後最新的輸入框中的值同步到setup
中的msg
變數。
總結一下就是給input標籤的vnode新增了一個vModelText
的自定義指令,並且給指令繫結的值為msg
變數。還有就是在input標籤的vnode中新增了一個onUpdate:modelValue
的屬性,屬性值是一個回撥函式,觸發這個回撥函式就會將msg
變數的值更新為輸入框中的最新值。我們知道input輸入框中的值對應的是value屬性,監聽的是input和change事件。那麼這裡有兩個問題:
-
如何將
vModelText
自定義指令繫結的msg
變數的值傳遞給input輸入框中的value屬性的呢? -
input標籤監聽input和change事件,編譯後input上面卻是一個名為
onUpdate:modelValue
的props回撥函式?
要回答上面的兩個問題我們需要看vModelText
自定義指令是什麼樣的。
vModelText
自定義指令
vModelText
是一個執行時的v-model指令,為什麼說是執行時呢? 面試官:只知道v-model是modelValue語法糖,那你可以走了 文章中我們已經講過了,在編譯時就會將元件上面的v-model指令編譯成modelValue
屬性和@update:modelValue
事件。所以當執行時在元件上已經沒有了v-model指令了,只有原生input在執行時依然還有v-model指令,也就是vModelText
自定義指令。
我們來看看vModelText
自定義指令的程式碼:
const vModelText = {
created(el, { modifiers: { lazy, trim, number } }, vnode) {
// ...
},
mounted(el, { value }) {
// ...
},
beforeUpdate(el, { value, modifiers: { lazy, trim, number } }, vnode) {
// ...
},
}
從上面可以看到vModelText
自定義指令中使用了三個鉤子函式:created
、mounted
、beforeUpdate
,我們來看看上面三個鉤子函式中使用到的引數:
-
el
:指令繫結到的元素。這可以用於直接操作 DOM。 -
binding
:一個物件,包含以下屬性。上面的例子中是直接解構了binding
物件。-
value
:傳遞給指令的值。例如在v-model="msg"
中,其中msg
變數的值為“hello word”,value
的值就是“hello word”。 -
modifiers
:一個包含修飾符的物件,v-model支援lazy
,trim
,number
這三個修飾符。-
lazy
:預設情況下,v-model
會在每次input
事件後更新資料。你可以新增lazy
修飾符來改為在每次change
事件後更新資料,在input輸入框中就是失去焦點時再更新資料。 -
trim
:去除使用者輸入內容中兩端的空格。 -
number
:讓使用者輸入自動轉換為數字。
-
-
-
vnode
:繫結元素的 VNode(虛擬DOM)。
mounted
鉤子函式
我們先來看mounted
鉤子函式,程式碼如下:
const vModelText = {
mounted(el, { value }) {
el.value = value == null ? "" : value;
},
}
mounted
中的程式碼很簡單,在mounted
時如果v-model繫結的msg
變數的值不為空,那麼就將msg
變數的值同步到input輸入框中。
created
鉤子函式
我們接著來看created
鉤子函式中的程式碼,如下:
const assignKey = Symbol("_assign");
const vModelText = {
created(el, { modifiers: { lazy, trim, number } }, vnode) {
el[assignKey] = getModelAssigner(vnode);
const castToNumber =
number || (vnode.props && vnode.props.type === "number");
addEventListener(el, lazy ? "change" : "input", (e) => {
if (e.target.composing) return;
let domValue = el.value;
if (trim) {
domValue = domValue.trim();
}
if (castToNumber) {
domValue = looseToNumber(domValue);
}
el[assignKey](domValue);
});
if (trim) {
addEventListener(el, "change", () => {
el.value = el.value.trim();
});
}
if (!lazy) {
addEventListener(el, "compositionstart", onCompositionStart);
addEventListener(el, "compositionend", onCompositionEnd);
}
},
}
created
鉤子函式中的程式碼主要分為五部分。
第一部分
首先我們來看第一部分程式碼:
el[assignKey] = getModelAssigner(vnode);
我們先來看這個getModelAssigner
函式。程式碼如下:
const getModelAssigner = (vnode) => {
const fn = vnode.props["onUpdate:modelValue"];
return isArray(fn) ? (value) => invokeArrayFns(fn, value) : fn;
};
getModelAssigner
函式的程式碼很簡單,就是返回vnode
上面名為onUpdate:modelValue
的props回撥函式。前面我們已經講過了執行這個回撥函式會同步更新v-model繫結的msg
變數。
所以第一部分程式碼的作用就是取出input標籤上面名為onUpdate:modelValue
的props回撥函式,然後賦值給input標籤物件的assignKey
方法上面,後面再輸入框中的input或者chang事件觸發時會手動呼叫。這個assignKey
是一個Symbol,唯一的識別符號。
第二部分
再來看第二部分程式碼:
const castToNumber =
number || (vnode.props && vnode.props.type === "number");
castToNumber
表示是否使用了.number
修飾符,或者input輸入框上面是否有type=number
的屬性。如果castToNumber
的值為true,後續處理輸入框的值時會將其轉換成數字。
第三部分
我們接著來看第三部分的程式碼:
addEventListener(el, lazy ? "change" : "input", (e) => {
if (e.target.composing) return;
let domValue = el.value;
if (trim) {
domValue = domValue.trim();
}
if (castToNumber) {
domValue = looseToNumber(domValue);
}
el[assignKey](domValue);
});
對input輸入框進行事件監聽,如果有.lazy
修飾符就監聽change事件,否則監聽input事件。看看,這不就和.lazy
修飾符的作用對上了嘛。.lazy
修飾符的作用是在每次change事件觸發時再去更新資料。
我們接著看裡面的事件處理函式,來看看第一行程式碼:
if (e.target.composing) return;
當使用者使用拼音輸入法輸入漢字時,正在輸入拼音階段也會觸發input事件的。但是一般情況下我們只希望真正合成漢字時才觸發input去更新資料,所以在輸入拼音階段觸發的input事件需要被return。至於e.target.composing
什麼時候被設定為true
,什麼時候又是false
,我們接著會講。
後面的程式碼就很簡單了,將輸入框中的值也就是el.value
賦值給domValue
變數。如果使用了.trim
修飾符,就執行trim
方法,去除掉domValue
變數中兩端的空格。
如果castToNumber
的值為true,表示使用了.number
修飾符或者在input上面使用了type=number
。呼叫looseToNumber
方法將domValue
字串轉換為數字。
最後將處理後的domValue
,也就是處理後的輸入框中的輸入值,作為引數呼叫el[assignKey]
方法。我們前面講過了el[assignKey]
中存的就是input標籤上面名為onUpdate:modelValue
的props回撥函式,執行el[assignKey]
方法就是執行回撥函式,在回撥函式中會將v-model繫結的msg
變數的值更新為處理後的輸入框中的輸入值。
現在你知道了為什麼input標籤監聽input和change事件,編譯後input上面卻是一個名為onUpdate:modelValue
的props回撥函式了?
因為在input或者change事件的回撥中會將輸入框的值根據傳入的修飾符進行處理,然後將處理後的輸入框的值作為引數手動呼叫onUpdate:modelValue
回撥函式,在回撥函式中更新繫結的msg變數。
第四部分
我們接著來看第四部分的程式碼,如下:
if (trim) {
addEventListener(el, "change", () => {
el.value = el.value.trim();
});
}
這一塊程式碼很簡單,如果使用了.trim
修飾符,觸發change事件,在input輸入框中就是失去焦點時。就會將輸入框中的值也trim一下,去掉前後的空格。
為什麼需要有這塊程式碼,前面在input或者change事件中不是已經對輸入框中的值進行trim處理了嗎?而且後面的beforeUpdate
鉤子函式中也執行了el.value = newValue
將輸入框中的值更新為v-model繫結的msg
變數的值。
答案是:前面確實對輸入框中拿到的值進行trim處理,然後將trim處理後的值更新為v-model繫結的msg變數。但是我們並沒有將輸入框中的值更新為trim處理後的,雖然在beforeUpdate
鉤子函式中會將輸入框中的值更新為v-model繫結的msg變數。但是如果只是在輸入框的前後輸入空格,那麼經過trim處理後在beforeUpdate
鉤子函式中就會認為輸入框中的值和msg
變數的值相等。就不會執行el.value = newValue
,此時輸入框中的值還是有空格的,所以需要執行第四部分的程式碼將輸入框中的值替換為trim後的值。
第五部分
我們接著來看第五部分的程式碼,如下:
if (!lazy) {
addEventListener(el, "compositionstart", onCompositionStart);
addEventListener(el, "compositionend", onCompositionEnd);
}
如果沒有使用.lazy
修飾符,也就是在每次input時都會對繫結的變數進行更新。
這裡監聽的compositionstart
事件是:文字合成系統如開始新的輸入合成時會觸發 compositionstart
事件。舉個例子:當使用者使用拼音輸入法開始輸入漢字時,這個事件就會被觸發。
這裡監聽的compositionend
事件是:當文字段落的組成完成或取消時,compositionend 事件將被觸發。舉個例子:當使用者使用拼音輸入法,將輸入的拼音合成漢字時,這個事件就會被觸發。
來看看onCompositionStart
中的程式碼,如下:
function onCompositionStart(e) {
e.target.composing = true;
}
程式碼很簡單,將e.target.composing
設定為true。還記得我們前面在input輸入框的input或者change事件中會先去判斷這個e.target.composing
,如果其為true,那麼就return掉,這樣就不會在輸入拼音時也會更新v-model繫結的msg
變數了。
我們來看看onCompositionEnd
中的程式碼,如下:
function onCompositionEnd(e) {
const target = e.target;
if (target.composing) {
target.composing = false;
target.dispatchEvent(new Event("input"));
}
}
當將拼音合成漢字時會將e.target.composing
設定為false,這裡為什麼要呼叫target.dispatchEvent
手動觸發一個input事件呢?
答案是:將拼音合成漢字時input事件會比compositionend事件先觸發,由於此時的e.target.composing
的值還是true,所以input事件中後續的程式碼就會被return。所以才需要將e.target.composing
重置為false後,手動觸發一個input事件,更新v-model繫結的msg
變數。
beforeUpdate
鉤子函式
我們接著來看看beforeUpdate
鉤子函式,會在每次因為響應式狀態變更,導致頁面更新之前呼叫,程式碼如下:
const vModelText = {
beforeUpdate(el, { value, modifiers: { lazy, trim, number } }, vnode) {
el[assignKey] = getModelAssigner(vnode);
// avoid clearing unresolved text. #2302
if (el.composing) return;
const elValue =
number || el.type === "number" ? looseToNumber(el.value) : el.value;
const newValue = value == null ? "" : value;
if (elValue === newValue) {
return;
}
if (document.activeElement === el && el.type !== "range") {
if (lazy) {
return;
}
if (trim && el.value.trim() === newValue) {
return;
}
}
el.value = newValue;
},
};
看完了前面的created
函式,再來看這個beforeUpdate
函式就很簡單了。beforeUpdate
鉤子函式最終要做的事情就是最後的這行程式碼:
el.value = newValue;
這行程式碼的意思是將輸入框中的值更新成v-model繫結的msg
變數,為什麼需要在beforeUpdate
鉤子函式中執行呢?
答案是msg
是一個響應式變數,如果在父元件上面因為其他原因改變了msg
變數的值後,這個時候就需要將input輸入框中的值同步更新為最新的msg變數。這也就解釋了我們前面的問題:如何將vModelText
自定義指令繫結的msg
變數的值傳遞給input輸入框中的value屬性的呢?
第一行程式碼是:
el[assignKey] = getModelAssigner(vnode);
這裡再次將vnode
上面名為onUpdate:modelValue
的props回撥函式賦值給el[assignKey]
,之前在created
的時候不是已經賦值過一次了嗎,這裡為什麼會再次賦值呢?
答案是在有的場景中是不會快取onUpdate:modelValue
回撥函式,如果沒有快取,那麼每次執行render函式都會生成新的onUpdate:modelValue
回撥函式。所以才需要在beforeUpdate
鉤子函式中每次都將最新的onUpdate:modelValue
回撥函式賦值給el[assignKey]
,當在input或者change事件觸發時執行el[assignKey]
的時候就是執行的最新的onUpdate:modelValue
回撥函式。
再來看看第二行程式碼,如下:
// avoid clearing unresolved text. #2302
if (el.composing) return;
這行程式碼是為了修復bug:如果在輸入拼音的過程中,還沒有合成漢字之前。如果有其他的響應式變數的值變化導致頁面重新整理,這種時候就應該return。否則由於此時的msg變數的值還是null,如果執行el.value = newValue
,輸入框中的輸入值就會被清空。詳情請檢視issue: https://github.com/vuejs/core/issues/2302
後面的程式碼就很簡單了,其中的document.activeElement
屬性返回獲得當前焦點(focus)的 DOM 元素,還有type = "range"
我們平時基本不會使用。根據使用的修飾符拿到處理後的input輸入框中的值,然後和v-model繫結的msg
變數進行比較。如果兩者相等自然不需要執行el.value = newValue
將輸入框中的值更新為最新值。
總結
現在來看這個流程圖你應該就很容易理解了:
在元件上面使用v-model和原生input上面使用v-model區別主要有三點:
-
元件上面的v-model編譯後會生成
modelValue
屬性和@update:modelValue
事件。而在原生input上面使用v-model編譯後不會生成
modelValue
屬性,只會生成onUpdate:modelValue
回撥函式和vModelText
自定義指令。(在 面試官:只知道v-model是modelValue語法糖,那你可以走了 文章中我們已經講過了@update:modelValue
事件其實等價於onUpdate:modelValue
回撥函式) -
在元件上面使用v-model,是由子元件中定義一個名為
modelValue
的props來接收父元件使用v-model繫結的變數,然後使用這個modelValue
繫結到子元件的表單中。在原生input上面使用v-model,是由編譯後生成的
vModelText
自定義指令在mounted
和beforeUpdate
鉤子函式中去將v-model繫結的變數值更新到原生input輸入框的value屬性,以保證v-model繫結的變數值和input輸入框中的值始終一致。 -
在元件上面使用v-model,是由子元件使用emit丟擲
@update:modelValue
事件,在@update:modelValue
的事件處理函式中去更新v-model繫結的變數。而在原生input上面使用v-model,是由編譯後生成的
vModelText
自定義指令在created
鉤子函式中去監聽原生input標籤的input或者change事件。在事件回撥函式中去手動呼叫onUpdate:modelValue
回撥函式,然後在回撥函式中去更新v-model繫結的變數。
關注(圖1)公眾號:【前端歐陽】,解鎖我更多vue原理文章。
加我(圖2)微信回覆「666」,免費領取歐陽研究vue原始碼過程中收集的原始碼資料,歐陽寫文章有時也會參考這些資料。同時讓你的朋友圈多一位對vue有深入理解的人。