computed/watch 的响应式链路
在遵循直觉的前提下,说起 "响应式" 这三个字,你会想起什么? 笔者首先的下意识反应是 观察者模式 和 发布订阅模式。正如常人直觉,当我们设计一个响应式系统时,不外乎是在设计:
-
类型 1. 从观察到触发观察的逻辑。
observer --observe--> target --notify--> all observers
-
类型 2. 从订阅到经过
event bus
或message broker
实现向订阅者发布topic
的逻辑。subscriber --subscribe--> topic --publish via message broker--> subscribers
在 Vue.js
中最令人津津乐道的响应式特性也与此多多少少有关联。本文将以解答问题的方式以最纯粹最简洁的方式回顾 vue.js
中关于响应式模块的功能设计。每提出一个问题,就在源码中寻找对应的解决方案,抛开无关逻辑,用最核心的逻辑回答问题。为什么笔者要结合 computed/watch
与 data
来阐述响应式设计?私以为这三者是一个整体,形成 observer --observe--> target --notify--> observer
的完整链路。只有合理高效地依赖收集,才会有有效的依赖通知。
注意:本文并不是一个覆盖所有细节的指南,而是一个着重于探究响应式设计的核心思维链路。
本文基于现有
Vue.js
v2.6.11 版本。且使用vm
表示Vue
实例。
如何感知状态修改
TL, DR: 通过 Object.defineProperty
重写 data[dataKey]
的 accessor
来实现 感知 data[dataKey]
的取值和赋值。
众所周知,在 vue.js v2
中,所有的组件状态由 data
选项维护,而组件派生状态由 computed
属性维护。当我们通过 Object.defineProperty
修改了原 data
对象的 assessor
时,就不难通过新的 assessor
感知任意 data[dataKey]
的取值和赋值,并且,我们可以让每个 data[dataKey]
都拥有一个 唯一且必须 的 subscriber
队列,该队列用于存储所有依赖当前 data[dataKey]
的 subscribers
:
-
在触发当前
data[dataKey]
的取值时,将subscriber
加入到当前data[dataKey]
的subscriber
队列中 -
在触发当前
data[dataKey]
的赋值时,将自身的subscribers
队列中的subscriber
逐一发布更新通知。
那么我们不难根据以上分析写出一个 publish/subscribe
模式下的发布触发器。
Object.defineProperty(vm.data, keyInDataObject, {
get() {
// 在此函数中触发依赖收集
},
set() {
// 在此函数中触发通知依赖更新
}
})
以上其实就是 vue.js v2
中最为核心的响应式系统的原理。那么在现实中,vue.js
又是如何在代码层面实现以上逻辑的呢?
初始化 data
通过合理利用断点调试而展示的 调用堆栈,不难得到,所有组件的 data
对象在 initData 函数中实现 accessor
修改。
function initData(vm: Component) {
let data = vm.$options.data
data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}
// 省略代码
const keys = Object.keys(data)
// 省略代码
while (i--) {
// 省略代码
if (props && hasOwn(props, key)) {
process.env.NODE_ENV !== 'production' &&
warn(
`The data property "${key}" is already declared as a prop. ` +
`Use prop default value instead.`,
vm
)
} else if (!isReserved(key)) {
proxy(vm, `_data`, key)
}
}
observe(data, true /* asRootData */)
}
在 initData 函数中,不难看出其职责:
-
为了避免使用同一个引用类型(而造成一处修改触发其他引用的地方错误取值),在复用组件中,所有的
$options.data
选项必须为函数类型。并将$options.data
函数返回的data
对象保存在vm._data
属性中。 -
迭代
$options.data
函数返回的data
对象:- 其每一个
vm[dataKey]
的值,都将通过重写vm[dataKey]
的accessor
实现被代理到vm._data[dataKey]
上。
- 其每一个
-
通过
observe
函数触发重写data[dataKey]
的accessor
的处理逻辑,并在accessor
预埋依赖收集
点和发布更新
点。
改写 accessor
/**
* Attempt to create an observer instance for a value,
* returns the new observer if successfully observed,
* or the existing observer if the value already has one.
*/
export function observe(value: any, asRootData: ?boolean): Observer | void {
if (!isObject(value) || value instanceof VNode) {
return
}
let ob: Observer | void
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value)
}
if (asRootData && ob) {
ob.vmCount++
}
return ob
}
正如注释所说,observe 函数本质上,就是一个 Observer
类的 实例化工厂函数。
而 Observer
类对应以下代码:
/**
* Observer class that is attached to each observed
* object. Once attached, the observer converts the target
* object's property keys into getter/setters that
* collect dependencies and dispatch updates.
*/
export class Observer {
value: any
dep: Dep
vmCount: number // number of vms that have this object as root $data
constructor(value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
this.walk(value)
}
}
}
从构造函数不难看出,其核心职责在于:
-
在原始
value
上挂载一个__ob__
属性,其值保持引用为一个 Observer 类的实例。 -
区分数组类型和其他引用类型数据;
-
若为数组时,由
protoAugment
或copyAugment
函数处理,之后由 observer.observeArray 函数统一处理。 -
其他引用类型时,由 observer.walk 方法处理。
-
在 observer.walk
方法中,迭代每一个 dataKey
由 defineReactive 函数来实现 assessor
的改写。
export class Observer {
/**
* Walk through all properties and convert them into
* getter/setters. This method should only be called when
* value type is Object.
*/
walk(obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
}
当迭代改写每一个 data[dataKey]
的 accessor
时,会因为第一轮的 defineReactive(obj, keys[i])
而 迭代观察 data[dataKey]
的后代的值(因为此时传参 shallow
为 undefined
值),当 data[dataKey]
的后代的值不为引用类型值时,会放弃观察。
export function defineReactive(
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// ...
let childOb = !shallow && observe(val)
}
最终,所有的 data[dataKey]
及其所有后代引用类型值都会经过 defineReactive 函数都被改写为以下的 accessor
:
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
const 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) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
那么结合最初本章节对于感知取值和赋值的 逻辑思考,不难理解上文中的 accessor
存在的核心目的:
通过改写原有引用类型值的 accessor
层,在原有 getter/setter
层的基础上新增一个 代理层,该代理层用于 感知所有的取值与赋值,以用于触发依赖收集和通知依赖更新消息。
-
原始
accessor
raw <---> getter/setter
-
修改后的
accessor
raw <----> collect/notify dep <----> getter/setter
如何在改写 accessor 后保存数据值
TL, DR: 通过调用 defineReactive 函数与 assessor
形成的闭包保存当前 data[dataKey]
的值。
在一般情况下,原始的 object
通过自身即可保持自身对应的键值,当我们借助 Object.defineProperty
改写 data[dataKey]
的 accessor
后,我们不得不面对 data[dataKey]
的值的保存问题?这些值应该存在哪?怎么存?
当我们面临这种存在强对应关系的数据存储场景时:
-
一种常见的处理方式是通过
key-value
的键值对的形式存储在额外变量中,如WeakMap
存储。Tips: 通过
WeakMap
存值是vue.js v3
的实现。 -
另一种另辟蹊径,不太遵循直觉的方式是通过 函数闭包 形成私有作用域来保存原始值。
形如:
tsfunction createPrivateValue(initialValue = 0) { let val = initialValue return { add() { return ++val }, current() { return val } } } const privateVal = createPrivateValue(0) privateVal.add() // 1 privateVal.current() // 1
在 Vue.js v2
中,因兼容性考虑,作者使用了第二种方式来解决 accessor
被改写后的键值的存储问题。
export function defineReactive(
obj: Object,
key: string,
val: any
// ...
) {
// ...
const property = Object.getOwnPropertyDescriptor(obj, key)
// ...
// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
// ...
get: function reactiveGetter() {
const value = getter ? getter.call(obj) : val
// ...
return value
},
set: function reactiveSetter(newVal) {
const value = getter ? getter.call(obj) : val
// ...
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
// ...
}
})
}
在 defineReactive
函数体中不难看出,所有的 data
对象的键的值,都由 reactiveGetter/reactiveSetter
与 defineReactive
形成的 闭包中的 val
变量 保存。
如何对数组进行响应式处理
TL,DR: 通过代理可变异数组的原型方法实现数组的响应式处理。
尽管数组同样可以通过 Object.defineProperty
实现响应式处理,但是作者在 issue #8562 提及因为性能以及收益比的原因放弃对 Array
类型使用 Object.defineProperty
函数处理。
在 initData
函数的调用栈中,在 observe 函数中区分了 引用类型值
和 基本类型值
,而在 Observer 类的构造函数中,区分了 数组类型值和非数组类型值 以及 原型对象环境 的监听处理。如下:
/**
* Augment a target Object or Array by intercepting
* the prototype chain using __proto__
*/
function protoAugment(target, src: Object) {
/* eslint-disable no-proto */
target.__proto__ = src
/* eslint-enable no-proto */
}
/**
* Augment a target Object or Array by defining
* hidden properties.
*/
/* istanbul ignore next */
function copyAugment(target: Object, src: Object, keys: Array<string>) {
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
def(target, key, src[key])
}
}
export class Observer {
value: any
dep: Dep
vmCount: number // number of vms that have this object as root $data
constructor(value: any) {
// ...
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
// ...
}
}
}
由代码显而易见,对于不同的原型环境有不同的响应式处理方式:
-
若当前
JS
运行时支持 非标准 但被广泛支持的 __proto__ 属性获取实例的隐式原型时,那么通过将数组的的隐式原型对象改为自定义的原型对象,再将自定义的原型对象的原型对象定义为数组构造函数的显式原型,即:原始原型链为:
array --__proto__--> Array.prototype
修改后的原型链为:
array --__proto__--> arrayMethodsPrototype --__proto__--> Array.prototype
实现数组响应式的核心在于 重写数组原型链,使得所有的可变异方法不再是原始的可变异方法,而是内含响应式处理的
arrayMethodsPrototype
对象上的可变异方法。 -
若当前
JS
运行时不支持__proto__
属性获取实例的隐式原型时,那么直接将可变异数组的自定义方法直接通过Object.defineProperty
的方法定义在 数组实例 上,并成为不可枚举属性。
如同非数组引用类型值改写 accessor
的思路一样,数组实现响应式的核心也是将所有对值的访问和赋值都代理到一个 统一的访问代理层 来处理响应式的问题。经过以上处理,只要数组被调用了可变异数组的方法,那么就会触发自定义的可变异方法,而不再是原始的 Array.prototype
对象上的可变异数组方法,那么我们就可以处理对应的数组变异问题,并触发响应式更新。
为什么 vm[newDataKey] 的方式不能触发响应式通知
TL,DR:因 JavaScript
限制,无法感知向一个对象新增属性的行为,那么就无法 主动改写 其新增属性的 accessor
实现响应式。
在 Vue.js v2
的 官方文档 中特别提到,对于 根状态对象 的新增,无法实现新增属性的响应式处理。其面临的 问题本质 是,在 JavaScript
中无法感知对象属性的新增和删除(原 Object.observe
函数已被废弃),本质上是 无法调和 新增属性时,无法探测或监听到新增 的属性(除设定了 trap methods
的 Proxy
实例外),且无法对新增属性的 accessor
进行响应式处理的矛盾。
在面临需要对一个已经被处理过的状态对象新增属性时,我们唯一能够实现该属性响应式的操作是 主动 修改其 assessor
为响应式 assessor
,如调用 Vue.set 函数。
computed 的本质
TL, DR: computed[computedKey]
的核心实现在于每一个 computed[computedKey]
都对应了一个唯一的 lazyWatcher
。其 lazyWatcher.value
值即为 computed[computedKey]
的值,并且 computed[computedKey]
的 accessor
会根据当前 lazyWatcher
的 dirty
属性决定是使用缓存值还是重新计算。
本文称实例化
Watcher
时,传入lazy: true
参数而返回的watcher
实例为lazyWatcher
。
在初始化 $options.computed
时,所有的 computed[computedKey]
都由 initComputed
函数处理。
function initComputed(vm: Component, computed: object) {
const watchers = (vm._computedWatchers = Object.create(null))
// ...
for (const key in computed) {
// ...
if (!isSSR) {
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
)
}
if (!(key in vm)) {
defineComputed(vm, key, userDef)
} else if (process.env.NODE_ENV !== 'production') {
// ...
}
}
}
由以上函数体可见,initComputed
的主要职责在于:
-
所有对应的
lazyWatcher
都存储在当前vue
实例的_computedWatchers
容器属性中,该属性主要用于将在computedKey
的accessor
中调用对应的lazyWatcher
获取其computed[computedKey]
的返回值。 -
对应的
computedKey
都有一个唯一的lazyWatcher
实例,该实例的主要职责在于 计算当前computedKey
的值。 -
通过
defineComputed
在vm
实例上定义vm[computedKey]
属性,并 定义其accessor
中包含 对取值的判断,是使用缓存值,还是重新计算值。
computed 如何追踪 data 依赖
得益于 stack
数据结构的特点,所有的上层栈都 直接依赖 于最近的下层栈,间接依赖 于祖先下层栈。computed[computedKey]
借助了上述 stack
数据结构来实现 computed[computedKey]
的取值。
-
在每次取值
computed[computeKey]
的过程中,首先会将对应的watcher
实例加入到一个名为targetStack
栈中,并且设置栈顶为Dep.target
变量,表示当前正在计算lazyWatcher
对应的computed[computedKey]
的值。 -
在这个过程中,若存在其他
data[dataKey]
取值时,那么在data[dataKey]
对应的reactiveGetter
中可从栈 targetStack 依赖了当前的data[dataKey]
,将Dep.target
对应的lazyWatcher
加入到data[dataKey].__ob__.dep.subs
容器中作为data[dataKey]
的 观察者。 -
当
data[dataKey]
发生赋值行为,即触发对应的reactiveSetter
时,会触发dep.notify
函数,进而触发subs[i].update
函数,进而触发lazyWatcher.dirty
标识切换为true
。因为
subs
中存储的是watcher
实例,故subs[i].update
对应watcher.update
方法。 -
在 3 的基础上,下一次再获取指定
computed[computedKey]
时,会再computedGetter
中检查lazyWatcher.dirty
,并因lazyWatcher.dirty
为true
时,调用lazyWatcher.evaluate
,并在其内部重新调用原始的用户定义的computed[computedKey]
函数。在得到结果后成为lazyWatcher.value
的新值,并作为computedGetter
的结果输出。
嵌套的 computed 的依赖追踪
注:据前文,
computed[computedKey]
对应唯一lazyWatcher
实例,renderWatcher
(即在view
中使用computed
或data
插值)同理具有以下原理。
TL, DR: 借助 computedGetter
函数体中对 Dep.target
的判断和每一个 computed[computedKeyA]
对应一个唯一的 lazyWatcherA
实例的原理,实现在 computedGetter
中触发被依赖的 lazyWatcherA.deps[i]
的所有与 data[dataKey]
相关的的 dep.depend()
依赖收集,进而触发 dep
收集当前 Dep.target
对应的 lazyWatcher
。
// https://github.com/vuejs/vue/blob/v2.6.11/src/core/instance/state.js#L241-L254
function createComputedGetter(key) {
return function computedGetter() {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
// 当此语句为 true 时,那么表示当前 this 对应的 computed[computedKey] 被其他
// computed[computedKey] 依赖了
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
}
}
首先,预设场景为 computed[computedKeyA]
依赖于 computed[computedKeyB]
。如下:
// in the SomeComponent.vue
export default {
computed: {
computedKeyA() {
return doSomeThing(this.computedKeyB)
},
computedKeyB() {
// do something you like
}
}
}
示例代码中,在初次获取 vm[computedKeyA]
的值时,经由 computedGetter
函数触发 lazyWatcherA.get
的求值时,会首先将 lazyWatcherA
成为 Dep.target
,表示当前正在获取 lazyWatchA
的值,在这个过程中会触发 vm[computedKeyB]
的求值,那么再次经由 lazyWatcherB.get
的求值时,在 computedGetter
中会触发 Dep.target
的检查:
function computedGetter() {
// ...
if (Dep.target) {
watcher.depend()
}
}
那么在返回 vm[computedKeyB]
之前会依次触发以下逻辑:
// https://github.com/vuejs/vue/blob/v2.6.11/src/core/observer/watcher.js#L215-L223
export default class Watcher {
// ... 省略无关代码
/**
* Depend on all deps collected by this watcher.
*/
depend() {
let i = this.deps.length
while (i--) {
this.deps[i].depend()
}
}
}
// https://github.com/vuejs/vue/blob/v2.6.11/src/core/observer/dep.js#L31-L35
/**
* A dep is an observable that can have multiple
* directives subscribing to it.
*/
export default class Dep {
// ...省略无关代码
depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
}
// https://github.com/vuejs/vue/blob/v2.6.11/src/core/observer/watcher.js#L125-L137
export default class Watcher {
/**
* Add a dependency to this directive.
*/
addDep(dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
}
// https://github.com/vuejs/vue/blob/v2.6.11/src/core/observer/dep.js#L23-L25
export default class Dep {
// ...
addSub(sub: Watcher) {
this.subs.push(sub)
}
}
根据以上调用路径不难发现,这与前文 computed 如何追踪 data 依赖 章节表述的追踪逻辑一致,那么我们不难得到以下结论:
在嵌套的 computed[computedKeyA]
中,被嵌套的 computed[computedKeyB]
会在 computed[computedKeyA]
的取值过程中 获取所有的 computed[computedKeyB]
的依赖,并与它们逐一通过 lazyWatcherA.deps
进行连接。本质上在嵌套的 computed[computedA]
中的依赖收集是一个 完整的依赖关系全量复制 的过程。
另外我们也不难发现,在 watcher
实例中,watcher.dep
的主要作用就是用于主动找到那些当前 watcher
订阅的依赖。
更深层次地思考,为什么要进行依赖关系的全量复制?
本质上是为了解决 computed
的值及时更新问题,若没有以上依赖关系复制,那么仅有当 computed[computedKeyB]
的值发生变化时,computed[computedKeyA]
才会重新计算。而 computed[computedKeyB]
的值是惰性的,那么即使 computed[computedKeyB]
的依赖发生变化,也仅有在取值时,才会重新计算,进而才会导致此时的 computed[computedKeyA]
在取值时重新计算。若此时 computed[computedKeyB]
没有被其他视图或其他 computed
依赖的话,那么 computed[computedKeyB]
永远都不会触发 computedGetter
中的 dirty
检查进而不会触发重新求值,进而 computed[computedKeyA]
始终都不会转变为 dirty
状态,进而永远不会重新计算,即使这时 computed[computedKeyB]
的依赖已经更新。
若有了依赖关系复制,即表示 computed[computedKeyA]
直接依赖于 computed[computedKeyB]
的 依赖。那么 computed[computedKeyA]
是否更新并不直接依赖于 computed[computedKeyB]
的取值,而是在 computed[computedKeyB]
的依赖发生变化时,就会主动在下一次 computed[computedKeyA]
的取值时,重新计算 computed[computedKeyA]
。
computed 如何实现缓存
TL,DR:在 initComputed
中实例化对应的 computedKey
的 watcher
实例时,传递额外的实例化选项 { lazy: true }
,在设置 lazy
为 true
时,那么仅当获取 computed[computedKey]
时的值时,才会触发 watcher
求值,并返回 watcher.value
作为 computed[computedKey]
的返回值。
核心在于通过 defineComputed
函数改写的 computed[computedKey]
中 accessor
的 getter
中对 watcher.dirty
的判断。在 watcher.dirty
为 false
时,那么永远返回当前 watcher.value
的值。反之,调用 watcher.evaluate
方法求值并返回。
有如下场景:当 data[dateKey]
触发 setter
函数时,那么会触发所有 data[dataKey].__ob__.dep.subs
中所有的 watcher.update
方法,该方法会将所有的 lazy watcher
(,即 computed[computedKey]
唯一对应的 watcher
实例)的 dirty
修改为 true
。那么在下一次 computed[computedKey]
取值时,就会首先触发 watcher.evaluate
求值,在求值结束后返回新的 wathcer.value
而不再是直接返回 watcher.value
。
-
初次调用
computedGetter --> lazyWatcher.dirty = lazyWatcher.lazy = true --> lazyWatcher.evaluate --> lazyWatcher.get --> lazyWatcher.value
-
返回缓存值
computedGetter --lazyWatcher.dirty: false--> lazyWatcher.value
-
依赖更新
reactiveSetter --> dep.notify --> subs[i].update --> lazyWatcher.dirty = true --> queueWatcher
注意:在依赖更新时,并不会立即重新计算
computed[computedKey]
的值,该值的重新计算 仅仅 依赖于在触发computedGetter
时,当前lazyWatcher.dirty
是否为true
。computedGetter -- lazyWatcher.dirty: true --> lazyWatcher.evaluate --> lazyWatcher.get, lazyWatcher.dirty = false
如何触发 computed 重新计算
TL,DR: 在触发 data[dataKey]
名为 reactiveSetter
的 accessor
时,会触发 dep.notify
方法,该方法对应会触发调用 data[dataKey].__ob__.dep.subs
中所有的订阅者,以实现通知当前它们依赖的数据已经更新。
正如前文 如何感知状态修改 的阐述,在初始化 data[dataKey]
时,会对每一个 data
中键值的 getter
进行以下 accessor
改造:
Object.defineProperty(target, key, {
// ...
set: function reactiveSetter(newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
当 data[dataKey]
被赋值时,即数据更新时,会触发 reactiveSetter
函数调用,在设定新值后,最后会调用 dep.notify 方法向所有的订阅者发送数据更新通知,如下:
export class Dep {
// ...
notify() {
// stabilize the subscriber list first
const subs = this.subs.slice()
if (process.env.NODE_ENV !== 'production' && !config.async) {
// subs aren't sorted in scheduler if not running async
// we need to sort them now to make sure they fire in correct
// order
subs.sort((a, b) => a.id - b.id)
}
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
其中 subs[i].update()
对应 watcher.update 方法:
export default class Watcher {
// ...
update() {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
}
前文已经说明 computed[computedKey]
本质上是通过 lazyWatcher
实现计算输出值,那么在调用以上方法后,对应的 lazyWatcher
的 watcher.dirty
属性由 false
转为 true
,表示当前 watcher.value
不再有效,在下一次获取 computed[computedKey]
的值,即调用内部的 computedGetter
时将触发重新计算。注意,此处的 computed[computedValue]
依然是 仅在 取值时会发生重新计算。
从源码角度看 computed 与 watch 的差异
TL,DR: 核心差异在于 computed[computedKey]
所对应的 lazyWatcher
是通过运行时追踪依赖,那么在运行时中可能存在 零或多个 依赖;而 watch[watchKey]
是通过 watchKey
的声明方式声明依赖,这里声明式是指在实例化 normalWatcher
时,始终返回一个 在运行时期间只依赖 于 vm[watchKey]
的 watcher
实例,即 normalWatcher
始终 仅有唯一 的依赖。
每一个 watch[watchKey]
都要通过 initWatch
实现初始化:
function initWatch(vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key]
if (Array.isArray(handler)) {
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i])
}
} else {
createWatcher(vm, key, handler)
}
}
}
function createWatcher(
vm: Component,
expOrFn: string | Function,
handler: any,
options?: Object
) {
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
if (typeof handler === 'string') {
handler = vm[handler]
}
return vm.$watch(expOrFn, handler, options)
}
initWatch
函数的职责在于:迭代 $options.watch
的键名,分别对其进行依赖处理,并创建 normalWatcher
。
watch
初始化本质上是调用了对外暴露的 API
—— vm.$watch 函数。而 vm.$watch
本质上是调用的原型方法 Vue.prototype.$watch
,如下:
export function stateMixin(Vue: Class<Component>) {
// ...
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {}
options.user = true
const 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()
}
}
}
由上我们可以对比 lazyWatcher
实例化的差异:
-
在 computed 的本质 章节中
lazyWatcher
实例化:tswatchers[key] = new Watcher(vm, getter || noop, noop, computedWatcherOptions)
-
$options.watch[watchKey]
实例化:tsconst watcher = new Watcher(vm, expOrFn, cb, options)
从实例化传参,我们发现:
-
lazyWatcher
是没有cb
回调函数的。lazyWatcher
的cb
回调函数为空函数noop
。因为结合前文对所有watcher
的更新方式阐述,其更新是依赖于watcher.update
,而当watcher
为lazy
类型时,那么永远不会触发watcher.run
函数,就更加不会触发watcher.cb
函数,那么lazyWatcher
是没有必要有cb
函数的。 -
lazyWatcher
的lazyWatcher.getter
方法对应的类型为函数类型即getter || noop
。而watch[watchKey]
的normalWatcher.getter
传入时为字符串。该字符串在实例化normalWatcher
时会经历以下流程创建一个针对于当前vm
实例的 取值函数:tsexport default class Watcher { // ... constructor(/* omit parameters */) { // ... // parse expression for getter if (typeof expOrFn === 'function') { this.getter = expOrFn } else { this.getter = parsePath(expOrFn) if (!this.getter) { this.getter = noop process.env.NODE_ENV !== 'production' && warn( `Failed watching path: "${expOrFn}" ` + 'Watcher only accepts simple dot-delimited paths. ' + 'For full control, use a function instead.', vm ) } } }
所有的
watch[watchKey]
由 parsePath 函数转化并处理vm
属性的监听。ts/** * Parse simple path. */ const bailRE = new RegExp(`[^${unicodeLetters}.$_\\d]`) export function parsePath(path: string): any { if (bailRE.test(path)) { return } const segments = path.split('.') // 返回一个 vm[watchKey] 取值函数 return function (obj) { for (let i = 0; i < segments.length; i++) { if (!obj) return obj = obj[segments[i]] } return obj } }
parsePath
本质上是一个 高阶工具函数,用于迭代递归处理传入的对象路径取值。那么watch[watchKey]
对应的normalWatcher.getter
同样是一个取值函数,该函数同样仅接受vm
实例作为参数。最终实现了watchKey
所对应的normalWatcher
实例仅仅依赖于vm[watchKey]
的值。注意,当
watchKey
为包含“.”
分隔符的字符串时,如dataKey.subDataKey.nestedDataKey
,将通过parsePath
分步 取值,而不是一次性直接获取vm[watchKey]
。这里是因为直接取值是取的名为dataKey.subDataKey.nestedDataKey
的键名的值,而不是取得vm
下dataKey
属性下的subDataKey
下的nestedDataKey
的值。 -
由前文的分析可知,
computed[computedKey]
和watch[watchKey]
在核心实现上都依赖于Watcher
实例。computed[computedKey]
对应的是lazyWatcher
,而watch[watchKey]
对应的是normalWatcher
。它们在实例化上的区别对应了computed[computedKey]
函数和watch[watchKey]
对应的回调函数的区别。在实例化时,
lazyWatcher
为了实现懒计算,那么永远不会主动调用lazyWatcher.getter
,直到等到获取computed[computedKey]
的新值时,才会触发lazyWatcher
的计算(细节见前文 computed 如何实现缓存 章节)。与之相反的normalWatcher
会在实例化时,通过normalWatcher.get
方法,首次调用normalWatcher.getter
函数。这里的首次调用同样是为了订阅对应的依赖,将当前的normalWatcher
实例加入到对应依赖的subs
订阅者队列中。
watcher 如何触发更新
TL,DR:如同 computed[computedKey]
的依赖追踪核心一样。在触发 watcher.getter
时,会将当前 watcher
实例加入到前文已经分析过的 targetStack
中,不同于 computed[computedKey]
的是,此时的 watcher
永远只会拥有 唯一一个 vm[depKey]
依赖。
在初始化 normalWatcher
时,会根据 watch[watchKey]
的类型赋值 normalWatcher.getter
函数。在调用 normalWatcher.getter
期间,同样会加入到前文已经着重分析过的 targetStack
栈中,表示当前正在追踪并订阅当前 normalWatcher
的唯一依赖。在唯一的依赖触发更新时,将触发订阅了该依赖的 normalWatcher
的 run
函数,进而触发传入的 normalWatcher.cb
回调函数。
watcher in stack
+
|
subscribe
|
v
data[dataKey]
+
|
notify
|
v
dep.notify
+
|
v
dep.subs[i].update
+
|
v
watcher.run
+
|
v
watcher.cb
回顾
Vue.js v2
响应式模块中的精妙之处之一,以及核心原理是引入了一个 访问代理层 来实现所有数据的取值和赋值的感知行为,借助观察者模式的变体实现通知对应数据依赖的观察者实现响应式行为。
-
借助
accessor
实现依赖收集触发和依赖更新触发; -
借助
stack
结构实现订阅追踪数据依赖; -
借助特定标识
dirty
来解决数据缓存的问题,在满足特定条件下,始终使用缓存值以实现节约计算力的目的; -
在基于依赖收集和更新通知的前提下,实现观察者模式中的观察者
observer
和主题subject
之间的异步回调函数的调用关系。