Vue响应式
前言
数据响应式系统是Vue最显著的一个特性,我们通过Vue官方文档回顾一下。
数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题。
现在是时候深入一下了!
本文针对响应式系统的原理进行一个详细介绍。
响应式是什么
我们先来看一个例子
<div id="app">
<p>{{color}}</p>
<button @click="changeColor">change color!</button>
</div>
new Vue({
el: '#app',
data() {
color: 'blue'
},
methods: {
changeColor() {
this.color = 'yellow';
}
}
})
当我们点击按钮的时候,视图的p标签文本就会从 blue
改变成yellow
。
Vue要完成这次更新,其实需要做两件事情:
- __监听__数据
color
的变化。 - 当数据
color
更新变化时,自动__通知__依赖该数据的视图。
换成专业那么一点点点的名词就是利用数据劫持/数据代理
去进行依赖收集
、发布订阅模式
。
我们只需要记住一句话:在getter中收集依赖,在setter中触发依赖
如何追踪侦测数据的变化
首先有个问题,如何侦测一个对象的变化?
目前来说,侦测对象变化有两种方法。大家都知道的!
Object.defineProperty
vue 2.x就是使用Object.defineProperty
来数据响应式系统的。但是用此方法来来侦测变化会有很多缺陷。例如:
- Object.defineProperty无法监控到数组下标的变化,导致通过数组下标添加元素,不能实时响应;
- Object.defineProperty只能劫持对象的属性,从而需要对每个对象,每个属性进行遍历,如果,属性值是对象,还需要深度遍历。
- …
本文也是利用Object.defineProperty来介绍响应式系统。
Proxy
vue3就是通过proxy实现响应式系统的。而且在国庆期间已经发布pre-alpha版本。
相比旧的Object.defineProperty
, proxy
可以代理整个对象,并且提供了多个traps
,可以实现诸多功能。此外,Proxy支持代理数组的变化等等
当然proxy也有一个致命的缺点,就是无法通过polyfill
模拟,兼容性较差。
依赖收集的重要角色 Dep Watcher
Dep
、Watcher
是数据响应式中两个比较重要的角色。
收集依赖的地方 Dep
因为在视图模板上可能有多处地方都引用同一个数据,所以要有一个地方去存放数据的依赖,这个地方就是Dep。
Dep主要维护一个__依赖的数组__,当我们利用render函数生成VNode的时候,会触发数据的getter,然后则会把依赖push到Dep的依赖数组中。
依赖是Watcher!
我们可以把Watcher
理解成一个__中介的角色__,数据发生变化时,会触发数据的setter,然后通过遍历Dep中的依赖Watcher,然后Watcher通知额外的操作,额外的操作可能是更新视图、更新computed、更新watch等等…
原理实现
还是那句话:在getter中收集依赖,在setter中触发依赖。
下面我们看看代码:
代码有点长,下面会有步骤来讲解一次。
/*
* 劫持数据的getter、setter
*/
function defineReactive(data, key, val) {
// 1-1:把color数据变成响应式
const dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
// 3. 因为模板编译watcher访问到了color,从而触发get方法,触发了收集依赖的方法
dep.depend();
return val;
},
set(newVal) {
if (val === newVal) {
return;
}
val = newVal;
// 4-1. 假设我们通过 `this.color = 'yellow';`去更改`color`的值,就会触发set方法。
dep.notify();
}
});
}
/*
* dep类,收集依赖,和触发依赖
*/
class Dep {
constructor() {
this.subs = []; // 收集依赖的数组
}
// 收集依赖
depend() {
// 3-1. 通过外部的变量来添加到color的依赖中.
if (window.target && !this.subs.includes(window.target)) {
this.subs.push(window.target);
}
}
// 通知依赖更新
notify() {
// 4-2. 遍历
this.subs.forEach(watcher => {
watcher.update();
});
}
}
/**
* 数据与外部的中介
*/
class Watcher {
constructor(expr, cb) {
this.cb = cb;
this.expr = expr;
// 2-1. 这里触发了get方法
this.value = this.get();
}
get() {
// 2-2. 这里把自己(watcher)赋值给了外部其中的一个变量
window.target = this;
// 2-3. data[this.expr]触发了color的get
const value = data[this.expr];
window.target = undefined;
return value;
}
update() {
this.value = this.get();
this.cb(this.value);
}
}
下面我们来走一次流程。
括号里面的1-1,2-2是对应代码的执行点。
1. 把数据变成响应式
利用defineReactive
把color数据变成响应式(1-1),执行过这个方法后,我们调用console.log(this.color)
的时候可以触发get方法。同理当我们this.color = 'yellow'
。
注意:在Object.defineProperty
上面初始化一个存放依赖的__dep__,这里其实是把__dep__作为数据color
的一个__私有变量__,让get和set的方法可以访问到,也是我们经常说的闭包。
2. 编译模板创建watcher
假设我们现在编译模板遇到{{color}}
。
Vue就会创建一个Watchter,伪代码如下:
new Watcher('color', () => {
// 当color发生变化的时候,会触发这里的方法。
});
这里高能!!
Watcher的构造函数里面调用了get()
方法(2-2),把自己(watcher)赋值给了一个__外部变量__。
然后再触发get方法(2-3)。
3. get中收集依赖
因为模板编译watcher访问到了color,从而触发get方法,触发了收集依赖的方法。
进入到dep.depend
方法中(3-1),这里因为在Watcher中把自己存到了外部变量中,所以在dep.depend方法中可以收集到依赖。
现在,依赖就被收集了。
4. 通过setter触发依赖
假设我们通过 this.color = 'yellow';
去更改color
的值,就会触发set方法,执行dep.notify(4-1)。
会遍历依赖数组,从而去触发Watcher的cb方法。 cb就是上面伪代码new Watcher
的那个回调函数。
只要回调函数里面运行了操作dom方法,或者触发了diff算法更新dom,都可以把视图进行更新。
响应式简易流程大概就是这样了…
侦测数据变化的类型
其实数据监听变化有两种类型,一种是“推”(push),另一种是“拉”(pull)。
React和Angular中的变化侦测都属于“拉”,就是说在数据发生变化的时候,它不知道哪个数据变了,然后会发送一个信号给框架,框架收到信号后,会进行一个暴力比对来找出那些dom节点需要重新渲染。Angular中使用的是脏检查,在React使用虚拟dom的diff。
vue的数据监听属于“推”。当数据发生变化时,就知道哪个数据发生变化了,从而去更新有此数据依赖的视图。
因此,框架知道的数据更新信息越多,也就可以进行更细粒度的更新。比如,直接通过dom api操作dom。
相对“拉”的力度是最粗的。看到这里,是不是觉得vue的更新效率最快。
我们看看下面的例子
<template>
<div>{{a}}</div>
<div>{{b}}</div>
</template>
这里我们看出模板只有a、b两个数据依赖,也就是说我们要创建两个闭包dep去存放两个watcher依赖,我们知道闭包的缺点就是内存泄露。如果有1000个数据依赖在模板上,每个数据所绑定的依赖就越多,依赖追踪在内存上的开销就会越大。
所以,从Vue.js2.0开始,它引入了虚拟dom,一个状态所绑定的依赖不再是具体的dom节点,而是一个__组件__,即一个组件一个Watcher。 这样状态变化后,会通知到组件,组件内部再使用虚拟 dom进行比对。这可以大大降低依赖数量,从而降低依赖追踪所消耗的内存。但并不是引入虚拟dom后,渲染速度变快了。准确的来说,应该是__80%的场景下变得更快了,而剩下的20%反而变慢了__。
个人觉得,鱼和熊掌不可兼得。
“推”是牺牲内存来换更新速度。
“拉”则是牺牲更新速度来获取内存。
总结
Vue响应式的灵魂:在getter中收集依赖,在setter中触发依赖。
我们再看看图,回顾一下整个流程。
- 通过
defineReactive
,遍历data里面的属性,把数据的getter/setter劫持,用来收集和触发依赖。 - 当外界通过Watcher读取数据时,会触发getter从而将Watcher添加到Dep依赖中。
- 当数据发生变化时,会触发setter,从而Dep会向依赖(Wachter)发送通知。
- Watcher收到通知后,会向外界发送通知,变化通知到外界后可能接触视图更新,也有可能触发用户的某个回调函数等等。
参考文献:《深入浅出Vue.js》