不管你有没有用过 Vue,你都会经常听到 Vue 是一个响应式的库。最近看了一下 Vue 的源码,实现了一个简易版的响应式系统。
首先,看下面的例子
<template>
<div id="app">
<p class="name">{{fullName}}</p>
</div>
</template>
<script>
var vm = new Vue({
el: '#app',
data: {
firstName: 'liu',
lastName: 'laohan'
},
computed: {
fullName() {
return this.firstName + ' ' + this.lastName
}
}
})
</script>
复制代码
了解过 Vue 的都知道,当 firstName
或者 lastName
的值发生变化时,fullName
的值都会发生变化,并且视图也会更新。
你可能会好奇,Vue 是怎么知道 firstName
或者 lastName
发生变化,并且也保证 fullName
也得到变化的呢?
这跟我们平时看到的 javascript 的执行并不相同阿,例如
let firstName = 'liu';
let lastName = 'laohan';
let fullName = firstName + ' ' + lastName;
lastName = 'datou';
console.log(`name: ${fullName}`);
复制代码
你可能想都不用想就知道上面的 log 会打印出什么内容
>> name: liu laohan
复制代码
但在 Vue 中,我们希望的是 lastName
的内容发生变化时,fullName
的值也更新,即希望上面的 log 会打印出
>> name: liu datou
复制代码
不幸的是,上面的 js 并不会是响应式的,如果我们希望 fullName
的内容是响应式的,还需要做一些其他的事情。
现在,我们要解决的问题就是把计算 fullName
的过程保留起来,然后在 lastName
发生变化时,再次执行一次这个计算的过程。
计算 fullName
的过程,其实也就是一个函数嘛,我们可以实现如下
let firstName = 'liu';
let lastName = 'laohan';
let fullName = '';
target = () => {
fullName = firstName + ' ' + lastName;
};
record();
target();
复制代码
这样子,我们就把计算 fullName
的过程封装在一个匿名函数中,然后调用 record
函数。
record
函数的实现方式也很简单
let storage = [];
function record() {
storage.push(target);
}
复制代码
现在我们把计算的过程存储在变量 storage
中了。当 lastName
的值发生变化时,我们只需要 replay
一下就可以了
lastName = 'datou';
console.log(fullName); // => liu laohan
replay();
console.log(fullName); // => liu datou
复制代码
看起来是不是很简单,很容易的就实现了我们要达到的效果。 完整代码如下
let firstName = 'liu';
let lastName = 'laohan';
let fullName = '';
let target = null;
let storage = [];
function record() {
storage.push(target);
}
function replay() {
storage.forEach(cb => cb());
}
target = () => {
fullName = firstName + ' ' + lastName;
};
record();
target();
lastName = 'datou';
console.log(fullName); // => liu laohan
replay();
console.log(fullName); // => liu datou
复制代码
大概总结一下,其实就是以下两点:
- 记录目标值的计算过程,如上述的 target 函数,记录 fullName 的求值过程
- 在影响目标值的变量(如 firstName、lastName)发生变化时,重新计算目标值
可以看到,上面的实现方式是很简单粗暴的。如果之后 lastName
的值再次变化时,要想 fullName
的值还是响应的,那就要如下:
lastName = 'dahei';
replay();
复制代码
每次变量 lastName 发生变化时,都要在后面跟着调用 replay
函数。这会使代码看起来很冗余。
接下来,我们尝试着用别的方式来实现响应式系统。
在之前,我们已经可以实现 fullName 随着 lastName 值变化而变化,如果我们想在 firstName 改变时,fullName 的值也跟着变化。按照上面的实现,增加一些代码,也是可以实现。只是你会发现,每次改变变量时,都要手动地在后面加一行 replay()
是很繁琐的。而作为一个喜欢偷懒的人来说,这很让人反感。
Object.defineProperty
如果你没有听过或者不了解 Object.defineProperty()
,可以看看 这里。 Object.defineProperty()
方法允许我们修改一个对象的现有属性,我们要用到的就是属性描述符中的 get
和 set
。
get: 一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。当访问该属性时,该方法会被执行 set: 一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。当属性值修改时,触发执行该方法。
如上所述,当我们访问一个对象的属性时,getter 方法会被调用,当修改一个属性的值时,setter 方法会被调用。可以看看下面的一个 demo:
const info = {
firstName: 'liu',
lastName: 'laohan',
};
Object.defineProperty(info, 'lastName', {
get() {
console.log('getter');
},
set() {
console.log('setter');
},
});
info.lastName; // => getter
info.lastName = 'datou'; // => setter
复制代码
可以看到,当我们访问属性时,get 方法被调用了。而修改属性的值时, set 方法就被调用了。 你可能会疑问,这对实现响应式系统有什么用呢?还记得上面我们每次修改 lastName 时都在后面跟着调用 replay()
吗,这就是因为我们不知道什么时候 lastName 的值改变了,现在我们就可以通过 set 方法是否被调用而知道属性值是否发生变化了。那既然 get 方法被调用了,就表示这个属性是被访问了,是不是我们也可以通过在 get 方法里存储依赖这个属性的目标值的计算过程呢,答案是肯定的。
const info = {
firstName: 'liu',
lastName: 'laohan',
};
Object.keys(info).forEach(key => {
let internalValue = info[key];
Object.defineProperty(info, key, {
get() {
// todo: 存储依赖该属性的目标值的计算过程,如之前的record
return internalValue;
},
set(val) {
internalValue = val;
// todo: 重新调用目标值的计算过程,如之前的 replay
},
});
});
复制代码
现在,差的就是在 get 和 set 方法里面实现类似我们之前的 record 和 replay 方法了。 在这个 demo 中,直接使用上面的方法也不会有什么问题。但为了有更好的复用,我们换种实现方式。
class Dep {
constructor() {
this.subs = [];
}
depend() {
if (target && !!this.subs.includes(target)) {
this.subs.push(target);
}
}
notify() {
this.subs.forEach(sub => sub());
}
}
复制代码
这次,我们就把目标值的计算过程存储到 subs 数组中了,然后用 notify 代替之前的 replay。 在实际应用中,target 的值会发生变化的。比如在 vue 中,target 有时候是更新组件,有时候是更新 computed 的值。所以在这里用一个 watcher 函数封装一下 target 的行为。
watcher(() => {
fullName = firstName + lastName;
});
复制代码
把上面的几个点整合在一起,完整的代码就是
const info = {
firstName: 'liu',
lastName: 'laohan',
};
let target = null;
class Dep {
constructor() {
this.subs = [];
}
depend() {
if (target && !!this.subs.includes(target)) {
this.subs.push(target);
}
}
notify() {
this.subs.forEach(sub => sub());
}
}
Object.keys(info).forEach(key => {
let internalValue = info[key];
const dep = new Dep();
Object.defineProperty(info, key, {
get() {
dep.depend();
return internalValue;
},
set(val) {
internalValue = val;
dep.notify();
},
});
});
function watcher(func) {
target = func;
target();
target = null;
}
watcher(() => {
const fullName = info.firstName + info.lastName;
});
复制代码