专题四:ref 的实现

🎯 本节目标

reactive 使用 Proxy 拦截对象,但 Proxy 有个致命弱点:它不能拦截基本数据类型(Primitives) ,比如 number, string, boolean

  • let a = 1; -> 这是一个值,没法在他身上绑 get/set。

为了解决这个问题,Vue 3 引入了 ref

  1. Ref : 既然基本类型不能拦截,那我就把它包裹在一个对象里 (Class),这个对象有个属性叫 .value

  2. Accessors : 利用类属性的 getset 拦截 .value 的访问。

  3. proxyRefs : 让你在 template 里写 {``{ count }} 而不用写 {``{ count.value }} 的魔法。


第一步:实现基础 ref

我们先实现最简单的功能:把一个数字变成响应式。

1. 编写测试用例 (Red)

新建 src/reactivity/tests/ref.spec.ts

TypeScript

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

describe("ref", () => {
  it("should hold a value", () => {
    const a = ref(1);
    expect(a.value).toBe(1);
  });

  it("should be reactive", () => {
    const a = ref(1);
    let dummy;
    let calls = 0;

    effect(() => {
      calls++;
      dummy = a.value;
    });

    expect(calls).toBe(1);
    expect(dummy).toBe(1);

    // 修改值,触发更新
    a.value = 2;
    expect(calls).toBe(2);
    expect(dummy).toBe(2);

    // 设置同样的值,不应该触发更新
    a.value = 2;
    expect(calls).toBe(2);
  });
});

2. 代码实现 (Green)

新建 src/reactivity/ref.ts

关键点ref 不像 reactive 那样依赖全局的 targetMap 来存储依赖。因为 ref 本身就是一个对象,它只关心 value 这一个值的变化,所以它的依赖(Dep)直接存在自己实例内部的一个 Set 里即可。

为了复用逻辑,我们需要先去 src/reactivity/effect.ts 做一点小改动,把依赖收集和触发的底层逻辑暴露出来。

A. 准备工作:修改 effect.ts

我们需要把 tracktrigger 里关于"是否收集"和"执行 effect"的逻辑拆分一下(在 Vue 源码中叫 trackEffectstriggerEffects)。

打开 src/reactivity/effect.ts,添加这两个辅助函数:

TypeScript

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

// ... 原有的代码 ...

// 新增:判断是否需要收集
export function isTracking() {
  return shouldTrack && activeEffect !== undefined;
}

// 新增:直接收集依赖到指定的 dep (Set) 中
export function trackEffects(dep) {
  // 看看 dep 里有没有这个 effect,没有就加进去
  if (dep.has(activeEffect)) return;
  
  dep.add(activeEffect);
  activeEffect.deps.push(dep); // (这一步是反向收集,暂时不用管,为了完整性可以先加上)
}

// 新增:触发指定的 dep (Set) 中的所有 effect
export function triggerEffects(dep) {
  for (const effect of dep) {
    if (effect.scheduler) {
      effect.scheduler();
    } else {
      effect.run();
    }
  }
}

(注意:如果你的 effect.ts 还没实现 scheduler,忽略上面的 scheduler 判断即可,直接 run)

B. 实现 RefImpl 类

现在编写 src/reactivity/ref.ts

TypeScript

复制代码
// src/reactivity/ref.ts
import { isTracking, trackEffects, triggerEffects } from "./effect";
import { hasChanged } from "../shared"; // 假设你有个工具库,没有的话就在本文件写个对比函数

class RefImpl {
  private _value: any;
  public dep; // 存放依赖的 Set
  private _rawValue: any; // 存原始值,用于对比

  constructor(value) {
    this._rawValue = value;
    this._value = value;
    // 既然是 ref,就得有自己的 dep 容器
    this.dep = new Set();
  }

  get value() {
    // 收集依赖
    trackRefValue(this);
    return this._value;
  }

  set value(newVal) {
    // 只有值变了才触发通知 (对比 hasChanged)
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
      this._value = newVal;
      triggerEffects(this.dep);
    }
  }
}

function trackRefValue(ref) {
  if (isTracking()) {
    trackEffects(ref.dep);
  }
}

export function ref(value) {
  return new RefImpl(value);
}

补丁:src/shared/index.ts (如果没有就新建) 添加:

TypeScript

复制代码
export const hasChanged = (val, newVal) => {
  return !Object.is(val, newVal);
};

运行测试 npm run test。 🎉 通过!


第二步:Ref 嵌套对象的情况

如果 ref({ count: 1 }),Vue 的处理方式是:把里面的对象自动转换成 reactive

1. 增加测试用例

src/reactivity/tests/ref.spec.ts 添加:

TypeScript

复制代码
  it("should make nested properties reactive", () => {
    const a = ref({
      count: 1,
    });
    let dummy;
    effect(() => {
      dummy = a.value.count;
    });
    expect(dummy).toBe(1);
    
    // 修改内部对象的属性
    a.value.count = 2;
    expect(dummy).toBe(2);
  });

2. 修改 Ref 实现

我们需要引入 reactive,并在设置值的时候判断是否为对象。

TypeScript

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

// 辅助函数:如果是对象就 wrap 成 reactive,否则直接返回
function convert(value) {
  return isObject(value) ? reactive(value) : value;
}

// src/shared/index.ts 里补一个 isObject
// export const isObject = (val) => val !== null && typeof val === "object";

class RefImpl {
  // ... 属性不变 ...

  constructor(value) {
    this._rawValue = value;
    // 核心修改:初始化时进行转换
    this._value = convert(value);
    this.dep = new Set();
  }

  get value() {
    trackRefValue(this);
    return this._value;
  }

  set value(newVal) {
    // 核心修改:对比的时候要用 rawValue (原始对象) 对比
    if (hasChanged(newVal, this._rawValue)) {
      this._rawValue = newVal;
      this._value = convert(newVal); // 如果 set 的新值是对象,也要转换
      triggerEffects(this.dep);
    }
  }
}

运行测试。🎉 通过!


第三步:isRef 和 unRef

这两个是常用的工具函数。

  • isRef: 检查变量是否是 ref。

  • unRef: 如果是 ref 返回 .value,否则返回本身。(常用于 val = unRef(val) 防御性编程)。

1. 测试用例

src/reactivity/tests/ref.spec.ts

TypeScript

复制代码
  it("isRef", () => {
    const a = ref(1);
    const user = reactive({ age: 1 });
    expect(isRef(a)).toBe(true);
    expect(isRef(1)).toBe(false);
    expect(isRef(user)).toBe(false);
  });

  it("unRef", () => {
    const a = ref(1);
    expect(unRef(a)).toBe(1);
    expect(unRef(1)).toBe(1);
  });

2. 代码实现

RefImpl 加一个标志位。

TypeScript

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

class RefImpl {
  public __v_isRef = true; // 标志位
  // ...
}

export function isRef(ref) {
  return !!ref.__v_isRef;
}

export function unRef(ref) {
  // 是 ref 就拿 value,不是就拿本身
  return isRef(ref) ? ref.value : ref;
}

第四步:proxyRefs (魔法时刻 🪄)

这可能是本节最晦涩但最有用的部分。 场景 :在 Vue 的 template 里,你写 {``{ count }} 就可以直接显示 ref 的值,不需要写 {``{ count.value }}原理 :Vue 在 setup 返回对象给 template 时,套了一层 proxyRefs

1. 测试用例

src/reactivity/tests/ref.spec.ts

TypeScript

复制代码
  it("proxyRefs", () => {
    const user = {
      age: ref(10),
      name: "xiaohong",
    };
    
    // 创建代理
    const proxyUser = proxyRefs(user);
    
    // get: 不需要 .value
    expect(user.age.value).toBe(10);
    expect(proxyUser.age).toBe(10);
    expect(proxyUser.name).toBe("xiaohong");

    // set: 不需要 .value
    proxyUser.age = 20;
    expect(proxyUser.age).toBe(20);
    expect(user.age.value).toBe(20);

    // set: 如果把 ref 换成普通值,原 ref 应该被替换
    proxyUser.age = ref(10);
    expect(proxyUser.age).toBe(10);
    expect(user.age.value).toBe(10);
  });

2. 代码实现

这也是一个 Proxy,核心逻辑是:get 时如果是 ref 就拆包,set 时如果是 ref 就赋值给 .value

TypeScript

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

export function proxyRefs(objectWithRefs) {
  return new Proxy(objectWithRefs, {
    get(target, key) {
      // 这里的 unRef 帮我们省去了 .value
      return unRef(Reflect.get(target, key));
    },
    set(target, key, value) {
      // 这里的逻辑有点绕:
      // 如果原来的值是 ref,并且新值不是 ref,那我们要去修改原来那个 ref 的 .value
      if (isRef(target[key]) && !isRef(value)) {
        return (target[key].value = value);
      } else {
        return Reflect.set(target, key, value);
      }
    },
  });
}

运行测试。🎉 全部通过!


🧠 核心知识点总结 (Review)

  1. Ref vs Reactive:

    • Reactive 是 Proxy,针对对象,自动拦截。

    • Ref 是 Class,针对基本类型(或对象),利用 getter/setter 拦截 .value

    • Ref 包裹对象时,内部其实还是调用的 Reactive。

  2. 依赖收集差异:

    • Reactive 依赖全局 targetMap (WeakMap)。

    • Ref 依赖实例内部的 this.dep (Set)。

  3. proxyRefs:

    • 这是 Vue 3 在模板中自动脱 ref 的原理,本质上是一个针对 ref 特殊处理的 Proxy。

✅ 你的今日任务

  1. 修改 effect.ts : 抽离 trackEffectstriggerEffects

  2. 实现 Ref : 完成 RefImpl 类及其单测。

  3. 实现 proxyRefs: 完成自动脱 ref 功能。

(Ref 是个盒子,Reactive 是个守卫。proxyRefs 是个自动开箱机)

现在,你已经手握 Vue 3 响应式系统最核心的两把武器了(Ref & Reactive)。 下一节,我们要进入 专题五:computed 计算属性 ,它将把你刚学的 effectref 完美结合起来,并引入 缓存调度器 的概念。

相关推荐
计算机学姐9 小时前
基于SpringBoot的校园资源共享系统【个性化推荐算法+数据可视化统计】
java·vue.js·spring boot·后端·mysql·spring·信息可视化
澄江静如练_10 小时前
优惠券提示文案表单项(原生div写的)
前端·javascript·vue.js
Irene199110 小时前
Vue2 与 Vue3 响应式实现对比(附:Proxy 详解)
vue.js·响应式实现
JQLvopkk11 小时前
Vue框架技术详细介绍及阐述
前端·javascript·vue.js
+VX:Fegn089511 小时前
计算机毕业设计|基于springboot + vue物流配送中心信息化管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·小程序·课程设计
北辰alk11 小时前
一文解锁vue3中hooks的使用姿势
vue.js
北辰alk11 小时前
vue3 如何监听路由变化
vue.js
北辰alk11 小时前
Vue3 生命周期深度解析:从 Options API 到 Composition API 的完整指南
vue.js