前言
Vue3探秘系列文章链接:
不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)
Hello~大家好。我是秋天的一阵风
在日常开发或者面试中经常会提到一个问题:什么是双向数据绑定?
很多同学就会开始侃侃而谈,比如说: "Vue对数据进行了响应式处理,当数据改变时,页面也会重新渲染。。。实现的原理在Vue2
是通过Object.defineProperty
,在 Vue3
则是通过Proxy API
。。。" 等等。。。。
其实很多同学都把双向数据绑定和响应式原理搞混了。上面所说的其实是响应式原理,且响应式原理是一种单向行为,是数据到DOM
的映射。
而双向数据绑定呢,是一种双向行为,除了数据更改引起DOM
的变化以外,在操作DOM
以后,反过来也会影响数据的变化。
而Vue
中的内置指令 v-model
就是一种双向数据绑定的实现。
v-model
的使用是有限制的,一般只能在特定的html
标签比如input
,select
,textarea
和自定义组件
中使用。
接下来我们就从 普通html标签 和 自定义组件 两个方面来探究它的实现原理。
一、普通html元素使用v-model
我们来看在普通表单元素上作用 v-model
,还是先举一个基本的示例:
<input v-model="searchText"/>
。
我们先看这个模板编译后生成的 render
函数:
import { vModelText as _vModelText, createVNode as _createVNode, withDirectives as _withDirectives, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return _withDirectives((_openBlock(), _createBlock("input", {
"onUpdate:modelValue": $event => (_ctx.searchText = $event)
}, null, 8 /* PROPS */, ["onUpdate:modelValue"])), [
[_vModelText, _ctx.searchText]
])
}
1. _withDirectives 函数
render函数
中又出现了我们熟悉的_withDirectives
,我们在之前探究自定义指令的实现原理时就分析过这个方法:
function withDirectives(vnode, directives) {
const internalInstance = currentRenderingInstance
if (internalInstance === null) {
(process.env.NODE_ENV !== 'production') && warn(`withDirectives can only be used inside render functions.`)
return vnode
}
const instance = internalInstance.proxy
const bindings = vnode.dirs || (vnode.dirs = [])
for (let i = 0; i < directives.length; i++) {
let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]
if (isFunction(dir)) {
dir = {
mounted: dir,
updated: dir
}
}
bindings.push({
dir,
instance,
value,
oldValue: void 0,
arg,
modifiers
})
}
return vnode
}
我们简单回顾下,withDirectives
接收一个vnode
和指令数组directives
,核心逻辑就是就给vnode
添加一个dirs属性
,属性的值就是这个元素节点上的所有指令构成的对象数组
所以在这里,其实就是使用withDirectives
给vnode
添加 vModelText
指令对象。
除此之外,还额外传递了一个名为 onUpdate:modelValue
的 prop
,它的值是一个函数,这个函数就是用来更新变量 searchText
。
2. vModelText
我们来看 vModelText
的实现:
const vModelText = {
created(el, { value, modifiers: { lazy, trim, number } }, vnode) {
el.value = value == null ? '' : value
el._assign = getModelAssigner(vnode)
const castToNumber = number || el.type === 'number'
addEventListener(el, lazy ? 'change' : 'input', e => {
if (e.target.composing)
return
let domValue = 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)
}
},
beforeUpdate(el, { value, modifiers: { trim, number } }, vnode) {
el._assign = getModelAssigner(vnode)
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
}
}
}
const getModelAssigner = (vnode) => {
const fn = vnode.props['onUpdate:modelValue']
return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
}
function onCompositionStart(e) {
e.target.composing = true
}
function onCompositionEnd(e) {
const target = e.target
if (target.composing) {
target.composing = false
trigger(target, 'input')
}
}
vModelText
指令实现了两个钩子函数:created
和beforeUpdate
created
- 我们先看 created 函数:第一个参数 el 是节点的
DOM 对象
,第二个参数是binding 对象
,第三个参数 vnode 是节点的vnode对象
。如果你对参数不熟悉,可以在官网这里查看参数的更详细信息
3. created
函数首先把 v-model
绑定的值 value
赋值给 el.value
,这个就是数据到 DOM 的单向流动;
-
接着通过
getModelAssigner
方法获取props
中的onUpdate:modelValue
属性对应的函数,赋值给el._assign
属性; -
最后通过
addEventListener
来监听input
标签的事件,它会根据是否配置lazy
这个修饰符来决定监听input
还是change
事件,当前案例lazy
为false
,所以监听的是input
事件。 -
我们接着看这个事件监听函数,当用户手动输入一些数据触发事件的时候,会执行函数,并通过
el.value
获取input
标签新的值,然后调用el._assign
方法更新数据,这就是 DOM 到数据的流动。
至此,我们就实现了数据的双向绑定,就是这么简单。
扩展: 指令还可以接收不同的修饰符,也就是
modifiers
对象。
如果是
lazy
为true
,则监听的change
事件,在input
元素数去焦点且值改变的时候才会触发。如果
trim
为true
,在获取 DOM 的值后,会手动调用trim
方法去除首尾空格。另外,还会额外监听change
事件执行el.value.trim()
把 DOM 的值的首尾空格去除。如果
number
为true
,或者input
的type
是number
,就会把DOM
的值转成number 类型
后再赋值给数据。
beforeUpdate
beforeUpdate
非常简单,主要就是在组件更新前判断如果数据的值和 DOM 的值不同,则把数据更新到 DOM 上。
二、自定义组件使用v-model
我们通过一个示例说明:
app.component('custom-input', {
props: ['modelValue'],
template: `
<input v-model="value">
`,
computed: {
value: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
}
}
}
})
我们先通过 app.component
全局注册了一个 custom-input
自定义组件,内部我们使用了原生的input
并使用了 v-model
指令实现数据的绑定。
注意这里我们不能直接把modelValue
作为 input
对应的 v-model
数据,因为不能直接对props
的值修改,因此这里使用计算属性。
计算属性value
对应的 getter
函数是直接取 modelValue
这个 prop
的值,而setter
函数是派发一个自定义事件 update:modelValue
。
接下来你可以用两种方式来使用这个自定义组件:
<custom-input v-model="searchText"/>
<custom-input :modelValue="searchText" @update:modelValue="$event=>{searchText = $event}"/>
你会发现,这两个模板编译后生成的 render 函数
都是一样的,而且编译的结果似乎和指令没有什么关系,并没有调用 withDirective
函数。:
import { resolveComponent as _resolveComponent, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
const _component_custom_input = _resolveComponent("custom-input")
return (_openBlock(), _createBlock(_component_custom_input, {
modelValue: _ctx.searchText,
"onUpdate:modelValue": $event => (_ctx.searchText = $event)
}, null, 8 /* PROPS */, ["modelValue", "onUpdate:modelValue"]))
}
-
因为
v-model
作用于组件上本质就是一个语法糖,就是往组件传入了一个名为modelValue
的prop
,它的值是往组件传入的数据 data,另外它还在组件上监听了一个名为update:modelValue
的自定义事件,事件的回调函数接受一个参数,执行的时候会把参数$event
赋值给数据data
。 -
正因为这个原理,所以我们想要实现自定义组件的
v-model
,首先需要定义一个名为modelValue
的prop
,然后在数据改变的时候,派发一个名为update:modelValue
的事件。 -
当然,
modelValue
这个变量名你如果不喜欢,也是可以更换的。在Vue3
中你甚至还可以定义多个v-model
。当然这不是我们本篇的重点,你可以在官网查看更多详细信息
三、自定义事件的派发
现在我们知道了v-model
就是一个语法糖,由prop
和一个自定义事件来实现。
之前也探究过prop
是如何传递到组件里面去,这个属于单向传递。
请你注意,在编译结果中,是传递了两个prop,一个是modelValue,另外一个prop是 onUpdate:modelValue
那么自定义事件是如何进行派发更新呢?
这个需要我们继续探究:子组件执行this.$emit('update:modelValue',value)
方法派发自定义事件,$emit
内部执行了 emit
方法,其实核心在于 emit
方法之中
function emit(instance, event, ...args) {
const props = instance.vnode.props || EMPTY_OBJ
let handlerName = `on${capitalize(event)}`
let handler = props[handlerName]
if (!handler && event.startsWith(‘update: ’)) {
handlerName = on$ {
capitalize(hyphenate(event))
}
handler = props[handlerName]
}
if (handler) {
callWithAsyncErrorHandling(handler, instance, 6
/* COMPONENT_EVENT_HANDLER */
, args)
}
}
1.emit
方法支持 3 个参数,第一个参数 instance
是组件的实例,也就是执行$emit
方法的组件实例,第二个参数 event
是自定义事件名称,第三个参数 args
是事件传递的参数。
-
emit
方法首先获取事件名称,把传递的 event
首字母大写,然后前面加上on
字符串,比如我们前面派发的update:modelValue
事件名称,处理后就变成了onUpdate:modelValue
。 -
接下来,通过这个事件名称,从
props
中根据事件名找到对应的prop
值,作为事件的回调函数。 -
如果找不到对应的
prop
并且event
是以update:
开头的,则尝试把event
名先转成连字符形式然后再处理。 -
找到回调函数
handler
后,再去执行这个回调函数,并且把参数args
传入。针对v-model
场景,这个回调函数就是拿到子组件回传的数据然后修改父元素传入到子组件的prop
数据,这样就达到了数据双向通讯的目的。
总结
本篇我们一起探究了v-model在普通表单元素和自定义组件上的使用方式和实现原理。除此之外,还明白了自定义事件派发的原理。希望同学们不要再把响应式原理和双向数据绑定原理搞混~