从零到一打造 Vue3 响应式系统 Day 20 - Reactive:reactive 极端案例

在完成 reactive 的基本实现之后,接下来会遇到几种常见且必须处理的情况:

  • 原始对象传入 Reactive 对象
  • Reactive 对象再次传入 Reactive
  • 对 Reactive 对象重复赋相同数值
  • 嵌套 对象作为 ref 的值
  • 将包含 ref 的 Reactive 对象进行解构并保持数值同步
  • 初始化嵌套 Reactive 对象

情况一:原始对象传入 Reactive 对象

这是最基础也最直观的案例。

如果把同一个原始对象 多次传入 reactive,当前的简化版本会返回不同的 Proxy 实例

xml 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <title>Document</title>
  <style>
    body {
      padding: 150px;
    }
  </style>
</head>

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

    const obj = {
      a:0
    }

    const state = reactive(obj)
    const state2 = reactive(obj)
    console.log(state === state2)

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

    setTimeout(() => {
      state.a = 1
    }, 1000)
  </script>
</body>

</html>

当我们将同一个原始对象 多次传入 reactive 函数时,会发现返回的代理对象彼此不相等state !== state2),这与官方的行为(返回相等的代理对象)不符。

为什么会出现 state !== state2?原因在于目前的 createReactiveObject 函数,每次调用都会无条件new Proxy() 一个新的代理对象。

vbnet 复制代码
function createReactiveObject(target) {

  if (!isObject(target)) return target

  // 这里每次都会新建一个代理对象
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key,receiver)
    },
    set(target, key, newValue, receiver) {
      const res = Reflect.set(target, key, newValue, receiver)
      trigger(target, key)
      return res
    }
  })

  return proxy
}

因此需要加入缓存机制 ,避免让相同对象被重复代理

vbnet 复制代码
/**
 * 存储 target 与响应式对象的关联关系
 * key: target / value: proxy
 */
const reactiveMap = new WeakMap()

function createReactiveObject(target) {
  // reactive 只处理对象
  if (!isObject(target)) return target

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

  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key,receiver)
    },
    set(target, key, newValue, receiver) {
      const res = Reflect.set(target, key, newValue, receiver)
      trigger(target, key)
      return res
    }
  })

  // 缓存 target 与响应式对象的关联
  reactiveMap.set(target, proxy)

  return proxy
}

如果没有缓存,会导致:

  • 内存浪费:重复创建无用的代理对象。
  • 依赖分裂 :两个不同的 proxy 操作同一个 target,但各自的依赖收集不一致,可能导致更新失效或重复触发

情况二:Reactive 对象传入 Reactive

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

    const obj = {
      a:0
    }

    const state = reactive(obj)
    const state2 = reactive(state)
    console.log(state === state2)
  </script>
</body>

在官方实现中,预期结果为 true。因此我们需要在再次传入已是 Proxy 的对象 时,仍然返回缓存的代理对象

官方的做法是在代理对象的 get 中拦截访问某个特殊属性 来识别并返回缓存;我们也可以采用更直接的办法:引入 reactiveSet 记录所有通过 reactive 创建的代理对象,防止重复代理

scss 复制代码
/**
 * 保存所有使用 reactive 创建的响应式对象
 * 用于检查是否被重复 reactive
 */
const reactiveSet = new Set()

function createReactiveObject(target) {
  // reactive 只处理对象
  if (!isObject(target)) return target

  // 如果 target 已存于 reactiveSet 中,说明它本身就是一个 proxy
  // 直接通过 reactiveMap 取回对应的"已缓存代理"
  if (reactiveSet.has(target)) {
    return reactiveMap.get(target)
  }
  ...
  ...
})

  // 存储 target 与 proxy 的关联
  reactiveMap.set(target, proxy)

  // 记录该 proxy 已是响应式对象
  reactiveSet.add(proxy)

  return proxy
}

// 判断 target 是否为响应式对象:只要在 reactiveSet 中存在即为 true
export function isReactive(target) {
  return reactiveSet.has(target)
}

重点是避免重复代理 :若传入的已经是 proxy,就应该原样返回

注:Vue 官方常见做法是通过 Proxyget_v_isReactive 之类的内部标记进行判断;我们这里用 reactiveSet 简化了识别逻辑。本质上都是在区分 target 与 proxy 的身份,以避免陷入无穷无尽的代理链。


情况三:Reactive 对象重复赋相同数值

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

    const state = reactive({
      a:0
    })

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

    setTimeout(() => {
      state.a = 0
    }, 1000)
  </script>
</body>

按上述实现,给相同数值赋值时,控制台会重复输出两次;但官方只会输出一次

原因是官方会检查新旧值是否相同,相同则不触发更新。

因此我们需要在 set 时判断新旧值是否发生变化 ,以避免不必要的通知

先在 @vue/shared 新增一个辅助函数,用于判断值是否变化:

javascript 复制代码
// shared.ts
export function isObject(value) {
  return typeof value === 'object' && value !== null
}
// 判断新值与旧值是否发生变化;变化返回 true,否则返回 false
export function hasChanged(newValue, oldValue) {
  return !Object.is(newValue, oldValue)
}

reactive.ts 中引入并使用:

javascript 复制代码
// reactive.ts
import { isObject, hasChanged } from '@vue/shared'
...
...
set(target, key, newValue, receiver) {
  const oldValue = target[key]
  const res = Reflect.set(target, key, newValue, receiver)
  if (hasChanged(newValue, oldValue)) {
    // 仅当值确实变化时才触发更新
    trigger(target, key)
  }
  return res
}
...

情况四:嵌套对象 传入 ref

为避免多层嵌套对象直接作为 ref 的值后失去响应 ,当传入 ref 的值是对象时,应先转换成 reactive

kotlin 复制代码
// ref.ts
import { isObject } from '@vue/shared'
import { reactive } from './reactive'

enum ReactiveFlags {
  IS_REF = '__v_isRef'
}

class RefImpl {
  _value;
  [ReactiveFlags.IS_REF] = true

  subs: Link
  subsTail: Link
  constructor(value) {
    // 如果 value 是对象,则先转为响应式对象
    this._value = isObject(value) ? reactive(value) : value
  }

  get value() {
    if (activeSub) {
      trackRef(this)
    }
    return this._value
  }

  set value(newValue) {
    // 若新旧值确实发生变化,再更新
    if (hasChanged(newValue, this._value)) {
      // 若新值是对象,同样转成 reactive
      this._value = isObject(newValue) ? reactive(newValue) : newValue
      triggerRef(this)
    }
  }
}

这样,当 ref 的值是对象时,内部会被转为 reactive,从而继续参与依赖收集与触发


情况五:将包含 ref 的 Reactive 对象解构并保持同步

为了正确处理 refreactive 的整合,我们需要满足三点:

  1. ref 传入 reactive 后,读取时可直接拿到值 (无需 .value
xml 复制代码
<body>
  <div id="app"></div>
  <script type="module">
    // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive, ref, effect } from '../dist/reactivity.esm.js'

    // 如果 target.a 是一个 ref,就应当直接拿到它的值,而不是 .value
    const a = ref(0)
    const state = reactive({ a })

    effect(() => {
      // 不用 state.a.value 也可以读取
      console.log('reactive', state.a)
    })
  </script>
</body>
  1. ref 传入 reactive 后,当 reactive 更新同名字段时,ref.value 也要同步更新
xml 复制代码
<body>
  <div id="app"></div>
  <script type="module">
    // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive, ref, effect } from '../dist/reactivity.esm.js'
    
    const a = ref(0)
    const state = reactive({ a })

    effect(() => {
      console.log('reactive', state.a)
    })

    setTimeout(() => {
      // 更新 reactive 字段,ref.value 需要同步
      state.a = 1
      console.log('ref', a.value)
    }, 1000)
  </script>
</body>
  1. 若把 state.a 直接换成一个新的 ref,原有变量 a 不应被动同步(这是预期的非同步)
xml 复制代码
<body>
  <div id="app"></div>
  <script type="module">
    // import { reactive, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive, ref, effect } from '../dist/reactivity.esm.js'

    const a = ref(0)
    const state = reactive({ a })

    effect(() => {
      console.log('reactive', state.a)
    })

    setTimeout(() => {
      // 将字段替换为"新的 ref",原来的 a 不同步(预期行为)
      state.a = ref(1)
      console.log('ref', a.value)
    }, 1000)
  </script>
</body>

实现要点

  • ref 传入 reactive,读取时自动解 .value
kotlin 复制代码
// reactive.ts
...
...
get(target, key, receiver) {
  // 收集依赖:绑定 target[key] 与当前 effect 的关系
  track(target, key)
  const res = Reflect.get(target, key, receiver)
  // 若是 ref,则返回其 .value
  if (isRef(res)) {
    return res.value
  }
  return res
},
...
...

这样即可做到读取直出数值 ,但 a 内部的 value 仍需在赋值时同步更新。

  • 在 Proxy 的 setter 中判断新旧值并决定是否触发 ref 更新

首先在 @vue/shared 中导出 hasChanged(前文已给出实现),然后在 set 中处理**老值为 ref、新值不是 ref**的情形:

scss 复制代码
set(target, key, newValue, receiver) {
  const oldValue = target[key]

  /**
   * const a = ref(0)
   * target = { a }
   * 当执行 target.a = 1 时,本质上是 a.value = 1
   */
  if (isRef(oldValue) && !isRef(newValue)) {
    oldValue.value = newValue
    // 更新了 ref 的值,已经触发了依赖
    // 直接返回,避免下方 trigger 再触发一次(双重触发)
    return true
  }

  const res = Reflect.set(target, key, newValue, receiver)

  if (hasChanged(newValue, oldValue)) {
    // 仅当值变化时才触发更新
    trigger(target, key)
  }
  return res
}

情况六:初始化嵌套 Reactive 对象

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

    const state = reactive({
      a: {
        b: 0
      }
    })

    effect(() => {
      console.log(state.a.b)
    })

    setTimeout(() => {
      state.a.b = 1
    }, 1000)
  </script>
</body>

运行后会发现,当 state.a.b 被修改时,effect 没有重新触发

原因是:属性 a 的值(红色部分)本身不是响应式对象,只有外层对象(橙色)被代理了。

get 逻辑示例(初始版本):

vbnet 复制代码
get(target, key, receiver) {
  track(target, key)
  const res = Reflect.get(target, key, receiver)
  console.log(res)
  if (isRef(res)) {
    return res.value
  }
  return res
}

trigger 中打印会发现没有响应 ,因为只有被代理的对象才具备依赖映射,非响应式对象的属性变更不会触发:

javascript 复制代码
export function trigger(target, key) {
  console.log('trigger', target, key)
  ...
}
// ------没有任何反应

解决办法 :在 get 中若读取到的 res 仍然是对象,则惰性地把它再转成响应式对象(递归式"按需响应")。

scss 复制代码
get(target, key, receiver) {
  track(target, key)
  const res = Reflect.get(target, key, receiver)

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

  if (isObject(res)) {
    /**
     * 如果 res 是对象,则将其转为响应式对象(惰性转换)
     */
    return reactive(res)
  }
  return res
}

重构:抽离 handlers,减少重复创建

为提升性能 并遵循单一职责原则 ,应将 Proxy 的拦截处理抽离为独立模块,使所有代理实例共享同一份 handlers ,避免每次 createReactiveObject 都重新创建对象。

baseHandlers.ts

javascript 复制代码
import { hasChanged, isObject } from '@vue/shared'
import { track, trigger } from './dep'
import { isRef } from './ref'
import { reactive } from './reactive'

export const mutableHandlers = {
  get(target, key, receiver) {
    // 收集依赖:绑定 target[key] 与当前 effect
    track(target, key)
    const res = Reflect.get(target, key, receiver)

    // 若为 ref,直接返回其 value
    if (isRef(res)) {
      // target = { a: ref(0) }
      return res.value
    }

    // 若为对象,惰性地转为响应式对象
    if (isObject(res)) {
      return reactive(res)
    }
    return res
  },

  set(target, key, newValue, receiver) {
    const oldValue = target[key]

    /**
     * const a = ref(0)
     * target = { a }
     * 更新 target.a = 1 等价于 a.value = 1
     */
    if (isRef(oldValue) && !isRef(newValue)) {
      oldValue.value = newValue
      // 设置 ref.value 已触发依赖;避免二次 trigger
      return true
    }

    const res = Reflect.set(target, key, newValue, receiver)

    if (hasChanged(newValue, oldValue)) {
      // 值变化才触发更新
      trigger(target, key)
    }
    return res
  }
}

dep.ts

javascript 复制代码
import { Link, link, propagate } from "./system"
import { activeSub } from "./effect"

class Dep {
  subs: Link
  subsTail: Link
  constructor() {}
}

const targetMap = new WeakMap()

export function track(target, key) {
  if (!activeSub) return
  // 通过 targetMap 取得 target 的依赖映射
  let depsMap = targetMap.get(target)

  // 首次收集依赖:若不存在则新建
  // key: obj / value: depsMap
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let dep = depsMap.get(key)

  // 第一次建立 key 对应的 Dep,并保存到 depsMap
  // key: key / value: Dep
  if (!dep) {
    dep = new Dep()
    depsMap.set(key, dep)
  }

  link(dep, activeSub)
}

export function trigger(target, key) {
  const depsMap = targetMap.get(target)
  // 未收集过依赖则直接返回
  if (!depsMap) return

  const dep = depsMap.get(key)
  // 该 key 从未在 effect 中被使用过
  if (!dep) return

  // 触发依赖链
  propagate(dep.subs)
}

reactive.ts

javascript 复制代码
import { isObject } from '@vue/shared'
import { mutableHandlers } from './baseHandlers'

/**
 * 存储 target 与响应式对象的关联关系
 * key: target / value: proxy
 */
const reactiveMap = new WeakMap()

/**
 * 保存所有通过 reactive 创建的响应式对象
 * 用于检查是否重复 reactive
 */
const reactiveSet = new Set()

function createReactiveObject(target) {
  // reactive 只处理对象
  if (!isObject(target)) return target

  // 若 target 已是 proxy,则直接返回缓存的 proxy
  if (reactiveSet.has(target)) {
    return reactiveMap.get(target)
  }

  // 若该 target 已被代理过,复用已存在的 proxy
  const existingProxy = reactiveMap.get(target)
  if (existingProxy) {
    return existingProxy
  }

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

  // 记录 target ↔ proxy
  reactiveMap.set(target, proxy)

  // 标记该 proxy 为响应式对象
  reactiveSet.add(proxy)

  return proxy
}

export function reactive(target) {
  return createReactiveObject(target)
}

// 判断对象是否为响应式对象(是否在 reactiveSet 中)
export function isReactive(target) {
  return reactiveSet.has(target)
}

小结:六大情境背后的设计取舍

  • 缓存机制:避免重复代理与依赖分裂。
  • 身份识别 :区分原始对象、代理对象与 ref
  • 性能优化 :仅在值发生变化时触发更新。
  • API 体验 :在 reactive自动解构 ref.value,让读取更直觉。
  • 惰性策略:按需把嵌套对象转为响应式,提升初始化性能。
  • 工程化 :抽离 handlers,提高复用性与可维护性。

这些选择本质上是在性能、易用性与一致性 之间做权衡。理解这些"边界案例",不仅能写出可用的响应式系统,更能洞察 Vue 3 背后的设计思路。


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

相关推荐
文心快码BaiduComate2 小时前
Comate分饰多角:全栈开发一个Python学习网站
前端·后端·python
90后的晨仔3 小时前
Vue 插槽(Slots)全面解析与实战指南
前端·vue.js
Slice_cy3 小时前
📚 uniapp版本懒加载 + 不定高虚拟列表实现
前端
golang学习记3 小时前
从0死磕全栈之Next.js API 路由实战:不用后端,前端也能写接口!
前端
Nathan202406163 小时前
Kotlin-Sealed与Open的使用
android·前端·面试
MQliferecord3 小时前
前端性能优化实践经验总结
前端
RoyLin3 小时前
SurrealDB - 统一数据基础设施
前端·后端·typescript
longlongago~~3 小时前
富文本编辑器Tinymce的使用、行内富文本编辑器工具栏自定义class、katex渲染数学公式
前端·javascript·vue.js
2501_915921433 小时前
前端用什么开发工具?常用前端开发工具推荐与不同阶段的选择指南
android·前端·ios·小程序·uni-app·iphone·webview