Learn vue3 effect from test cases
This sharing is mainly about effect
some functions, options and implementation principles of this API.
The core content is how effect tracks changes within reactive
test case
Because there are more than 700 lines of test cases in the effect.spec.ts file, some of them are omitted later. I won’t go into details this time. You can check and study them by yourself.
The following content comes from vue-next/packages/reactivity/ tests /effect.spec.ts
-
Number of verification calls
it('should run the passed function once (wrapped by a effect)', () => { const fnSpy = jest.fn(() => {}) effect(fnSpy) // 验证这里会立即执行一次函数 expect(fnSpy).toHaveBeenCalledTimes(1) })
-
The function of verifying basic response attributes
is probably the core of effect. How to track attribute changes and call callback functions is the main content of this sharingit('should observe basic properties', () => { // 定义一个属性 let dummy // 初始化一个响应式的 属性 const counter = reactive({ num: 0 }) // 注意这里回调函数内的操作 effect(() => (dummy = counter.num)) expect(dummy).toBe(0) counter.num = 7 expect(dummy).toBe(7) }) // 多个 reactive 属性 it('should observe multiple properties', () => { let dummy const counter = reactive({ num1: 0, num2: 0 }) effect(() => (dummy = counter.num1 + counter.num1 + counter.num2)) expect(dummy).toBe(0) counter.num1 = counter.num2 = 7 expect(dummy).toBe(21) }) // 触发多个 effect it('should handle multiple effects', () => { let dummy1, dummy2 const counter = reactive({ num: 0 }) effect(() => (dummy1 = counter.num)) effect(() => (dummy2 = counter.num)) expect(dummy1).toBe(0) expect(dummy2).toBe(0) counter.num++ expect(dummy1).toBe(1) expect(dummy2).toBe(1) }) // 嵌套 it('should observe nested properties', () => { let dummy const counter = reactive({ nested: { num: 0 } }) effect(() => (dummy = counter.nested.num)) expect(dummy).toBe(0) counter.nested.num = 8 expect(dummy).toBe(8) }) // 删除属性 it('should observe delete operations', () => { let dummy const obj = reactive({ prop: 'value' }) effect(() => (dummy = obj.prop)) expect(dummy).toBe('value') delete obj.prop expect(dummy).toBe(undefined) }) // 删除后 再添加,验证 has 方法的响应 it('should observe has operations', () => { let dummy const obj = reactive<{ prop: string | number }>({ prop: 'value' }) effect(() => (dummy = 'prop' in obj)) expect(dummy).toBe(true) delete obj.prop expect(dummy).toBe(false) obj.prop = 12 expect(dummy).toBe(true) }) // 原型链上的属性响应测试 it('should observe properties on the prototype chain', () => { let dummy const counter = reactive({ num: 0 }) const parentCounter = reactive({ num: 2 }) // 设置原型对象 Object.setPrototypeOf(counter, parentCounter) effect(() => (dummy = counter.num)) expect(dummy).toBe(0) // 删除自身的 num 属性 delete counter.num expect(dummy).toBe(2) // 测试原型上的 num parentCounter.num = 4 expect(dummy).toBe(4) // 又添加回来了 counter.num = 3 expect(dummy).toBe(3) }) // 和上面大致相同 it('should observe has operations on the prototype chain', () => { let dummy const counter = reactive({ num: 0 }) const parentCounter = reactive({ num: 2 }) Object.setPrototypeOf(counter, parentCounter) effect(() => (dummy = 'num' in counter)) expect(dummy).toBe(true) delete counter.num expect(dummy).toBe(true) delete parentCounter.num expect(dummy).toBe(false) counter.num = 3 expect(dummy).toBe(true) }) // 测试原型上的属性修饰方法 it('should observe inherited property accessors', () => { let dummy, parentDummy, hiddenValue: any const obj = reactive<{ prop?: number }>({}) const parent = reactive({ set prop(value) { hiddenValue = value }, get prop() { return hiddenValue } }) Object.setPrototypeOf(obj, parent) effect(() => (dummy = obj.prop)) effect(() => (parentDummy = parent.prop)) expect(dummy).toBe(undefined) expect(parentDummy).toBe(undefined) obj.prop = 4 expect(dummy).toBe(4) // 这里的 parent.prop === 4 但 parentDummy === undefined // this doesn't work, should it? // expect(parentDummy).toBe(4) parent.prop = 2 expect(dummy).toBe(2) expect(parentDummy).toBe(2) }) // 此次省略 N 多测试用例 //关于这个测试用例比较有意思 it('should observe json methods', () => { let dummy = <Record<string, number>>{} const obj = reactive<Record<string, number>>({}) effect(() => { // 通过 json 转换 dummy = JSON.parse(JSON.stringify(obj)) }) obj.a = 1 // 这里依旧可以跟踪到 expect(dummy.a).toBe(1) })
-
About some options and other functions
// options.lazy it('lazy', () => { const obj = reactive({ foo: 1 }) let dummy const runner = effect(() => (dummy = obj.foo), { lazy: true }) expect(dummy).toBe(undefined) // 需要手动执行一次才可以跟踪到 expect(runner()).toBe(1) expect(dummy).toBe(1) obj.foo = 2 expect(dummy).toBe(2) }) // options.scheduler it('scheduler', () => { let runner: any, dummy const scheduler = jest.fn(_runner => { runner = _runner }) const obj = reactive({ foo: 1 }) effect( () => { dummy = obj.foo }, { scheduler } ) expect(scheduler).not.toHaveBeenCalled() // 传入了 scheduler,第一次会默认执行一次 expect(dummy).toBe(1) // should be called on first trigger obj.foo++ expect(scheduler).toHaveBeenCalledTimes(1) // should not run yet expect(dummy).toBe(1) // manually run runner() // should have run expect(dummy).toBe(2) }) // options.onTrack it('events: onTrack', () => { let events: DebuggerEvent[] = [] let dummy const onTrack = jest.fn((e: DebuggerEvent) => { events.push(e) }) const obj = reactive({ foo: 1, bar: 2 }) const runner = effect( () => { // 这里执行 get has 都会调用一次 track dummy = obj.foo dummy = 'bar' in obj dummy = Object.keys(obj) }, { onTrack } ) expect(dummy).toEqual(['foo', 'bar']) // 注意这里 onTrack 被执行了 3 次 expect(onTrack).toHaveBeenCalledTimes(3) expect(events).toEqual([ { effect: runner, target: toRaw(obj), type: OperationTypes.GET, key: 'foo' }, { effect: runner, target: toRaw(obj), type: OperationTypes.HAS, key: 'bar' }, { effect: runner, target: toRaw(obj), type: OperationTypes.ITERATE, key: ITERATE_KEY } ]) }) // options.onTrigger it('events: onTrigger', () => { let events: DebuggerEvent[] = [] let dummy const onTrigger = jest.fn((e: DebuggerEvent) => { events.push(e) }) const obj = reactive({ foo: 1 }) const runner = effect( () => { dummy = obj.foo }, { onTrigger } ) obj.foo++ expect(dummy).toBe(2) expect(onTrigger).toHaveBeenCalledTimes(1) expect(events[0]).toEqual({ effect: runner, target: toRaw(obj), type: OperationTypes.SET, key: 'foo', oldValue: 1, newValue: 2 }) delete obj.foo expect(dummy).toBeUndefined() expect(onTrigger).toHaveBeenCalledTimes(2) expect(events[1]).toEqual({ effect: runner, target: toRaw(obj), type: OperationTypes.DELETE, key: 'foo', oldValue: 2 }) }) // stop 不是在 options 传递的,额外的一个方法 it('stop', () => { let dummy const obj = reactive({ prop: 1 }) const runner = effect(() => { dummy = obj.prop }) obj.prop = 2 expect(dummy).toBe(2) stop(runner) obj.prop = 3 expect(dummy).toBe(2) // stopped effect should still be manually callable runner() expect(dummy).toBe(3) }) // options.onTrigger it('events: onStop', () => { const onStop = jest.fn() const runner = effect(() => {}, { onStop }) stop(runner) expect(onStop).toHaveBeenCalled() }) // stop 后恢复之前的自动跟踪 it('stop: a stopped effect is nested in a normal effect', () => { let dummy const obj = reactive({ prop: 1 }) const runner = effect(() => { dummy = obj.prop }) stop(runner) obj.prop = 2 expect(dummy).toBe(1) // observed value in inner stopped effect // will track outer effect as an dependency // 将 runner 重新放入 effect 中,相当于 dummy = obj.prop 又一次被跟踪 effect(() => { runner() }) expect(dummy).toBe(2) // notify outer effect to run obj.prop = 3 expect(dummy).toBe(3) }) // reactive 被标记了 markNonReactive 不会响应 it('markNonReactive', () => { const obj = reactive({ foo: markNonReactive({ prop: 0 }) }) let dummy effect(() => { dummy = obj.foo.prop }) expect(dummy).toBe(0) obj.foo.prop++ expect(dummy).toBe(0) obj.foo = { prop: 1 } expect(dummy).toBe(1) }) // 设置 NaN 不会被多次触发跟踪回调 it('should not be trigger when the value and the old value both are NaN', () => { const obj = reactive({ foo: NaN }) const fnSpy = jest.fn(() => obj.foo) effect(fnSpy) obj.foo = NaN expect(fnSpy).toHaveBeenCalledTimes(1) }) })
A simple analysis of the principle of effect tracking
Let's recall first, in the last sharing about reactive, after creating, an operation
proxy
will be performed on :targetMap
set
The following content comes from: vue-next/packages/reactivity/src/reactive.ts
function createReactiveObject( target: unknown, toProxy: WeakMap<any, any>, toRaw: WeakMap<any, any>, baseHandlers: ProxyHandler<any>, collectionHandlers: ProxyHandler<any> ) { // ... const handlers = collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers observed = new Proxy(target, handlers) toProxy.set(target, observed) toRaw.set(observed, target) if (!targetMap.has(target)) { // 注意这里 set 了一个 map targetMap.set(target, new Map()) } return observed }
Then
handlers
there are calls intrack
ortrigger
.For example, the objects
Get
andSet
:The following content comes from: vue-next/packages/reactivity/src/baseHandlers.ts
function createGetter(isReadonly: boolean) { return function get(target: object, key: string | symbol, receiver: object) { // ... track(target, OperationTypes.GET, key) // 这个 track 是引用的 effect.ts // ... } } function set( target: object, key: string | symbol, value: unknown, receiver: object ): boolean { // ... // don't trigger if target is something up in the prototype chain of original if (target === toRaw(receiver)) { /* istanbul ignore else */ // 这里区分开发环境与生产环境,主要是了 debug 使用(vue-tools 留接口?) if (__DEV__) { const extraInfo = { oldValue, newValue: value } if (!hadKey) { trigger(target, OperationTypes.ADD, key, extraInfo) } else if (hasChanged(value, oldValue)) { trigger(target, OperationTypes.SET, key, extraInfo) } } else { if (!hadKey) { trigger(target, OperationTypes.ADD, key) } else if (hasChanged(value, oldValue)) { trigger(target, OperationTypes.SET, key) } } } return result }
A brief description of the effect implementation process
- Create a reactive object =>
obj
- Call
effect
and pass the callback function. The callback involvesobj
the get operation, such asobj.a
- effect internal execution
createReactiveEffect
- createReactiveEffect adds some effect attributes internally, then returns a function, and returns a run function inside the function.
- The run function determines whether the effectStack contains the current effect, if not, adds it, and then executes the callback passed in the second step.
- At this point
createReactiveEffect
, the execution is completed, and the effect internally determines whether to execute the returned functionoption.lazy
immediately (it will be executed later)createReactiveEffect
- The callback function is called.
obj.a
When it is executed, the proxy's get processing method is started, andtrack
the method is called. - track finds the current effect based on effectStack (the implementation here is relatively seconds, which will be introduced later), then finds
targetMap
the map, and finally puts the current effect function into it. - When
obj.a
changes occur, execution beginstrigger
- The trigger function is equivalent to the reverse operation of track, which is taken out and executed (of course, it is actually much more complicated than track)
Source code level understanding
The following is displayed in sequence according to the above steps, the content comes from vue-next/packages/reactivity/src/effect.ts
// 对外 API,effect 函数 export function effect<T = any>( // 回调 fn: () => T, // 配置项 options: ReactiveEffectOptions = EMPTY_OBJ ): ReactiveEffect<T> { // 这里判断传入的 fn 是否已经是一个 effect 了。如果是获取源回调函数, if (isEffect(fn)) { fn = fn.raw } // 创建 effect,传入 回调与配置项 const effect = createReactiveEffect(fn, options) // 判断是否开启了 lazy 模式 if (!options.lazy) { // 没有开启,立即执行 effect effect() } return effect } // createReactiveEffect 函数 function createReactiveEffect<T = any>( fn: () => T, options: ReactiveEffectOptions ): ReactiveEffect<T> { // 这里 effect 被赋值了一个函数 const effect = function reactiveEffect(...args: unknown[]): unknown { // 执行 reactiveEffect 后会 执行 run ,同时传递 effect, fn, args return run(effect, fn, args) } as ReactiveEffect // 给 effect 设置自身属性 effect._isEffect = true effect.active = true effect.raw = fn // 这里会缓存 track 的列表,后续用于 stop 将自己删除掉 effect.deps = [] effect.options = options return effect } // run 函数 function run(effect: ReactiveEffect, fn: Function, args: unknown[]): unknown { // 判断是否被 stop 了 // 被 stop 后,只有主动调用 runner(调用 effect 返回的函数)才会走到这里 if (!effect.active) { return fn(...args) } // 判断响应队列是包含了 当前的 effect if (!effectStack.includes(effect)) { // 不是太理解 为什么每次都要 cleanup? cleanup(effect) try { // 注意这里 push 了一次 effect effectStack.push(effect) return fn(...args) } finally { // 等 fn 执行完后,立刻取出 effect effectStack.pop() } } } // 假设 effect 已调用一次,那么这里已经触发了 get 代理事件,也就是执行了 track 函数 export function track(target: object, type: OperationTypes, key?: unknown) { // 判断 effectStack 队列是否有值 // shouldTrack 应该是开放模式下 vue-tools 可以暂停跟踪的功能 if (!shouldTrack || effectStack.length === 0) { return } // 注意这里 为什么是 effectStack[effectStack.length - 1] // 最后一个就是当前的 effect 吗? const effect = effectStack[effectStack.length - 1] if (type === OperationTypes.ITERATE) { key = ITERATE_KEY } // 从 targetMap 中取值,用于添加涉及到的 effect!!! let depsMap = targetMap.get(target) if (depsMap === void 0) { targetMap.set(target, (depsMap = new Map())) } // targetMap 是 weekMap // depsMap 是 Map // dep 是 Set() // 这里有个 key,是 target 上面的属性,所以这里的 effect 存储也按对应的字段区分开了 let dep = depsMap.get(key!) if (dep === void 0) { depsMap.set(key!, (dep = new Set())) } // 将当前 effect 放入, 供后面 trigger 使用 if (!dep.has(effect)) { dep.add(effect) effect.deps.push(dep) if (__DEV__ && effect.options.onTrack) { effect.options.onTrack({ effect, target, type, key }) } } } // const effect = effectStack[effectStack.length - 1] 说明 // 因为上面 run 函数中 push 之后,立刻执行了 fn(),fn 中又触发了 代理钩子 // 代理钩子中又调用了 track // 因为 js 单线程的机制,effectStack.length - 1 永远会是 run 函数中 push 的那一个 // trigger 函数 export function trigger( target: object, type: OperationTypes, key?: unknown, extraInfo?: DebuggerEventExtraInfo ) { // 从 targetMap 中获取 depsMap const depsMap = targetMap.get(target) // 表示没有被 effect 调用过 if (depsMap === void 0) { // never been tracked return } // 涉及到的 effect 存放(比如有获取 obj.a 操作的 effect) const effects = new Set<ReactiveEffect>() // 计算属性。 const computedRunners = new Set<ReactiveEffect>() // 下面会根据 key 获取对应的 effect // clear 的时候 不区分字段了 all in(数组的时候才会有CLEAR) if (type === OperationTypes.CLEAR) { // collection being cleared, trigger all effects for target depsMap.forEach(dep => { // addRuners 只是取出 effect 放入到 effects 和 computedRunners,因为内部有 if 逻辑,所以抽出来一个函数 addRunners(effects, computedRunners, dep) }) } else { // schedule runs for SET | ADD | DELETE if (key !== void 0) { // 只取出 key 对应的 effect 放入对应的集合 addRunners(effects, computedRunners, depsMap.get(key)) } // also run for iteration key on ADD | DELETE if (type === OperationTypes.ADD || type === OperationTypes.DELETE) { const iterationKey = Array.isArray(target) ? 'length' : ITERATE_KEY addRunners(effects, computedRunners, depsMap.get(iterationKey)) } } // 使用调度器 运行传入的 effect 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. // 这里不太明白 为什么 computedRunners 要先运行 computedRunners.forEach(run) effects.forEach(run) } // 只是取出 effect 放入到 effects 和 computedRunners function addRunners( effects: Set<ReactiveEffect>, computedRunners: Set<ReactiveEffect>, effectsToAdd: Set<ReactiveEffect> | undefined ) { if (effectsToAdd !== void 0) { effectsToAdd.forEach(effect => { 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() } }
- Create a reactive object =>