Based on the effect
method implemented in the previous article, based on the single test in the Vue3 source code, three functions of this method are improved, namely:
- runner:
effect
can return a self-executing parameterrunner
function - scheduler:
effect
supports adding thescheduler
function in the second parameter option - stop:
effect
Addsstop
function
runner
Single test
Add test cases about runner
in the effect.spec.ts
file.
it("should be return runner when call effect", () => {<!-- --> let foo = 1; const runner = effect(() => {<!-- --> foo + + ; return "foo"; }); expect(foo).toBe(2); const r = runner(); expect(foo).toBe(3); expect(r).toBe("foo"); });
The meaning of the above test case is that the function inside effect
will execute itself once, and the value of foo
becomes 2. effect
is an executable function runner
. When runner
is executed, the effect
internal function will also be executed, so The value of foo
will increment to 3 again, and the return value of runner
is the return value of the effect
internal function.
Implementation
The effect
function needs to be able to return its input parameter execution function, and the internal execution function can return.
class ReactiveEffect {<!-- --> private _fn: any; constructor(fn) {<!-- --> this._fn = fn; } run() {<!-- --> reactiveEffect = this; return this._fn(); } } export function effect(fn) {<!-- --> let _effect = new ReactiveEffect(fn); _effect.run(); const runner = _effect.run.bind(_effect) return runner; }
It should be noted that there is a problem pointed by this
, and the current instance needs to be bound when returning the _effect.run
function.
Verification
Execute yarn test effect
scheduler
Single test
it("scheduler", () => {<!-- --> let dummy; let run: any; const scheduler = jest.fn(() => {<!-- --> run = runner; }); const obj = reactive({<!-- --> foo: 1 }); const runner = effect( () => {<!-- --> dummy = obj.foo; }, {<!-- --> scheduler, } ); expect(scheduler).not.toHaveBeenCalled(); expect(dummy).toBe(1); // should be called on first trigger obj.foo + + ; expect(scheduler).toHaveBeenCalled(); // should not run yet expect(dummy).toBe(1); // manually run run(); // should have run expect(dummy).toBe(2); });
The meaning of the above test case code is: the effect
method receives the second parameter, which is an option list object, one of which is scheduler
, which is a function. Here, jest.fn
is used to simulate a function that assigns the variable run
to the runner
function. During the first execution, the scheduler
function is not called and executed. The first parameter function of effect
is executed automatically, so dummy
is assigned a value of 1. ;When the responsive object changes, that is, when obj.foo + +
, scheduler
will be executed, but the value of dummy
is still 1, It means that the first parameter function is not executed; run
is executed, that is, when the effect
return function runner
is executed, the first parameter function is executed. Because obj.foo + +
, dummy
becomes 2.
The requirements included in scheduler
can be summarized:
- Give a
fn
of ascheduler
through the second parameter ofeffect
- When
effect
is executed for the first time, the first parameterfunction
is executed. - When the reactive object triggers the
set
operation, thefunction
will not be executed, but thescheduler
will be executed. - When
runner
is executed,function
will be executed again
Implementation
The first is that the effect
function can receive a second object parameter.
export function effect(fn, options: any = {<!-- -->}) {<!-- --> let _effect = new ReactiveEffect(fn, options.scheduler); _effect.run(); const runner = _effect.run.bind(_effect) return runner; }
The Class class must also receive scheduler
accordingly.
class ReactiveEffect {<!-- --> private _fn: any; public scheduler: Function | undefined; constructor(fn, scheduler) {<!-- --> this._fn = fn; this.scheduler = scheduler; } run() {<!-- --> reactiveEffect = this; return this._fn(); } }
When the responsive object triggers the set
operation, that is, when the dependency is triggered, in the trigger
method, execute scheduler
and only need to determine whether there is scheduler
, executed when it exists.
export function trigger(target, key) {<!-- --> let depMap = targetMap.get(target); let dep = depMap.get(key); for (const effect of dep) {<!-- --> if (effect.scheduler) {<!-- --> effect.scheduler(); } else {<!-- --> effect.run(); } } }
Verification
stop
Single test
import {<!-- --> effect, stop } from "../reactivity/effect"; 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); }); it("onStop", () => {<!-- --> const onStop = jest.fn(); const runner = effect(() => {<!-- -->}, {<!-- --> onStop }); stop(runner); expect(onStop).toHaveBeenCalled(); });
The stop
function has two test cases, corresponding to different functions. Let’s analyze them one by one.
In "stop"
, the function within effect
executes itself once, so the first assertion is that dummy
is the value 2 assigned above; execute stop
method, stop
method is an externally exposed method from effect
. It receives the runner
function as a parameter, even if the response is updated formula object, the function inside effect
is not executed, and dummy
is still 2; execute runner
again to resume execution of effect
Inner function, dummy
becomes 3.
In summary, stop
can prevent the execution of functions within effect
.
In "onStop"
, the effect
function receives the second parameter. There is a property in the object which is onStop
, and it receives a function. When executed When stop
, the onStop
function will be executed.
Implementation
When triggering dependencies, the trigger
method loops through all effect
methods in dep
. If you need to prevent execution, you can start from dep
to delete the item.
First, the stop
method receives the runner
function as a parameter.
export function stop(runner) {<!-- --> runner.effect.stop(); }
By mounting an effect
instance on the runner
function, you can obtain the stop
method defined in the Class class.
class ReactiveEffect {<!-- --> private _fn: any; public scheduler: Function | undefined; constructor(fn, scheduler) {<!-- --> this._fn = fn; this.scheduler = scheduler; } run() {<!-- --> reactiveEffect = this; return this._fn(); } stop() {<!-- -->} } export function effect(fn, options: any = {<!-- -->}) {<!-- --> let _effect = new ReactiveEffect(fn, options.scheduler); extend(_effect, options); _effect.run(); const runner: any = _effect.run.bind(_effect); runner.effect = _effect; // Mount effect instance return runner; }
So how to delete an item from dep
that needs to be prevented from execution?
In the track
method, dep.add(reactiveEffect)
creates the dep
Set
structure and effect
instance relationship, but there is no mapping relationship between instances and dep
in the Class
class, so you can define a Class
class The code>deps array is used to store all dep
of the instance. When the stop
method needs to be called, the dep
will be deleted. effect
instance method.
class ReactiveEffect {<!-- --> private _fn: any; public scheduler: Function | undefined; deps = []; constructor(fn, scheduler) {<!-- --> this._fn = fn; this.scheduler = scheduler; } run() {<!-- --> reactiveEffect = this; return this._fn(); } stop() {<!-- --> this.deps.forEach((dep: any) => {<!-- --> dep.delete(this); }); } } export function track(target, key) {<!-- --> ... dep.add(reactiveEffect); reactiveEffect.deps.push(dep); // Store deps }
Verification
Optimization
Although the single test passed, there is room for optimization in the code. Let’s refactor it.
The logic in the stop
method can be extracted into a separate function.
class ReactiveEffect {<!-- --> ... stop() {<!-- --> cleanupEffect(this); } } function cleanupEffect(effect) {<!-- --> effect.deps.forEach((dep: any) => {<!-- --> dep.delete(effect); }); }
For performance optimization, when the user keeps calling the stop method, it will cause the loop to be traversed for no reason. Therefore, a flag bit can be set to determine whether the delete operation has been called and executed.
class ReactiveEffect {<!-- --> private _fn: any; public scheduler: Function | undefined; deps = []; active = true; constructor(fn, scheduler) {<!-- --> this._fn = fn; this.scheduler = scheduler; } run() {<!-- --> reactiveEffect = this; return this._fn(); } stop() {<!-- --> if (this.active) {<!-- --> cleanupEffect(this); this.active = false; } } }
After refactoring, you need to perform unit testing again to ensure that the function is not damaged.
Implementation
To implement the second function onStop
of stop
.
First, mount the onStop
method on the effect
instance.
export function effect(fn, options: any = {<!-- -->}) {<!-- --> let _effect = new ReactiveEffect(fn, options.scheduler); _effect.onStop = options.onStop _effect.run(); const runner: any = _effect.run.bind(_effect); runner.effect = _effect; return runner; }
When stop
is executed, the onStop
function will be executed.
class ReactiveEffect {<!-- --> private _fn: any; public scheduler: Function | undefined; deps = []; active = true; onStop?: () => void; constructor(fn, scheduler) {<!-- --> this._fn = fn; this.scheduler = scheduler; } run() {<!-- --> reactiveEffect = this; return this._fn(); } stop() {<!-- --> if (this.active) {<!-- --> cleanupEffect(this); if (this.onStop) {<!-- --> this.onStop(); } this.active = false; } } }
Verification
Optimization
The second parameter options
of the effect
method may have many options, and each time it is mounted to the instance through _effect.onStop = options.onStop
It is not elegant, so this piece of logic can be extracted and used as a public method.
Create a new folder shared under src and create a new index.ts
export const extend = Object.assign;
Then in effect
, you can use the extend
method to express it more semantically.
export function effect(fn, options: any = {<!-- -->}) {<!-- --> let _effect = new ReactiveEffect(fn, options.scheduler); extend(_effect, options); _effect.run(); const runner: any = _effect.run.bind(_effect); runner.effect = _effect; return runner; }
After refactoring, execute yarn test effect
again to verify whether the function is damaged.
Verification
Finally, all unit tests need to be executed to verify whether the new functions damage the original code. Execute yarn test
When executing the reactive
single test, the above error occurred, indicating that reactiveEffect
may be undefined
and does not exist deps
.
In reactive.spec.ts
, the core functions of reactive
are simply tested. At this time, the effect
method is not involved. The assignment of reactiveEffect
is triggered when effect
executes itself, so it is in the initial undefined
state.
export function track(target, key) {<!-- --> ... if (!reactiveEffect) return; // Boundary processing dep.add(reactiveEffect); reactiveEffect.deps.push(dep); }
Finally, it was verified again, the test passed, and the function was successfully completed.
Updated on 2023/11/13
Modify stop single test
Based on the original, modify the stop
test case in effect
.
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; obj.prop + + ; expect(dummy).toBe(2); // stopped effect should still be manually callable runner(); expect(dummy).toBe(3); });
Run single test yarn test effect
Error report analysis
Briefly analyze the reasons for the error.
obj.prop + +
can be understood as obj.prop = obj.prop + 1
, there are get
and set
Two operations, triggering the get
operation will re-collect dependencies, causing the cleanupEffect
method in stop
to delete all effect
and become invalid.
Implementation
Knowing that the root cause is that the get
operation is triggered first and the function in effect
is re-executed, that is, the track
method is called. The logic that needs to be improved should be Start this way. We can define a global variable shouldTrack
to determine whether the track
operation is required.
let reactiveEffect; let shouldTrack; // definition export function track(target, key) { ... if(!shouldTrack) return // Direct return without dependency collection if (!reactiveEffect) return; dep.add(reactiveEffect); reactiveEffect.deps.push(dep); }
When assigning a value, the set
operation is triggered, the trigger
function is executed, and the final call is the run
method in the Class class ReactiveEffect
. The run
method originally directly returns the execution result of the parameter function. Here you need to judge the situation of stop
, which can be judged based on active
.
If the active
value is assigned to false
after calling the stop
method, then fn
will be returned directly;
If the stop
method is not called, first set shouldTrack
to true
, which means that track
can be called, and then execute fn
, and return the execution result, but a reset operation is required before returning. Set shouldTrack
to false
, because if stop
, the run
function will directly return
and will not set shouldTrack
to true
, then when track
, it will go to !shouldTrack
and directly return
and no longer collect dependencies.
run() { if (!this.active) { return this._fn(); } shouldTrack = true; reactiveEffect = this; const result = this._fn(); shouldTrack = false; return result; }
Refactoring
For boundary judgment of shouldTrack
and reactiveEffect
in track
, you can refer to the top of the track
function body and encapsulate a separate function synthesis these two judgments.
Dependency collection can be optimized here. When reactiveEffect
exists in dep
, it will no longer be collected repeatedly.
export function track(target, key) { if (!isTracking()) return; ... if (dep.has(reactiveEffect)) return; dep.add(reactiveEffect); reactiveEffect.deps.push(dep); } function isTracking() { return shouldTrack & amp; & amp; reactiveEffect !== undefined; }
Debugging
Modify the single test and use a simpler single test to clearly see the above process through debugging.
it("stop", () => { let dummy; const obj = reactive({ prop: 1 }); const runner = effect(() => { dummy = obj.prop; }); stop(runner); obj.prop + + ; expect(dummy).toBe(1); });
Here is a video explanation for a more vivid understanding. View the video details.