从零到一打造 Vue3 响应式系统 Day 29 - readonly:数据保护实现

在开始 readonly 之前,我们先补充一下 Proxy 的知识:

Proxy

Proxy 是实现 reactivereadonly 等功能的核心。它会在目标对象前架设一个"代理"或"拦截层",让我们有机会对外界的访问操作进行自定义处理。

拦截与代理

Proxy 的工作模式可以想象成一个保安:

  • 目标对象 (target) :是公司内部的办公室。
  • 代理对象 (proxy) :保安本人。
  • 处理器 (handler) :是保安的应对手册,里面写了访问对象时该如何处理的逻辑。

任何外部代码(访客)要访问对象属性(进办公室)都需要经过 Proxy(保安),Proxy 会查询 handler(保安手册)来决定如何响应。

handler 中,最关键的陷阱 (trap) 之一就是 getget(target, key, receiver) :这个陷阱的触发时机是当代码试图读取代理对象属性时。即使原始对象上并不存在这个属性,它也可以通过 handler 的规则去处理。

了解这些之后,可以开始实现了!

readonly 只接受对象参数。在前面的文章中我们提到,ref 如果传入的是对象,那它内部也会调用 reactive。因此,在 readonly 的实现中,我们只要能正确处理 reactive 对象(或普通对象)就可以。

HTML 复制代码
<body>
  <div id="app"></div>
  <script type="module">
    import {  readonly, reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    // import { readonly, effect, reactive } from '../dist/reactivity.esm.js'

    const state = reactive({
      a: 1,
      b: {
        c: 1
      }
    })

    const readonlyState = readonly(state)

    effect(() => {
      console.log(readonlyState.a)
    })

    setTimeout(() => {
      state.a++ // 修改原始的 reactive 对象
    }, 1000)
  </script>
</body>

如果你设置一个 readonly 对象,当修改原始的 reactive 对象时,readonly 仍然会接收到响应式的触发更新。

JavaScript 复制代码
setTimeout(() => {
  readonlyState.a++ // 尝试修改 readonly 对象
}, 1000)

但如果你修改的是 readonly 对象本身,那就会在控制台收到警告。

查看这个 readonly 对象,可以发现它很像一个 reactive 对象,是通过 _isReadonly 标记来判断的。这跟我们上一个章节在实现 shallow 时的思路特别像。

首先,我们先在 ref.ts 中增加枚举标记,分别是 IS_REACTIVE 以及 IS_READONLY

TypeScript 复制代码
// ref.ts
export enum ReactiveFlags {
  IS_REF = '__v_isRef',
  IS_REACTIVE = '__v_isReactive',
  IS_READONLY = '__v_isReadonly'
}

接着调整一下 reactive.ts,我们移除原有的 Set 检查,改为通过标记来判断是否需要重复代理。

TypeScript 复制代码
// reactive.ts
import { ReactiveFlags } from './ref'
// ...
function createReactiveObject(target, handlers, proxyMap) {
  // reactive 只处理对象
  if (!isObject(target)) return target

  // 统一处理"防止重复代理"的情况
  // 如果 target 已经是 reactive 或 readonly,直接返回
  if (target[ReactiveFlags.IS_REACTIVE] || target[ReactiveFlags.IS_READONLY]) {
    return target
  }

  // 如果这个 target 已经被代理过,直接返回已经创建好的 proxy
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }

  // 创建 target 的代理对象
  const proxy = new Proxy(target, handlers)

  // 存储 target 和响应式对象的关联关系
  proxyMap.set(target, proxy)

  return proxy
}
// ...
// 调整 isReactive 判断
export function isReactive(target) {
  return !!(target && target[ReactiveFlags.IS_REACTIVE])
}

// 先新增一个空实现,等一下再来补充
export function readonly(target) {
  return createReactiveObject(target, readonlyHandlers, readonlyMap) // (提前写好)
}

// 新增 readonly 判断
export function isReadonly(value) {
  return !!(value && value[ReactiveFlags.IS_READONLY])
}

接着回到 baseHandlers.ts,新增一个 readonlyHandler

TypeScript 复制代码
// baseHandlers.ts
// 导入标记
import { isRef, ReactiveFlags } from './ref'
// 引入 reactive 和 readonly 函数
import { reactive, readonly } from './reactive'

// 扩展 createGetter,使其接受一个 isReadonly 参数
function createGetter(isShallow = false, isReadonly = false) {
  return function get(target, key, receiver) {
    // 拦截对标记的访问
    if (key === ReactiveFlags.IS_REACTIVE) {
      return !isReadonly
    } else if (key === ReactiveFlags.IS_READONLY) {
      return isReadonly
    }

    // 非只读时才收集依赖
    if (!isReadonly) {
      track(target, key)
    }

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

    if (isRef(res)) {
      return res.value
    }

    if (isObject(res)) {
      // 关键:如果是只读,则递归调用 readonly
      return isReadonly ? readonly(res) : (isShallow ? res : reactive(res))
    }
    return res
  }
}

// ...
// 创建只读的 getter
const readonlyGet = createGetter(false, true) 
// 创建只读的 handler,并阻止 set 和 delete 操作
export const readonlyHandlers = {
  get: readonlyGet,
  set(target, key) {
    console.warn(`Set operation on key "${String(key)}" failed: target is readonly.`)
    return true // 阻止修改
  },
  deleteProperty(target, key) {
    console.warn(`Delete operation on key "${String(key)}" failed: target is readonly.`)
    return true // 阻止删除
  }
}

createGetter 的标记逻辑是:IS_REACTIVEIS_READONLY 标记在原始对象上并不存在,但当外部代码(如 isReadonly())访问它们时,代理对象的 getter 会被触发。getter 会根据创建时传入的 isReadonly 参数,返回对应的布尔值。

我们回到 reactive.ts,完成 readonly 的实现:

TypeScript 复制代码
// reactive.ts
import { mutableHandlers, shallowReactiveHandlers, readonlyHandlers } from './baseHandlers'

// 创建一个 readonly 缓存 map
const readonlyMap = new WeakMap()
// ...
function createReactiveObject(target, handlers, proxyMap) {
  // reactive 只处理对象
  if (!isObject(target)) return target

  // 如果遇到重复代理,或是只读对象,无需处理,并返回其自身
  if (target[ReactiveFlags.IS_REACTIVE] || target[ReactiveFlags.IS_READONLY]) {
    return target
  }
  
  // ... (检查 existingProxy 逻辑不变)

  // 创建 target 的代理对象
  const proxy = new Proxy(target, handlers)

  // 存储 target 和代理的关联关系
  proxyMap.set(target, proxy)

  return proxy
}
// ...
export function readonly(target) {
  return createReactiveObject(target, readonlyHandlers, readonlyMap)
}

这样我们就完成了 readonly 的实现。

循环引用

有些人可能会发现我们遇到了循环引用的状态:

rust 复制代码
ref.ts -> reactive.ts -> baseHandlers.ts -> ref.ts

这个问题在 CommonJS 中需要特别注意和避免,但在现代的 ESM (ES Modules) 中可以正常运作。

什么是循环引用?

在过往的 CommonJS 中,require() 是同步执行的。当模块 A 依赖模块 B,而模块 B 同时又依赖模块 A 时,这会导致其中一个模块在被引入时没有被完全初始化,从而引发运行时错误。

实时绑定 (Live Binding)

ESM 的 import/export 机制与 CommonJS 完全不同。它导出的不是一个值的拷贝 ,而是一个实时绑定 ,可以把它想象成一个指向原始变量内存地址的指针

ESM 通过一个巧妙的两阶段过程来处理模块,从而解决了循环引用的问题:

  • 第一阶段:解析与绑定

    • JavaScript 引擎首先会扫描所有相关的模块文件,解析 importexport 语句,创建一个完整的"依赖图"。
    • 在这个阶段,引擎会为所有 export 的变量、函数、类在内存中创建绑定并分配空间 ,但不会执行任何代码
  • 第二阶段:执行与赋值

    • 在所有绑定都建立好之后,引擎才开始执行每个模块的主体代码,将实际的函数或值放到之前预留的内存位置中。
    • 以我们这次的情况来说:当 baseHandlers.ts 需要 import { readonly } from './reactive' 时,它得到的是 readonly 这个函数的"实时绑定"(一个内存地址引用)。
    • baseHandlers.ts 模块(例如 createGetter 函数的定义)可以顺利执行完毕。
    • 之后,reactive.ts 模块也会执行,将 readonly 函数的定义(即函数体)填充到它的绑定中。

关键是执行时机

最关键的一点是:

baseHandlers.ts 里的 createGetter 在定义时,只是引用了 readonly 的绑定,它并没有被立即调用

get 处理器要等到未来某个代理对象的属性被访问时,才会被真正执行。而到那个时候,所有模块早就完成了第二阶段的执行和赋值。因此,当 get 处理器内部调用 readonly(res) 时,它能访问到完整的、已定义的 readonly 函数,不会有任何问题。


想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

相关推荐
xiaofeichaichai3 小时前
Webpack
前端·webpack·node.js
问心无愧05134 小时前
ctf show web入门111
android·前端·笔记
唐某人丶4 小时前
模型越来越强,我们还需要 Agent 工程吗?—— 从价值重估到 Harness 实践
前端·agent·ai编程
智码看视界4 小时前
现代Web开发基础:全栈工程师的起航点
前端·后端·c5全栈
JS菌4 小时前
手写一个 AI Agent 全栈项目:从沙箱执行到子智能体的完整实现
前端·人工智能·后端
excel6 小时前
HLS TS 文件损坏的元凶:Git 提交与拉取
前端
Aphasia3116 小时前
https连接传输流程
前端·面试
徐小夕6 小时前
万字长文!千万级文档 RAG 知识库系统落地实践
前端·算法·github
梦梦代码精6 小时前
2026年PHP开源商城系统实测对比:架构、多商户、商用授权,谁才是真·省心?
vue.js·docker·架构·开源·代码规范