赞
踩
所谓响应式(Reactive),放在一个前端框架中,指的就是框架能够主动观察(Observe)状态数据的变化(即Vue中的data),并收集所有依赖(Depend)该数据的监控(Watch)回调代码,在数据发生改动时,主动执行该监控回调以更新目标数据或者更新视图。
相应地,在Vue版本2.6.14的源码中,存在以下三个主要的类来专门实现响应式:
当然,除了这三个类,还有很多辅助函数和代码来协助完成整个响应式系统,我们下面会逐一分析。但总的来说,我们要实现的目的就是如下代码所示:
- const data = {
- count: {
- total: 0,
- free: 0,
- },
- };
-
- observe(data); // 开始劫持数据,为data的每个属性设置getter 和 setter
- new Watcher(data, "count.total", (newVal, oldVal) => {
- console.log(
- "监控到count.total发生了改变,开始更新UI..."
- );
- });
-
- data.count = 10
也就是说,本文主要是围绕以下以下两个目标进行分析阐述的:
同时,为了降低分析的复杂度,我们这里不会考虑目标对象的子属性是数组的形式。
要实现响应式,我们首先需要做的就是要截获数据的访问和修改,也就是所谓的数据劫持。
在Javascript中,我们可以使用defineProperty方法来给一个对象添加一个属性,并通过实现其getter和setter来监控该属性的访问和修改。
比如,下面的代码通过defineProperty方法给data对象添加了一个count属性,并实现了getter和setter
- const data = { };
-
- let val = 0;
- Object.defineProperty(data, "count", {
- // getter
- get() {
- ...// 数据访问劫持,在返回数据之前先做些依赖收集相关的事情
- return val;
- },
- // setter
- set(newVal) {
- val = newVal;
- ... // 数据修改劫持,在修改数据之后通知依赖count的所有watcher进行数据更新
- },
- });
-
以上代码通过getter和setter,实现了对data对象的count属性的数据劫持。在访问data.count时,getter会先进行依赖收集相关的逻辑(我们下面会谈到),然后才返回一个指定的数据;而在修改data.count时,setter会在将该数据修改后,通知所有依赖该数据的回调进行执行。
这里唯一美中不足的就是,如果我们data需要监控多个属性,那么我们就需要定义多个不同的val来为不同的属性服务。所以这里我们最好是将这部分代码封装成一个新的方法,同时利用闭包的特性,使得val无需进行全局暴露即可以正常的给getter和setter 使用。
- function defineReactive(data, key, val = data[key]) {
- Object.defineProperty(data, key, {
- // getter
- get() {
- ...// 数据访问劫持,在返回数据之前先做些依赖收集相关的事情
- return val;
- },
- // setter
- set(newVal) {
- val = newVal;
- ... // 劫持修改,在修改数据之后通知依赖count的所有watcher进行数据更新
- },
- });
- }
-
- const data = {};
- defineReactive(data, "count", 0);
通过defineReactive,我们可以很方便的指定对象及对象的某个属性来对其实现数据劫持,但是这里每个属性都需要调用一遍的话太过于麻烦,所以我们希望通过进一步的封装,只提供一个对象作为参数,即可对其下的所有属性自动实现数据劫持。
这里就是我们引入Observer这个类的地方
- export class Observer {
- constructor (value) {
- ...
- def(value, '__ob__', this)
- this.walk(value)
- }
-
- walk (obj) {
- const keys = Object.keys(obj)
- for (let i = 0; i < keys.length; i++) {
- defineReactive(obj, keys[i])
- }
- }
- }
将一些在当前看来无关紧要的代码去掉后,整个Observer类看上去就非常简单,接受一个value对象,然后在构造函数阶段就直接调用walk方法,将value对象下面的每个属性都通过defineReactive方法来实现数据劫持。
另外,为了防止对某个属性重复进行劫持,这里我们会通过def方法把当前Obserer的示例以__ob__的属性名加入到正在处理对象(或者子属性)中。其中def是对defineProperty的进一步封装:
- /**
- * Define a property.
- */
- function def (obj, key, val, enumerable) {
- Object.defineProperty(obj, key, {
- value: val,
- enumerable: !!enumerable,
- writable: true,
- configurable: true
- });
- }
同时,为了方便调用,vue框架还实现一个叫做observe的辅助函数。
- function observe (value) {
- if (!isObject(value)) {
- return
- }
- var ob;
- if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
- ob = value.__ob__;
- } else {
- ob = new Observer(value);
- }
- return ob
- }
如果目标对象(或者递归中的子属性)已经被劫持,即已经有__ob__属性,则直接返回该保存的Observer实例对象。如果没有被劫持,则调用Observer构造函数触发walk成员方法来对下一层属性进行数据劫持。
但是,通过Observer的walk成员函数,我们只能实现劫持目标对象下一层的子属性,如果子属性还有子属性呢?比如:
- data: {
- count: {
- total: 0,
- lost: 0
- }
- }
这个时候我们就要考虑引入递归了,即在每次对子属性进行数据劫持之前,先对Observer构造函数进行一次递归调用,这样我们就能遍历整个对象的所有层级,并对任意嵌套属性进行数据劫持。
所以这里的递归调用我们很容易就能想到放到walk方法下面,因为我们就是从这里开始循环劫持对象的下一层属性的。
- export class Observer {
- constructor (value) {
- ...
- this.walk(value)
- }
-
- walk (obj) {
- const keys = Object.keys(obj)
- for (let i = 0; i < keys.length; i++) {
- observe(obj[keys[i]]) //等同于new Observer(obj[keys[i]]);
- defineReactive(obj, keys[i])
- }
- }
- }
但vue的做法是将递归放入到了defineReactive方法中,因为考虑到defineReactive今后还需要用到该子属性对应的Observer实例对象。因此,此时defineReactive的代码大致如下:
- function defineReactive(data, key, val = data[key]) {
- let childOb = observe(val)
- Object.defineProperty(data, "count", {
- // getter
- get() {
- ...// 数据访问劫持,在返回数据之前先做些依赖收集相关的事情
- return val;
- },
- // setter
- set(newVal) {
- val = newVal;
- ... // 劫持修改,在修改数据之后通知依赖count的所有watcher进行数据更新
- },
- });
- }
这样一来,整个递归就不是在Observer对象自身单个方法上面完成了,而是跨越了多个方法,大致流程如下
至此,我们实现了为对象的所有属性增加一个getter和setter以实现对数据的劫持,即能够捕捉到对属性的访问和修改。
跟着要做的事情,就是在getter中收集依赖,在setter中触发依赖,如此一来我们截获访问和修改才有意义。
所谓依赖收集,指的就是将依赖某个属性的代码给保存起来,以便在今后截获到对这个属性的修改时,将触发所有依赖这个属性的依赖回调代码。
这听上去就是个很典型的发布订阅/观察者模式的应用场景。
所以这里很自然的就可以定义一个叫做Dep的类,实现订阅(addSub)和通知(notify)。
- class Dep {
- constructor() {
- this.subs = [];
- }
- addSub(依赖回调) {
- this.subs.push(依赖回调);
- }
- notify() {
- this.subs.forEach(依赖回调 => 依赖回调())
- }
- }
只是在vue中,我们不是直接将依赖回调给保存到subs数组中,而是先将依赖回调保存到Watcher实例中,然后将该实例保存到subs数组中。
所以这里的Watcher应该持有以下一些基本数据,从而将目标对象所依赖的属性和依赖回调给关联起来:
如此一来,Watcher类大概可以实现成下面这个样子
- class Watcher{
- constructor(vm,expr,cb){ // vm,因为实现了数据代理,所以相当于data;expr,即访问路径,如'count.total';cb: 依赖回调
- this.vm = vm;
- this.expr = expr;
- this.cb = cb;
- }
- update(){
- //todo: 调用依赖回调cb
- }
- }
而上面的Dep类则可以相应的改成
- class Dep {
- constructor() {
- this.subs = [];
- }
- addSub(watcher) {
- this.subs.push(watcher);
- }
- notify() {
- this.subs.forEach(watcher => watcher.update())
- }
- }
在vue中,依赖收集阶段其实并不是直接通过调用addSub来把watcher加入到subs中的,而是通过增加一个叫做depend的方法。
- class Dep {
- constructor() {
- this.subs = []
- }
- depend() {
- this.addSub(Dep.target)
- }
- addSub(sub) {
- this.subs.push(sub)
- }
- notify() {
- this.subs.forEach(watcher => watcher.update())
- }
- }
而该depend的方法在哪里调用呢?就在defineReactive的getter中,因为谁引用了该属性,谁就依赖了该属性。
所以defineReactive方法稍微修改下getter,为每个属性添加一个dep对象,用来存储所有依赖这个属性的watcher。同时,在getter中开始触发依赖的收集。
- function defineReactive(data, key, val = data[key]) {
- const dep = new Dep()
- let childOb = observe(val)
- Object.defineProperty(data, key, {
- // getter
- get() {
- if (Dep.target) {
- dep.depend()
- }
- return val
- },
- // setter
- set(newVal) {
- val = newVal;
- ... // 劫持修改,在修改数据之后通知依赖count的所有watcher进行数据更新
- },
- });
- }
那么上面Dep中depend方法以及这里getter中用到的Dep.target究竟是什么呢?事实上,它就是一个全局的Watcher实例,这主要会在Watcher这个类中进行设置。
在Watcher类的构造函数中,会调用成员方法get来访问所依赖的属性,从而引发该属性的getter执行。
- class Watcher{
- constructor(vm,expr,cb){ // vm,因为实现了数据代理,所以相当于data;expr,即访问路径,如'count.total';cb: 依赖回调
- this.vm = vm;
- this.expr = expr;
- this.cb = cb;
- this.value = this.get() // 访问目标属性以触发getter从而发起依赖收集流程
- }
- update(){
- //todo: 调用依赖回调cb
- }
- get() {
- Dep.target = this
- const value = 读取vm中的expr路径的属性 // 从而触发对应属性的getter
- Dep.target = null
- return value
- }
- }
因为上面代码在访问依赖属性之前先把Dep.target设置成当前的watcher实例本身,所以defineReactive的getter中就会认为当前正处于依赖收集阶段,所以就会继续调用dep.depend方法,从而将该watcher实例加入到该属性的dep实例所维护的subs数组中。
完了后上面的代码会继续走,将Dep.target设置成null,从而结束依赖收集阶段。
也就是说,只有Watcher中的get方法会触发getter中的dep.depend,即只有在new Watcher(vm, key,...)
会引发依赖收集。
- const data = {
- count: { total: 100 }
- }
- new Watcher(vm, 'count.total, () => {console.log('total 发生改变'})
而一般的数据访问则不会进行依赖收集,比如下面vue组件methods配置项中的代码:
- ...
- methods: {
- getPrice() {
- const price = this.data.count.total * 10
- }
- }
这段代码虽然有访问data下count.total属性,但是因为没有设置Dep.target,所以在defineReactive的getter中会直接忽视,不会进入到依赖收集阶段来。
至此,依赖收集完成。稍微总结下整个流程:
从前面的分析中我们可以看到,依赖收集是从defineReactive的getter中开始的,即一旦有访问对象某个属性,且设置了Dep.target,则开始依赖收集。
那么派发更新又是从哪里开始的呢?很明显,是从setter开始的。我们先更新下defineReactive的setter:
- function defineReactive(data, key, val = data[key]) {
- const dep = new Dep()
- let childOb = observe(value)
- Object.defineProperty(data, key, {
- // getter
- get: function reactiveGetter () {
- if (Dep.target) {
- dep.depend()
- }
- return val
- },
- // setter
- set(newVal) {
- if (newVal === val) return
- val = newVal
- observe(newVal)
- dep.notify()
- }
- }
setter做了以下事情:
下面我们看下Dep类的notify方法:
- class Dep {
- constructor() {
- this.subs = []
- }
- ...
- notify() {
- this.subs.forEach(watcher => watcher.update())
- }
- }
很简单,收到派发更新的调用后,循环调用每个依赖该属性的watcher的update方法。
接着我们看下Watcher的update方法大概会怎么实现:
- class Watcher{
- constructor(vm,expr,cb){ //vm,因为实现了数据代理,所以相当于data;expr,即访问路径,如'count.total';cb: 依赖回调
- this.vm = vm;
- this.expr = expr;
- this.cb = cb;
- this.getters = parsePath(this.expr)
- this.value = this.get()
- }
- update(){
- const oldValue = this.value
- this.value = this.getters.call(this.vm, this.vm); //读取vm中的expr路径的属性,即取得新值
- this.cb.call(this.vm, this.value, oldValue)
- }
- get() {
- window.target = this //开始依赖收集
- const value = this.getters.call(this.vm, this.vm) //读取vm中的expr路径的属性, 从而触发对应属性的getter,
- window.target = null //结束依赖收集
- return value
- }
- }
首先这里得先介绍下parsePath这个高阶函数:
- function parsePath(path) {
- path = path.split('.')
- return function (obj) {
- path.forEach((key) => {
- obj = obj[key]
- })
- return obj
- }
- }
该方法接受一个对象的访问路径,比如'count.total',然后返回一个函数。该函数将接收一个对象,并返回外层函数所提供的访问路径的属性。比如:
- const data = {
- count: {
- total: 18,
- free: 10,
- },
- };
- const getter = parsePath('count.data')
- //那么可以直接通过getter获取指定对象对应路径中的值
- const totalCount = getter(data) // 返回18
了解这个方法的用途之后,Watcher的update方法就很好理解了:
- update(){
- const oldValue = this.value
- this.value = this.getters.call(this.vm, this.vm); //读取vm中的expr路径的属性,即取得新值
- this.cb.call(this.vm, this.value, oldValue)
- }
新值和旧值作为依赖回调的参数,可以回看下文章最开始的Watcher示例以加深理解:
- ...
- observe(data);
- new Watcher(data, "count.total", (newVal, oldVal) => {
- console.log(
- "监控到count.total发生了改变,开始更新UI..."
- );
- });
至此,Vue响应性原理的学习就算告一段落了。虽然vue源码的具体实现会有不同,但是原理上应该是相差不远。所以有了这些基础后,一是让我们对vue的响应式原理有了进一步的理解,二是让我们带着这些知识查看vue源码时可以更加得心应手。
https://youtu.be/MmdYWQC57-Y?list=PLmOn9nNkQxJFbDF2ZZgaSlMiurxt9saFx
https://juejin.cn/post/6932659815424458760
我是@天地会珠海分舵,「青葱日历」和「三日清单」 作者。能力一般,水平有限,觉得我说的还有那么点道理的不妨点个赞关注下!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。