特殊元素&指令:双向绑定是如何实现的?
前言
有些小伙伴在理解 Vue
的响应式原理的时候,可能会认为 Vue
的响应式是双向绑定的,但实际上这是不准确的,所谓数据的双向绑定可以体现为以下两部分:
- 数据流向
DOM
的绑定:数据的更新最终映射到对应的视图更新。 DOM
流向数据的绑定:操作DOM
的变化引起数据的更新。
我们在前面的章节花了不少篇幅介绍了响应式原理,其实这块就是着重在介绍数据流向 DOM
的过程。
在 Vuejs
中,我们则会经常通过 v-model
指令来实现数据的 “双向绑定”。 v-model
指令既可以作用在普通表单元素,也可以作用在一些组件上。接下来我们将分别介绍这两种情况的实现原理。
表单元素
在使用 Vuejs
编写表单类的 UI
控件时,经常会使用 v-model
指令来为 <input>
、<select>
、<textarea>
进行数据的双向绑定。
我们使用 Vue
提供的官方模版转换工具来尝试一下在 <input>
、<select>
、<textarea>
输入类型的表单中使用 v-model
指令会被编译成什么样子:
模版:
<input v-model='value1' />
<textarea v-model='value2' />
<select v-model='value3' />
编译结果
import { vModelText as _vModelText, createElementVNode as _createElementVNode, withDirectives as _withDirectives, vModelSelect as _vModelSelect, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = ["onUpdate:modelValue"]
const _hoisted_2 = ["onUpdate:modelValue"]
const _hoisted_3 = ["onUpdate:modelValue"]
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock(_Fragment, null, [
_withDirectives(_createElementVNode("input", {
"onUpdate:modelValue": $event => ((_ctx.value1) = $event)
}, null, 8 /* PROPS */, _hoisted_1), [
[_vModelText, _ctx.value1]
]),
_withDirectives(_createElementVNode("textarea", {
"onUpdate:modelValue": $event => ((_ctx.value2) = $event)
}, null, 8 /* PROPS */, _hoisted_2), [
[_vModelText, _ctx.value2]
]),
_withDirectives(_createElementVNode("select", {
"onUpdate:modelValue": $event => ((_ctx.value3) = $event)
}, null, 8 /* PROPS */, _hoisted_3), [
[_vModelSelect, _ctx.value3]
])
], 64 /* STABLE_FRAGMENT */))
}
可以看到通过 v-model
绑定的元素,在转成渲染函数的时候,最外层都被套上了一个 withDirectives
函数,这个函数传入了两个变量,一个通过 createElementVNode
创建的 vnode
节点,另一个是一个数组类型的参 directives
,这个我们后面再介绍。我们先来简单看一下 withDirectives
这个函数的实现:
export function withDirectives(vnode, directives) {
const internalInstance = currentRenderingInstance
if (internalInstance === null) {
return vnode
}
const instance = getExposeProxy(internalInstance) || internalInstance.proxy
// 获取指令集
const bindings = vnode.dirs || (vnode.dirs = [])
// 遍历 directives
for (let i = 0; i < directives.length; i++) {
let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]
// 如果存在指令
if (dir) {
// 指令是个函数,构造 mounted、updated 钩子
if (isFunction(dir)) {
dir = {
mounted: dir,
updated: dir
}
}
// 存在 deep 属性,遍历访问每个属性
if (dir.deep) {
traverse(value)
}
// bindings 中添加构造好的指令元素
bindings.push({
dir,
instance,
value,
oldValue: void 0,
arg,
modifiers
})
}
}
return vnode
}
可以看到 withDirectives
函数主要就是为 vnode
节点上添加 dirs
属性,对于我们示例中的 <input>
节点而言,生成的 dir
内容大致为( select
节点类似,这里就不再介绍了,有兴趣的可以在源码详细了解):
{
dir: vModelText,
value: _ctx.value1,
...
}
其中 vModelText
是一个对象,内置了 v-model
指令相关的生命周期的实现:
export const vModelText = {
// created 生命周期
created(el, { modifiers: { lazy, trim, number } }, vnode) {
// 获取 props 上 onUpdate:modelValue 函数
el._assign = getModelAssigner(vnode)
const castToNumber =
number || (vnode.props && vnode.props.type === 'number')
// 注册 input/change 事件
addEventListener(el, lazy ? 'change' : 'input', e => {
// ...
let domValue = el.value
// .trim 修饰符
if (trim) {
domValue = domValue.trim()
}
if (castToNumber) {
domValue = looseToNumber(domValue)
}
// 执行 onUpdate:modelValue 函数
el._assign(domValue)
})
if (trim) {
addEventListener(el, 'change', () => {
el.value = el.value.trim()
})
}
// ...
},
mounted(el, { value }) {
// 赋值
el.value = value == null ? '' : value
},
beforeUpdate(el, { value, modifiers: { lazy, trim, number } }, vnode) {
// 更新 el._assign
el._assign = getModelAssigner(vnode)
if (el.composing) return
if (document.activeElement === el && el.type !== 'range') {
if (lazy) {
return
}
if (trim && el.value.trim() === value) {
return
}
if (
(number || el.type === 'number') &&
looseToNumber(el.value) === value
) {
return
}
}
// 更新值
const newValue = value == null ? '' : value
if (el.value !== newValue) {
el.value = newValue
}
}
}
可以看到 vModelText
内置了 created
、mounted
、beforeUpdate
钩子函数。
在 created
的时候,会从 pops
上获取 onUpdate:modelValue
函数,这个函数也就是我们在遇到 v-model
指令后,Vue
的编译器自动转换生成的。然后再监听对应 DOM
上的 change
或者 input
事件,事件触发时再回调执行 onUpdate:modelValue
函数。
在 mounted
的时候,会将当前的值 value
赋值给 el.value
。
指令生命周期的触发
前面我们提到了 v-model
注册的指令节点,会生成一个带有 dirs
的属性,属性中会包含类似于 vModelText
这样的对象,这个对象内部包含了一些生命周期函数,那这些生命周期函数又是在何时执行的呢?再回到我们之前的 mountElement
函数内,这次我们着重看一下与指令相关的代码实现:
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
// ...
const { type, props, shapeFlag, transition, dirs } = vnode
if (dirs) {
// 执行 created 钩子函数
invokeDirectiveHook(vnode, null, parentComponent, 'created')
}
// ...
if (props) {
// 处理 props,比如 class、style、event 等属性
}
if (dirs) {
// 执行 beforeMount 钩子函数
invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
}
// 挂载 dom
hostInsert(el, container, anchor)
if (
(vnodeHook = props && props.onVnodeMounted) ||
needCallTransitionHooks ||
dirs
) {
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
needCallTransitionHooks && transition!.enter(el)
// 执行 mounted 钩子函数
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
}, parentSuspense)
}
}
可以看到指令相关的钩子函数在进行 vnode
初始化挂载的时候,会在挂载的各个阶段被分别调用,从而完成生命周期函数的执行过程。
组件
我们首先来看一下,v-model
在组件中一些常规的使用方式:
<Component v-model="value1" />
<Component v-model:title="bookTitle" />
<Component v-model:first-name="first" v-model:last-name="last" />
在组件上,v-model
不仅仅可以使用 modelValue
作为 prop
,以 update:modelValue
作为对应的事件,还支持了给 v-model
一个自定义参数来更改这些名字。因为有了自定义参数的功能,所以也就支持了一个组件多个 v-model
绑定的功能。
接下来再看看通过 Vue 3 Template Explorer 将上述模版转出来的渲染函数的表达形式:
import { resolveComponent as _resolveComponent, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_Component = _resolveComponent("Component")
return (_openBlock(), _createElementBlock(_Fragment, null, [
_createVNode(_component_Component, {
modelValue: _ctx.value1,
"onUpdate:modelValue": $event => ((_ctx.value1) = $event)
}, null, 8 /* PROPS */, ["modelValue", "onUpdate:modelValue"]),
_createVNode(_component_Component, {
title: _ctx.bookTitle,
"onUpdate:title": $event => ((_ctx.bookTitle) = $event)
}, null, 8 /* PROPS */, ["title", "onUpdate:title"]),
_createVNode(_component_Component, {
"first-name": _ctx.first,
"onUpdate:firstName": $event => ((_ctx.first) = $event),
"last-name": _ctx.last,
"onUpdate:lastName": $event => ((_ctx.last) = $event)
}, null, 8 /* PROPS */, ["first-name", "onUpdate:firstName", "last-name", "onUpdate:lastName"])
], 64 /* STABLE_FRAGMENT */))
}
可以看到,编译器在处理组件带有 v-model
指令的时候,会将其根据相关参数进行解析,最后组成一个 props
传入组件中。拿一个 v-model:title = 'bookTitle'
举例,生成的 props
大致是这样的:
{
title: value,
"onUpdate:title": $event => _ctx.bookTitle = $event
}
所以这也解释了为什么组件内部需要定义一个 props
用来承接 title
的值;定义一个 emit
,在 title
值变化的时候,用来触发 onUpdate:title
,并传入更新后的值。
<!-- Component.vue -->
<script setup>
defineProps(['title'])
defineEmits(['update:title'])
</script>
<template>
<input
type="text"
:value="title"
@input="$emit('update:title', $event.target.value)"
/>
</template>
接下来我们再看看这个 $emit
是如何触发 onUpdate:title
函数的执行的。先来看看 $emit
函数的实现:
export function emit(instance, event, ...rawArgs) {
if (instance.isUnmounted) return
const props = instance.vnode.props || EMPTY_OBJ
let args = rawArgs
// 定义事件名称
let handlerName
// update:xxx => onUpdate:xxx
let handler =
props[(handlerName = toHandlerKey(event))] ||
props[(handlerName = toHandlerKey(camelize(event)))]
// 找到了 handler 触发调用
if (handler) {
callWithAsyncErrorHandling(
handler,
instance,
ErrorCodes.COMPONENT_EVENT_HANDLER,
args
)
}
// ...
}
其中第一个参数是当前组件实例,$emit
自动为我们绑定了当前组件,event
为事件名称,rawArgs
就是传入的一些参数。整个函数逻辑还是很清晰的,就是将传入的 event
名称转成 onUpdate:xxx
的写法,然后在 props
上找对应的函数,也就是我们传入的那个事件函数。找到了后就通过 callWithAsyncErrorHandling
方法进行调用,完成事件的执行。
总结
v-model
不管是在表单元素还是在组件元素上都会被编译器转成一个 props
对象,在表单元素上是这样的:
{
"onUpdate:modelValue": $event => _ctx.bookTitle = $event
}
而在组件时则会编译成:
{
title: value,
"onUpdate:title": $event => _ctx.bookTitle = $event
}
那么,所谓的双向数据绑定的 DOM
操作触发数据的更新就可以理解为:
在表单元素上,事件名 modelValue
是默认的,通过 vModelText
函数在内部实现了一个监听 DOM
变更的事件 change/input
来实现对数据值的更新操作。
在组件元素上,则是通过组件内部自定义值接受和事件派发机制完成对数据的更新操作。