“别具一格”的vue双向数据绑定原理
Posted 恪愚
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了“别具一格”的vue双向数据绑定原理相关的知识,希望对你有一定的参考价值。
背景和一点点看法
见网上许多文章讲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'
}
},
})
既然是双向绑定,我们不妨试着改变一下 value 的值:
<button @click="handleInput">改变</button>
handleInput(){
this.value='1231'
},
嗯,确实改变了。
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, $options) {
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 } }, 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')
}
}
这里我们最想关注的大概就是 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
事件。
- 当用户在使用一些中文输入法的时候,会触发
compositionstart
事件,这个时候设置e.target.composing
为true
,这样虽然 input 事件触发了,但是 input 事件的回调函数里判断了e.target.composing
的值,如果为true
则直接返回,不会把 DOM 值赋值给数据。 - 然后当用户从输入法中确定选中了一些数据完成输入后,会触发
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双向数据绑定原理的主要内容,如果未能解决你的问题,请参考以下文章