从零到一打造 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」,一起跟日安当同学。

相关推荐
时代拖油瓶5 小时前
我劝你必须知道——Intl.Segmenter
前端·javascript
韭菜炒大葱5 小时前
对象字面量与JSON:JavaScript中最强大的数据结构
javascript
海在掘金611275 小时前
从"万能函数"到"精准工具":泛型如何消除重复代码
前端
云心雨禅5 小时前
DNS工作原理:从域名到IP
运维·前端·网络协议·tcp/ip·github
Dorian_Ov05 小时前
Mybatis操作postgresql的postgis的一些总结
前端·gis
Moshow郑锴6 小时前
从 “瞎埋点” 到 “精准分析”:WebTagging 设计 + 页面埋点指南(附避坑清单)
前端
非凡ghost6 小时前
PixPin截图工具(支持截长图截动图) 中文绿色版
前端·javascript·后端
૮・ﻌ・6 小时前
Vue2(一):创建实例、插值表达式、Vue响应式特性、Vue指令、指令修饰符、计算属性
前端·javascript·vue.js
小小爱大王7 小时前
AI 编码效率提升 10 倍的秘密:Prompt 工程 + 工具链集成实战
java·javascript·人工智能