简介
使用Vue开发应用,当我们修改Vue组件的data的属性后,视图会自动更新,这种特性我们可以称为“响应式”。那么Vue是如何实现响应式的呢?即Vue使如何实现我们修改data属性的值后,视图能够自动更新的呢?
简单地说,Vue在组件初始化时候,通过发布-订阅模式将data的属性设置为响应式,data的属性作为发布者,Vue会在渲染时候创建订阅者,订阅者会订阅data的属性值变化,进行更新视图的操作。
除了渲染需要订阅data的属性的变化,computed和watch也需要订阅data属性变化,它们都是通过一个名为“Watcher”的类来实现订阅的。
Vue通过数据代理技术来实现发布-订阅模式。
下面我们介绍Vue中使用到的数据代理技术,并介绍Vue组件初始化时候是如何把data设置为响应式的,然后介绍一下computed和watch的实现原理,最后简单介绍一下Watcher这个类。
数据代理
我们知道,Vue中我们修改data的属性的值时候,会触发视图更新,因此很容易想到,Vue修改了data的属性的行为。让用户设置data属性时候可以做相应地操作。
我们可以修改数据的属性的行为,当我们在访问或者修改对象的某个属性时,访问或者修改的行为实际是我们修改后的,这样我们就可以进行额外的操作或者修改返回的结果。这种让我们指定的行为代替数据的默认行为的技术叫“数据代理”。
【前端面试刷题网站:灵题库,收集大厂面试真题,相关知识点详细解析。】
在Vue2.0中,使用Object.defineProperty()
方法来进行数据代理,但是这种方法无法代理数组类型的数据属性,Vue2.0中通过改写数组方法的方式来监听数组的改变。在Vue3.0时候改用ES6的新特性Proxy
来进行数据代理,就可以方便地监听数组变化了。这两种数据代理方法的详细用法请参考文章【1】。
把data设置为响应式
在Vue实例化时候,Vue会把data设置为响应式,即让用户修改data属性时候,依赖这个属性的地方能够被通知到,从而做出响应。
其中有两个比较重要的类,Dep和Watcher,后面会介绍到。
下面看如何将data设置为响应式,实例代码会将Vue源码精简和简单修改,省略与本节无关的细节。
首先看Vue组件实例化时候对data的处理,Vue会将组件的data的每个属性定义get和set方法,在get中收集依赖(即将订阅者保存),在set中通知订阅者。
// 调用 walk 方法,遍历 data 中的每一个属性,监听数据的变化。
function walk(obj) {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i]);
}
}
// 执行 defineProperty 监听数据读取和设置。
function defineReactive(obj, key, val) {
// 为每个属性创建 Dep(依赖搜集的容器,后文会讲)
const dep = new Dep();
// 绑定 get、set
Object.defineProperty(obj, key, {
get() {
const value = val;
// 如果有 target 标识,则进行依赖收集
if (Dep.target) {
dep.depend();
}
return value;
},
set(newVal) {
val = newVal;
// 修改数据时,通知页面重新渲染
dep.notify();
},
});
}
代码中的Dep是一个发布-订阅的实现,我们看到在data的属性的get方法中使用dep.depend()收集依赖,在set方法中使用dep.notify()通知订阅者。
下面看Dep的代码
class Dep {
// 根据 ts 类型提示,我们可以得出 Dep.target 是一个 Watcher 类型。
static target: ?Watcher;
// subs 存放搜集到的 Watcher 对象集合
subs: Array<Watcher>;
constructor() {
this.subs = [];
}
addSub(sub: Watcher) {
// 搜集所有使用到这个 data 的 Watcher 对象。
this.subs.push(sub);
}
depend() {
if (Dep.target) {
// 搜集依赖,最终会调用上面的 addSub 方法
Dep.target.addDep(this);
}
}
notify() {
const subs = this.subs.slice();
for (let i = 0, l = subs.length; i < l; i++) {
// 调用对应的 Watcher,更新视图
subs[i].update();
}
}
}
这里的Watcher是订阅者用来订阅dep的类,通过实例化Watcher并传入订阅的值和回调来订阅,dep会在订阅的值改变后发布给订阅者。
下面看Watcher的代码
class Watcher {
constructor(vm: Component, expOrFn: string | Function) {
// 将 vm._render 方法赋值给 getter。
// 这里的 expOrFn 其实就是 vm._render,后文会讲到。
this.getter = expOrFn;
this.value = this.get();
}
get() {
// 给 Dep.target 赋值为当前 Watcher 对象
Dep.target = this;
// this.getter 其实就是 vm._render
// vm._render 用来生成虚拟 dom、执行 dom-diff、更新真实 dom。
const value = this.getter.call(this.vm, this.vm);
return value;
}
addDep(dep: Dep) {
// 将当前的 Watcher 添加到 Dep 收集池中
dep.addSub(this);
}
update() {
// 开启异步队列,批量更新 Watcher
queueWatcher(this);
}
run() {
// 和初始化一样,会调用 get 方法,更新视图
const value = this.get();
}
}
渲染界面时候会实例化Watcher,从而订阅渲染用到的data的属性。
渲染的代码如下
const updateComponent = () => {
vm._update(vm._render());
};
// 结合上文,我们就能得出,updateComponent 就是传入 Watcher 内部的 getter 方法。
new Watcher(vm, updateComponent);
// new Watcher 会执行 Watcher.get 方法
// Watcher.get 会执行 this.getter.call(vm, vm) ,也就是执行 updateComponent 方法
// updateComponent 会执行 vm._update(vm._render())
// 调用 vm._render 生成虚拟 dom
// 调用 vm._update(vnode) 渲染虚拟 dom
渲染视图时候实例化Watcher并传递参数getter为updateComponent。
实例化时候,调用Watcher的get方法,这个方法首先执行Dep.target = this
(注意,这是精简后的代码,还有其他与当前无关的逻辑后面会提及),将自身绑定到调用getter,即updateComponent。
在执行updateComponent的过程中,会用到data的某些属性,这样就会触发属性的get方法,在上面设置data响应式代码中我们看到get方法判断如果存在Dep.target
,就将这个依赖收集到dep的依赖池(subs
)中。
当data属性改变,会触发set方法,从而调用dep.notify()
,在dep.notify
方法中调用每个watcher的update方法,然后将watcher加入到异步队列中。
在下个tic清空异步队列时候(flushSchedulerQueue
)会调用watcher.run
,watcher.run
调用getter
方法,即updateComponent
,从而更新视图。
简单总结为,在组件初始化时候遍历data的属性,为每个属性设置get方法和set方法,在get方法中收集依赖,在set方法里通知订阅者,更新视图时候创建订阅者,更新视图时候如果依赖了data的某个属性,就会触发这个属性的get方法时候,该订阅者(更新视图的方法)就会被data的属性收集,在更新属性时候触发set方法,从而触发界面更新。
computed原理
在Vue组件模板中,如果一个表达式有复杂计算,可以使用computed(计算属性)。
computed依赖某些data属性,并计算得到一个新的值。
{
name: 'myComponent',
data() {
return {
message: 'hello'
};
},
computed: {
info() {
// 字符串翻转
return this.message.split('').reverse().join('');
}
}
}
当data相关属性变化时候,并不会重新计算computed的值,只会标记数据已经发生改变,当前的是脏数据(dirty),后面如果其他地方(比如渲染)用到computed,发现是dirty就会重新计算,如果不是dirty,直接使用当前的值,不需要重新计算,这样可以避免不必要的复杂计算。
这里有两个关键逻辑
- 在computed依赖的data属性更新后,需要对computed标记dirty
- 在访问computed时候,会判断是否是dirty,
dirty ? 重新计算 : 返回当前的值
。
computed的原理其实就是如何实现这两个关键逻辑。
第一个逻辑的实现思路是,订阅data属性的变化,在data属性变化时候标记dirty。
第二个逻辑的实现思路是,设定computed的get方法,在访问时候处理相关逻辑。
下面看关键的代码,注意代码是简化的。
// 组件实例化时候初始化computed
function initComputed(vm: Component, computed: Object) {
for (const key in computed) {
const getter = computed[key];
const watcher = new Watcher(
vm,
getter || noop,
noop,
{lazy: true}
);
Object.defineProperty(vm, key, {
get() {
if (watcher) {
if (watcher.dirty) {
watcher.evaluate();
}
// 把watcher绑定的所有dep,都绑定到当前的Dep.target上
if (Dep.target) {
watcher.depend();
}
return watcher.value;
}
}
});
}
}
初始化computed,对每个computed的key,都实例化一个watcher。另外每个computed的key都绑定到vm实例上,并设置get方法。
我们看下Watcher的关键方法,同上一节“把data设置为响应式”的Watcher代码相比,下面的Watcher代码突出了computed使用的场景。
实际的Watcher代码更综合更复杂,请参考Vue源码。
class Watcher {
constructor(vm: Component, expOrFn: string | Function, cb, options) {
this.getter = expOrFn;
this.lazy = !!options.lazy;
this.value = this.get();
}
get() {
// 实际执行了Dep.target = this
pushTarget(this);
const value = this.getter.call(this.vm, this.vm);
return value;
}
addDep(dep: Dep) {
dep.addSub(this);
}
update() {
if (this.lazy) {
this.dirty = true;
}
else {
queueWatcher(this);
}
}
evalute() {
this.value = this.get();
this.dirty = false;
}
}
下面我们分析上面两段代码是如何实现两个关键的逻辑的。
在initComputed时候,对每个computed的key,实例化一个Watcher,实例的getter参数是computed的方法。
Watcher构造函数中会调用get方法,先将watcher绑定到Dep.target,然后调用getter方法(即computed的方法),调用computed方法时候会访问该computed key所依赖的data属性,从而触发data属性的get方法,我们在上一节“把data设置为响应式”中已经说明过,在data属性的get方法中会收集依赖,因此该watcher会被data属性所收集,即该watcher订阅了所依赖的data属性。
这样在data属性变化时候,会触发dep.notify
,从而调用watcher的upadte方法,我们看到watcher的update方法中会判断this.lazy,因为实例化watcher时候传入的options.lazy
为 为true,所以这里标记this.dirty为true。这样就实现了第一个逻辑。
另外我们看到初始化computed时候,设定了computed的get方法,当用户访问这个computed属性时候,首先判断如果dirty为true,则执行watcher.get()
方法,并赋值给watcher,如果dirty为false则不处理。最后返回watcher.value
,这样就实现了第二个逻辑。
总结一下,computed的原理是:
- 在初始化时候实例化watcher,实例化watcher时候对依赖的data属性取一次值,从而触发data属性收集依赖。当改变data属性时,会通知订阅者watcher,由于watcher设置了lazy选项,因此会将watcher置为dirty(即数据更新),但不会重新计算。
- 设置computed的get方法,在访问computed的时候,判断如果是dirty,就重新计算,否则直接返回当前的值。
通俗地说,computed是data属性的一个订阅者,它在初始化时候被data属性收集依赖,当computed依赖的data属性改变后,标记该computed为dirty,即数据更改过,当渲染使用到computed时候,再计算出computed的值从而得到最新的正确的值。
还有一个面试中不常问的问题:Vue是如何让computed和渲染都能够监听到data属性的变更的呢?
这个问题相当于:computed的watcher和渲染的watcher都是如何绑定到data属性的依赖池中的?
computed的watcher我们已经分析过,是在初始化时候就已经绑定,那么渲染时候如果用到了computed,而不是直接访问data属性,那么渲染的watcher是如何绑定到data属性的dep上的呢?
我们知道依赖收集的关键是watcher先将自己挂到Dep.target
上,然后访问data属性,data属性的get方法就能将Dep.target
对应的watcher收集了。
实际上,在watcher.get()
方法中,是通过调用pushTarget()
来设置watcher到Dep.target
的。pushTarget()
是将watcher推入watcher栈中,watcher栈用来管理Dep.target上面挂载的watcher,它解决了再一个订阅者的执行中遇到另一个订阅者的问题(在渲染过程中遇到computed)。
// /vue/src/core/observer/dep.js
// The current target watcher being evaluated.
// This is globally unique because only one watcher
// can be evaluated at a time.
Dep.target = null
const targetStack = []
export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
export function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
在渲染视图时候,首先render会创建一个watcher,在watcher中将自身推入targetStack
,然后在updateComponent时候遇到了computed,触发computed的getter,如果是computed的watcher是dirty,那么执行watcher.evalute()
,evalute方法调用watcher.get()
方法,注意watcher.get()
方法首先pushTarget,在最后会popTarget,这样在执行完watcher.evalute()
,当前的Dep.target指向targetStack的上一个元素,即渲染的watcher。
然后执行watcher.depend()
,就是把computed的watcher绑定的所有dep,都绑定到Dep.target,即渲染的watcher上(这样做是因为上一个watcher依赖computed,也一定依赖computed所依赖的data属性)。这样渲染的watcher就绑定到相应的data属性的dep上面了。
在data属性变化后,首先会执行computed的watcher的update方法,置为dirty,然后执行渲染的watcher,渲染过程中用到computed又会进行计算,从而得到更新后的界面。
watch原理
watch实现的功能是监听data属性变化,当属性变化时候触发用户定义的方法。
{
name: 'myComponent',
data() {
return {
message: 'hello'
};
},
watch: {
message(value) {
console.log('message change: ', value);
}
},
mounted() {
this.message = 'world';
}
}
下面看初始化watch的代码(动态watch的原理类似),注意代码简化过。
function initWatch (vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key];
const watcher = new Watcher(vm, key, handler);
}
}
Vue在初始化时候调用initWatch初始化,订阅相应的key,实例化一个watcher,watcher实例化时候会调用get,对监听的key进行取值,从而触发监听的key的getter方法,进而将watcher自身加入到监听的data属性的dep的依赖池中,如果监听的是computed,则取值时候也会触发data属性的getter,从而进行watcher绑定。
当data属性改变后,会触发watcher的update,然后放入update的队列中,在清空watcher队列(flushSchedulerQueue
)时候,会调用watcher.run()
方法,调用回调方法。
通俗地讲,在组件初始化时候,遍历所有的watch,对每个watch创建订阅者,绑定依赖的data属性,当data属性改变后发布给订阅者,然后会执行相应地回调。
Watcher
watcher是一个订阅者,它可以和相应的dep绑定,从而订阅data属性变化。
它的getter参数很关键,getter参数是订阅者根据依赖的属性获取值的一个方法。在Watcher实例化的时候就会取一次值,在这个取值操作中会访问watcher依赖的属性,从而触发属性的dep的收集。因此在Watcher实例化的时候,就已经绑定了发布者了。
当data的属性更新后,会重新执行watcher的getter,取得最新的值来做后面的处理。
渲染有一个watcher、computed有一个watcher、watch也有一个watcher。
渲染的watcher的getter是updateComponent,实际它不关心取值,当属性值改变后再次执行updateComponent即可。
computed的watcher的getter是用户定义计算方法,computed就是根据这个计算方法返回结果的。当属性值改变后会更新dirty而不会调用getter进行取值,然后取computed值时候再重新计算,这就是惰性求值。
watch的watcher的getter是watch所监听的属性,属性值改变后会触发重新求值,并用新的值调用watch的回调。
Watcher还有一个cb参数,是callback回调,对于渲染和computed,它们的watcher的cb都是noop,这是因为在data属性值改变后,调用getter重新渲染就行了,而computed只用标记dirty,也不需要其他操作。
watch的cb是用户定义的方法,当属性改变后,不但要用getter重新求值,还要用新的值调用回调。
双向绑定
你可以用 v-model 指令在表单
<input>、<textarea>
及<select>
元素上创建双向数据绑定。它会根据控件类型自动选取正确的方法来更新元素。尽管有些神奇,但 v-model 本质上不过是语法糖。它负责监听用户的输入事件以更新数据,并对一些极端场景进行一些特殊处理。
—— 表单输入绑定
简单地说,Vue在编译模板时候会将v-model
指令特殊处理:
- 创建订阅者,当组件的data属性改变时候,修改表单元素的value。
- 给表单元素创建事件(change事件或者input事件),事件的回调中,修改组件的数据。