初探 Vue 3 响应式源码(二):Effect

626 阅读8分钟

上一篇我们讲了reactive的核心实现,这次来讲整个响应式系统的核心球员:副作用函数(Effect)

1、 什么是 Effect?

在 Vue3 中,Effect副作用函数 是实现响应式非常关键的一环。

它代表一段“副作用”代码,当响应式数据变化时,这段代码会被重新执行。

有的同学要问了,什么是副作用?

副作用就是“对外部环境产生了影响”的函数,例如 let a = 0; fn1(){ a++ };

与之相反的就是“纯函数”,也就是“对外部不会产生影响的”,例如 fn2 (a,b) { return a + b}

const obj = reactive({ count: 0 });

// 定义一个 Effect
effect(() => {
  console.log('count 的值变为:', obj.count); // 立即执行,控制台输出:count 的值变为:0
});

obj.count = 1; // 控制台输出:count 的值变为:1

这里的 effect 会立即执行一次传入的函数,并在 obj.count 变化时再次执行。


2、实现一个基础 Effect

2.1 基本实现与问题分析

核心机制

Effect 的本质是建立副作用函数与响应式数据间的联系。当响应式数据变化时,能自动重新执行副作用函数。

let activeEffect = null; // 当前激活的副作用函数

function effect(fn) {
  activeEffect = fn;    // 标记为激活状态
  fn();                 // 执行函数触发 getter 收集依赖
  activeEffect = null;  // 重置防止无效收集
}

3. 依赖收集的完善

如上我们完成了effect函数的基本,传给他函数后他可以执行,但当前他仅仅可以再传入的时候执行一次,我们的响应式可是只要数据变化就该重新执行才对,要着呢么办呢?

依赖收集!收集是哪些地方引用着响应式的数据,这些数据变化后才知道来重新来执行effect


3.1 依赖收集的核心逻辑

依赖收集的核心是:在读取响应式数据时,将当前活跃的 Effect 函数(即 activeEffect)与响应式数据的属性关联起来

为了实现这一点,我们需要:

  1. 在读取响应式数据时,触发 getter,收集当前活跃的 Effect
  2. 在修改响应式数据时,触发 setter,通知所有关联的 Effect 重新执行。

3.2 实现依赖收集

1. 依赖存储结构

我们需要一个全局的 WeakMap 来存储响应式对象与属性之间的依赖关系:

  • WeakMap 的键是响应式对象。
  • WeakMap 的值是一个 Map,其中键是对象的属性,值是一个 Set,存储所有依赖该属性的 effect 函数。
const targetMap = new WeakMap(); // 全局依赖存储
2. 依赖收集函数

在 getter 中,我们需要将当前活跃的 Effect 函数与属性关联起来:

function track(target, key) {
  if (!activeEffect) return; // 没有活跃的 Effect,直接返回

  // 获取 target 对应的依赖 Map
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  // 获取 key 对应的依赖 Set
  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }

  // 将当前活跃的 effect 添加到依赖集合中
  deps.add(activeEffect);

  // 将依赖集合添加到 effect 的 deps 中(用于 cleanup)
  activeEffect.deps.push(deps);
}
3. 触发更新函数

在 setter 中,我们需要通知所有依赖该属性的 Effect 函数重新执行:

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return; // 没有依赖,直接返回

  const deps = depsMap.get(key);
  if (!deps) return; // 没有依赖该属性的 effect,直接返回

  // 遍历依赖集合,执行所有 effect
  const effectsToRun = new Set(deps); // 避免无限循环
  effectsToRun.forEach(effect => effect());
}

3.3 与响应式系统结合

联系我们上节已经实现的 Vue 3 响应式系统(一): Reactive ,将依赖收集和触发更新与 getter 和 setter 结合起来:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, key); // 读取时收集依赖
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      trigger(target, key); // 修改时触发更新
      return result;
    },
  });
}

3.4 测试依赖收集

以下是一个完整的测试用例,验证依赖收集和触发更新的逻辑:

const obj = reactive({ count: 0 });

effect(() => {
  console.log(`count is: ${obj.count}`);
});

obj.count++; // 触发 effect 重新执行
obj.count++; // 再次触发 effect 重新执行
输出结果
count is: 0
count is: 1
count is: 2

3.5 依赖收集的流程解析

  1. 初始化阶段

    • 调用 Effect,执行传入的函数。
    • 读取 obj.count,触发 getter,调用 track
    • 将当前 effect 与 obj.count 关联起来。
  2. 更新阶段

    • 修改 obj.count,触发 setter,调用 trigger
    • 找到所有依赖 obj.count 的 Effect 函数,重新执行。
  3. 清理阶段

    • 每次执行 Effect 前,调用 cleanup 清理旧依赖。
    • 确保依赖集合不会无限增长。

4、 嵌套 Effect 解决方案

上面的实现的effect有个问题,那就是当双层effect时,会有如下问题:

示例:

let activeEffect = null;

function effect(fn) {
  activeEffect = fn;
  fn();
  activeEffect = null;
}

// 测试嵌套 effect
effect(() => {
  console.log('外层 effect 执行');
  effect(() => {
    console.log('内层 effect 执行');
  });
  console.log('外层 effect 继续执行');
});

输出结果:

外层 effect 执行
内层 effect 执行

可以看到,外层 effect 在执行完内层 effect 后,activeEffect 被置为 null,导致外层 effect 的后续逻辑无法正确收集依赖。


解决办法:使用栈结构管理嵌套 Effect

为了解决嵌套 effect 的问题,我们可以使用一个栈(effectStack)来保存当前执行的 effect 函数。每次执行 effect 时,将其推入栈中;执行完毕后,将其弹出栈,并恢复上一个 effect

const effectStack = []; // 用于管理嵌套 effect 的栈

function effect(fn) {
  const effectFn = () => {
    activeEffect = effectFn; // 标记当前活跃的 effect
    effectStack.push(effectFn); // 推入栈
    try {
      fn(); // 执行副作用函数
    } finally {
      effectStack.pop(); // 执行完毕后弹出栈
      activeEffect = effectStack[effectStack.length - 1]; // 恢复上一个 effect
    }
  };
  effectFn.deps = []; // 存储该 effect 依赖的集合
  effectFn(); // 立即执行一次
}

测试嵌套 effect

effect(() => {
 console.log('外层 effect 执行');
 effect(() => {
   console.log('内层 effect 执行');
 });
 console.log('外层 effect 继续执行');
});

输出结果

外层 effect 执行
内层 effect 执行
外层 effect 继续执行

执行流程解析

  1. 外层 effect 开始执行,effectStack 变为 [外层 effect]
  2. 内层 effect 开始执行,effectStack 变为 [外层 effect, 内层 effect]
  3. 内层 effect 执行完毕,effectStack 弹出内层 effect,恢复为 [外层 effect]
  4. 外层 effect 继续执行后续逻辑。

5、 调度系统

嵌套解决完了,但此时发现一个新的问题:

const obj = reactive({ count: 0 });

effect(() => {
  console.log(`count is: ${obj.count}`);
});

// 连续多次修改响应式数据
obj.count++;
obj.count++;
obj.count++;

输出结果:

count is: 0
count is: 1
count is: 2
count is: 3

我们发现:

  • 每次修改 obj.count 时,都会立即触发 effect 函数的执行。
  • 在这个例子中,effect 函数被连续执行了 3 次,但实际上我们只关心最终的结果(count is: 3),中间的两次执行是多余的。
  • 如果 effect 函数的逻辑非常复杂(例如涉及大量计算或 DOM 操作),这种频繁的执行会导致严重的性能问题。

问题的根源

问题的根源在于:当前的响应式系统在每次数据变化时都会立即执行副作用函数,而没有对执行时机进行优化。我们需要一种机制,能够将多次数据变化合并为一次副作用函数的执行,从而避免不必要的重复计算。

解决方案:引入调度系统

为了解决这个问题,我们可以引入一个调度系统(Scheduler) 。调度系统的核心思想是:将副作用函数的执行推迟到合适的时机,而不是在每次数据变化时立即执行。通过这种方式,我们可以:

  1. 合并多次更新:将短时间内多次数据变化合并为一次副作用函数的执行。
  2. 优化性能:减少不必要的重复计算,提升响应式系统的性能。

实现调度系统

我们可以通过以下步骤实现一个简单的调度系统:

  1. 任务队列:使用一个队列来存储需要执行的副作用函数。
  2. 延迟执行:利用 JavaScript 的微任务机制(如 Promise.resolve().then())将副作用函数的执行推迟到当前任务结束后。
  3. 批量执行:在合适的时机一次性执行队列中的所有任务。

以下是调度系统的具体实现:

let isFlushing = false; // 是否正在刷新队列
const queue = new Set(); // 任务队列

function flushQueue() {
  if (isFlushing) return; // 如果正在刷新队列,直接返回
  isFlushing = true; // 标记为正在刷新队列

  // 创建一个新的 Set 避免无限循环
  const effectsToRun = new Set(queue);
  queue.clear(); // 清空队列

  // 执行所有任务
  effectsToRun.forEach(effect => effect());

  isFlushing = false; // 标记为刷新完成
}

function scheduler(effect) {
  queue.add(effect); // 将副作用函数加入队列
  Promise.resolve().then(flushQueue); // 使用微任务延迟执行
}

将调度系统与 effect 结合

我们可以将调度系统与 effect 函数结合,使得副作用函数的执行可以被调度器控制。具体来说,可以在 trigger 函数中调用调度器,而不是立即执行副作用函数。

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return; // 没有依赖,直接返回

  const deps = depsMap.get(key);
  if (!deps) return; // 没有依赖该属性的 effect,直接返回

  // 创建一个新的 Set 避免无限循环
  const effectsToRun = new Set();

  deps.forEach(effect => {
    // 避免重复触发当前正在执行的 effect
    if (effect !== activeEffect) {
      effectsToRun.add(effect);
    }
  });

  //  effectsToRun.forEach(effect => scheduler(effect));
}

测试调度系统

我们再次运行之前的例子:

const obj = reactive({ count: 0 });

effect(() => {
  console.log(`count is: ${obj.count}`);
});

// 连续多次修改响应式数据
obj.count++;
obj.count++;
obj.count++;

console.log('测试完成');

输出结果:

count is: 0
测试完成
count is: 3

执行流程解析:

  1. effect 首次执行,读取 obj.count,触发 track,收集依赖。
  2. 连续修改 obj.count 三次,触发 trigger,但由于调度器的存在,副作用函数不会立即执行。
  3. 所有同步代码执行完毕后,调度器开始工作,将队列中的副作用函数一次性执行。
  4. 最终输出 count is: 3,避免了中间的重复执行。

通过引入调度系统,我们解决了:

  1. 性能优化:避免了短时间内多次数据变化导致的重复执行。
  2. 执行时机控制:可以将副作用函数的执行推迟到合适的时机,例如 DOM 更新后或异步任务完成后(后续的computed等,就要依赖这个)

6、 依赖清理机制

又一个问题,在条件分支中,依赖可能会动态变化。例如:

const obj = reactive({ show: true, text: 'hello' });

effect(() => {
  if (obj.show) {
    console.log(obj.text); // 初次执行收集 text 依赖
  }
});

obj.show = false; // 条件分支切换
obj.text = 'modified'; // 此时不应触发 effect,但基础实现仍会触发

在effect中,虽然依赖了obj.showobj.text

但如果obj.show为false的情况下,obj.text再怎么变化也不会影响effect中的执行的,但我们的例子中,text属性的再次变化,依然引起了effect的再次执行,这与我们期望不同且造成了浪费。

解决方案:清理旧依赖

每次执行 effect 前,清理其旧依赖,避免冗余更新。

function cleanup(effectFn) {
  for (const dep of effectFn.deps) { // 遍历所有依赖集合
    dep.delete(effectFn); // 从集合中移除当前 effect
  }
  effectFn.deps.length = 0; // 重置依赖集合
}

更新后的 effect 实现

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn); // 清理旧依赖
    activeEffect = effectFn;
    effectStack.push(effectFn);
    try {
      fn(); // 在执行过程中,访问数据时又会触发track函数,又会将effect重新
    } finally {
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1];
    }
  };
  effectFn.deps = []; // 初始化依赖集合
  effectFn();
}

测试条件分支

const obj = reactive({ show: true, text: 'hello' });

effect(() => {
  console.log(obj.show ? obj.text : 'nothing');
});

obj.show = false; // 条件分支切换
obj.text = 'modified'; // 不应触发 effect

输出结果

hello
nothing

可以看到,当 obj.show 为 false 时,修改 obj.text 不会触发 effect,因为旧依赖已被清理。


7、 避免无限循环

在实现依赖收集和触发更新的过程中,可能会遇到无限循环的问题。例如,当一个 effect 函数在执行时修改了某个响应式属性,而这个属性又依赖了当前 effect,就会导致 effect 不断重新执行,形成无限循环。


7.1 问题复现

以下是一个典型的无限循环场景:

const obj = reactive({ count: 0 });

effect(() => {
  obj.count++; // 在 effect 中修改依赖的属性
  console.log(`count is: ${obj.count}`);
});
执行流程
  1. effect 首次执行,读取 obj.count,触发 track,收集依赖。
  2. 修改 obj.count,触发 trigger,通知所有依赖的 effect 重新执行。
  3. effect 重新执行,再次修改 obj.count,触发 trigger
  4. 重复上述过程,导致无限循环。

7.2 解决方案

为了避免无限循环,我们需要在 trigger 中避免重复触发当前正在执行的 effect。具体来说,可以通过以下方式实现:

  1. 在 trigger 中,使用一个新的 Set 来存储需要执行的 effect 函数。
  2. 如果当前 effect 已经在执行中,则跳过。

7.3 实现避免无限循环

修改 trigger 函数
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return; // 没有依赖,直接返回

  const deps = depsMap.get(key);
  if (!deps) return; // 没有依赖该属性的 effect,直接返回

  // 创建一个新的 Set 避免无限循环
  const effectsToRun = new Set();

  deps.forEach(effect => {
    // 避免重复触发当前正在执行的 effect
    if (effect !== activeEffect) {
      effectsToRun.add(effect);
    }
  });

  // 执行所有需要运行的 effect
  effectsToRun.forEach(effect => effect());
}
修改 effect 函数

在 effect 函数中,确保 activeEffect 在执行期间不会被重复触发:

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn); // 清理旧依赖
    activeEffect = effectFn; // 标记当前活跃的 effect
    effectStack.push(effectFn); // 推入栈
    try {
      fn(); // 执行副作用函数
    } finally {
      effectStack.pop(); // 执行完毕后弹出栈
      activeEffect = effectStack[effectStack.length - 1]; // 恢复上一个 effect
    }
  };
  effectFn.deps = []; // 初始化依赖集合
  effectFn(); // 立即执行一次
}

7.4 测试避免无限循环

以下是一个测试用例,验证避免无限循环的逻辑:

const obj = reactive({ count: 0 });

effect(() => {
  obj.count++; // 在 effect 中修改依赖的属性
  console.log(`count is: ${obj.count}`);
});

console.log('测试完成');
输出结果
count is: 1
count is: 2
测试完成
执行流程解析
  1. effect 首次执行,读取 obj.count,触发 track,收集依赖。
  2. 修改 obj.count,触发 trigger,但由于当前 effect 正在执行,跳过重复触发。
  3. effect 执行完毕,输出 count is: 1
  4. 由于 obj.count 被修改,effect 再次执行,但不会进入无限循环。
  5. 最终输出 count is: 2 和 测试完成

7.5 总结

通过避免重复触发当前正在执行的 effect,我们成功解决了无限循环的问题。具体实现包括:

  1. 在 trigger 中,使用新的 Set 存储需要执行的 effect
  2. 跳过当前正在执行的 effect,避免重复触发。

这种机制确保了响应式系统在复杂场景下的稳定性,例如:

  • 在 effect 中修改依赖的属性。
  • 多个 effect 相互依赖的场景。

8、总结

至此,我们实现了一个基础的响应式系统:

  1. reactive 通过 Proxy 拦截对象操作。
  2. effect 注册副作用函数,并在依赖变化时重新执行。
  3. 依赖收集(track)和触发更新(trigger)通过全局的 targetMap 管理。
  4. 调度器机制避免无限循环并控制执行时机。