什麼是雙向繫結?
廢話不多說,我們先來看一個 v-model
基本的示例:
<input type="text" v-model="search">
首先,我們要明白一點的是:v-model
的本質是指令。因此,它跟我們一般的自定義指令是一樣的,需要實現 Vue.js
生命週期的鉤子函式。
其次,v-model
實現了雙向繫結,也就是:資料到 DOM 的單向流動、DOM 到資料的單向流動。
明白了上面這兩點,再來看程式碼就清晰多了。
// packages/runtime-dom/src/directives/vModel.ts
export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
created() {},
mounted() {},
beforeUpdate() {}
}
開啟 v-model
的原始碼我們可以看到,它實現了對應的 Vue.js
生命週期鉤子函式,實際上它就是一個內建的自定義指令。
那麼,v-model
如何實現雙向繫結的呢?具體來說,資料到 DOM 的單向流動以及DOM 到資料的單向流動是如何實現的。
資料到 DOM 的單向流動
// packages/runtime-dom/src/directives/vModel.ts
export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
// set value on mounted so it's after min/max for type="range"
mounted(el, { value }) {
el.value = value == null ? '' : value
}
}
資料到 DOM 的單向流動實現非常簡單,一行程式碼就搞定了,就是把 v-model
繫結的值賦值給 el.value
。
DOM 到資料的單向流動
// packages/runtime-dom/src/directives/vModel.ts
export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
created(el, { modifiers: { lazy, trim, number } }, vnode) {
el._assign = getModelAssigner(vnode)
// see: https://github.com/vuejs/core/issues/3813
const castToNumber = number || (vnode.props && vnode.props.type === 'number')
// 實現 lazy 功能
addEventListener(el, lazy ? 'change' : 'input', e => {
// `composing=true` 時不把 DOM 的值賦值給資料
if ((e.target as any).composing) return
let domValue: string | number = el.value
if (trim) {
domValue = domValue.trim()
} else if (castToNumber) {
domValue = toNumber(domValue)
}
// DOM 的值改變時,同時改變對應的資料(即改變 v-model 上繫結的變數的值)
el._assign(domValue)
})
// 實現 trim 功能
if (trim) {
addEventListener(el, 'change', () => {
el.value = el.value.trim()
})
}
// 不配置 lazy 時,監聽的是 input 的 input 事件,它會在使用者實時輸入的時候觸發。
// 此外,還會多監聽 compositionstart 和 compositionend 事件。
if (!lazy) {
// 這是因為,使用者使用拼音輸入法開始輸入漢字時,這個事件會被觸發,
// 此時,設定 `composing=true`,在 input 事件回撥裡可以進行判斷,避免將 DOM 的值賦值給資料,
// 因為此時並未輸入完成。
addEventListener(el, 'compositionstart', onCompositionStart)
// 當使用者從輸入法中確定選中了一些資料完成輸入後(如中文輸入法常見的按空格確認輸入的文字),
// 設定 `composing=false`,在 onCompositionEnd 中手動觸發 input 事件,完成資料的賦值。
addEventListener(el, 'compositionend', onCompositionEnd)
// Safari < 10.2 & UIWebView doesn't fire compositionend when
// switching focus before confirming composition choice
// this also fixes the issue where some browsers e.g. iOS Chrome
// fires "change" instead of "input" on autocomplete.
addEventListener(el, 'change', onCompositionEnd)
}
}
}
function onCompositionStart(e: Event) {
(e.target as any).composing = true
}
function onCompositionEnd(e: Event) {
const target = e.target as any
if (target.composing) {
target.composing = false
target.dispatchEvent(new Event('input'))
}
}
const getModelAssigner = (vnode: VNode): AssignerFn => {
const fn = vnode.props!['onUpdate:modelValue']
return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
}
程式碼有點多,但原理很簡單:
- 通過自定義監聽事件
addEventListener
來監聽input
元素的input
或change
事件 - 當使用者手動輸入資料時執行對應的函式,並通過
el.value
獲取input
的新值 - 呼叫
el._assign
(onUpdate:modelValue
屬性對應的函式)方法v-model
繫結的值
一句話總結:通過使用 addEventListener
來實現 DOM 到資料的單向流動。
除此之外,還有對 lazy
的處理、trim
的處理、數字的處理、以及解決正在輸入時文字被清空的問題。
關於 onCompositionStart
和 onCompositionEnd
兩個方法的作用,詳見 text added with IME to input that has v-model is gone when the view is updated #2302。
而所謂的 onUpdate:modelValue
就是一個語法糖,藉助 Vue 3 Template Explorer,我們可以檢視其編譯後生成的 render
函式,可以發現它做所的事情並沒有什麼神奇的地方,就是幫我們自動更新 v-model
上繫結的變數的值。
<input type="text" v-model="search">
import { vModelText as _vModelText, withDirectives as _withDirectives, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return _withDirectives((_openBlock(), _createElementBlock("input", {
type: "text",
// `onUpdate:modelValue` 所做的事,
// 就是自動幫我們更新 `v-model` 上繫結的變數的值。
"onUpdate:modelValue": $event => ((_ctx.search) = $event)
}, null, 8 /* PROPS */, ["onUpdate:modelValue"])), [
[_vModelText, _ctx.search]
])
}
最後是 beforeUpdate
的實現,如果資料的值和 DOM 的值不一致,則將資料更新到 DOM:
// packages/runtime-dom/src/directives/vModel.ts
beforeUpdate(el, { value, modifiers: { lazy, trim, number } }, vnode) {
el._assign = getModelAssigner(vnode)
// avoid clearing unresolved text. #2302
// 輸入某些語言如中文,在沒有輸入完成時,在更新時會自動將已存在的文字清空,具體可見 issue#2302
if ((el as any).composing) return
if (document.activeElement === el) {
if (lazy) {
return
}
if (trim && el.value.trim() === value) {
return
}
if ((number || el.type === 'number') && toNumber(el.value) === value) {
return
}
}
const newValue = value == null ? '' : value
if (el.value !== newValue) {
el.value = newValue
}
}
以上就是 text
型別的 input
元素雙向繫結原理,當然 input
元素型別不止這個,還有諸如 radio
、checkbox
等型別,大家有興趣的話可以自己去看,但是原理都是相同的,就是實現兩個功能:資料到 DOM 的單向流動、DOM 到資料的單向流動。