专题三:完善响应式 —— readonly 与 isReactive

🎯 本节目标

  1. 实现 readonly:创建一个只读的响应式对象,它不能被修改,且不需要收集依赖(因为根本不会变)。

  2. 实现工具函数 :isReactive 和 isReadonly,用于判断一个对象是什么类型。

  3. 代码重构 :因为 reactive 和 readonly 极其相似,我们需要把 Proxy 的 handler 抽离出来,避免代码冗余。


第一步:实现 readonly

readonly 和 reactive 的区别在于:

  1. Set : readonly 不允许修改,修改时应该报警(console.warn)。

  2. Get : readonly 依然可以读取,但因为它不会变,所以不需要调用 track 收集依赖。

1. 编写测试用例 (Red)

新建 src/reactivity/tests/readonly.spec.ts:

TypeScript

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

describe("readonly", () => {
  it("should make nested values readonly", () => {
    const original = { foo: 1, bar: { baz: 2 } };
    const wrapped = readonly(original);

    // 1. 依然是 Proxy,不等于原对象
    expect(wrapped).not.toBe(original);
    // 2. 能够读取值
    expect(wrapped.foo).toBe(1);
  });

  it("should call console.warn when set", () => {
    // 模拟 console.warn
    console.warn = vi.fn();

    const user = readonly({
      age: 10,
    });

    // 修改只读属性
    user.age = 11;

    // 期望 warn 被调用
    expect(console.warn).toBeCalled();
  });
});

2. 代码重构与实现 (Refactor & Green)

现在 reactive.ts 里的代码会变得臃肿。我们需要把 get 和 set 的逻辑抽离出去。

新建 src/reactivity/baseHandlers.ts。这是一个非常经典的文件结构,Vue 3 源码也是这么拆分的。

A. 编写 baseHandlers.ts

我们需要两个 Handler:

  • mutableHandlers (给 reactive 用)

  • readonlyHandlers (给 readonly 用)

这里利用高阶函数(createGetter/createSetter)来生成函数,通过传入参数控制行为。

TypeScript

复制代码
// src/reactivity/baseHandlers.ts
import { track, trigger } from "./effect";

// 缓存 get 和 set,只创建一次,提高性能
const get = createGetter();
const set = createSetter();
const readonlyGet = createGetter(true);

// 高阶函数:生成 getter
function createGetter(isReadonly = false) {
  return function get(target, key) {
    const res = Reflect.get(target, key);

    if (!isReadonly) {
      // 只有非只读的时候,才需要收集依赖
      track(target, key);
    }

    return res;
  };
}

// 高阶函数:生成 setter
function createSetter() {
  return function set(target, key, value) {
    const res = Reflect.set(target, key, value);
    // 触发更新
    trigger(target, key);
    return res;
  };
}

// 普通响应式对象的 handler
export const mutableHandlers = {
  get,
  set,
};

// 只读对象的 handler
export const readonlyHandlers = {
  get: readonlyGet,
  set(target, key) {
    // 报警
    console.warn(`key: ${String(key)} set 失败,因为 target 是 readonly`, target);
    return true;
  },
};
B. 修改 reactive.ts

现在 reactive.ts 就变得非常清爽了,只负责创建 Proxy。

TypeScript

复制代码
// src/reactivity/reactive.ts
import { mutableHandlers, readonlyHandlers } from "./baseHandlers";

export function reactive(raw) {
  return createActiveObject(raw, mutableHandlers);
}

export function readonly(raw) {
  return createActiveObject(raw, readonlyHandlers);
}

// 内部通用函数:创建 Proxy
function createActiveObject(raw, baseHandlers) {
  return new Proxy(raw, baseHandlers);
}

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


第二步:实现 isReactive 与 isReadonly

这是 Vue 面试中的高频题:怎么判断一个对象是被 Proxy 代理过的? 答案是:给它设个陷阱。 我们在 get 里在这个对象上读取一个特殊的 Key(比如 __v_isReactive),如果读取成功了,说明触发了 Proxy 的 get 拦截,那就是响应式的;如果是普通对象,读取这个 Key 会返回 undefined。

1. 编写测试用例 (Red)

更新 src/reactivity/tests/reactive.spec.ts 和 readonly.spec.ts。

src/reactivity/tests/reactive.spec.ts:

TypeScript

复制代码
import { reactive, isReactive } from "../reactive";

describe("reactive", () => {
  it("happy path", () => {
    const original = { foo: 1 };
    const observed = reactive(original);
    
    // 新增测试
    expect(isReactive(observed)).toBe(true);
    expect(isReactive(original)).toBe(false);
  });
});

src/reactivity/tests/readonly.spec.ts:

TypeScript

复制代码
import { readonly, isReadonly } from "../reactive";

describe("readonly", () => {
  it("should make nested values readonly", () => {
    const wrapped = readonly({ foo: 1 });
    
    // 新增测试
    expect(isReadonly(wrapped)).toBe(true);
    expect(isReadonly({ foo: 1 })).toBe(false);
  });
});

2. 实现逻辑 (Green)

A. 修改 baseHandlers.ts

我们需要定义两个枚举字符串(Flags),当用户访问这两个 key 时,直接返回 true。

TypeScript

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

// 定义枚举以便复用
export const enum ReactiveFlags {
  IS_REACTIVE = "__v_isReactive",
  IS_READONLY = "__v_isReadonly",
}

function createGetter(isReadonly = false) {
  return function get(target, key) {
    // 拦截特殊 key 的读取
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly;
    } else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly;
    }

    const res = Reflect.get(target, key);

    if (!isReadonly) {
      track(target, key);
    }
    return res;
  };
}
// ... set 和其他代码保持不变
B. 修改 reactive.ts

导出这两个工具函数。这里的实现技巧非常巧妙:我们尝试去读取那个特殊的属性,如果对象是 Proxy,就会触发 baseHandlers 里的 get,从而返回 true。

TypeScript

复制代码
// src/reactivity/reactive.ts
import { mutableHandlers, readonlyHandlers, ReactiveFlags } from "./baseHandlers";

export function reactive(raw) {
  return createActiveObject(raw, mutableHandlers);
}

export function readonly(raw) {
  return createActiveObject(raw, readonlyHandlers);
}

// 检查是否是 Reactive
export function isReactive(value) {
  // 如果 value 是普通对象,value["__v_isReactive"] 会是 undefined,转为布尔值就是 false
  // 如果 value 是 proxy,会触发 get,返回 true
  return !!value[ReactiveFlags.IS_REACTIVE];
}

// 检查是否是 Readonly
export function isReadonly(value) {
  return !!value[ReactiveFlags.IS_READONLY];
}

function createActiveObject(raw, baseHandlers) {
  return new Proxy(raw, baseHandlers);
}

再次运行 npm run test。 🎉 全部通过!


🧠 核心知识点总结 (Review)

  1. 代码重构 (Refactoring):

    • 通过 createGetter (高阶函数) 传递 isReadonly 参数,我们用一套逻辑同时处理了 reactive 和 readonly,大大减少了重复代码。
  2. Flags 模式:

    • isReactive 的实现原理不是在对象上真的加了一个属性,而是通过 拦截 get 。你问我 "你是 Reactive 吗?"(访问 __v_isReactive),如果是 Proxy,我就拦截回答 "Yes";如果是普通对象,没人拦截,自然返回 undefined。
  3. Readonly 的优化:

    • readonly 不会变,所以不需要 track。这是 Vue 3 性能优化的细节之一。

✅ 你的今日任务

  1. 重构 :将你的代码拆分为 baseHandlers.ts 和 reactive.ts。

  2. 实现 :完成 readonly 功能及 isReactive/isReadonly 检查。

  3. 测试:确保所有测试用例(包括上一节的 effect 测试)全部通过。

下一节,进入 专题四:ref 的实现 。这时候你会面临一个有趣的问题:reactive 只能代理对象,那我想把一个数字 1 变成响应式怎么办?

相关推荐
哈基米南北3 天前
ViewRootImpl 事件分发责任链分析
源码
Amos_Web3 天前
Rspack 源码解析(十二):JavaScript Chunk 是如何被渲染出来的
前端·rust·源码
RuoyiOffice3 天前
2026最推荐基于SpringBoot+Vue3的项目管理经营一体化:从商机、合同、任务与工时走到回款和利润
spring boot·项目管理·vue3·crm·合同管理·ruoyi office·项目经营
淡海水4 天前
13-04-面试-源码级深度追问链
数据结构·unity·面试·c#·游戏引擎·源码·il2cpp
RuoyiOffice4 天前
SpringBoot+Vue3 节日主题换肤实战:一条参数换全站配色,节后自动还原
spring boot·vue3·spring boot 3·vben admin·ruoyi office·节日主题·换肤方案
斑鸠喳喳6 天前
可重入锁 ReentrantLock
java·源码
RuoyiOffice6 天前
SpringBoot3+Vue3 流程抄送已读:打开详情消红点,待办与知会怎么分开
spring boot·vue3·flowable·spring boot 3·ruoyi office·流程抄送·已读回执
志尊宝7 天前
Vue3 零基础每日笔记(038):亲手封装 useDebounceFn 与 useThrottleFn——高频事件的流量阀
前端·javascript·vue·html·vue3
志尊宝7 天前
Vue3 零基础每日笔记(043):defineModel 与 defineExpose 的 TS 用法——3.4+ 新 API 进阶
前端·javascript·vue·html·vue3
大胡子大叔8 天前
YOLO26(极速目标检测) + SAM3(精准掩码生成)附源码
目标检测·源码·流水线·分割·yolo26·sam3