专题二:核心机制 —— reactive 与 effect

🎯 本节目标

Vue 3 响应式的核心魔法在于:当数据改变时,自动执行相关的函数。 为了实现这个目标,我们需要搞定两个狠角色:

  1. Reactive : 使用 Proxy 拦截数据的读取和修改。

  2. Effect: 一个副作用函数,负责收集依赖并在数据变化时重新执行。


第一步:实现最基础的 reactive

我们先不考虑复杂的依赖收集,先定一个小目标:把一个普通对象变成 Proxy 代理对象

1. 编写测试用例 (Red)

新建 src/reactivity/tests/reactive.spec.ts。 我们需要验证两点:

  1. reactive 返回的对象和原对象不一样(是被代理过的)。

  2. reactive 返回的对象依然能像原对象一样访问属性。

TypeScript

复制代码
// src/reactivity/tests/reactive.spec.ts
import { reactive } from "../reactive";

describe("reactive", () => {
  it("happy path", () => {
    const original = { foo: 1 };
    const observed = reactive(original);
    
    // 1. 它们不应该是同一个对象
    expect(observed).not.toBe(original);
    // 2. 依然能访问属性
    expect(observed.foo).toBe(1);
  });
});

此时运行 npm run test,一定会报错,因为我们连 reactive.ts 都还没建。

2. 编写代码 (Green)

新建 src/reactivity/reactive.ts。 这里我们利用 ES6 的 Proxy 来拦截对象。

TypeScript

复制代码
// src/reactivity/reactive.ts

export function reactive(raw) {
  return new Proxy(raw, {
    // 拦截读取操作
    get(target, key) {
      // Reflect.get 是规范的读取方式,等价于 target[key]
      const res = Reflect.get(target, key);
      // TODO: 这里以后要收集依赖 (track)
      return res;
    },
    // 拦截设置操作
    set(target, key, value) {
      const res = Reflect.set(target, key, value);
      // TODO: 这里以后要触发更新 (trigger)
      return res;
    }
  });
}

再次运行 npm run test。 🎉 通过! 我们迈出了第一步,成功拦截了对象的读写。


第二步:实现 effect 与依赖收集 (核心!)

这是 Vue 3 最难理解的地方。我们用一个测试用例把逻辑串起来。

1. 编写测试用例 (Red)

新建 src/reactivity/tests/effect.spec.ts。 这是整个响应式系统的灵魂用例,请务必读懂它:

TypeScript

复制代码
// src/reactivity/tests/effect.spec.ts
import { reactive } from "../reactive";
import { effect } from "../effect";

describe("effect", () => {
  it("happy path", () => {
    // 1. 创建一个响应式对象
    const user = reactive({
      age: 10,
    });

    let nextAge;
    // 2. effect 函数会默认先执行一次
    effect(() => {
      nextAge = user.age + 1;
    });

    // 3. 验证首次执行结果
    expect(nextAge).toBe(11);

    // 4. update: 修改响应式对象的值
    user.age++;

    // 5. 核心目标:user.age 变了,effect 里的函数应该自动重新执行,nextAge 应该变成 12
    expect(nextAge).toBe(12);
  });
});

运行测试,会报错:

  1. 找不到 effect 函数。

  2. 即使有了 effect,修改 user.agenextAge 也不会变,因为我们还没把它们关联起来。

2. 实现 Effect 类的框架

新建 src/reactivity/effect.ts。 我们需要一个全局变量 activeEffect 来记录当前正在执行的副作用函数。

TypeScript

复制代码
// src/reactivity/effect.ts

class ReactiveEffect {
  private _fn: any;

  constructor(fn) {
    this._fn = fn;
  }

  run() {
    // 1. 标记当前活动的 effect 是我自己
    activeEffect = this;
    // 2. 执行传入的 fn (这时候 fn 内部会读取 data.age,触发 reactive 的 get)
    this._fn();
  }
}

// 全局变量,用来暂存当前正在执行的 effect
let activeEffect;

export function effect(fn) {
  const _effect = new ReactiveEffect(fn);
  // effect 默认初始化执行一次
  _effect.run();
}

3. 实现依赖收集 (Track)

现在 reactiveeffect 还是割裂的。 我们需要在读取属性(get)时,把当前的 activeEffect 存起来。

数据结构设计(非常重要): 我们需要建立这样的映射关系: target (对象) -> key (属性) -> dep (依赖的 effect 集合)

(示意:WeakMap 存对象 -> Map 存 Key -> Set 存 Effect)

src/reactivity/effect.ts 中添加 track 函数:

TypeScript

复制代码
// src/reactivity/effect.ts

// 仓库:用来存所有的依赖关系
// WeakMap 的 key 必须是对象,且弱引用(利于垃圾回收)
const targetMap = new WeakMap();

export function track(target, key) {
  // 如果当前没有 activeEffect,说明不是在 effect 函数里读取的,不需要收集
  if (!activeEffect) return;

  // 1. 根据 target 找 depsMap
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  // 2. 根据 key 找 dep (Set)
  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }

  // 3. 把当前 effect 存进去
  dep.add(activeEffect);
}

4. 实现触发更新 (Trigger)

在设置属性(set)时,把刚才收集到的 effect 拿出来运行。

src/reactivity/effect.ts 中添加 trigger 函数:

TypeScript

复制代码
// src/reactivity/effect.ts

export function trigger(target, key) {
  let depsMap = targetMap.get(target);
  let dep = depsMap.get(key);

  // 遍历所有收集到的 effect 并执行
  if (dep) {
    for (const effect of dep) {
      effect.run();
    }
  }
}

5. 将 Track 和 Trigger 接入 Reactive

最后一步,回到 src/reactivity/reactive.ts,在 get 里调 track,在 set 里调 trigger

TypeScript

复制代码
// src/reactivity/reactive.ts
import { track, trigger } from "./effect"; // 引入刚才写的函数

export function reactive(raw) {
  return new Proxy(raw, {
    get(target, key) {
      const res = Reflect.get(target, key);
      
      // 核心:收集依赖!
      track(target, key);
      return res;
    },
    set(target, key, value) {
      const res = Reflect.set(target, key, value);
      
      // 核心:触发更新!
      trigger(target, key);
      return res;
    }
  });
}

第三步:见证奇迹

现在,回到终端,再次运行:

Bash

复制代码
npm run test

如果代码没写错,你应该会看到令人激动的绿色: ✓ src/reactivity/tests/effect.spec.ts

这意味着:

  1. effect(() => nextAge = user.age + 1) 执行,触发 user.ageget

  2. get 调用 track,把这个 effect 存进了 targetMap

  3. user.age++ 修改值,触发 set

  4. set 调用 trigger,找出了刚才存的 effect。

  5. effect 重新运行,nextAge 更新为 12。


🧠 核心知识点总结 (Review)

  1. Proxy: 是响应式的拦截器,它不需要像 Vue 2 那样递归遍历对象属性,而是懒拦截(访问到了才拦截)。

  2. WeakMap -> Map -> Set: 这是 Vue 3 存储依赖的数据结构金字塔。

    • WeakMap: 存 Target 对象 (如果不被引用了,自动回收)。

    • Map: 存 Target 的 Key。

    • Set: 存 Key 对应的 Effect 集合 (Set 可以自动去重,防止重复添加同一个 effect)。

  3. activeEffect: 一个全局指针,巧妙地解决了 "如何在 get 中获取当前正在执行的函数" 这个问题。

✅ 你的今日任务

  1. 代码实现 :按照教程完成 reactive.tseffect.ts

  2. 调试 :在 tracktrigger 里加上 console.log,观察一下打印顺序,看看到底是什么时候收集、什么时候触发的。

  3. 思考 :现在的 effect 很基础。如果我通过 effect 返回一个 runner 函数,手动调用它也能执行,该怎么做?(这将是下一节 schedulerstop 功能的基础)。

相关推荐
代码老祖17 小时前
vue3 vue-pdf-embed实现pdf自定义分页+关键词高亮
前端·javascript
未等与你踏清风17 小时前
Elpis npm 包抽离总结
前端·javascript
前端小菜鸟也有人起17 小时前
浏览器不支持vue router
前端·javascript·vue.js
腥臭腐朽的日子熠熠生辉17 小时前
nest js docker 化全流程
开发语言·javascript·docker
奔跑的web.17 小时前
Vue 事件系统核心:createInvoker 函数深度解析
开发语言·前端·javascript·vue.js
再希17 小时前
TypeScript初体验(四)在React中使用TS
javascript·react.js·typescript
江公望17 小时前
VUE3中,reactive()和ref()的区别10分钟讲清楚
前端·javascript·vue.js
徐同保18 小时前
上传文件,在前端用 pdf.js 提取 上传的pdf文件中的图片
前端·javascript·pdf
怕浪猫18 小时前
React从入门到出门第四章 组件通讯与全局状态管理
前端·javascript·react.js