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.jsv2.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 层的基础上新增一个 代理层,该代理层用于 感知所有的取值与赋值,以用于触发依赖收集和通知依赖更新消息。
-
原始
accessorraw <---> getter/setter -
修改后的
accessorraw <----> 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之间的异步回调函数的调用关系。
