在上一篇文章Vue 3.x 响应式原理——ref源码分析中,笔者简述了Vue 3.x 的 ref
API 的实现原理,本文是响应式原理核心部分之一,effect
模块用于描述 Vue 3.x 存储响应,追踪变化,这篇文章从effect
模块的track
和trigger
开始,探索在创建响应式对象时,立即触发其getter
一次,会使用track
收集到其依赖,在响应式对象变更时,立即触发trigger
,更新该响应式对象的依赖。
阅读此文之前,如果对以下知识点不够了解,可以先了解以下知识点:
笔者之前也写过相关文章,也可以结合相关文章:
- 你可能忽视的ES6语法——反射和代理
- Vue 3.0 最新进展,Composition API
- Vue 3.0 前瞻,体验 Vue Function API
- Vue Composition API 响应式包装对象原理
- Vue 3.x 响应式原理——reactive源码分析
- Vue 3.x 响应式原理——ref源码分析
从track开始
track
是收集依赖的函数,怎么理解呢,例如我们使用计算属性computed
时,其依赖的属性更新会引起计算属性被重新计算,就是靠得这个track
。在reactive
模块时,我们就看到了响应式对象的getter
都会在内部调用这个track
:
function createGetter(isReadonly: boolean) {
return function get(target: object, key: string | symbol, receiver: object) {
// 通过Reflect拿到原始的get行为
const res = Reflect.get(target, key, receiver)
// 如果是内置方法,不需要另外进行代理
if (isSymbol(key) && builtInSymbols.has(key)) {
return res
}
// 如果是ref对象,代理到ref.value
if (isRef(res)) {
return res.value
}
// track用于收集依赖
track(target, OperationTypes.GET, key)
// 判断是嵌套对象,如果是嵌套对象,需要另外处理
// 如果是基本类型,直接返回代理到的值
return isObject(res)
// 这里createGetter是创建响应式对象的,传入的isReadonly是false
// 如果是嵌套对象的情况,通过递归调用reactive拿到结果
? isReadonly
? // need to lazy access readonly and reactive here to avoid
// circular dependency
readonly(res)
: reactive(res)
: res
}
}
在阅读reactive
模块的代码时我们就带有这样的疑问:怎么理解这里的track
调用呢?笔者之前有看过 Vue 1.x 的响应式源码的部分,这里猜想应该是和 Vue 1.x 差不多的,相关文章可见Vue源码学习笔记之Dep和Watcher。
我们假设,在初始化响应式对象时,就会调用其getter
一次,在getter
调用前,我们初始化一个结构,假设叫dep
,在初始化这个响应式对象,即其getter
调用过程中,如果对其它响应式对象进行取值,则会触发了其它响应式对象的getter
方法,在其它响应式对象的getter
方法中,调用了track
方法,track
方法会把被依赖的响应式对象及其相关特征属性存入其对应的dep
中,这样在被依赖者更新时,这次初始化的响应式对象会重新调用getter
,触发重新计算。
现在,我们开始来看track
,并从中印证我们的猜想:
// 全局开关,默认打开track,如果关闭track,则会导致 Vue 内部停止对变化进行追踪
let shouldTrack = true
export function pauseTracking() {
shouldTrack = false
}
export function resumeTracking() {
shouldTrack = true
}
export function track(target: object, type: OperationTypes, key?: unknown) {
// 全局开关关闭或effectStack为空,无需收集依赖
if (!shouldTrack || effectStack.length === 0) {
return
}
// 从effectStack取出一个叫做effect的变量,这里先猜想:effect用于描述当前响应式对象
const effect = effectStack[effectStack.length - 1]
// 如果当前操作是遍历,标记为遍历
if (type === OperationTypes.ITERATE) {
key = ITERATE_KEY
}
// targetMap是在创建响应式对象时初始化的,target是响应式对象,targetMap映射到一个空map,这个map指的就是depsMap
// 所以可以看出来,targetMap两层map,第一层从响应式对象映射到depsMap,第二层才是depsMap,通过后面的代码我们知道depsMap是相关操作:SET,ADD,DELETE,CLEAR,GET,HAS,ITERATE到一个Set的映射,Set里存放的是对应的effect
// 如果depsMap为空,这时候在targetMap里面初始化一个空的Map
let depsMap = targetMap.get(target)
if (depsMap === void 0) {
targetMap.set(target, (depsMap = new Map()))
}
// 通过key拿到dep这个Set
let dep = depsMap.get(key!)
// 如果dep为空,初始化dep为一个Set
if (dep === void 0) {
depsMap.set(key!, (dep = new Set()))
}
// 开始收集依赖:将effect放入dep,并且更新effect里的deps属性,将dep也放到effect.deps里,用于描述当前响应式对象的依赖
if (!dep.has(effect)) {
dep.add(effect)
effect.deps.push(dep)
// 开发环境下,触发相应的钩子函数
if (__DEV__ && effect.options.onTrack) {
effect.options.onTrack({
effect,
target,
type,
key
})
}
}
}
通过上面的代码,基本印证了刚刚我们的猜想,不过有几个地方我们可能有点似懂非懂:
effectStack
是一个什么结构,为什么从effectStack
栈顶部effectStack[effectStack.length - 1]
取到的就恰好是用于描述当前需要收集依赖的响应式对象的effect
?effect
的结构又是怎样的,是在哪里被初始化的?- 收集到的依赖
deps
,又是怎么在对应的响应式对象更新时,对应更新具有依赖的响应式对象的?
下面在针对上述三点可能的疑问,回到effect
模块的源码来寻找答案:
看effect的结构
首先来看effectStack
和effect
的结构:
export interface ReactiveEffect<T = any> {
(): T // ReactiveEffect是一个函数类型,其参数列表为空,返回值类型为T
_isEffect: true // 标识为effect
active: boolean // active是effect激活的开关,打开会收集依赖,关闭会导致收集依赖无效
raw: () => T // 原始监听函数
deps: Array<Dep> // 存储依赖的deps
options: ReactiveEffectOptions // 相关选项
}
export interface ReactiveEffectOptions {
lazy?: boolean // 延迟计算的标识
computed?: boolean // 是否是computed依赖的监听函数
scheduler?: (run: Function) => void // 自定义的依赖收集函数,一般用于外部引入@vue/reactivity时使用
onTrack?: (event: DebuggerEvent) => void // 本地调试时使用的相关钩子函数
onTrigger?: (event: DebuggerEvent) => void // 本地调试时使用的相关钩子函数
onStop?: () => void // 本地调试时使用的相关钩子函数
}
// 判断一个函数是否是effect,直接判断_isEffect即可
export function isEffect(fn: any): fn is ReactiveEffect {
return fn != null && fn._isEffect === true
}
通过上面的代码可以知道,effect
是一个函数,其下挂载了一些属性,用于描述其依赖和状态。其中raw
是保存其原始监听函数,这里我们可以猜想effect
既然也是函数类型,那么其调用时,除了调用原始函数raw
之外,还会进行依赖收集,下面来看effect
的代码:
// effectStack是用于存放所有effect的数组
export const effectStack: ReactiveEffect[] = []
export function effect<T = any>(
fn: () => T,
options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
// fn已经是一个effect函数了,利用fn.raw重新创建effect
if (isEffect(fn)) {
fn = fn.raw
}
// 创建监听函数
const effect = createReactiveEffect(fn, options)
// 如果不是延迟执行,立刻调用一次effect来进行收集依赖
if (!options.lazy) {
effect()
}
return effect
}
// 停止收集依赖的函数
export function stop(effect: ReactiveEffect) {
// 当前effect是active的
if (effect.active) {
// 清除effect的所有依赖
cleanup(effect)
// 如果有onStop钩子,调用钩子函数
if (effect.options.onStop) {
effect.options.onStop()
}
// active标记为false,标记这个effect已经停止收集依赖了
effect.active = false
}
}
// 创建effect
function createReactiveEffect<T = any>(
fn: () => T,
options: ReactiveEffectOptions
): ReactiveEffect<T> {
// effect其实就是调用run,在下面可以看到run就是收集依赖的过程
const effect = function reactiveEffect(...args: unknown[]): unknown {
return run(effect, fn, args)
} as ReactiveEffect
// 初始化时,初始化effect的各项属性
effect._isEffect = true
effect.active = true
effect.raw = fn
effect.deps = []
effect.options = options
return effect
}
// 开始收集依赖
function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown {
// 当active标记为false,直接调用原始监听函数
if (!effect.active) {
return fn(...args)
}
// 当前effect不在effectStack中,就开始收集依赖
if (!effectStack.includes(effect)) {
// 收集依赖前,先清理一次effect的依赖
// 这里先清理的一次的目的是重新对同一个属性创建新的监听时,要先把原始的监听的依赖清空
cleanup(effect)
try {
// effect放入effectStack中
effectStack.push(effect)
// 调用原始函数,在这里调用原始函数时,如果原始函数里面对响应式对象进行取值了,会触发这个响应式对象的getter,在其getter中调用了track,就收集到依赖了
return fn(...args)
} finally {
// 调用完成后,出栈
effectStack.pop()
}
}
}
// 清理依赖的方法,遍历deps,并清空
function cleanup(effect: ReactiveEffect) {
const { deps } = effect
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect)
}
deps.length = 0
}
}
上面的代码基本很好理解,在创建监听时就会调用一次effect
,只要effect
是active
的,就会触发依赖收集。依赖收集的核心是在这里调用原始监听函数时,如果原始函数里面对响应式对象进行取值了,会触发这个响应式对象的getter
,在其getter
中调用了track
。
结合上面的代码,再理解track
:
- 在
track
时,effectStack
栈顶就是当前的effect
,因为在调用原始监听函数前,执行了effectStack.push(effect)
,在调用完成最后,会执行effectStack.pop()
出栈。 effect.active
为false
时会导致effectStack.length === 0
,这时不用收集依赖,在track
函数调用开始时就做了此判断。- 判断
effectStack.includes(effect)
的目的是避免出现循环依赖:设想一下以下监听函数,在监听时,出现了递归调用原始监听函数修改依赖数据的情况,如果不判断effectStack.includes(effect)
,effectStack
又会把相同的effect
放入栈中,增加effectStack.includes(effect)
避免了此类情况。
const counter = reactive({ num: 0 });
const numSpy = () => {
counter.num++;
if (counter.num < 10) {
numSpy();
}
}
effect(numSpy);
trigger
通过上面对effect
和track
的解析,我们已经基本清楚了依赖收集的过程了,对于整个effect
模块的理解,就只差trigger
。既然track
用于收集依赖,我们很容易知道trigger
是响应式数据改变后,通知依赖其的响应式数据改变的方法,通过阅读trigger
即可回答上面的问题:收集到的依赖deps
,又是怎么在其依赖更新时,对应更新具有依赖的响应式对象的?
下面来看trigger
:
export function trigger(
target: object,
type: OperationTypes,
key?: unknown,
extraInfo?: DebuggerEventExtraInfo
) {
// 通过原始对象,映射到对应的依赖depsMap
const depsMap = targetMap.get(target)
// 如果这个对象没有依赖,直接返回。不触发更新
if (depsMap === void 0) {
// never been tracked
return
}
// effects集合
const effects = new Set<ReactiveEffect>()
// 用于comptuted的effects集合
const computedRunners = new Set<ReactiveEffect>()
// 如果是清除整个集合的数据,那就是集合每一项都会发生变化,调用addRunners将需要更新的依赖加入执行队列里面
if (type === OperationTypes.CLEAR) {
// collection being cleared, trigger all effects for target
depsMap.forEach(dep => {
addRunners(effects, computedRunners, dep)
})
} else {
// SET | ADD | DELETE三种操作都是对于响应式对象某一个属性而言的,只需要通知依赖这一个属性的状态更新
// schedule runs for SET | ADD | DELETE
if (key !== void 0) {
addRunners(effects, computedRunners, depsMap.get(key))
}
// 此外,对于添加和删除,还有对依赖响应式对象的迭代标识符的数据进行更新
// also run for iteration key on ADD | DELETE
if (type === OperationTypes.ADD || type === OperationTypes.DELETE) {
// 数组是length,对象是ITERATE_KEY
// 为什么这里要对length单独处理?原因是在对数组、Set等调用push/pop/delete/add等方法时,不会触发对应数组下标的set,而是通过劫持length和ITERATE_KEY的改变来实现的
// 所以这里要把length或者ITERATE_KEY的依赖更新,这样就可以保证在调用push/pop/delete/add等方法时,也会通知依赖响应式数据的状态更新了
const iterationKey = isArray(target) ? 'length' : ITERATE_KEY
// 依赖响应式对象的迭代标识符的数据进行更新
addRunners(effects, computedRunners, depsMap.get(iterationKey))
}
}
const run = (effect: ReactiveEffect) => {
scheduleRun(effect, target, type, key, extraInfo)
}
// Important: computed effects must be run first so that computed getters
// can be invalidated before any normal effects that depend on them are run.
// 进行更新
// 计算属性的effect必须先执行,因为正常的响应式属性可能会依赖于计算属性的数据
computedRunners.forEach(run)
// 再执行正常监听函数
effects.forEach(run)
}
// 将effect添加到执行队列中
function addRunners(
effects: Set<ReactiveEffect>,
computedRunners: Set<ReactiveEffect>,
effectsToAdd: Set<ReactiveEffect> | undefined
) {
// effectsToAdd是所有的依赖
if (effectsToAdd !== void 0) {
// 将一个effect的依赖都放入执行队列
effectsToAdd.forEach(effect => {
// 对computed的对象单独处理,computed是分开的队列
if (effect.options.computed) {
computedRunners.add(effect)
} else {
effects.add(effect)
}
})
}
}
// 触发所有依赖更新
function scheduleRun(
effect: ReactiveEffect,
target: object,
type: OperationTypes,
key: unknown,
extraInfo?: DebuggerEventExtraInfo
) {
// 开发环境,触发对应钩子函数
if (__DEV__ && effect.options.onTrigger) {
const event: DebuggerEvent = {
effect,
target,
key,
type
}
effect.options.onTrigger(extraInfo ? extend(event, extraInfo) : event)
}
// 调用effect,即监听函数,进行更新
if (effect.options.scheduler !== void 0) {
effect.options.scheduler(effect)
} else {
effect()
}
}
上面的代码根据注释也很好理解,trigger
就是当响应式属性更新时,通知其依赖的数据进行更新。在trigger
内部会维护两个队列effects
和computedRunners
,分别是普通属性和计算属性的依赖更新队列,在trigger
调用时,Vue 会找到更新属性对应的依赖,然后将需要更新的effect
放到执行队列里面,执行队列是Set
类型,可以很好地保证同一个effect
不会被重复调用。在完成了依赖查找之后,对effects
和computedRunners
进行遍历,调用scheduleRun
进行更新。
小结
本文讲述了effect
模块的原理,通过track
入手,了解到effect
的结构,知道effect
内部有一个deps
的属性,这个属性是一个数组,用来存储监听函数的依赖。在响应式对象初始化时,getter
调用,会调用track
收集依赖,在对其属性进行更改、删除、增加时,会调用trigger
来更新依赖,完成了数据通知和响应。