Vue响应式原理

“响应式”,是指当数据改变后,Vue 会通知到使用该数据的代码。例如,视图渲染中使用了数据,数据改变后,视图也会自动更新。

Vue中最重要的概念就是响应式数据,一方面指衍生数据和元数据之间的响应,通过数据链来实现;另一方面则是指视图与数据之间的绑定

数据链

得益于数据链,在Vue中我们可以通过修改元数据的值来触发一系列数据的更新。当你修改数据起点时,所有存在在网上的节点值都将同步更新

衍生数据应该怎样实现从而保证其值只依赖于元数据而不允许被外界修改呢?

通过函数式编程,衍生数据也得以实现。函数式编程的核心是根据元数据生成新的衍生数据,提供唯一确定的输入,函数将返回唯一确定的输出,它并不会修改原有变量的值。这在运用JS闭包概念进行开发时尤为重要,在函数作用域内调用域外或全局的变量时并不会修改它们的值。实际上,函数式编程就是建立了一条数据流通的链路,开发者只需要关注输入和输出两端的内容就可以,这是封装复用的一种最佳实践

Vue实例提供了computed计算属性选项,以供开发者生成衍生数据对象。虽然计算属性以函数形式声明,却并不接受参数,也只能以属性的方式调用。由于计算属性的this指向Vue实例,所以它可以获取实例上所有已挂载的可见属性。

一般在模板语法内使用表达式非常便利,模板也只用于简单的运算,当表达式过于复杂时,在模板中放入太多逻辑会让模板过重且难以维护。为此,Vue提供了计算属性computed。可以像绑定普通属性一样在模板中绑定计算属性。当你的计算属性的依赖数据发生改变时,你的相关计算属性也会重新计算。引用了计算属性computed后,就可将复杂的逻辑放入计算中进行处理,同时computed有缓存功能,可防止复杂计算逻辑多次调用引起的性能问题。即在 Vue 中计算属性是 惰性的,只有当依赖数据发生改变时,才会触发计算,否则,它的值是上一次触发计算的缓存值。注意是改变依赖的数据而不是改变数据的值!

原模版语法

引入计算属性computed

使用methods来实现同样功能

不同的是计算属性是基于它们的依赖进行缓存的。计算属性只有在它的相关依赖发生改变时才会重新求值。这就意味着只要book的属性还没有发生改变,多次访问totalPrice计算属性会立即返回之前的计算结果,而不必再次执行函数。
相比之下,每当触发重新渲染时,调用方法将总是再次执行函数。如果你不需要缓存功能,就使用methods。

数据绑定视图

模型(Model)层只是普通的JavaScript对象,修改它则视图(View)自动更新。当我们把普通的JavaScript对象传给Vue实例的data选项时,Vue将遍历对象属性,并使用Object.defineProperty将其全部转化为getter/setter,并在组件渲染时将属性记录为依赖。之后当依赖项的setter函数被调用时,会通知watcher重新计算并更新其关联的所有组件,把数据渲染进DOM。

通俗来说就是Vue修改了每个添加到data上的对象,当该对象发生变化时Vue会收到通知,从而实现响应式。对象的每个属性会被替换为getter和setter方法,因此可以像使用正常对象一样使用它,但当你修改这个属性时,Vue会知道它发生了变化。

由于Object.defineProperty是ES5中一个无法shim(自定义拓展)的特性,所以Vue,应用无法运行在不支持Object.defineProperty的IE8及其以下版本浏览器上

响应式声明渲染机制

以下面这个对象为例:

1
const data = {	userId: 10 };

当userId发生变化时,你如何得知它发生变化了呢?可以存储这个对象的一个副本,然后比较二者,但这并不是最高效的方法。这种方法称为脏检查,也是Angular1所采用的方法。
另外一种方法是,使用Object.defineProperty()覆写这个属性:

1
2
3
4
5
6
7
8
9
10
const storedData = {};
storedData.userId = data.userId;
Object.defineProperty(data, ’userId‘, {
get() {return storedData.userId;},
set(value) {
console.log(’User ID changed! ‘);
storedData.userId = value; },
configurable: true,
enumerable: true
};

Object API的defineProperty方法属性配置项(描述符)

因为getter/setter方法是在Vue实例初始化的时候添加的,只有已经存在的属性是响应式的;当为对象添加一个新的属性时,直接添加并不会使这个属性成为响应式的

1
2
3
4
5
6
7
8
const vm = new Vue({
data: {
formData: {
username: ’someuser‘
}
}
});
vm.formData.name = ’Some User‘;

尽管formData.username属性是响应式的,并且会对变化做出响应,但formData.name属性并非如此。有几种方法处理这种情况。
最简单的办法是在初始化时在对象上定义这个属性,并把它的值设置为undefined。上例中的formData对象会变成下面这样:

1
2
3
formData: {
username: ’someuser‘,
name: undefined }

或者,也可以使用Object.assign()来创建一个新的对象然后覆盖原有对象,当需要一次性更新多个属性时,这是最有效的办法:

1
2
vm.formData = Object.assign({}, vm.formData, {
name: ’Some User‘ });

最后,Vue还提供了Vue.set()方法,可以使用它将属性设置为响应式的Vue.set(vm.formData, ’name‘, ’Some User‘);
在组件内部也可以使用this.$set来调用这个方法。

双向绑定

一个双向绑定的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<body>
<!-- 数据双向绑定 -->
<div id="app">
<input type="text" v-model="msg">
<p>{{msg}}</p>
</div>
<script>
var app = new Vue({
el:'#app',//el: 挂载点
data:{ //data:数据选项
msg:'hello'
}
})
</script>
</body>

在我们对文本框输入值时,实例 data 中的 msg 值也随之变化。其中一个方向的绑定是双大括号中的 ,绑定至底层 Vue 实例的数据,在浏览器中就被渲染成实例 data 选项中 msg 的值;另一个方向的绑定是v-model, 将用户输入的值绑定到了Vue 实例的数据msg

绑定的方式有两种:

  • Vue.js 借鉴了 Angular.js 的双花括号的方式,进行向页面输出数据和调用对象方法。可以由模板引擎根据数据实时进行修正,Vue负责驱动模板把数据渲染到DOM上;
  • 属性名也是一种指令,如v-model就是双向绑定。使用v-model时一定要记住,如果设置了value、checked和selected属性,这些属性会被忽略。如果想设置输入元素的初始值,应该在data对象中设置。

底层原理

核心机制是 观察者模式

  • 通过 Object.defineProperty() 替换配置对象属性的 set、get 方法,实现“拦截”
  • watcher 在执行 getter 函数时触发数据的 get 方法,从而建立依赖关系
  • 写入数据时触发 set 方法,从而借助 dep 发布通知,进而 watcher 进行更新

Vue 中 watcher 的观察对象,确切来说是一个求值表达式,或者函数。这个表达式或者函数,在一个 Vue 实例的上下文中求值或执行。这个过程中,使用到数据,也就是 watcher 所依赖的数据。用于记录依赖关系的属性是 deps,对应的是由 dep 对象组成的数组,对应所有依赖的数据。而表达式或函数,最终会作为求值函数记录到 getter 属性,每次求值得到的结果记录在 value 属性。另外,还有一个重要的属性 cb,记录回调函数,当 getter 返回的值与当前 value 不同时被调用

当数据是对象时

当数据是数组时,原型链重写

在数组的七个方法上进行拦截,重写时添加了一个on.dep.notify() 进行视图更新

计算属性在内部也是基于 watcher 实现的,每个计算属性对应一个 watcher,其 getter 也就是计算属性的声明函数。不过,计算属性对应的 watcher 与直接通过 vm.$watch() 创建的 watcher 略有不同,它其实没有 cb(空函数),只有 getter,并且它的值只在被使用时才计算并缓存。

计算属性对应的 watcher 初始创建的时候,并没有执行 getter,这个时候就会设置 dirty 为 true,这样当前获取计算属性的值的时候,会执行 getter 得到 value,然后标记 dirty 为 false。这样后续再获取计算属性的值,不需要再计算(执行 getter),直接就能返回缓存的 value。