从零构建一个响应式系统
- 响应式系统是什么?
- 以一个简单例子来说明响应式系统?
- 什么是依赖?什么是依赖搜集?
- Watcher 是什么?
- Dep 是什么?
- 如何建立Dep和Watcher之间的关系?
- 完整实现一个简单的响应式系统?
简介
响应式系统是Vue
非常核心的特性之一。每当我们在Vue
中改变数据时,视图会自动进行更新,不用我们做额外的处理,极大地提高了我们的开发效率。
那么,Vue
又是如何实现响应式系统的呢?其实,响应式系统的核心实现是主要运用了一个方法 - Object.defineProperty
,它会将数据转换成 getter/setter
形式。这里借用Vue
官网上的一句话可以概括其核心思路:
响应式原理
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
举个例子来讲就是:每当执行渲染流程的时候,会去获取渲染相关的数据。比如我们在模板中定义了 ,渲染的时候就要获取这个
name
属性。又因为Vue
对数据里的name
属性进行了getter/setter
处理,那么获取name
的时候会触发getter
,此时就能记录下渲染函数里有个叫做name
依赖项。之后在更新name
属性的时候,会触发setter
。由于已经知道渲染函数中使用了name
,那么我们就可以在setter
里通知渲染函数进行更新。从而达到数据改变时视图自动更新的目的。
下面是Vue
官网的阐述响应式原理的一张图片:
该系列响应式原理总共三个章节,通过这三个章节的学习,我们将从源码级别来理解上面这张图的含义。本章节主要通过从零构建一个极简的响应式系统,来了解响应式系统的前身。
以一个例子开始
前面提到的“通过Object.defineProperty
将数据转换成getter/setter
形式”比较抽象。现在想一想,如果现在要实现每当改变data的count属性时,dom里的count自动更新
的功能,我们该如何通过Object.defineProperty
来实现?
// 每当改变data的count属性时,视图里的count自动更新
// 视图
<div id="app">{{ count }}</div>
// 数据
const data = { count: 1 }
先让我们来看看Object.defineProperty
的使用方法:点击这里查看详细使用方法
const data = { count: 1 }
let val = data.count
Object.defineProperty(data, 'count', {
get() {
console.log('get 触发')
return val
},
set(newVal) {
console.log('set 触发')
val = newVal
}
})
console.log(data.count) // 先打印 get 触发 ,随后打印 1
data.count = 2 // 打印 set 触发
console.log(data.count) // 先打印 get 触发 ,随后打印 2
可以看出,Object.defineProperty
相当于对数据做了一层代理:每次获取属性的时候会触发get
方法,每次设置属性的时候会触发set
方法。
清楚了这个之后,实现上面设置属性自动更新的功能就比较容易了。我们可以直接将更新视图的操作放在set
方法里,如下所示:
Object.defineProperty(data, 'count', {
get() { //... },
set(newValue) {
const $app = document.querySelector('#app')
$app.innerHTML = "count:" + newValue
}
})
这样每次设置data.count
的时候,就会触发set
方法,同时找到对应的视图元素进行更新。但是这样写的话不具备通用性,因为data
上可能不仅仅只有一个count
属性,如果有其他的属性同样需要进行响应式处理。所以我们将上面的函数进行封装,使得它能够对任意的属性都能进行自动更新处理。点击这里查看效果
// 更新视图
const updateComponent = () => {
// 这里直接将data里的 键和值 拼接,当做html进行渲染
// 仅做演示,实际上的更新流程复杂得多。
let html = "";
Object.entries(data).forEach(([key, value]) => {
html += `${key}: ${value} <br/>`;
});
const $app = document.querySelector("#app");
$app.innerHTML = html;
};
// 封装将数据变为响应式的函数
const defineReactive = (obj, key) => {
let val = obj[key];
Object.defineProperty(obj, key, {
get() {
return val;
},
set(newValue) {
// 在set的时候做一下优化,如果值没有发生变化,那么就不更新DOM。
if (newValue === val) {
return;
}
val = newValue;
// 更新 DOM
updateComponent();
}
});
};
// 将 data 中的 count, title 属性设置为响应式
const data = { count: 0, title: "1" };
defineReactive(data, "count");
defineReactive(data, "title");
通过defineReactive
方法,我们可以对不同的key
进行响应式处理。但是上面的代码比较零散,我们现在进一步将其封装到一个类里面。这个类需要满足三点要求:
- 首先,这个类需要将
data
作为参数的一部分传入,如options.data
。 - 其次,在这个类实例化的过程中,我们需要将
data
处理成响应式。 - 最后,
data
改变时,同时能触发视图改变。
根据上述三点要求,我们可以构建一个简单的类,叫做IVue
,点击这里查看运行效果
class IVue {
constructor(options) {
// 1. 需要将data作为参数的一部分传入
// options 的形式为 { id: '#app', data: {...} }
this.$options = options;
// 2. 需要将data处理成响应式
this.initData();
}
// 对data响应式处理
initData() {
// 遍历 data 的 key,均处理成响应式
const data = this.$options.data || {};
Object.keys(data).forEach((key) => {
this.defineReactive(data, key);
});
}
// 对某个key就行响应式处理
defineReactive(obj, key) {
const self = this;
let val = obj[key];
Object.defineProperty(obj, key, {
get() {
return val;
},
set(newValue) {
if (newValue === val) {
return;
}
val = newValue;
self.updateComponent();
}
});
}
// 更新 dom
updateComponent() {
let html = "";
const data = this.$options.data;
Object.entries(data).forEach(([key, value]) => {
html += `${key}: ${value} <br/>`;
});
// 这里的 id 就是传入的 id
const el = this.$options.id;
const $app = document.querySelector(el);
$app.innerHTML = html;
}
}
const vm = new IVue({
id: "#app",
data: {
count: 0,
title: "1"
}
});
const data = vm.$options.data;
data.count = data.count + 1 // 触发视图更新
data.title = data.title + 1 // 触发视图更新
IVue
封装完毕,运行效果和未封装时一模一样,perfect!有没有觉得IVue
有点眼熟?哈哈哈,它就是“究极简化版”的Vue
。
回过头来看看,我们已经达成了修改数据触发视图自动更新的目的。但实际上还存在很多比较明显的问题,比如:
- 首先,这里只能触发视图更新,如果有更多的回调函数需要触发是无法完成的。比如在
Vue
中,computed
和watch
也都会跟随数据的修改而变化。 - 其次,任何响应式数据触发到会导致视图重新渲染,很浪费资源。按道理来讲,只有在渲染时用到的数据改变时,才应该进行重新渲染。
针对问题1,我们需要建立数据
和回调函数
之间的关系,从而能在修改数据时,知道哪些回调函数需要执行。
针对问题2,我们同样需要建立数据
和渲染函数
之间的关系,只有清楚地知道哪些数据是在渲染过程中用到的,那么才能避免不必要的更新。
Vue
则是巧妙地使用了两个类来处理这种关系:Dep
类和Watcher
类。Dep
类用来保存数据和回调函数之间的关系,数据可以通过闭包里的Dep
来访问其相关函数。Watcher
类则是对回调函数的封装,一个回调函数对应一个Watcher
。Dep
和Watcher
是多对多的关系:一个函数可以依赖多个数据,一个数据可以被多个函数依赖。下面我们将分别通过这两个类来完善我们的响应式系统。
什么是依赖?
在面试时当被问到响应式原理的时候,相信很多人都能回答上“在get
的时候搜集依赖,在set
的时候触发状态更新”,但是这里的依赖具体指的是什么呢?
这里还是借用Vue
官网的那句话:
响应式原理
每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
仔细阅读我们会发现,“接触”过的数据才是依赖!注意,这里不是”函数是数据的依赖“,很多人误以为数据改变,会触发多个函数改变,那么这些函数就是该数据的依赖,这种理解是有偏差的。
为什么说数据才是依赖呢?举个例子,在组件要渲染的时候,会执行渲染函数,此时肯定是对dom
进行处理,而dom
里面又存在我们定义好的一些模板数据。因此不同的数据,渲染的结果也不同。换句话来讲,也就是渲染函数是依赖于它使用到的数据的,所以说数据才是依赖。
明白了这个之后,我们就能理解搜集依赖的本质其实就是:函数(如:渲染时指的是渲染函数)执行时到底依赖了哪些数据。只有知道了依赖了哪些数据,后续才能在相应数据改变时,重新执行该函数(即重新渲染视图)。
Watcher
类
要想知道一个函数依赖于哪些数据,单单通过一个函数是很难办到的,所以我们需要用一个类来管理这个函数,这个类叫做Watcher
。Watcher
类需要满足两点要求:一是能够执行相应的函数,二是需要记录它依赖了哪些数据。所以构建完成后如下所示:
class Watcher {
constructor(vm, expOrFn) {
// 记录依赖了哪些数据
this.deps = []
// 这里的 vm 指代的上下文环境
this.vm = vm
this.getter = expOrFn
this.value = this.get()
}
// 用于调用相应函数
get() {
const value = this.getter.call(this.vm)
return value
}
// 用于更新
update() {
// 这里更新只是简单处理
const value = this.getter.call(this.vm)
return value
}
// 依赖了哪些数据就添加哪些数据
addDep(dep) {
this.deps.push(dep)
}
}
Watcher
包含一个get
方法,用于执行对应的回调函数。通过Watcher
我们可以将渲染函数updateComponent
(也就是回调函数)进行封装:
function $mount() {
const updateComponent = () => { ... }
new Watcher(updateComponent)
}
在实例化Watcher
的同时,会触发updateComponent
的执行,那么接下来就该搜集updateComponent
的依赖了。
Dep
类
要想知道哪些函数依赖某个数据,我们需要单独使用一个类来管理这个数据,这个类叫做Dep
。它需要能够知道哪些函数依赖了这个数据,并且能够对函数(watcher
)进行增加删除处理,另外还能通知函数去执行。代码如下:
class Dep {
constructor() {
this.subs = []
}
addSub(sub) {
this.subs.push(sub)
}
removeSub(sub) {
this.subs = this.subs.filter((item) => sub !== item)
}
notify() {
this.subs.forEach((sub) => { sub.update() })
}
}
建立dep
和Watcher
之间的关系
现在依赖搜集的类写好了,但是如何在实际中建立起Dep
和Watcher
之间的关系呢?
我们知道,实例化Watcher
的时候会获取相应的数据,而data
上的数据已经做了响应式处理,那么就会触发对应的get
方法。此时,我们就可以在get
方法进行记录依赖关系。
但是!get
方法里如何知道到底是谁使用了该数据呢?这里的方法比较巧妙,我们在执行函数的时候,将当前的Watcher
挂到全局(这里挂载到Dep.target
上),这样在get
的时候不就能知道当前正在执行什么函数了吗?所以我们首先需要改写Watcher
类的get
方法
class Watcher {
...
get() {
Dep.target = this
const value = this.getter.call(this.vm)
Dep.target = null
return value
}
}
随后,我们在get
的时候记录这种关系。此外,在set
的时候,我们会通过这种关系去通知相应函数进行更新。
defineReactive(obj, key) {
const self = this;
let val = obj[key];
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
// 记录函数与数据的关系
dep.depend()
return val;
},
set(newValue) {
if (newValue === val) {
return;
}
val = newValue;
dep.notify()
}
});
}
这里我们还需要改写Dep
类,添加一个depend
方法,用于记录两者之间的关系
class Dep {
...
depend() {
// 如果存在正在执行的函数
if (Dep.target) {
// 数据添加函数
this.addSub(Dep.target)
// 函数添加数据
Dep.target.addDep(this)
}
}
}
好了,这样我们就完整的建立起了Dep
和Watcher
之间的关系了。另外,还有一点值得注意的是,dep
定义的位置非常巧妙。这里通过闭包的形式,使得每个数据都有自己独立的一份dep
,数据无论是get
还是set
的时候都能正常访问这份独立的dep
,这种闭包的运用方式值得我们学习。
总结
经过这一章节的学习,我们实现了一个简单的响应式系统,完整的代码如下:
class Dep {
constructor() {
this.subs = []
}
addSub(sub) {
this.subs.push(sub)
}
removeSub(sub) {
this.subs = this.subs.filter((item) => sub !== item)
}
notify() {
this.subs.forEach((sub) => { sub.update() })
}
depend() {
// 如果存在正在执行的函数
if (Dep.target) {
// 数据添加函数
this.addSub(Dep.target)
// 函数添加数据
Dep.target.addDep(this)
}
}
}
class Watcher {
constructor(vm, expOrFn) {
// 记录依赖了哪些数据
this.deps = []
// 这里的 vm 指代的上下文环境
this.vm = vm
this.getter = expOrFn
this.value = this.get()
}
// 用于调用相应函数
get() {
Dep.target = this
const value = this.getter.call(this.vm)
Dep.target = null
return value
}
// 用于更新
update() {
// 这里更新只是简单处理
const value = this.getter.call(this.vm)
return value
}
// 依赖了哪些数据就添加哪些数据
addDep(dep) {
this.deps.push(dep)
}
}
class IVue {
constructor(options) {
// 1. 需要将data作为参数的一部分传入
// options 的形式为 { id: '#app', data: {...} }
this.$options = options;
// 2. 需要将data处理成响应式
this.initData();
// 3. 挂载dom,并搜集依赖
this.$mount()
}
// 对data响应式处理
initData() {
// 遍历 data 的 key,均处理成响应式
const data = this.$options.data || {};
Object.keys(data).forEach((key) => {
this.defineReactive(data, key);
});
}
// 对某个key就行响应式处理
defineReactive(obj, key) {
const self = this;
let val = obj[key];
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
// 记录函数与数据的关系
dep.depend()
return val;
},
set(newValue) {
if (newValue === val) {
return;
}
val = newValue;
dep.notify()
}
});
}
$mount() {
const updateComponent = () => {
let html = "";
const data = this.$options.data;
Object.entries(data).forEach(([key, value]) => {
html += `${key}: ${value} <br/>`;
});
// 这里的 id 就是传入的 id
const el = this.$options.id;
const $app = document.querySelector(el);
$app.innerHTML = html;
}
new Watcher(this, updateComponent)
}
}
const vm = new IVue({
id: "#app",
data: {
count: 0,
title: "1"
}
});
const data = vm.$options.data;
data.count = data.count + 1 // 触发视图更新
data.title = data.title + 1 // 触发视图更新
一个极简的响应式系统就完成了!但是这还仅仅不够的,因为还有许多细节被我们省略了,下个章节我们将会结合Vue
源码来理解整个响应式系统的构建。