背景和一点点看法

见网上许多文章讲vue双向数据绑定一开口就大谈 ​​Object.defineProperty​​​ 和 ​​proxy​​​。其实不然。这是vue中响应式的“基石”。
vue 中有两个“特别的”概念:响应式双向数据绑定
其实响应式原理是一种单向行为:它是数据到 DOM (也就是view视图)的映射;而真正的双向绑定,除了数据变化会引起 DOM 的变化之外,还应该在操作 DOM 改变后反过来影响数据的变化!

vue 中提供了(内置的) ​​v-model​​指令实现双向绑定。


v-model和双向绑定的简单实现

首先,​​v-model​​​并不是可作用到任意标签,它只能在一些特定的表单标签如 ​​input​​​、​​select​​​、​​textarea​​以及自定义组件中使用。

通常你会了解到 ​​v-model​​​其实只是一个语法糖,它实际是依靠​​v-bind:​​​绑定响应式数据 & 触发 input 绑定事件并传递数据。
这么说也可以:

<input v-model="value">

<!--可以认为等价于-->
<input
v-bind:value="value"
v-on:input="value= $event.target.value"
>

我们用自定义组件和上面代码来实现一个类似​​v-model​​的数据绑定:

<script src="https://unpkg.com/vue/dist/vue.js"></script>
<div id="root"></div>
<script type="text/javascript">const component = {
template: `
<div>
<input type="text" @input="handleInput">
</div>
`,
methods: {
handleInput (e) {
this.$emit('input', e.target.value)
}
}
}

let vm = new Vue({
conponents: {
CompA: component
},
el: '#root',
template: `
<div>
<comp-a></comp-a>
</div>
`
})</script>

这样一个初始化的 demo 就搭建好了:

  • 我们定义了一个组件​​component​​​,实例化了一个 Vue 对象。​​v-model​​绑定的值,是从外层的 Vue 实例中传进去的。
  • 首先我们要在组件​​component​​​ 里面定义一个​​props​​;
  • 然后就可以在 Vue 实例的​​template​​​ 模板里面去加上这个 value ,同时绑定​​input​​事件;
  • 同样,组件​​component​​​里面的​​input​​ 也得绑定 value :

我们将上面代码中script部分完善一下:

const component = {
props: ['value'],
template: `
<div>
<input type="text" @input="handleInput" :value="value">
</div>
`,
methods: {
handleInput (e) {
this.$emit('input', e.target.value)
}
}
}

let vm = new Vue({
components: {
CompA: component
},
el: '#root',
template: `
<div>
<div>{{value}}</div>
<comp-a :value="value" @input="value= $event.target.value"></comp-a>
</div>
`,
data () {
return {
value: 'mxcnb'
}
},
})

“别具一格”的vue双向数据绑定原理_数据


既然是双向绑定,我们不妨试着改变一下 value 的值:

<button @click="handleInput">改变</button>
handleInput(){
this.value='1231'
},

“别具一格”的vue双向数据绑定原理_双向数据绑定_02


嗯,确实改变了。

vue双向绑定原理

我们大概了解了:vue双向数据绑定的原理是通过 prop 向组件传递数据(对自定义组件来说就是:在数据渲染时使用 prop 渲染数据,将 prop 绑定到子组件自身的数据上);并监听自定义事件接受组件反传的数据并更新(对自定义组件来说就是:修改数据时更新自身数据来替代 prop ,监听子组件自身数据的改变,触发事件通知父组件更改绑定到prop的数据)。

这里监听的事件对原生input组件来说就是内置的​​onUpdate:modelValue​​​函数;对自定义组件来说就是自定义事件;
通过 prop 传递的数据就是​​​v-bind​​​绑定的data;
反传的数据就是用户输入后改变了的value;

为了进一步体验“监听子组件数据”的过程,我们完全可以将上面 components 部分修改如下:

const component = {
props: ['value'],
template: `
<div>
<input type="text" v-model="_value">
</div>
`,
computed:{
_value:{
get(){
return this.value
},
set(value){
this.$emit('input', value)
}
}
},
}

vue源码中做了什么

仍然以开篇一段简单的代码说起:

<input v-model="value">

我们先看这个模板编译后生成的 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,) {
return _withDirectives((_openBlock(), _createBlock("input", {
"onUpdate:modelValue": $event => (_ctx.value = $event)
}, null, 8 /* PROPS */, ["onUpdate:modelValue"])), [
[_vModelText, _ctx.value]
])
}

可以看到,作用在 ​​input​​​ 标签的 ​​v-model​​​ 指令在编译后,除了使用 ​​withDirectives​​​ 给这个 ​​vnode​​​ 添加了 ​​vModelText​​​ 指令对象外,还额外传递了一个名为 ​​onUpdate:modelValue​​ 的 prop,它的值是一个函数,这个函数就是用来更新变量 value 。

我们来看 vModelText 的实现:

const vModelText = {
created(el, { value, modifiers: { lazy, trim, number } },) {
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 } },) {
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')
}
}

这里我们最想关注的大概就是 ​​created​​ 函数了:第一个参数 el 是节点的 DOM 对象,第二个参数是 binding 对象,第三个参数 vnode 是节点的 vnode 对象。

created 函数首先把 ​​v-model​​​ 绑定的值 value 赋值给 ​​el.value​​,这个就是数据到 DOM 的单向流动;
接着通过 getModelAssigner 方法获取 ​​​props​​​ 中的 ​​onUpdate:modelValue​​​ 属性对应的函数,赋值给 ​​el._assign​​​ 属性;最后通过 ​​addEventListener​​ 来监听 input 标签的事件,它会根据是否配置 lazy 这个修饰符来决定监听 input 还是 change 事件。

我们接着看这个事件监听函数,当用户手动输入一些数据触发事件的时候,会执行函数,并通过 ​​el.value​​​ 获取 input 标签新的值,然后调用 ​​el._assign​​ 方法更新数据,这就是 DOM 到数据的流动。

有趣的lazy修饰符

如果配置了 lazy 修饰符,那么监听的是 input 的 ​​change​​ 事件,它不会在input输入框实时输入的时候触发,而会在 input 元素值改变且失去焦点的时候触发。

如果不配置 lazy,监听的是 input 的 ​​input​​​ 事件,它会在用户实时输入的时候触发。此外,还会多监听 ​​compositionstart​​​ 和 ​​compositionend​​ 事件。

  1. 当用户在使用一些中文输入法的时候,会触发​​compositionstart​​​ 事件,这个时候设置​​e.target.composing​​​ 为​​true​​​,这样虽然 input 事件触发了,但是 input 事件的回调函数里判断了​​e.target.composing​​​ 的值,如果为​​true​​ 则直接返回,不会把 DOM 值赋值给数据。
  2. 然后当用户从输入法中确定选中了一些数据完成输入后,会触发​​compositionend​​​ 事件,这个时候判断​​e.target.composing​​​ 为​​true​​​ 的话则把它设置为​​false​​,然后再手动触发元素的 input 事件,完成数据的赋值。

这一点非常巧妙,笔者曾尝试这样实现:

text.addEventListener("keydown",(e)=>{
// 一般情况下,按下中文情况下的字母、非空格、非shift键、非enter键或按下的不是数字键时,可以不及时响应
if(e.key=="Process" && e.code!="Enter" && e.code!="Space" && e.key!=" " && e.key!="Shift" && e.key!="Enter" && !Number.isNaN(e.key)){
composing=false;
}else{
composing=true;
}
})
text.addEventListener("input",(e)=>{
if(composing){
test.value=text.value
}
})

“别具一格”的vue双向数据绑定原理_数据_03