前言
本专栏是由一个问题引起,如果你已经知道答案了,可以忽略本专栏。
<!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.target
在 pushTarget(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 字符串的过程中会递归遍历对象,这样就会去读取对象的所有子属性,触发对象的每个子属性去收集渲染订阅者,这完美回答了开篇的问题。