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

Posted 恪愚

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了“别具一格”的vue双向数据绑定原理相关的知识,希望对你有一定的参考价值。

背景和一点点看法

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

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


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

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

通常你会了解到 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'
	    }
	},
})

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

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

3
嗯,确实改变了。

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 事件,它会在用户实时输入的时候触发。此外,还会多监听 compositionstartcompositionend 事件。

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

4

以上是关于“别具一格”的vue双向数据绑定原理的主要内容,如果未能解决你的问题,请参考以下文章

vue数据双向绑定原理-observer

Vue2从入门到精通详解Vue数据双向绑定原理及手动实现双向绑定

VUE底层原理之数据双向绑定

vue数据双向绑定原理

vue(原理)_数据双向绑定

剖析Vue原理&实现双向绑定MVVM