Open xingbofeng opened 4 years ago
在上一篇文章Vue 3.x 响应式原理——ref源码分析中,笔者简述了Vue 3.x 的 ref API 的实现原理,本文是响应式原理核心部分之一,effect模块用于描述 Vue 3.x 存储响应,追踪变化,这篇文章从effect模块的track和trigger开始,探索在创建响应式对象时,立即触发其getter一次,会使用track收集到其依赖,在响应式对象变更时,立即触发trigger,更新该响应式对象的依赖。
ref
effect
track
trigger
getter
阅读此文之前,如果对以下知识点不够了解,可以先了解以下知识点:
笔者之前也写过相关文章,也可以结合相关文章:
track是收集依赖的函数,怎么理解呢,例如我们使用计算属性computed时,其依赖的属性更新会引起计算属性被重新计算,就是靠得这个track。在reactive模块时,我们就看到了响应式对象的getter都会在内部调用这个track:
computed
reactive
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,触发重新计算。
dep
现在,我们开始来看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.length - 1]
deps
下面在针对上述三点可能的疑问,回到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的代码:
raw
// 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。
active
结合上面的代码,再理解track:
effectStack.push(effect)
effectStack.pop()
effect.active
false
effectStack.length === 0
effectStack.includes(effect)
const counter = reactive({ num: 0 }); const numSpy = () => { counter.num++; if (counter.num < 10) { numSpy(); } } effect(numSpy);
通过上面对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进行更新。
effects
computedRunners
Set
scheduleRun
本文讲述了effect模块的原理,通过track入手,了解到effect的结构,知道effect内部有一个deps的属性,这个属性是一个数组,用来存储监听函数的依赖。在响应式对象初始化时,getter调用,会调用track收集依赖,在对其属性进行更改、删除、增加时,会调用trigger来更新依赖,完成了数据通知和响应。
在上一篇文章Vue 3.x 响应式原理——ref源码分析中,笔者简述了Vue 3.x 的
ref
API 的实现原理,本文是响应式原理核心部分之一,effect
模块用于描述 Vue 3.x 存储响应,追踪变化,这篇文章从effect
模块的track
和trigger
开始,探索在创建响应式对象时,立即触发其getter
一次,会使用track
收集到其依赖,在响应式对象变更时,立即触发trigger
,更新该响应式对象的依赖。笔者之前也写过相关文章,也可以结合相关文章:
从track开始
track
是收集依赖的函数,怎么理解呢,例如我们使用计算属性computed
时,其依赖的属性更新会引起计算属性被重新计算,就是靠得这个track
。在reactive
模块时,我们就看到了响应式对象的getter
都会在内部调用这个track
:在阅读
reactive
模块的代码时我们就带有这样的疑问:怎么理解这里的track
调用呢?笔者之前有看过 Vue 1.x 的响应式源码的部分,这里猜想应该是和 Vue 1.x 差不多的,相关文章可见Vue源码学习笔记之Dep和Watcher。我们假设,在初始化响应式对象时,就会调用其
getter
一次,在getter
调用前,我们初始化一个结构,假设叫dep
,在初始化这个响应式对象,即其getter
调用过程中,如果对其它响应式对象进行取值,则会触发了其它响应式对象的getter
方法,在其它响应式对象的getter
方法中,调用了track
方法,track
方法会把被依赖的响应式对象及其相关特征属性存入其对应的dep
中,这样在被依赖者更新时,这次初始化的响应式对象会重新调用getter
,触发重新计算。现在,我们开始来看
track
,并从中印证我们的猜想:通过上面的代码,基本印证了刚刚我们的猜想,不过有几个地方我们可能有点似懂非懂:
effectStack
是一个什么结构,为什么从effectStack
栈顶部effectStack[effectStack.length - 1]
取到的就恰好是用于描述当前需要收集依赖的响应式对象的effect
?effect
的结构又是怎样的,是在哪里被初始化的?deps
,又是怎么在对应的响应式对象更新时,对应更新具有依赖的响应式对象的?下面在针对上述三点可能的疑问,回到
effect
模块的源码来寻找答案:看effect的结构
首先来看
effectStack
和effect
的结构:通过上面的代码可以知道,
effect
是一个函数,其下挂载了一些属性,用于描述其依赖和状态。其中raw
是保存其原始监听函数,这里我们可以猜想effect
既然也是函数类型,那么其调用时,除了调用原始函数raw
之外,还会进行依赖收集,下面来看effect
的代码:上面的代码基本很好理解,在创建监听时就会调用一次
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)
避免了此类情况。trigger
通过上面对
effect
和track
的解析,我们已经基本清楚了依赖收集的过程了,对于整个effect
模块的理解,就只差trigger
。既然track
用于收集依赖,我们很容易知道trigger
是响应式数据改变后,通知依赖其的响应式数据改变的方法,通过阅读trigger
即可回答上面的问题:收集到的依赖deps
,又是怎么在其依赖更新时,对应更新具有依赖的响应式对象的?下面来看
trigger
:上面的代码根据注释也很好理解,
trigger
就是当响应式属性更新时,通知其依赖的数据进行更新。在trigger
内部会维护两个队列effects
和computedRunners
,分别是普通属性和计算属性的依赖更新队列,在trigger
调用时,Vue 会找到更新属性对应的依赖,然后将需要更新的effect
放到执行队列里面,执行队列是Set
类型,可以很好地保证同一个effect
不会被重复调用。在完成了依赖查找之后,对effects
和computedRunners
进行遍历,调用scheduleRun
进行更新。小结
本文讲述了
effect
模块的原理,通过track
入手,了解到effect
的结构,知道effect
内部有一个deps
的属性,这个属性是一个数组,用来存储监听函数的依赖。在响应式对象初始化时,getter
调用,会调用track
收集依赖,在对其属性进行更改、删除、增加时,会调用trigger
来更新依赖,完成了数据通知和响应。