effect函数的完整实现与追踪:深入Vue3响应式核心

在上一篇文章中,我们实现了一个简易版的 effect。但真实的 Vue3 中,effect 要复杂得多:它需要处理嵌套 effect、支持停止追踪、提供 runner 函数、实现调度器等。本文将一步步完善 effect 的实现,最终与Vue3源码对标。

前言:从极简到完善

在上一篇文章 副作用的概念与effect基础:Vue3响应式系统的核心 中,我们实现了一个简易版的 effect ,这个版本有哪些问题呢?

  1. 嵌套 effect 会破坏 activeEffect
  2. 无法停止 effect 的追踪
  3. 没有返回 runner 函数(可以手动执行)
  4. 不支持调度器(scheduler)
  5. 没有 effect 选项(lazy、onTrack等)
  6. 没有处理错误情况

本篇文章,将一步步解决这些问题。

全局 activeEffect 的设计挑战

为什么需要 activeEffect?

当执行 effect 时,我们怎么知道当前正在运行的 effect 到底是哪个呢?这就是 activeEffect 的作用,使用全局变量 activeEffect 可以记录当前正在执行的 effect

activeEffect 工作流程

  1. 使用 effect 注册副作用函数 fn1 ,执行effect(fn1)
  2. 并将副作用函数 fn1 赋值给activeEffect :activeEffect = fn1
  3. fn1 执行,访问响应式数据
  4. track 函数被调用,将 activeEffect(fn1) 添加到依赖集合
  5. fn1 执行完毕,将 activeEffect 置空:activeEffect = null

activeEffect 工作过程示例

javascript 复制代码
function demonstrateActiveEffect() {
    let activeEffect = null;
    
    function track() {
        console.log(`   [track] 收集依赖,当前effect: ${activeEffect?.name || 'null'}`);
    }
    
    function run(effect) {
        console.log(`   [run] 开始执行 ${effect.name}`);
        activeEffect = effect;
        effect.fn();
        activeEffect = null;
        console.log(`   [run] 结束执行 ${effect.name}`);
    }
    
    const effect1 = {
        name: 'effect1',
        fn: () => {
            console.log('     effect1执行中');
            track();
        }
    };
    
    const effect2 = {
        name: 'effect2',
        fn: () => {
            console.log('     effect2执行中');
            track();
        }
    };
    
    console.log('1. 执行effect1:');
    run(effect1);
    
    console.log('\n2. 执行effect2:');
    run(effect2);
}

嵌套effect的处理(effect栈)

effect 是可以发生嵌套的,如以下示例:

javascript 复制代码
effect(fn1() {
    effect(fn2() {
        effect(fn3(){
            /* ... */
        })
    })
})

这段代码中,fn1 内部嵌套了 fn2;fn2 内部又嵌套了 fn3。当 fn1 执行时,会导致 fn2 的执行;当 fn2 执行时,又会导致 fn3 的执行。

为什么要处理 effect 嵌套问题?

以上述嵌套 effect 为例,如果我们用之前的 demonstrateActiveEffect() 函数处理,会发生什么问题呢?

此时,我们期望的结果是:fn1 执行过程中,fn2 开始执行,同时 fn3 也开始执行;fn1 和 fn2 应该被暂停。但实际上,当我们使用全局 activeEffect 来存储副作用函数时,它会被后面的副作用覆盖,即:fn2 会覆盖 fn1,fn3 会覆盖 fn2,导致最后的结果是只有 fn3 被收集了,无法再收集 fn1 和 fn2 。

为了解决这个问题,我们就需要一个副作用栈 effectStack ,在副作用函数执行时,将当前副作用函数压入栈底,待副作用函数执行完成后,再将其弹出,并始终让 activeEffect 指向栈顶的副作用函数。

javascript 复制代码
class EffectStack {
    constructor() {
        this.stack = [];
        this.current = null;
    }
    
    // 入栈
    push(effect) {
        console.log(`   [栈] 入栈: ${effect.name || 'anonymous'}`);
        this.stack.push(effect);
        this.current = effect;
    }
    
    // 出栈
    pop() {
        const popped = this.stack.pop();
        console.log(`   [栈] 出栈: ${popped?.name || 'anonymous'}`);
        this.current = this.stack[this.stack.length - 1] || null;
        return popped;
    }
    
    // 获取当前effect
    getCurrent() {
        return this.current;
    }
}

使用 effect 栈解决嵌套问题

javascript 复制代码
function demonstrateEffectStack() {
    const effectStack = new EffectStack();
    
    function track() {
        const current = effectStack.getCurrent();
        console.log(`   [track] 当前effect: ${current?.name || 'null'}`);
    }
    
    function effect(fn) {
        const effectFn = () => {
            effectStack.push(effectFn);
            fn();
            effectStack.pop();
        };
        effectFn.name = fn.name;
        effectFn();
        return effectFn;
    }
    
    console.log('使用effect栈后:');
    
    effect(function effect1() {
        console.log('  effect1开始');
        track(); // 收集effect1
        
        effect(function effect2() {
            console.log('  effect2开始');
            track(); // 收集effect2
            console.log('  effect2结束');
        });
        
        track(); // 现在能正确收集effect1了!
        console.log('  effect1结束');
    });
}

什么时候会出现嵌套的 effect 呢?

在 Vue 中,当我们使用了嵌套组件时,其实就发生了 effect 嵌套:

javascript 复制代码
<!-- Foo.vue -->
<template>
  <div>
    <Bar />
  </div>
</template>

<script setup lang="ts">
import Bar from './bar.vue'

</script>

上述代码相当于:

javascript 复制代码
effect(() => {
    Foo.render();
    effect(() => {
        Bar.render();
    });
})

effect 返回 runner 函数

runner函数的作用

effect 默认立即执行一次,但有时我们希望手动控制执行时机,因此我们就希望 effect 能返回一个函数,我们可以通过这个函数手动触发 effect 重新执行。

使用场景

  • 懒执行的effect(lazy: true)
  • 需要手动触发的更新
  • 可以随时停止的effect

实现带 runner 的 effect

javascript 复制代码
function effectWithRunner(fn, options = {}) {
    const _effect = new ReactiveEffect(fn);
    
    // runner函数
    const runner = () => {
        return _effect.run();
    };
    
    // 保存effect实例到runner上,方便后续操作
    runner.effect = _effect;
    
    // 如果不是懒执行,立即运行
    if (!options.lazy) {
        runner();
    }
    
    return runner;
}

runner的返回值

方案1:返回fn的执行结果

javascript 复制代码
function effect1(fn) {
    return fn();
}

方案2:返回runner函数

javascript 复制代码
function effect2(fn) {
    const runner = () => fn();
    runner();
    return runner;
}

方案3:返回effect实例(Vue3的做法)

javascript 复制代码
function effect3(fn) {
    const _effect = new ReactiveEffect(fn);
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    runner();
    return runner;
}

完整的effect实现

javascript 复制代码
console.log('\n=== 完整版effect实现 ===\n');

// 依赖存储
const targetMap = new WeakMap();

// effect栈
const effectStack = [];

// 当前激活的effect
function getCurrentEffect() {
    return effectStack[effectStack.length - 1];
}

// ReactiveEffect类
class ReactiveEffect {
    constructor(fn, scheduler = null) {
        this.fn = fn;
        this.scheduler = scheduler;
        this.deps = [];
        this.active = true; // 是否激活
        this.name = fn.name || 'anonymous';
    }
    
    run() {
        if (!this.active) {
            return this.fn();
        }
        
        try {
            // 入栈
            effectStack.push(this);
            cleanupEffect(this); // 清除旧的依赖
            console.log(`   [run] 开始执行 ${this.name}`);
            
            // 执行fn,期间会触发track
            return this.fn();
        } finally {
            // 出栈
            effectStack.pop();
            console.log(`   [run] 结束执行 ${this.name}`);
        }
    }
    
    stop() {
        if (this.active) {
            cleanupEffect(this);
            this.active = false;
        }
    }
}

// 清除effect的所有依赖
function cleanupEffect(effect) {
    const { deps } = effect;
    if (deps.length) {
        console.log(`   [cleanup] 清除 ${effect.name} 的 ${deps.length} 个依赖`);
        deps.forEach(dep => dep.delete(effect));
        deps.length = 0;
    }
}

// 依赖收集
function track(target, key) {
    const activeEffect = getCurrentEffect();
    if (!activeEffect) return;
    
    let depsMap = targetMap.get(target);
    if (!depsMap) {
        depsMap = new Map();
        targetMap.set(target, depsMap);
    }
    
    let dep = depsMap.get(key);
    if (!dep) {
        dep = new Set();
        depsMap.set(key, dep);
    }
    
    if (!dep.has(activeEffect)) {
        dep.add(activeEffect);
        activeEffect.deps.push(dep);
        console.log(`   [track] ${activeEffect.name} 依赖了 ${key}`);
    }
}

// 触发更新
function trigger(target, key) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    
    const dep = depsMap.get(key);
    if (!dep) return;
    
    console.log(`   [trigger] ${key} 变化,触发 ${dep.size} 个effect`);
    
    // 复制一份,防止在遍历过程中修改Set
    const effects = new Set(dep);
    effects.forEach(effect => {
        if (effect !== getCurrentEffect()) { // 避免无限循环
            if (effect.scheduler) {
                effect.scheduler();
            } else {
                effect.run();
            }
        }
    });
}

// 主effect函数
function effect(fn, options = {}) {
    const { lazy = false, scheduler = null } = options;
    
    const _effect = new ReactiveEffect(fn, scheduler);
    
    // 创建runner函数
    const runner = _effect.run.bind(_effect);
    runner.effect = _effect;
    
    // 立即执行(除非是懒执行)
    if (!lazy) {
        runner();
    }
    
    return runner;
}

结语

本篇文章简单介绍了 activeEffect 的设计与挑战,以及嵌套 effect 的处理。下一篇文章中,我们将介绍 effect 的执行调度、懒执行和停止跟踪等内容。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

相关推荐
Never_Satisfied2 小时前
在JavaScript / HTML中,img标签loading lazy加载时机详解
开发语言·javascript·html
Coffeeee2 小时前
年过完了,该上班了,我用Compose给大家放个烟花喜庆喜庆
前端·kotlin·android jetpack
Marshall1512 小时前
UniApp 安卓端版本检查更新功能完整实现
前端
小飞大王6662 小时前
WebSocket技术与心跳检测
前端·javascript·websocket·网络协议·arcgis
不会敲代码12 小时前
从零开始掌握LangChain工具调用:让AI拥有“动手能力”
前端·langchain
a1117762 小时前
波浪圆圈背景效果(html 开源)
前端·html
程序员ys2 小时前
网页白屏的原理与优化
前端·性能优化·浏览器