专题四: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 完美结合起来,并引入 缓存调度器 的概念。

相关推荐
冬奇Lab1 小时前
RAG 系列(九):效果不好怎么定位——用 RAGAS 做根因诊断
人工智能·llm·源码
kyriewen113 小时前
你等的Babel编译,够喝三杯咖啡了——用Rust重写的SWC,只需眨个眼
开发语言·前端·javascript·后端·性能优化·rust·前端框架
Momo__5 小时前
Vue 3.6 Vapor Mode:跳过虚拟 DOM,性能极致优化
前端·vue.js
walking9576 小时前
重新学习前端之JavaScript
前端·vue.js·面试
walking9576 小时前
重新学习前端之HTML
前端·vue.js·面试
walking9576 小时前
重新学习前端之Vue
前端·vue.js·面试
泉城老铁6 小时前
springboot实现word转换pdf
vue.js·后端
walking9576 小时前
重新学习前端之Linux
前端·vue.js·面试
walking9576 小时前
重新学习前端之CSS
前端·vue.js·面试
walking9576 小时前
重新学习前端之Git
前端·vue.js·面试