深入理解Vue双向绑定和单向数据流

了解过vue的基本都知道vue的v-model是双向绑定,但不可避免我们会误解v-model是双向数据流,其实不然,vue是单项数据流的,这时候可能有点疑惑,那接下来我们来了解下双向绑定和单向数据流

双向绑定

首先我们看下什么是双向绑定?

简单来说,双向绑定就是model的更新会触发view的更新,view的更新会触发model更新

其实关键点在于data如何更新view,因为view更新data其实可以通过事件监听即可,比如input监听oninput事件就可以实现了。那么当data改变,如何更新view的,其实就是通过Object.defineProperty()对属性设置一个set函数,当数据改变时就会触发set函数,所以我们只要将一些需要更新的方法放在set函数里就可以实现data更新view了。

Object.defineProperty

vue数据双向绑定是通过数据劫持结合发布者-订阅者模式的方式来实现的,数据劫持主要通过Object.defineProperty来实现。
那么我们可以通过下面例子来验证下

代码:

1
2
3
4
5
6
7
8
9
10
var vm = new Vue({
data: {
obj: {
a: 1
}
},
created: function () {
console.log(this.obj);
}
});

  • 我们可以看到属性a有两个相对应的get和set方法,为什么会多出这两个方法呢?

    因为vue是通过Object.defineProperty()来实现数据劫持的,不过在vue3.x使用Proxy来实现数据劫持

  • Object.defineProperty()是用来做什么的?

    它可以来控制一个对象属性的一些特有操作,比如读写权、是否可以枚举,这里我们主要先来研究下它对应的两个描述属性get和set,如果 还不熟悉其用法,查看这里:Object.defineProperty()使用

在平常,我们很容易就可以打印出一个对象的属性数据:

1
2
3
4
var Book = {
name: 'vue权威指南'
};
console.log(Book.name); // vue权威指南

如果想要在执行console.log(book.name)的同时,直接给书名加个书名号,那要怎么处理呢?或者说要通过什么监听对象Book的属性值。这时候Object.defineProperty()就派上用场了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var Book = {}
var name = '';
Object.defineProperty(Book, 'name', {
set: function (value) {
name = value;
console.log('你取了一个书名叫做' + value);
},
get: function () {
return '《' + name + '》'
}
})

Book.name = 'vue权威指南'; // 你取了一个书名叫做vue权威指南
console.log(Book.name); // 《vue权威指南》

我们通过Object.defineProperty()设置了对象Book的name属性,对其get和set进行重写操作,顾名思义,我们访问name属性时会自动执行get函数,设置name属性时,会自动执行 set 函数,所以当执行 Book.name = ‘vue权威指南’ 这个语句时,控制台会打印出 “你取了一个书名叫做vue权威指南”,紧接着,当读取这个属性时,就会输出 “《vue权威指南》”,因为我们在get函数里面对该值做了加工了。如果这个时候我们执行下下面的语句,控制台会输出什么?

1
console.log(Book);

通过打印结果可以看出,和上面例子结果相似,说明vue确实是通过这种方法来进行数据劫持的。

单向数据流

简而言之,单向数据流就是model的更新会触发view的更新,view的更新不会触发model的更新,它们的作用是单向的

其实所有的 prop 都使得其父子 prop 之间形成了一个单向下行绑定:父级 prop 的更新会向下流动到子组件中,但是反过来则不行。这样会防止从子组件意外改变父级组件的状态,从而导致你的应用的数据流向难以理解。

到这里是否存在一个疑惑,v-model不是双向绑定吗?,那接下来我们就来了解下v-model

v-model 用在 input 元素上

v-model在使用的时候很像双向绑定的,但是Vue是单项数据流,v-model 只是语法糖而已:

1
2
<input v-model="something" />
<input v-bind:value="something" v-on:input="something = $event.target.value" />

第一行的代码其实只是第二行的语法糖。然后第二行代码还能简写成这样:

1
<input :value="something" @input="something = $event.target.value" />

要理解这行代码,首先你要知道input元素本身有个oninput事件,这是HTML5 新增加的,类似onchange,每当输入框内容发生变化,就会触发oninput,通过$event把最新的value传递给something

我们仔细观察语法糖和原始语法那两行代码,可以得出一个结论:
在给 input 元素添加 v-model 属性时,默认会把 value 作为元素的属性,然后把input事件作为实时传递 value 的触发事件

语法糖是指在不影响功能的情况下 添加某种方法实现同样的效果 从而方便程序开发。

v-model 用在组件上

v-model 不仅仅能在 input 上用,在组件上也能使用,拿官网上的demo看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<currency-input v-model="price"></currency-input>
Vue.component('currency-input', {
template: '\
<span>\
$\
<input\
ref="input"\
v-bind:value="value"\
v-on:input="updateValue($event.target.value)"\
>\
</span>\
',
props: ['value'], // 为什么这里要用 value 属性,value在哪里定义的?
methods: {
// 不是直接更新值,而是使用此方法来对输入值进行格式化和位数限制
updateValue: function (value) {
var formattedValue = value
// 删除两侧的空格符
.trim()
// 保留 2 位小数
.slice(
0,
value.indexOf('.') === -1
? value.length
: value.indexOf('.') + 3
)
// 如果值尚不合规,则手动覆盖为合规的值
if (formattedValue !== value) {
this.$refs.input.value = formattedValue
}
// 通过 input 事件带出数值
// <!--为什么这里把 'input' 作为触发事件的事件名?`input` 在哪定义的?-->
this.$emit('input', Number(formattedValue))
}
}
})

如果你知道这两个问题的答案,那么恭喜你真正掌握了 v-model,如果你没明白,那么可以看下这段代码:

1
2
3
4
5
<currency-input v-model="price"></currency-input>

所以在组件中使用时,它相当于下面的简写:
//上行代码是下行的语法糖
<currency-input :value="price" @input="price = arguments[0]"><currency-input>

所以,给组件添加 v-model 属性时,默认会把 value 作为组件的属性,然后把 ‘input’ 值作为给组件绑定事件时的事件名。这在写组件时特别有用。

vue 组件数据流

从上面 v-model 的分析我们可以这么理解,双向数据绑定就是在单向绑定的基础上给可输入元素(input、textare等)添加了 change(input) 事件,来动态修改 model 和 view ,即通过触发($emit)父组件的事件来修改mv来达到 mvvm 的效果。而 vue 组件间传递数据是单向的,即数据总是由父组件传递到子组件,子组件在其内部可以有自己维护的数据,但它无权修改父组件传递给它的数据,当开发者尝试这样做的时候,vue 将会报错。这样做是为了组件间更好的解耦,在开发中可能有多个子组件依赖于父组件的某个数据,假如子组件可以修改父组件数据的话,一个子组件变化会引发所有依赖这个数据的子组件发生变化,所以 vue 不推荐子组件修改父组件的数据,直接修改 props 会抛出警告。流程图如下:

所以,当你想要在子组件去修改 props 时,把这个子组件当成父组件那样用,所以就有了

1、定义一个局部变量,并用 prop 的值初始化它。
2、定义一个计算属性,处理 prop 的值并返回。

额外的,每次父级组件发生更新时,子组件中所有的 prop 都将会刷新为最新的值。这意味着你不应该在一个子组件内部改变 prop。如果你这样做了,Vue 会在浏览器的控制台中发出警告。子组件想修改时,只能通过 $emit 派发一个自定义事件,父组件接收到后,由父组件修改。

有两种常见的试图改变一个 prop 的情形 :

这个 prop 用来传递一个初始值;这个子组件接下来希望将其作为一个本地的 prop 数据来使用。 在这种情况下,最好定义一个本地的 data 属性并将这个 prop 用作其初始值:

1
2
3
4
5
6
props: ['initialCounter'],
data: function () {
return {
counter: this.initialCounter
}
}

这个 prop 以一种原始的值传入且需要进行转换。 在这种情况下,最好使用这个 prop 的值来定义一个计算属性

1
2
3
4
5
6
props: ['size'],
computed: {
normalizedSize: function () {
return this.size.trim().toLowerCase()
}
}

参考:

vue的双向绑定原理及实现
深入理解Vue单向数据流
单向数据流