Vue 依赖收集
个人觉得这部分是比较难以理解的,因为当时学的时候也是看了几遍才搞懂这里面的关系。因为我们之前完成了数据劫持和模板编译,而要想实现响应式原理,那么就需要依赖收集,依赖收集分为 基本数据类型的依赖收集和 Object 类型数据(数组和对象)的依赖收集。想要理解依赖收集需要明白:
- 什么是依赖收集
- 为什么要依赖收集
- 怎么依赖收集
本文也将从这三个方面依次讲解,这里会用到之前讲解的数据劫持和模板编译的知识,大家不清楚的可以先看看。
1. 什么是依赖收集
接下来的篇幅有点多,一定要认真看,否则会被很多关键词绕晕的😊。
因为根据使用 Vue 的操作来看,我们在改变变量值的时候网页上的内容会跟着改变,想实现这个功能就需要依赖收集了。依赖收集顾名思义,就是收集和**【我】有关系的【依赖】,那么【我】指的是什么呢?在 Vue 中【我】指的就是每个组件,【依赖】指的就是每个组件中在页面中会使用到的数据。一定要注意【页面中】**这个词,因为我们只收集用到的数据,如果这个数据没在页面中用到,那么是不会进行依赖收集的。
上面说的有些抽象,为了更好的理解,我现在举个例子🌰:
- 按照我们的现有知识(假设你熟悉 Vue 的一点点知识),我们可以知道页面是由一个个**【组件】**组成的,如果只有一个页面,那也是一个根组件,我们一般命名为
App.Vue
; - 接着我们会配合使用
{{ }}
插值语法进行展示我们在data
中传入的**【变量】**,让这个变量最终以值的形式展现在页面中; - 最后,如果用户改变了这个变量的值后(假设这个操作会触发页面的更新),那么在页面上我们会看到最新的值。
这个例子中我们我们可以看到,Vue 让**【组件】和【变量】**联系在了一起,这也就是我们经常说的 MVVM 模式。那么现在我们可以抛出两个新的概念:
- Watcher
- Dep
不用担心,这两个概念之前一直提到过,只不过换了一个名字罢了,Watcher ?是不是有些耳熟?对就是每个组件里会有的那个 watch 对象,但是这里变成了 Watcher;然后就是 Dep,上过高中的应该学过 dependency 吧?没学过?那depend 总学过吧?翻译过来就是依赖的意思,为啥叫 Dep 呢?写源码的人比较懒吧🤔?
废话不多说,我们把之前提到的所有名词归类可以得到:【我,组件,Watcher】和【依赖,变量,Dep】,现在可以看出来依赖收集是什么了吧?按照我们上面说的就是收集和组件有关的变量,收集和 Watcher 有关的 Dep,所以我们可以得到如下等式:
组件
=
W
a
t
c
h
e
r
=
我
;
变量
=
D
e
p
=
依赖
组件=Watcher=我;变量=Dep=依赖
组件=Watcher=我;变量=Dep=依赖
根据 MVVM 我们又知道组件可以看作为**【视图】,变量可以看作【数据】**,所以综上所述,依赖收集就是每个组件视图中的 Watcher 收集在其中用到的的数据所对应的依赖变量(个人理解)。
花了这么大的篇幅讲解了依赖收集中的两个关键词:Watcher 和 Dep,在接下来你们只要记得 Watcher 对应组件,Dep 对应数据就行了,因为不说清楚,很容易看着看着就晕了。好了,说清楚什么是依赖收集,接下来说说为啥要依赖收集。
2. 为什么要依赖收集
上面说了什么是依赖收集,我们已经知道了那几个关键词的关系了,那我就直接说了。因为我们在开发的过程中一个页面肯定由一个或多个组件组成,那么这个组件中肯定有很多的变量,这些变量中我们可能会用到某些变量,将他们的值展示到页面中,那么我们肯定也想当这些变量发生改变时,页面也跟着一起改变,上面也提到过,为了实现这个需求我们就需要进行依赖收集,也就是收集用到的变量了,因为页面上没用到,你改变它的值肯定是不会去更新视图的,所以也没必要收集它是吧?
同时一个组件中我们肯定会使用很多的变量,这些变量可能会在其他组件中出现,比如用 props 传值等。也就是说组件 1 中可能存在变量 A、B、C,在组件 2 中可能存在变量 C、D、E。那么我们改变变量 C 的值肯定组件 1 和组件 2 肯定要同时更新其视图。因此我们就可以给每个组件在其创建的时候分配一个 Watcher 用它来收集用到的变量 id,当然每一个有用的变量也要收集其所在的组件 id,这样当变量自己发生改变时可以通知自己所在的组件进行视图更新了。可以看如下的图示:
当 C 发生改变,它要通知它所在的组件 1 和组件 2 进行更新,其他变量只要通知一个组件进行更新即可。
所以为什么要依赖收集呢?为的就是有某个变量进行改变的时候,【我】可以知道是不是要重新渲染自己这个组件。与之前介绍的数据劫持,我们就可以简单的实现响应式了。
3. 怎么依赖收集
记住一句话,这个很重要,记住了你就掌握精髓了:在 get
收集依赖,在 set
渲染视图。
因为之前也提到过,我们只收集页面中用到的变量,那么怎么做到这个呢?我们就可以在 get
中实现了,因为每次获取 data
中的变量,就会触发 get
,那么你可能会有疑问,如果在函数中或者其他地方使用到变量不也会触发吗,那怎么区分这两种场景呢?聪明的尤大大想到了一个好办法,后面会介绍。
接下来我们会介绍依赖收集的具体思路:
- 在之前我们知道了我们要渲染出真实的 DOM,需要先产生
render
函数,这个render
就是关键,因为我们在调用render
函数的时候,会有_s()
方法来获取data
上的变量,从而会触发get
方法,这样我们就可以只获取在页面中用到的变量了; - 那么怎么区分 Vue 渲染时的获取操作和用户的获取操作呢?我们可以定义一个全局的唯一变量,你想设成啥都行,但是为了方便我们在
Dep
类下设置了静态属性Dep.target
来存储当前的Watcher
,因为我们在每次执行渲染函数,也就是_update()
函数之前先把Dep.target
的值设置为当前的Watcher
,渲染时当出发get
操作时,因为此时Dep.target
是有值的,我们就对这个变量进行依赖收集,然后在渲染完后,准确的说是render
函数执行完就可以了,这样页面使用到的变量我们都会进行依赖收集; - 之前也说过
Watcher
要收集Dep
,同时Dep
也要收集Watcher
,那么怎么收集呢?这个部分不太好用语言描述,一会可以看具体代码进行解释; - 那么你可能会问,我们怎么给每个组件增加一个
Watcher
呢?之前也说过,在每个组件渲染之前增加,也就是在$mount()
函数里调用new Watcher
进行组件的实例化,这样每个组件就会有一个Watcher
实例啦😄。
这样就进行了依赖收集,但是这不是最终的依赖收集,这只是基础版本,因为这个版本会有个问题:
- 如果一个页面中重复使用了同一个变量多次呢,是要每次都收集一遍吗?答案当然是否定的,那么怎么解决呢,这个可能得再用一个篇幅来介绍;
有了之前的铺垫,你应该对依赖收集的过程有了一个简单的了解,但是你可能还是不太清楚,那么话不多说直接上代码,让你们看懂整个依赖收集的流程。
3.1 组件初始化
之前也说过在渲染前初始化 Watcher
,代码如下:
// 初始化函数
Vue.prototype._init = function (options) {
const vm = this;
vm.$options = options;
// 初始化数据,也就是进行数据劫持
initState(vm)
// 我们判断用户有没有传入根节点,如果传入那么我们就要进行页面的渲染
let el = vm.$options.el;
if (el) {
// 渲染页面
vm.$mount(el);
}
}
// 渲染组件的函数
Vue.prototype.$mount = function (el) {
// 在这里我们将用户的模板转化为了render函数并挂载到了Vue的options下
// 渲染组件
mountComponent(vm);
}
function mountComponent(vm) {
const updateCompnent = () => vm._update(vm._render());
const watcher = new Watcher(vm, updateCompnent);
console.log('-------------\n当前页面的watcher: ', watcher, '\n-------------');
}
这里三个函数不是在一个文件夹里的,写一起只是为了表现在真实场景下的执行顺序,也就是
_init => $mount => mountComponent => new Watcher()
3.2 基础版的依赖收集
// defineReactive.js
function defineReactive(data, key, value) {
observe(value);
let dep = new Dep(value); // 通过闭包让每个基本属性都有一个dep实例
Object.defineProperty(data, key, {
get() {
console.log('数据劫持get操作', key, '->', value);
// 只收集在渲染过程中模板中使用的变量,如果在外获取变量,target的值为null,就不收集
if (Dep.target) {
dep.depend(); // (1)
}
return value;
},
set(newVal) {
console.log('数据劫持set操作', newVal, '<-', value);
// 如果没改变值那么直接返回
if (value === newVal) return;
// 观察更改的值,让其也变成响应式
observe(newVal);
value = newVal;
dep.notify(); // 如果属性更新了那么就更新视图 (2)
}
})
}
// Watcher.js
// 存储组件中的变量
let id = 0;
function Watcher(..., fn, ...) {
this.id = id++; // 每个watcher的唯一标识符
this.fn = fn;
this.deps = []; // 记录watcher中的dep,实现计算属性和清理工作要用到
......
this.mountPage(); // 初次渲染页面
}
Watcher.prototype.mountPage = function () {
console.log('渲染页面: run...');
Dep.target = this;
this.fn();
Dep.target = null;
}
Watcher.prototype.update = function () {
// 不要问为啥,还记得上面说的那个问题吗,这个函数后面会进行重写,目前就是一个简单的更新函数
this.mountPage();
}
Watcher.prototype.addDep = function (dep) {
// 让Watcher记住dep
this.deps.push(dep);
dep.addWatcher(this);
}
// 收集依赖和通知watcher
let id = 0;
function Dep() {
this.id = id++; // 每个dep的唯一标识符
this.subscribe = []; // 存放含有这个变量的watcher
}
Dep.target = null; // 全局唯一变量
Dep.prototype.depend = function () {
// Dep.target有可能不存在,因为如果为基本类型就直接被return返回了,返回值为undefined
if (Dep.target) {
Dep.target.addDep(this);
}
}
Dep.prototype.addWatcher = function (watcher) {
// watcher加了当前dep后,dep把当前的watcher加进去
this.subscribe.push(watcher);
}
Dep.prototype.notify = function () {
// 让subscribe里的每个watcher都进行更新
this.subscribe.forEach(watcher => watcher.update())
}
基础代码就如上面所示,具体的流程就是:
-
首先在
$mount
中会调用new Watcher()
那么会直接触发mountPage
函数,那么会将Dep.target
赋值为当前的Watcher
; -
然后在执行
render
函数后会触发get
函数,也就是运行到上述代码中(1)
的地方,因为当前是在渲染过程中,所以Dep.target
是有值的,会触发依赖收集,也就是会运行depend
函数,然后会让Watcher
先记住自己,也就是运行addDep
函数把当前的dep
添加到deps
数组里; -
然后添加完后,
Watcher
会让dep
记住自己,也就是会调用addWatcher
函数,让dep
把自己添加到subscribe
数组中,这样依赖收集就完成了; -
最后只要变量被赋值且不与原来一样就会触发
dep.notify()
,也就是上面(2)
的代码,通知每个Watcher
进行视图更新,也就是调用update
函数。
这样就实现了简单的依赖收集和完成了简单的响应式系统,但是这个系统还是有问题的:
- 如果用户一次性修改了一个或多个变量的值,那么每次赋值都要更新吗?答案肯定是否定的,这样会特别消耗性能,因此之后我们要介绍一部更新策略;
- 还有我们只能对基础数据进行响应式数据,那么对于数组和对象更新就不会进行页面视图的更新。
4. 写在最后
- 完整代码:每个步骤都有相应的结果打印,可清楚查看每个过程,可见我的 GitHub 仓库 📦,如果喜欢可以给一颗 ⭐️ 支持一下
- 参考资料:
- 之后会再写几篇文章介绍:批量更新、
$nextTick
、数组的更新等,敬请期待,如果喜欢可以关注这个专栏 - 系列文章:
- 上一篇:【Vue 模板编译】