Skip to content

在 Vue 3 的响应式系统中,readonly 是一个用于创建只读代理对象的关键 API。它通过 Proxy 拦截所有可能修改对象的操作,并阻止这些操作的执行,从而确保对象的状态不可变。更进一步,Vue 还会在开发环境下对非法修改尝试抛出警告,帮助开发者及时发现潜在错误。本节将深入剖析 readonly 的实现原理,揭示其如何拦截各类修改操作并提供友好的开发时反馈。

一、readonly 的核心目标

readonly 的主要用途包括:

  1. 防止意外修改:保护全局状态、配置对象或从父组件传递的 props 不被子组件意外修改。
  2. 性能优化:只读对象不需要建立响应式依赖,可以跳过 track 操作,减少运行时开销。
  3. 语义清晰:明确标识一个对象是不可变的,提升代码可读性和可维护性。

reactive 不同,readonly 创建的对象是“深只读”的——不仅根对象不可变,其嵌套的所有对象和数组也同样被转换为只读。

二、实现原理:Proxy 拦截器的全面封锁

readonly 的核心实现依赖于 Proxy,通过定义一个特殊的 handler 来拦截所有可能修改对象的操作。以下是 readonly handler 的关键部分:

ts
const readonlyHandler = {
  get: createGetter(true),
  set(target, key) {
    // 开发环境警告
    if (__DEV__) {
      console.warn(
        `Set operation on key "${String(key)}" failed: target is readonly.`,
        target
      );
    }
    return true; // 在严格模式下会抛出 TypeError
  },
  deleteProperty(target, key) {
    if (__DEV__) {
      console.warn(
        `Delete operation on key "${String(key)}" failed: target is readonly.`,
        target
      );
    }
    return true;
  },
  has: createGetter(true),
  ownKeys: createGetter(true)
};

核心拦截器解析:

  1. get 拦截器

    • reactive 类似,get 拦截属性读取。
    • 关键区别:在 createGetter(isReadonly = true) 中,不会调用 track 进行依赖收集。因为只读对象的状态永远不会改变,没有必要建立响应式依赖。
    • 同样进行深层处理:如果获取的值是对象,递归调用 readonly 将其也转换为只读。
  2. set 拦截器

    • 这是阻止修改的核心。
    • 函数体为空,不执行 Reflect.set,直接阻止赋值操作。
    • 在开发环境(__DEV__)下,打印警告信息,提示开发者试图修改只读对象。
    • 返回 true 表示操作成功。这看似矛盾,实则是为了兼容非严格模式。在严格模式下(如模块脚本),返回 true 但未实际设置属性会抛出 TypeError;在非严格模式下,操作静默失败。
  3. deleteProperty 拦截器

    • 阻止 delete obj.key 操作。
    • 同样在开发环境下发出警告。
    • 返回 true,行为与 set 相同。
  4. hasownKeys

    • 使用 createGetter(true),意味着允许读取操作(如 'key' in objObject.keys(obj)),但不会进行依赖追踪。

三、深层只读的实现机制

readonly 如何确保嵌套对象也是只读的?答案在于 get 拦截器中的递归处理:

ts
function createGetter(isReadonly) {
  return function get(target, key, receiver) {
    const result = Reflect.get(target, key, receiver);

    if (isObject(result)) {
      // 如果值是对象,根据 isReadonly 参数决定是否递归
      return isReadonly ? readonly(result) : reactive(result);
    }

    return result;
  };
}

isReadonlytrue 时,createGetter 会返回一个始终调用 readonly 的函数。这意味着:

js
const state = readonly({
  user: { name: 'Vue', settings: { theme: 'dark' } }
});

// 访问 state.user
// 触发 get → 发现 user 是对象 → 调用 readonly(user) → 返回新的只读代理
// 再访问 state.user.settings → 同样被转换为只读

这种惰性递归(lazy recursion)确保了只有在实际访问嵌套对象时,才将其转换为只读,避免了不必要的代理开销。

四、开发时警告的设计哲学

readonly 在开发环境下抛出警告,而不是直接抛出错误,体现了 Vue 对开发体验的精心设计:

  • 友好性:警告不会中断应用运行,允许开发者在控制台看到问题后逐步修复。
  • 可追溯性:警告信息包含被修改的 keytarget 对象,便于定位问题源头。
  • 生产环境无开销:在生产构建中,__DEV__ 被替换为 false,警告代码被移除,不影响性能。
js
// 生产环境构建后
set(target, key) {
  return true; // 仅剩一行,极简
}

五、与 shallowReadonly 的对比

Vue 还提供了 shallowReadonly,它只将对象的第一层属性设置为只读,而嵌套对象保持原样。

js
const obj = shallowReadonly({
  nested: { count: 0 }
});

obj.nested.count++; // ✅ 允许,nested 是普通对象
obj.name = 'new';   // ❌ 阻止,第一层属性只读

shallowReadonly 的实现更简单,get 拦截器不递归调用 readonly

ts
function shallowGet(target, key, receiver) {
  track(target, key); // 即使是 shallow,也需要 track?
  return Reflect.get(target, key, receiver);
  // 不对结果进行 isObject 判断和转换
}

注意:shallowReadonly 仍可能调用 track,因为它需要支持响应式。真正的“浅”体现在不递归代理嵌套对象。

六、实际应用场景

1. 组件 Props 的保护

<script setup> 中,defineProps 返回的对象本质上是 shallowReadonly 的:

vue
<script setup>
const props = defineProps(['title']);

// 以下操作在开发环境下会警告
props.title = 'New Title'; // ❌ 警告:Cannot assign to read only property
</script>

这防止了子组件意外修改父组件的状态,强制使用事件机制进行通信。

2. 全局常量与配置

js
import { readonly } from 'vue';

const CONFIG = readonly({
  API_URL: 'https://api.example.com',
  FEATURES: {
    darkMode: true,
    analytics: false
  }
});

// 任何尝试修改 CONFIG 的操作都会被阻止
CONFIG.API_URL = '...'; // ❌ 静默失败 + 开发警告

3. 响应式状态的只读视图

有时你希望暴露状态的一部分作为只读接口:

js
const store = reactive({
  count: 0,
  messages: []
});

// 提供只读版本供外部使用
export const readonlyStore = readonly(store);

// 内部仍然可以修改
function increment() {
  store.count++;
}

七、底层细节:为什么返回 true?

setdeleteProperty 拦截器返回 true 的行为可能令人困惑。其背后的 JavaScript 机制如下:

  • 非严格模式下,如果 set 拦截器返回 true,即使没有实际设置属性,JavaScript 引擎也会认为操作成功,不会报错。
  • 严格模式下(如 ES 模块),如果 set 拦截器返回 true 但属性未被设置,引擎会抛出 TypeError

Vue 利用这一点:

  • 在开发环境下,通过 console.warn 提供即时反馈。
  • 在生产环境下,返回 true 确保行为一致,同时移除警告代码以优化体积。

八、总结

readonly 的实现原理可以概括为:

  1. 全面拦截:通过 Proxy 拦截 setdeleteProperty 等所有修改操作,阻止其执行。
  2. 深层递归:在 get 拦截器中惰性地将嵌套对象也转换为只读。
  3. 开发警告:在非生产环境下对非法修改尝试发出警告,辅助调试。
  4. 无依赖追踪:不调用 track,优化只读对象的性能。

readonly 不仅是一个简单的“冻结”工具,更是 Vue 响应式系统中确保数据流清晰、防止副作用污染的重要机制。理解其内部工作方式,有助于开发者更好地利用它来构建健壮、可维护的应用程序。