🚩Vue源码——如何深度收集渲染订阅者

1,321 阅读5分钟

前言

本专栏是由一个问题引起,如果你已经知道答案了,可以忽略本专栏。

<!DOCTYPE html>
<html>
     <head>
         <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
     </head>
     <body>
         <div id="app">
            <div @click="change">{{a}}</div>
         </div>
     </body>
     <script>
          var app = new Vue({
            el: '#app',
            data: {
                a: {
                    b: 1,
                    c: {
                        d: 1,
                    }
                }
            },
            methods:{
                change(){
                    this.a.c.d = 2;
                }
            }
          })
     </script>
</html>

页面展示效果

点击展示区域会发现页面展示变为

为什么执行 this.a.c.d = 2 后页面会刷新成如上图所示。或许你从这篇专栏中得知。在 Vue 挂载过程中,数据 this.a 收集了渲染订阅者。当执行 this.a.c.d = 2 后,数据 this.a 发生了变化,就会去通知渲染订阅者,渲染订阅者开始响应,最后 DOM 更新。

那么问题来了,真的只有数据 this.a 收集了渲染订阅者,当执行 this.a.c.d = 2 后,就会通知渲染订阅者。当你深入研究这些问题时,会发现某些流程走不通。本专栏将一一来阐述。

一、回顾收集渲染订阅者的流程

当读取数据时,会触发数据的 getter 函数,在其中执行以下代码收集订阅者:

if (Dep.target) {
    dep.depend();
    if (childOb) {
        childOb.dep.depend();
        if (Array.isArray(value)) {
            dependArray(value);
        }
    }
}

其中执行 dep.depend()childOb.dep.depend()dependArray(value) 这些代码都会触发数据(发布者)收集订阅者。但是执行这些代码有个先决条件 Dep.target,其是个全局对象,存储当前要收集的订阅者,还可以确保收集时只有一个订阅者被收集。那 Dep.target 在哪里被赋值,可以去这篇专栏中寻找答案。

在 Vue 的挂载过程中会实例化一个 Watcher 类,在 Watcher 构造函数中会执行 Watcher 的实例方法 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
}

其中 Dep.targetpushTarget(this) 定义,来看一下 pushTarget 函数。

Dep.target = null;
var targetStack = [];
function pushTarget(target) {
    targetStack.push(target);
    Dep.target = target;
}

也就是说在实例化 Watcher 类过程中 Dep.target 会被赋值,且其值是 Watcher 实例化对象,又因为它是在 Vue 的挂载中被实例化的,我们称它为渲染订阅者。

此时if (Dep.target) 是满足了,那现在只要数据被读取,就会执行 dep.depend() 来触发数据来收集这个渲染订阅者。

那么在渲染过程中去哪里读取数据 this.a ,还是在 get 实例方法中执行 this.getter.call(vm, vm) 时获取的,那么 this.getter 是什么呢?要去 Watcher 构造函数中去寻找。

var Watcher = function Watcher(vm, expOrFn, cb, options, isRenderWatcher) {
    if (typeof expOrFn === 'function') {
        this.getter = expOrFn;
    }
    this.value = this.lazy ? undefined : this.get();
}

可以看到当 Watcher 的构造函数的参数 expOrFn 是个函数时,this.getter 就是 expOrFn。那么要看一下,在 Vue 挂载过程中怎么实例化 Watcher。

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 */ );。

可以得知执行 this.getter.call(vm, vm) 就是执行 vm._update(vm._render(), hydrating),从这篇专栏中可以得知执行 vm._render() 主要作用是执行 vnode = render.call(vm._renderProxy, vm.$createElement) 生成 vnode,其中 render 是由模板编译成的渲染函数。

(function anonymous() {
    with(this){
    	return _c('div',
                  {attrs:{"id":"app"}},
                  [
                      _c('div',
                          {on:{"click":change}},
                          [
                              _v(_s(a))
                          ]
                        )
                  ]
               )
     }
})

with 语句的作用是为一个或一组语句指定默认对象,例 with(this){ a + b } 等同 this.a + this.b

那么执行 render 函数时,首先会执行 _v(_s(a)),至于为什么可以看这篇专栏

执行 _v(_s(a)),相当执行 this._v(this._s(this.a)),在执行中会读取 this.a ,触发 this.a 的 getter 函数,在里面执行 dep.depend() 收集渲染订阅者。

那么真正的问题来了。this.a 收集了渲染订阅者,在执行 this.a.c.d = 2 后,真的会去通知渲染订阅者吗?

二、回顾如何通知订阅者

当改变数据时,会触发数据的 setter 函数,在其中执行 dep.notify() 来通知订阅者,至于具体逻辑感兴趣可以看这篇专栏

此时可以得出一个结论,只有触发了数据的 setter 函数,才能通知订阅者。

那么用以下代码模拟一下变量 data 中的 a 属性变成响应式后,然后执行 data.a.c.d = 2,会不会触发 a 属性的 setter 函数。

let data = {
    a: {
        b: 1,
        c: {
            d: 1,
        }
    }

};
let val = data.a;
Object.defineProperty(data, 'a', {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
        console.log('get')
        return val
    },
    set: function reactiveSetter(newVal) {
        console.log('set')
    }
});
data.a.c.d = 2;

结果会发现,并不会执行 console.log('set') ,控制台并没有打印出 set 。这下问题大了,回到到最初的问题中,只有数据 this.a 收集了渲染订阅者,当执行 this.a.c.d = 2 后,不会通知渲染订阅者。

是不是要数据 this.a.c.d 收集了渲染订阅者,当执行 this.a.c.d = 2 后,才会通知渲染订阅者。可以先模拟一下。

let data = {
    a: {
        b: 1,
        c: {
            d: 1,
        }
    }

};
let val = data.a.c.d;
Object.defineProperty(data.a.c, 'd', {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
        console.log('get')
        return val
    },
    set: function reactiveSetter(newVal) {
        console.log('set')
    }
});
data.a.c.d = 2;

结果会发现,会执行 console.log('set') ,控制台也打印出 set 。说明要数据 this.a.c.d 收集了渲染订阅者,当执行 this.a.c.d = 2 后,才会通知渲染订阅者。

那么新问题又来了,在 Vue 中只有当数据被读取时,且 Dep.target 存在时,数据才会去收集订阅者。那么在哪里读取了数据 this.a.c.d

change 方法中,是有读取到 this.a.c.d,但是调用 change 方法时, Dep.target 为 undefined,不存在,故此时数据是不会去收集订阅者的。

那是在哪里呢?我们要回到读取数据 this.a 中去寻找答案,也就是在执行 render 函数中,执行 _v(_s(a)),也就是执行 this._v(this._s(this.a)),在 this._s(this.a) 中读取 this.a.c.d,更准确来说是在 this._s 方法中读取。

this._s 是在执行 renderMixin(Vue) 中执行 installRenderHelpers(Vue.prototype) 定义的,来看一下 installRenderHelpers 函数。

function installRenderHelpers (target) {
    target._o = markOnce;
    target._n = toNumber;
    target._s = toString;
    target._l = renderList;
    target._t = renderSlot;
    target._q = looseEqual;
    target._i = looseIndexOf;
    target._m = renderStatic;
    target._f = resolveFilter;
    target._k = checkKeyCodes;
    target._b = bindObjectProps;
    target._v = createTextVNode;
    target._e = createEmptyVNode;
    target._u = resolveScopedSlots;
    target._g = bindObjectListeners;
    target._d = bindDynamicKeys;
    target._p = prependModifier;
}

可以得知 this._s 就是 toString 函数,来看一下 toString 函数。

function toString(val) {
    return val == null ? '' :
        Array.isArray(val) || (isPlainObject(val) && val.toString === _toString) ?
        JSON.stringify(val, null, 2) :
        String(val)
}

因为 this.a 是对象,故在 toString 函数中执行 JSON.stringify(val, null, 2)。越来越接近真相了。

三、在JSON.stringify中深度收集渲染订阅者

这里要去实现 JSON.stringify 的实现原理中去寻找答案。这里大概模拟 JSON.stringify 把对象转成 JSON 字符串的过程,代码如下所示:

function stringify(data) {
    let result = '';
    let part = '';
    if (data === null) {
        return String(data);
    }
    switch (typeof data) {
        case 'number':
            return String(data);
    }

    switch (Object.prototype.toString.call(data)) {
        case '[object Object]':
            result += '{';
            for (let key in data) {
                part = stringify(data[key]);
                if (part !== undefined) {
                    result += '"' + key + '":' + part + ',';
                }
            }
            if (result !== '{') {
                result = result.slice(0, -1);
            }
            result += '}';
            return result;
    }
}

可得知在把对象转成 JSON 字符串的过程中会递归遍历对象,这样就会去读取对象的所有子属性,触发对象的每个子属性去收集渲染订阅者,这完美回答了开篇的问题。