Vue 3.0 进阶之双向绑定探秘

本文是 Vue 3.0 进阶系列
的第三篇文章,在阅读本文前,建议你先阅读
Vue 3.0 进阶之指令探秘


Vue 3.0 进阶之自定义事件探秘

这两篇文章。在看具体示例前,阿宝哥先来简单介绍一下双向绑定,它由两个单向绑定组成:

  • 模型 —> 视图数据绑定;
  • 视图 —> 模型事件绑定。

在 Vue 中 :value
实现了 模型到视图
的数据绑定, @event
实现了 视图到模型
的事件绑定:

<input :value="searchText" @input="searchText = $event.target.value" />

而在表单中,通过使用内置的 v-model
指令,我们可以轻松地实现双向绑定,比如
。介绍完上面的内容,接下来阿宝哥将以一个简单的示例为切入点,带大家一起一步步揭开双向绑定背后的秘密。

<div id="app">
<input v-model="searchText" />
<p>搜索的内容:{{searchText}}</p>
</div>
<script>
const { createApp } = Vue
const app = Vue.createApp({
data() {
return {
searchText: "阿宝哥"
}
}
})
app.mount('#app')
</script>

在以上示例中,我们在 input
搜索输入框中应用了 v-model
指令,当输入框的内容发生变化时, p
标签中内容会同步更新。

要揭开 v-model
指令背后的秘密,我们可以利用 Vue 3 Template Explorer
在线工具,来看一下模板编译后的结果:

<input v-model="searchText" />

const _Vue = Vue
return function render(_ctx, _cache, $props, $setup, $data, $options) {
with (_ctx) {
const { vModelText: _vModelText, createVNode: _createVNode,
withDirectives: _withDirectives, openBlock: _openBlock, createBlock: _createBlock } = _Vue

return _withDirectives((_openBlock(), _createBlock("input", {
"onUpdate:modelValue": $event => (searchText = $event)
}, null, 8 /* PROPS */, ["onUpdate:modelValue"])),
[
[_vModelText, searchText]
])
}
}


模板生成的渲染函数中,我们看到了
Vue 3.0 进阶之指令探秘

文章中介绍的 withDirectives
函数,该函数用于把指令信息添加到 VNode
对象上,它被定义在 runtime-core/src/directives.ts
文件中:

// packages/runtime-core/src/directives.ts
export function withDirectives<T extends VNode>(
vnode: T,
directives: DirectiveArguments
): T
{
const internalInstance = currentRenderingInstance
// 省略部分代码
const instance = internalInstance.proxy
const bindings: DirectiveBinding[] = vnode.dirs || (vnode.dirs = [])
for (let i = 0; i < directives.length; i++) {
let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]
// 在 mounted 和 updated 时,触发相同行为,而不关系其他的钩子函数
if (isFunction(dir)) { // 处理函数类型指令
dir = {
mounted: dir,
updated: dir
} as ObjectDirective
}
bindings.push({ // 把指令信息保存到vnode.dirs数组中
dir, instance, value,
oldValue: void 0, arg, modifiers
})
}
return vnode
}

除此之外,在模板生成的渲染函数中,我们看到了 vModelText
指令,通过它的名称,我们猜测该指令与模型相关,所以我们先来分析 vModelText
指令。

一、vModelText 指令

vModelText
指令是 ObjectDirective
类型的指令,该指令中定义了 3 个钩子函数:

  • created
    :在绑定元素的属性或事件监听器被应用之前调用。
  • mounted
    :在绑定元素的父组件被挂载后调用。
  • beforeUpdate
    :在更新包含组件的 VNode 之前调用。
// packages/runtime-dom/src/directives/vModel.ts
type ModelDirective = ObjectDirective

export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
created(el, { modifiers: { lazy, trim, number } }, vnode) {
// ...
},
mounted(el, { value }) {
// ..
},
beforeUpdate(el, { value, modifiers: { trim, number } }, vnode) {
// ..
}
}

接下来,阿宝哥将逐一分析每个钩子函数,这里先从 created
钩子函数开始。

1.1 created 钩子

// packages/runtime-dom/src/directives/vModel.ts
export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
created(el, { modifiers: { lazy, trim, number } }, vnode) {
el._assign = getModelAssigner(vnode)
const castToNumber = number || el.type === 'number' // 是否转为数值类型
// 若使用 lazy 修饰符,则在 change 事件触发后将输入框的值与数据进行同步
addEventListener(el, lazy ? 'change' : 'input', e => {
if ((e.target as any).composing) return // 组合输入进行中
let domValue: string | number = el.value
if (trim) { // 自动过滤用户输入的首尾空白字符
domValue = domValue.trim()
} else if (castToNumber) { // 自动将用户的输入值转为数值类型
domValue = toNumber(domValue)
}
el._assign(domValue) // 更新模型
})
if (trim) {
addEventListener(el, 'change', () => {
el.value = el.value.trim()
})
}
if (!lazy) {
addEventListener(el, 'compositionstart', onCompositionStart)
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)
}
},
}

对于 created
方法来说,它会通过解构的方式获取 v-model
指令上添加的修饰符,在 v-model
上可以添加 .lazy
.number
.trim
修饰符。这里我们简单介绍一下 3 种修饰符的作用:

  • .lazy
    修饰符:在默认情况下, v-model
    在每次 input
    事件触发后将输入框的值与数据进行同步。你可以添加 lazy
    修饰符,从而转为在 change
    事件之后进行同步。


    <input v-model.lazy="msg" />

  • .number
    修饰符:如果想自动将用户的输入值转为数值类型,可以给 v-model
    添加 number
    修饰符。这通常很有用,因为即使在 type="number"
    时,HTML 输入元素的值也总会返回字符串。如果这个值无法被 parseFloat()
    解析,则会返回原始的值。

    <input v-model.number="age" type="number" />

  • .trim
    修饰符:如果要自动过滤用户输入的首尾空白字符,可以给 v-model
    添加 trim
    修饰符。

    <input v-model.trim="msg" />

而在 created
方法内部,会通过 getModelAssigner
函数获取 ModelAssigner
,从而用于更新模型对象。

// packages/runtime-dom/src/directives/vModel.ts
const getModelAssigner = (vnode: VNode): AssignerFn => {
const fn = vnode.props!['onUpdate:modelValue']
return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
}

对于我们的示例来说,通过 getModelAssigner
函数获取的 ModelAssigner
对象是 $event => (searchText = $event)
函数。在获取   ModelAssigner
对象之后,我们就可以更新模型的值了。 created
方法中的其他代码相对比较简单,阿宝哥就不详细介绍了。这里我们来介绍一下 compositionstart
compositionend
事件。
中文、日文、韩文等需要借助输入法组合输入,即使是英文,也可以利用组合输入进行选词等操作。在一些实际场景中,我们希望等用户组合输入完的一段文字才进行对应操作,而不是每输入一个字母,就执行相关操作。

比如,在关键字搜索场景中,等用户完整输入 阿宝哥
之后再执行搜索操作,而不是输入字母 a
之后就开始搜索。要实现这个功能,我们就需要借助 compositionstart
compositionend
事件。另外,需要注意的是, compositionstart
事件发生在 input
事件之前,因此利用它可以优化中文输入的体验。

了解完 compositionstart
(组合输入开始) 和 compositionend
(组合输入结束)事件,我们再来看一下 onCompositionStart
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
trigger(target, 'input')
}
}

// 触发元素上的指定事件
function trigger(el: HTMLElement, type: string) {
const e = document.createEvent('HTMLEvents')
e.initEvent(type, true, true)
el.dispatchEvent(e)
}

当组合输入时,在 onCompositionStart
事件处理器中,会 e.target
对象上添加 composing
属性并设置该属性的值为 true
。而在 change
事件或 input
事件回调函数中,如果发现   e.target
对象的 composing
属性为 true
则会直接返回。当组合输入完成后,在 onCompositionEnd
事件处理器中,会把 target.composing
的值设置为 false
并手动触发 input
事件:

// packages/runtime-dom/src/directives/vModel.ts
export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
created(el, { modifiers: { lazy, trim, number } }, vnode) {
// 省略部分代码
addEventListener(el, lazy ? 'change' : 'input', e => {
if ((e.target as any).composing) return
// ...
})
},
}

好的, created
钩子函数就分析到这里,接下来我们来分析 mounted
钩子。

1.2 mounted 钩子

// 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
},
}

mounted
钩子的逻辑很简单,如果 value
值为 null
时,把元素的值设置为空字符串,否则直接使用 value
的值。

1.3 beforeUpdate 钩子

// packages/runtime-dom/src/directives/vModel.ts
export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement
> = {
beforeUpdate(el, { value, modifiers: { trim, number } }, vnode) {
el._assign = getModelAssigner(vnode)
// avoid clearing unresolved text. #2302
if ((el as any).composing) return
if (document.activeElement === el) {
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
}
}
}

相信使用过 Vue 的小伙伴都知道, v-model
指令不仅可以应用在 input
textarea
元素上,在复选框(Checkbox)、单选框(Radio)和选择框(Select)上也可以使用 v-model
指令。 不过需要注意的是,虽然这些元素上都是使用
v-model
指令,但实际上对于复选框、单选框和选择框来说,它们是由不同的指令来完成对应的功能。这里我们以单选框为例,来看一下应用
v-model
指令后,模板编译的结果:

<input type="radio" value="One" v-model="picked" />

const _Vue = Vue
return function render(_ctx, _cache, $props, $setup, $data, $options) {
with (_ctx) {
const { vModelRadio: _vModelRadio, createVNode: _createVNode,
withDirectives: _withDirectives, openBlock: _openBlock, createBlock: _createBlock } = _Vue

return _withDirectives((_openBlock(), _createBlock("input", {
type: "radio",
value: "One",
"onUpdate:modelValue": $event => (picked = $event)
}, null, 8 /* PROPS */, ["onUpdate:modelValue"])), [
[_vModelRadio, picked]
])
}
}

由以上代码可知,在单选框应用 v-model
指令后,双向绑定的功能会交给 vModelRadio
指令来实现。除了 vModelRadio
之外,还有 vModelSelect
vModelCheckbox
指令,它们被定义在 runtime-dom/src/directives/vModel.ts
文件中,感兴趣的小伙伴可以自行研究一下。

其实 v-model
本质上是语法糖。它负责监听用户的输入事件来更新数据,并在某些场景下进行一些特殊处理。需要注意的是 v-model
会忽略所有表单元素的 value
checked
selected
attribute 的初始值而总是将当前活动实例的数据作为数据来源。你应该通过在组件的 data
选项中声明初始值。

此外, v-model
在内部为不同的输入元素使用不同的 property 并抛出不同的事件:

  • text 和 textarea 元素使用 value
    property 和 input
    事件;
  • checkbox 和 radio 元素使用 check
    property 和 change
    事件;
  • select 元素将 value
    作为 prop 并将 change
    作为事件。

这里你已经知道,可以用 v-model
指令在表单