阅读 1780

🚩Vue源码——订阅者的收集

最近参加了很多场面试,几乎每场面试中都会问到Vue源码方面的问题。在此开一个系列的专栏,来总结一下这方面的经验,如果觉得对您有帮助的,不妨点个赞支持一下呗。

前言

上篇专栏中详解介绍了数据的监听过程。那么当监听到数据发生变化后又是如何通知视图更新,在 Vue 中是采用发布者订阅者这个设计模式来实现这个功能。其中发布者就是数据,订阅者就是 Watcher,另外还使用 Dep 来专门收集并管理订阅者,而订阅者还分为三种类型:render Watcher 、computed Watcher、user Watcher。本文主要介绍这三种订阅者是如何被收集。

一、render Watcher 渲染订阅者的收集

这篇专栏中介绍过,当读取数据时会触发 getter 函数,在 getter 函数中收集订阅者。

那什么时候读取数据呢?还记着在这篇专栏中介绍过,在 Vue 的挂载过程中执行 mountComponent 函数有一段比较重要的逻辑,代码如下:

var updateComponent;
updateComponent = function() {
    vm._update(vm._render(), hydrating);
};
new Watcher(vm, updateComponent, noop, {
    before: function before() {
        if (vm._isMounted && !vm._isDestroyed) {
            callHook(vm, 'beforeUpdate');
        }
    }
}, true /* isRenderWatcher */ );
复制代码

执行 new Watcher 实例化 Watcher 类 ,得到一个实例化对象就是订阅者,称为 Watcher。先来看一下 Watcher 构造函数。

因为在 Vue 中 Watcher 分为 render Watcher 、computed Watcher、user Watcher 三种类型,所以在构造函数中要定义很多实例对象来实现这几种 Watcher 。

在这里先精简一下 Watcher 构造函数,只保留跟 render Watcher 和 Dep 相关的实例对象。

var uid = 0;
var Watcher = function Watcher(vm, expOrFn, cb, options, isRenderWatcher) {
    this.vm = vm;
    if (isRenderWatcher) {
        vm._watcher = this;
    }
    vm._watchers.push(this);
    if (options) {
        this.deep = !!options.deep;
        this.lazy = !!options.lazy;
        this.before = options.before;
    } else {
        this.deep = this.lazy = false;
    }
    this.cb = cb;
    this.id = ++uid;
    this.deps = [];
    this.newDeps = [];
    this.depIds = new Set();
    this.newDepIds = new Set();
    if (typeof expOrFn === 'function') {
        this.getter = expOrFn;
    }
    this.value = this.lazy ? undefined : this.get();
};
复制代码
  • 参数 vm:Vue 实例化对象。
  • 参数 expOrFn:要监听的数据,可为一个字符串表示要观察的数据路径,也可为一个函数结果返回要观察的数据。
  • 参数 cb:回调函数,当监听的数据发生变化时调用。
  • 参数 options:一些配置选项。
  • 参数 isRenderWatcher:为 true 表示创建的 Watcher 是渲染 Watcher。

this.depsthis.newDeps 表示 Watcher 持有的 Dep 的数组集合,Dep 是用来专门收集并管理订阅者。那么这里为何需要有 2 个 Dep 的数组集合呢,稍后介绍。

this.depIdsthis.newDepIds 分别代表 this.depsthis.newDeps 中 Dep 的标识符 id 的 Set 集合,Set 是 ES6 的数据结构,它类似于数组,但是成员的值都是唯一的,没有重复。

最后执行 this.value = this.lazy ? undefined : this.get(),因在此场景中的 Watcher 构造函数的参数 options 里面没有 lazy 这个属性,故 this.lazyfalse ,会执行 this.get() ,来看一下 get 实例方法。

Watcher.prototype.get = function get() {
    pushTarget(this);
    var value;
    var vm = this.vm;
    try {
        value = this.getter.call(vm, vm);
    } catch (e) {
        if (this.user) {
            handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\""));
        } else {
            throw e
        }
    } finally {
        if (this.deep) {
            traverse(value);
        }
        popTarget();
        this.cleanupDeps();
    }
    return value
};
复制代码

先执行 pushTarget(this) ,来看一下 pushTarget 函数,

Dep.target = null;
var targetStack = [];
function pushTarget(target) {
    targetStack.push(target);
    Dep.target = target;
}
function popTarget() {
    targetStack.pop();
    Dep.target = targetStack[targetStack.length - 1];
}
复制代码

pushTarget 函数中,Dep.target 是 Dep 静态属性,执行 Dep.target = target 把当前 Watcher 赋值到 Dep.target ,这样保证了同一时间只能有一个 Watcher 被收集。执行 targetStack.push(target) 把当前 Watcher 添加到 targetStack 数组中,其作用是为了恢复这个 Watcher。

popTarget 函数中,执行 targetStack.pop() 把当前 Watcher 移除出 targetStack 数组,说明当前 Watcher 已经收集完毕,执行 Dep.target = targetStack[targetStack.length - 1] 把上一个未被收集的 Watcher 重新赋值给Dep.target

targetStack 数组就像一个栈,保证 Watcher 的收集顺序,至于为什么这么做,后续遇到相关场景再介绍。

在 try 语句中执行 value = this.getter.call(vm, vm),先来看一下 this.getter 是什么,

if (typeof expOrFn === 'function') {
    this.getter = expOrFn;
} 
复制代码

可以看出 this.getter 的值是 Watcher 构造函数的参数 expOrFn ,那么 this.getter 的值是 function() { vm._update(vm._render(), hydrating); }; 那么执行 this.getter.call(vm, vm) ,相当执行 vm._update(vm._render(), hydrating) ,则会先执行 vm._render()

回顾这篇专栏中介绍执行 vm._render() 生成 vnode 的过程中会去读取 data 中的数据,会触发数据的 getter 函数,在其中收集订阅者。而 getter 函数是在 defineReactive 函数中定义。

function defineReactive(obj, key, val, customSetter, shallow) {
    var dep = new Dep();
    var getter = property && property.get;
    var setter = property && property.set;
    if ((!getter || setter) && arguments.length === 2) {
        val = obj[key];
    }
    var childOb = !shallow && observe(val);
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            var value = getter ? getter.call(obj) : val;
            if (Dep.target) {
                dep.depend();
                if (childOb) {
                    childOb.dep.depend();
                    if (Array.isArray(value)) {
                        dependArray(value);
                    }
                }
            }
            return value
        },
        set: function reactiveSetter(newVal) {
        }
    });
}
复制代码

先执行 var dep = new Dep() 实例化一个 Dep 类,并赋值给常量 dep

在 getter 函数中 ,执行 if (Dep.target) ,此时 Dep.target 有值,值是当前 Wacther。故执行 dep.depend() , dep.depend 是 Dep 的实例方法。先来看一下 Dep 构造函数

var uid = 0;
var Dep = function Dep() {
    this.id = uid++;
    this.subs = [];
};
复制代码

Dep 构造函数非常简单,id实例对象是 Dep 的标识符,每创建一个 Dep 就会自增加 1,subs 实例对象是个收集订阅者的容器。

再来看一下 dep.depend 实例方法。

Dep.prototype.depend = function depend() {
    if (Dep.target) {
        Dep.target.addDep(this);
    }
}
复制代码

Dep.target 存在,故执行 Dep.target.addDep(this)addDep是 Wachter 的一个实例方法,到这里可以得知 Dep 实际上就是对 Watcher 的一种管理,Dep 脱离 Watcher 单独存在是没有意义的,所以本文会在介绍 Wachter 中穿插介绍 Dep 。

来看一下 addDep 实例方法。

Watcher.prototype.addDep = function addDep(dep) {
    var id = dep.id;
    if (!this.newDepIds.has(id)) {
        this.newDepIds.add(id);
        this.newDeps.push(dep);
        if (!this.depIds.has(id)) {
            dep.addSub(this);
        }
    }
}
复制代码

此时参数 dep 是当前要订阅的发布者中的创建 Dep ,执行 var id = dep.id,把 Dep 的实例对象 id 赋值给常量 id,实例对象 id 是 Dep 的一个标识符,每个 Dep 的 id 都不相同。

执行 if (!this.newDepIds.has(id)) ,其中 this.newDepIds 是 Dep 的标识符 id 的集合,是个 Set 数据结构, 故用 has 来判断 idthis.newDepIds中是不是已存在。

若不存在,执行 this.newDepIds.add(id)id 添加到this.newDepIds 中。执行 this.newDeps.push(dep) ,其中 this.newDeps 是 Dep 的集合,把当前要订阅的发布者中的创建 Dep 添加到 this.newDeps 中。

执行 if (!this.depIds.has(id)) 判断 idthis.depIds中是不是已存在,其中 this.depIds 也是一个 Dep 的标识符 id 的集合,和 this.newDepIds 有什么区别,放在后面介绍。若不存在,执行 dep.addSub(this) ,又回到 Dep 的实例方法addSub中,此时this是当前 Watcher 。

来看一下 Dep 的实例方法 addSub

Dep.prototype.addSub = function addSub (sub) {
    this.subs.push(sub);
}
复制代码

执行 this.subs.push(sub) 来收集订阅者,其中参数 sub 是 Watcher ,也就是订阅者,this.subs 是收集订阅者的容器。

以上已经完成了一个订阅者收集的过程。那么到这里就结束了么,其实并没有,因为上述逻辑是在 try 语句中执行的,无论执行是否成功,最后还要执行 finally 语句里面逻辑。

finally {
    if (this.deep) {
        traverse(value);
    }
    popTarget();
    this.cleanupDeps();
}
复制代码

因在此场景中的 Watcher 构造函数的参数 options 里面没有 deep 这个属性,所以 this.deepfalse ,不会执行 if 里面的代码。直接执行 popTarget()popTarget 函数的作用是把上一个未被收集的 Watcher 重新赋值给Dep.target,具体实现在前面已介绍过。

那 Vue 为什么要这么设计,举个例子来说明。假设在渲染过程中触发了对订阅者A的收集,但在收集过程中又触发了对订阅者B的收集。而在 Vue 中为了保证通知订阅者执行更新的顺序,同一时间同一地点只能收集一个订阅者,所以很巧妙的利用栈的工作原理,实现了先对订阅者B的收集,当对订阅者B的收集完毕,再去收集订阅者A。

至于怎么利用栈的工作原理,在 Vue 中创建一个数组 targetStack 来存储那些 被触发收集 但 未被收集 完毕 的订阅者,利用 pushTarget 函数模拟入栈,利用 popTarget 函数模拟出栈,保证发布者收集的订阅者的顺序。

执行 this.cleanupDeps(),来看一下 cleanupDeps 这个实例方法

Watcher.prototype.cleanupDeps = function cleanupDeps() {
    var i = this.deps.length;
    while (i--) {
        var dep = this.deps[i];
        if (!this.newDepIds.has(dep.id)) {
            dep.removeSub(this);
        }
    }
    var tmp = this.depIds;
    this.depIds = this.newDepIds;
    this.newDepIds = tmp;
    this.newDepIds.clear();
    tmp = this.deps;
    this.deps = this.newDeps;
    this.newDeps = tmp;
    this.newDeps.length = 0;
}
复制代码

cleanupDeps 实例方法的作用是移除清除无用的发布者,也就是通知发布者移除订阅者。先来看一下实现逻辑,

考虑到 Vue 是数据驱动的,所以每次数据变化都会重新 render,那么 vm._render() 方法又会再次执行,并再次触发数据的 getter 进行收集订阅者,所以 Wathcer 在构造函数中会初始化 2 个 Dep 的数组集合,也就是订阅了那些发布者的集合,this.newDeps 表示新订阅的发布者集合,而 this.deps 表示上一次订阅的发布者集合。

会首先遍历 this.deps,如果其中 Dep 的实例对象 idthis.newDepIds中不存在,则执行 dep.removeSub(this), 移除订阅者对这个发布者的订阅。然后把 this.newDepIdsthis.depIds 交换,this.newDepsthis.deps 交换,并把 this.newDepIdsthis.newDeps 清空。

那为什么要清除订阅者,假设个场景来解释一下,在一个页面中,用 v-if 来控制模块A和模块B,当满足 v-if 的值为 true,渲染模块A,会读取模块A中的数据,触发模块A中的数据收集这个页面的渲染 Watcher(订阅者)。如果这时 v-if 的值变为 false,渲染模块B,会读取模块B中的数据,触发模块B中的数据收集这个页面的渲染 Watcher(订阅者)。那么此时修改模块A的数据,也会通知这个订阅者执行更新。虽然模块A的数据改变了,但是模块A已经不在页面展示,这样的更新显然是浪费的。

如果一个订阅者每次被收集时,把订阅者中记录的新订阅的发布者集合和上一次订阅的发布者集合进行比对,通知那些没被再次订阅的发布者调用 dep.removeSub 实例方法,清除这个订阅者。这样更新就不会造成任何浪费。

最后来看一下 dep.removeSub 实例方法

Dep.prototype.removeSub = function removeSub(sub) {
    remove(this.subs, sub);
};
function remove(arr, item) {
    if (arr.length) {
        var index = arr.indexOf(item);
        if (index > -1) {
            return arr.splice(index, 1)
        }
    }
}
复制代码

dep.removeSub 实例方法很简单,调用 removesubthis.subs 这个订阅者容器中移除掉,sub 就是一个要移除的订阅者。

综上所述,在页面渲染过程中,在挂载阶段会创建一个渲染 Watcher (订阅者),在 Watcher 构造函数中最后执行 get 实例方法,在其中会调用 vm._render() 方法生成 vnode ,在其过程中会去读取 data 中的数据,就会触发数据的 getter 函数,在其中调用 Dep 的实例方法 depend,在其中又调用 Watcher 的实例方法 addDep 先把发布者的收集器 Dep 存储在 Watcher 的 newDepIds 中,然后调用 Dep 的实例方法 addSub 收集这个订阅者。最后调用 Watcher 的实例方法 cleanupDeps 清除无用的发布者,在其中遍历上次订阅的发布者集合 deps,通过这次订阅的发布者的标识 id 集合 newDepIds 来判断上次订阅的发布者是否在这次订阅的发布者集合 newDeps 中,若不在,调用 Dep 的实例方法 removeSub 去这个发布者中移除这个订阅者。清除完毕后,将 newDepsdeps 互换后,还有 newDepIdsdepIds 互换后,再把 newDepsnewDepIds 清空,这就完成了订阅者的收集流程。

二、user Watcher 用户自定义订阅者的收集

用户自定义订阅者的收集流程和渲染订阅者的收集流程基本相似,只是用户自定义订阅者可以实现一些额外的功能,例如深度监听、立即回调、取消监听功能,故收集流程还是有些差异。下面来介绍一下 用户自定义订阅者的收集流程。

1、创建用户自定义订阅者的内部逻辑

用户自定义订阅者是通过选项 watch 或 vm.$watch 定义的 Watcher。先来看一下通过选项 watch 创建用户自定义订阅者的内部逻辑。

watch 选项是在 initState 函数中执行以下代码初始化。

function initState(vm) {
    vm._watchers = [];
    var opts = vm.$options;
    if (opts.watch && opts.watch !== nativeWatch) {
        initWatch(vm, opts.watch);
    }
}
复制代码

其中 opts.watch !== nativeWatch这,是因为 Firefox 中有一个“监视”功能对象的原型 var nativeWatch = ({}).watch;,所以要排除一下。再来看一下initWatch函数。

function initWatch(vm, watch) {
    for (var key in watch) {
        var handler = watch[key];
        if (Array.isArray(handler)) {
            for (var i = 0; i < handler.length; i++) {
                createWatcher(vm, key, handler[i]);
            }
        } else {
            createWatcher(vm, key, handler);
        }
    }
}
复制代码

先回想官方文档中 watch 选项的用法

watch: {
    //用法一
    a: function(val, oldVal) {
    },
    //用法二
    b: 'someMethod',
    //用法三
    c: {
        handler: function(val, oldVal) { /* ... */ },
        deep: true
    },
    //用法四
    d: {
        handler: 'someMethod',
        immediate: true
    },
    //用法五
    e: [
        'handle1',
        function handle2(val, oldVal) { /* ... */ },
        {
            handler: function handle3(val, oldVal) { /* ... */ },
            /* ... */
        }
    ],
}
复制代码

回到 initWatch 函数中,遍历 watch 那么其每项 handler 可以是一个函数、一个对象、一个数组。如果 handler 是数组时要再遍历一遍,将每项调用 createWatcher 函数处理,若不是也要调用 createWatcher 函数处理。来看一下 createWatcher 函数。

function createWatcher(vm, expOrFn, handler, options) {
    if (isPlainObject(handler)) {
        options = handler;
        handler = handler.handler;
    }
    if (typeof handler === 'string') {
        handler = vm[handler];
    }
    return vm.$watch(expOrFn, handler, options)
}
复制代码

createWatcher 函数中最后执行 vm.$watch(expOrFn, handler, options),可知 user Watcher 是用 vm.$watch 实例方法创建的。

先来看一下 vm.$watch 实例方法的用法:vm.$watch( expOrFn, callback, [options] ),其返回值是一个取消观察函数,用来停止触发回调。

  • expOrFn 要监听的数据,可为一个字符串表示要观察的数据路径,也可为一个函数结果返回要观察的数据。
  • callback 当要观察的数据发生变化时的回调,可以是一个函数也可以是个对象。
  • options 额外选项,如:deep 表示要对数据的值进行深度观察,immediate 表示立即用观察的数据的值触发回调函数。

那么在 createWatcher 函数中 handler 是回调函数的意思,再来看以下逻辑就很容易理解了

if (isPlainObject(handler)) {
    options = handler;
    handler = handler.handler;
}
if (typeof handler === 'string') {
    handler = vm[handler];
}
复制代码

handler 是对象时,去对象的 handler 属性获取 handler,再执行 if (typeof handler === 'string') 判断 handler 是不是字符串,若是字符串,那么 handler 是选项 methods 中的一个方法。

下面介绍一下 Vue 中怎么实现 vm.$watch 实例方法。

Vue.prototype.$watch = function(expOrFn, cb, options) {
    var vm = this;
    if (isPlainObject(cb)) {
        return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {};
    options.user = true;
    var watcher = new Watcher(vm, expOrFn, cb, options);
    if (options.immediate) {
        try {
            cb.call(vm, watcher.value);
        } catch (error) {
            handleError(error, vm, 
            ("callback for immediate watcher \"" + (watcher.expression) + "\""));
        }
    }
    return function unwatchFn() {
        watcher.teardown();
    }
}
复制代码

执行 if (isPlainObject(cb)) 判断参数 cb 是否为对象,若是对象相当用选项 Watch 创建 user Watcher,故要执行 createWatcher(vm, expOrFn, cb, options) 创建 user Watcher并返回。

执行 options = options || {}; options.user = true; 处理一下参数 options,并加上属性 user 并设置为 true

执行 var watcher = new Watcher(vm, expOrFn, cb, options) ,实例化一个 Watcher 类。

来看一下 Watcher 构造函数。先精简一下,只保留跟 user Watcher 和 Dep 相关的实例对象。

var Watcher = function Watcher(vm, expOrFn, cb, options, isRenderWatcher) {
    this.vm = vm;
    vm._watchers.push(this);
    if (options) {
        this.deep = !!options.deep;
        this.user = !!options.user;
    } else {
        this.deep = this.user = false;
    }
    this.cb = cb;
    this.id = ++uid;
    this.active = true;
    this.deps = [];
    this.newDeps = [];
    this.depIds = new Set();
    this.newDepIds = new Set();
    this.expression = expOrFn.toString();
    if (typeof expOrFn === 'function') {
        this.getter = expOrFn;
    } else {
        this.getter = parsePath(expOrFn);
        if (!this.getter) {
            this.getter = noop;
            warn(
                "Failed watching path: \"" + expOrFn + "\" " +
                'Watcher only accepts simple dot-delimited paths. ' +
                'For full control, use a function instead.',
                vm
            );
        }
    }
    this.value = this.lazy ? undefined : this.get();
}
复制代码

执行 vm._watchers.push(this) 缓存 Watcher,vm._watchers 是一个 Watcher 的集合。

此时参数 options 有值为 {user : true},执行 if 里面的逻辑 this.user = !!options.user,故 Watcher 的实例对象 usertrue,这是 user Watcher 的标志。

参数 expOrFn 的含义是要监听的数据,也就要订阅的发布者,其值为函数时已经在渲染订阅者的介绍中讲过了,这里来介绍一下其值是字符串时,是怎么处理的。

当参数 expOrFn 是字符串时是代表数据路径,要用 parsePath 方法来解析获取数据,来看一下 parsePath 方法。

var bailRE = new RegExp(("[^" + (unicodeRegExp.source) + ".$_\\d]"));
function parsePath(path) {
    if (bailRE.test(path)) {
        return
    }
    var segments = path.split('.');
    return function(obj) {
        for (var i = 0; i < segments.length; i++) {
            if (!obj) {
                return
            }
            obj = obj[segments[i]];
        }
        return obj
    }
}
复制代码

例如参数 patha.b.c , 那么变量 segments[a,b,c],最后返回一个函数,赋值给 this.getter

最后执行 this.value = this.lazy ? undefined : this.get(),因在此场景中的 Watcher 构造函数的参数 options 里面没有 lazy 这个属性,所以 this.lazyfalse ,会执行 this.get(),在 get 实例方法中会执行value = this.getter.call(vm, vm)。在场景中 this.getter 是由 parsePath 函数生成的,其值如下所示

function(obj) {
    for (var i = 0; i < [a, b, c].length; i++) {
        if (!obj) {
            return
        }
        obj = obj[[a, b, c][i]];
    }
    return obj
}
复制代码

那么参数 objvm ,即 Vue 类的实例化对象 this。遍历 [a, b, c] 循环执行 obj = obj[[a, b, c][i]],步骤如下所示

  • obj = this.a
  • obj = this.a.b
  • obj = this.a.b.c

那么最后 obj 的值为 this.a.b.c并返回,又因在遍历中有去获取this.athis.a.bthis.a.b.c这些数据的值,会触发数据的描述符属性的 getter,在其中收集用户自定义的订阅者。其收集逻辑跟收集渲染订阅者的一样,就不重复介绍了。可以看到收集用户自定义订阅者的核心流程和收集渲染订阅者基本一样。

2、立即回调的实现

回到 vm.$watch 实例方法中,有以下这段逻辑。

if (options.immediate) {
    try {
        cb.call(vm, watcher.value);
    } catch (error) {
        handleError(error, vm, 
        ("callback for immediate watcher \"" + (watcher.expression) + "\""));
    }
}
复制代码

当参数 options中有属性 immediate 且值为 true,执行 cb.call(vm, watcher.value),这就是立即回调的实现逻辑。其中 watcher.value 就是通过 Watcher 的 get 实例方法求出来的要监听的数据的值。

3、取消监听功能的实现

回到 vm.$watch 实例方法中,有以下这段逻辑。

return function unwatchFn() {
    watcher.teardown();
}
复制代码

其中 watcher.teardown()是实现取消监听功能的关键,teardown 是 Watcher 的实例方法,来看一下这个方法。

Watcher.prototype.teardown = function teardown() {
    if (this.active) {
      if (!this.vm._isBeingDestroyed) {
          remove(this.vm._watchers, this);
      }
      var i = this.deps.length;
      while (i--) {
           this.deps[i].removeSub(this);
      }
      this.active = false;
    }
};
复制代码

取消监听功能,换句话来说就是移除该订阅者,故在 teardown 实例方法中做了两件事:

  • 从全局 Wacther 集合 this.vm._watchers 中移除该订阅者。

    执行 this.active ,若 this.active 的值为 true 代表这个订阅者未被移除。

    执行 if (!this.vm._isBeingDestroyed)vm._isBeingDestroyed是 Vue 实例是否被销毁的标志。为 true 表示实例被销毁。若为 false,执行 remove(this.vm._watchers, this),其中 this.vm._watchers是用 Vue 实例对象_watchers来保存当前 Vue 实例下有多少个订阅者的集合,remove函数作用是从数组中删除指定项。

    function remove(arr, item) {
        if (arr.length) {
            var index = arr.indexOf(item);
            if (index > -1) {
                return arr.splice(index, 1)
            }
        }
    }
    复制代码
  • 去该订阅者订阅的发布者中移除该订阅者。

    this.deps 是 Watcher 订阅者用来保存订阅了那些发布者的集合,遍历 this.deps 执行 this.deps[i].removeSub(this) 通知每个发布者调用 Dep 的实例方法 removeSub 移除该订阅者。

4、深度监听的实现

在 Watcher 的实例方法 get 中实现深度监听功能,其主要逻辑如下

if (this.deep) {
    traverse(value);
}
复制代码

若参数 options 的属性 deep 的值为 true ,那么 this.deep 的值为 true 则执行 traverse(value)traverse 函数是对一个对象做深层递归遍历,因为遍历过程中就是对一个子对象的访问,会触发子对象的 getter 函数,在其中收集订阅者,从而实现了深度监听,来看一下 traverse 函数。

var seenObjects = new Set();

function traverse(val) {
    _traverse(val, seenObjects);
    seenObjects.clear();
}

function _traverse(val, seen) {
    var i, keys;
    var isA = Array.isArray(val);
    if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
        return
    }
    if (val.__ob__) {
        var depId = val.__ob__.dep.id;
        if (seen.has(depId)) {
            return
        }
        seen.add(depId);
    }
    if (isA) {
        i = val.length;
        while (i--) {
            _traverse(val[i], seen);
        }
    } else {
        keys = Object.keys(val);
        i = keys.length;
        while (i--) {
            _traverse(val[keys[i]], seen);
        }
    }
}
复制代码

执行 if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode),若 val 不是数组且不是对象,或者 val 是个冻结的对象,或者 VNode 类实例化对象。符合以上几种情况,因为 val 此时已经没有子对象或者子对象不值得遍历下去来触发其子对象的收集订阅者,同时也通过获取 val 来触发自身收集订阅者。

执行 if (val.__ob__) , 判断 val 是否被监听过,若是,则执行 var depId = val.__ob__.dep.id,获取子对象的订阅者收集器 Dep 的标识 dep.id,那为什么能通过 val._ob_ 来获取到 dep.id,这是在监听数据时,会执行 observe(value),又在其中执行 new Observer(value),在其中执行 this.dep = new Dep(); def(value, '__ob__', this) 这样就把 dep 赋值到数据的 _ob_ 属性上,具体介绍可以看这篇专栏

执行 if (seen.has(depId)) 判断参数 seen 是否有 depId,有直接 return ,没有执行 seen.add(depId) 添加到参数 seen 。此逻辑是个优化,避免重复遍历子对象触发收集订阅者。参数 seen的值是通过 var seenObjects = new Set() 赋值,其值是个 Set 数据结构。

执行if (isA) 判断 val 是否是数组,若是数组,就循环数据,将数组中的每一项都递归调用_traverse(val[i], seen),同时也通过获取 val[i] 来触发自身收集订阅者。

若是对象,执行 keys = Object.keys(val) 获取 val 的键集合keys,然后循环 keys 中所有的key,递归调用 _traverse(val[keys[i]], seen),同时也通过获取 val[keys[i]] 来触发自身收集订阅者。

_traverse 递归调用完毕后,执行 seenObjects.clear(),清除在此过程中保存的 dep.id

三、computed Watcher 计算属性订阅者的收集

计算属性订阅者的收集和其它订阅者收集的流程不一样,另外计算属性还实现了缓存功能。

计算属性 computed 是在 Vue 实例初始化中的 initState 函数中,执行了 if (opts.computed) initComputed(vm, opts.computed) 来初始化的,来看一下 initComputed 函数。

var computedWatcherOptions = {
   lazy: true
};

function initComputed(vm, computed) {
    var watchers = vm._computedWatchers = Object.create(null);
    var isSSR = isServerRendering();

    for (var key in computed) {
        var userDef = computed[key];
        var getter = typeof userDef === 'function' ? userDef : userDef.get;
        if (getter == null) {
            warn(
                ("Getter is missing for computed property \"" + key + "\"."),
                vm
            );
        }
        if (!isSSR) {
            watchers[key] = new Watcher(vm, getter || noop, noop, computedWatcherOptions);
        }
        if (!(key in vm)) {
            defineComputed(vm, key, userDef);
        } else {
            if (key in vm.$data) {
                warn(("The computed property \"" + key + "\" is already defined in data."), vm);
            } else if (vm.$options.props && key in vm.$options.props) {
                warn(("The computed property \"" + key + "\" is already defined as a prop."), vm);
            }
        }
    }
}
复制代码

执行 var watchers = vm._computedWatchers = Object.create(null) 创建一个空对象 vm._computedWatchers 来存储计算属性 Watcher 。

遍历选项 computed ,将每个计算属性赋值给 userDef ,执行 var getter = typeof userDef === 'function' ? userDef : userDef.get;,因为在官网文档中介绍计算属性 computed 有两种用法

computed: {
    // 仅读取
    aDouble: function() {
        return this.a * 2
    },
    // 读取和设置
    aPlus: {
        get: function() {
            return this.a + 1
        },
        set: function(v) {
            this.a = v - 1
        }
    }
}
复制代码

所以先判断一下 userDef 是函数的话,直接赋值给 getter,若不是函数那就是对象把 userDef.get 赋值给 getter。这里的 getter 将作为 Watcher 构造函数的参数 expOrFn 的值,也称为计算属性的表达式。

执行 if (!isSSR) 在不是服务器渲染的场景下在执行 watchers[key] = new Watcher(vm, getter || noop, noop, computedWatcherOptions),创建计算属性的 Watcher 并添加到 vm._computedWatchers 中。这里要注意 Watcher 构造函数的参数 cb 是个空值 noop,参数 options 的值是 { lazy: true }。还是来看一下 Watcher 构造函数,先精简一下,只保留跟 computed Watcher 和 Dep 相关的实例对象。

var Watcher = function Watcher(vm, expOrFn, cb, options, isRenderWatcher) {
    this.vm = vm;
    vm._watchers.push(this);
    if (options) {
        this.lazy = !!options.lazy;
    }
    this.cb = cb;
    this.id = ++uid$2;
    this.active = true;
    this.dirty = this.lazy;
    this.deps = [];
    this.newDeps = [];
    this.depIds = new _Set();
    this.newDepIds = new _Set();
    this.expression = expOrFn.toString();
    if (typeof expOrFn === 'function') {
        this.getter = expOrFn;
    } else {
        this.getter = parsePath(expOrFn);
    }
    this.value = this.lazy ? undefined : this.get();
};
复制代码

来看一下最后一句代码 this.value = this.lazy ? undefined : this.get(),上面提到过参数 options 的值是 { lazy: true },故 this.lazy 的值 true,那么执行不到 this.get(),在收集渲染订阅者和用户自定义者的流程中,都是在 get 实例方法中触发数据的 getter 函数进行收集的。那么计算数据订阅者是在哪里收集?

回到 initComputed 函数中,执行 if (!(key in vm)) 判断计算属性的 key 值有没有在 Vue 中定义过。若没有,则执行 defineComputed(vm, key, userDef)。若有,判断计算属性的 key 是否已经被选项 data 或者选项 prop 的 key 所占用,如果是的话则在开发环境报相应的警告。下面来看一下 defineComputed 函数。

var sharedPropertyDefinition = {
    enumerable: true,
    configurable: true,
    get: noop,
    set: noop
};
function defineComputed(target, key, userDef) {
    var shouldCache = !isServerRendering();
    if (typeof userDef === 'function') {
        sharedPropertyDefinition.get = shouldCache ?
            createComputedGetter(key) :
            createGetterInvoker(userDef);
        sharedPropertyDefinition.set = noop;
    } else {
        sharedPropertyDefinition.get = userDef.get ?
            shouldCache && userDef.cache !== false ?
            createComputedGetter(key) :
            createGetterInvoker(userDef.get) :
            noop;
        sharedPropertyDefinition.set = userDef.set || noop;
    }
    if (sharedPropertyDefinition.set === noop) {
        sharedPropertyDefinition.set = function() {
            warn(
                ("Computed property \"" + key + "\" was assigned to but 
                it has no setter."),
                this
            );
        };
    }
    Object.defineProperty(target, key, sharedPropertyDefinition);
}
复制代码

defineComputed 函数是利用 Object.defineProperty 给计算属性添加 getter 和 setter,setter 通常是计算属性是一个对象时,并且拥有 set 方法的时候才有,否则是一个空函数。在平时的开发场景中,计算属性有 setter 的情况比较少,我们重点关注一下 getter 部分。

执行var shouldCache = !isServerRendering(); 判断是不是在服务端渲染,此场景显然不是,故 shouldCachefalse,若 userDef 是个函数,执行 createComputedGetter(key),若 userDef 是个对象,userDef.cache 是个废弃的选项,在 Vue2中默认为 true,故还是执行 createComputedGetter(key)。来看一下 createComputedGetter 函数。

function createComputedGetter(key) {
    return function computedGetter() {
        var watcher = this._computedWatchers && this._computedWatchers[key];
        if (watcher) {
            if (watcher.dirty) {
                watcher.evaluate();
            }
            if (Dep.target) {
                watcher.depend();
            }
            return watcher.value
        }
    }
}
复制代码

createComputedGetter 函数返回一个 getter 函数。每当使用计算属性时会触发这个 getter 函数。

执行 var watcher = this._computedWatchers && this._computedWatchers[key],其中 this._computedWatchers 时存储每个计算属性创建的 Wacther 的对象集合,其 key 就是计算属性。找到对应的 Wacther 赋值给常量 watcher

watcher 存在,执行 if (watcher.dirty)dirty 这个 Watcher 实例对象,是在 Watcher 构造函数中执行 this.dirty = this.lazy 赋值的。所以第一次执行 getter 函数时,watcher.dirtytrue,则执行 watcher.evaluate()。来看一下 evaluate 这个 Watcher 实例方法。

Watcher.prototype.evaluate = function evaluate() {
    this.value = this.get();
    this.dirty = false;
}
复制代码

执行 this.value = this.get(),调用 this.get(), 在 get 这个 Wacther 的实例方法中会执行计算属性的表达式,在执行中会访问表达式中的数据,就会触发数据的 getter 函数,开始收集计算属性订阅者,这里的收集流程就和渲染订阅者的收集流程一模一样。this.get() 执行完会返回一个值,就是计算属性的表达式计算结果赋值给 this.value,最后把 this.dirty 置为 false,当下次再使用计算属性时,由于 this.dirtyfalse,就不会执行 watcher.evaluate(),直接返回 watcher.value,这就是计算属性的缓存功能的实现逻辑了。当计算属性的表达式中的数据发生变化后,会通知计算属性 Watcher 把 this.dirty 置为 true,当再次使用计算属性时,会执行 watcher.evaluate(),重新执行计算属性的表达式,返回计算属性的新值,这个会在后续专栏详细介绍。

执行 if (Dep.target) { watcher.depend()}。因为在执行 this.get() 中计算属性订阅者收集完毕后,执行 popTarget() 把上一个订阅者(渲染订阅者)出栈,然后赋值给 Dep.target 。所以此时 Dep.target 是渲染订阅者。

来看一下 watcher.depend 实例方法

Watcher.prototype.depend = function depend() {
    var i = this.deps.length;
    while (i--) {
        this.deps[i].depend();
    }
};
复制代码

这里用一个实例来介绍一下,比如计算属性 a 是如下这样定义的。

<template>
    <div>{{a}}</div>
</template>
<script>
export default {
    data(){
        return{
            b:1,
            c:2
        }
    },
    computed:{
        a:function(){
            return  this.b + this.c
        }
    }
}
</script>
复制代码

那么此时渲染模板时读取到 a 这个计算属性时是如何收集渲染订阅者。是构成这个计算属性的值的 this.bthis.c 去收集这个渲染订阅者。这是因为计算属性 a 是由 this.bthis.c 发生变化引起的变化。

由于 this.bthis.c 先收集了计算属性订阅者,故在计算属性订阅者的 this.deps 包含者 this.bthis.c 的依赖收集器。遍历执行 this.deps[i].depend(),触发this.bthis.c 收集渲染订阅者。

四、后续

本文详细介绍了三种类型的订阅者的收集流程,下一篇专栏准备介绍发布者发生变化后,如何通知订阅者执行更新。另外 Watcher 还要很多实例对象和实例用法也将会在其中穿插介绍。尽情期待。谢谢。

文章分类
前端
文章标签