前言
本专栏是由一个问题引起,如果你已经知道答案了,可以忽略本专栏。
<!DOCTYPE html> <html> <head> <script src="https://www.geekschool.org/wp-content/uploads/2021/01/1611077816.3072076.jpg"></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 字符串的过程中会递归遍历对象,这样就会去读取对象的所有子属性,触发对象的每个子属性去收集渲染订阅者,这完美回答了开篇的问题。