想象你是一座城市的水电调度工程师。旧城区每栋楼用的都是老式门禁——想监控某个住户的用水量,你得提前在每户的管道口装一个“小监控器”(Object.defineProperty),装完才发现:新接入的管线它看不见、数组管道年久失修也锁不住。于是你和团队彻底翻新,给每栋楼装上“智能总闸”(Proxy):所有进出房屋的操作都从总闸过一遍,不用预埋监控器,新增管线、深层管道、数组分支统统尽收眼底。这座“城市水电系统”就是 Vue3 的响应式原理,而那套重新设计的“调度总控台”就是组合式 API。 本文会带你从零看明白 Vue3 如何用 Proxy 实现 ref/reactive 的响应式,理解 effect 调度与计算属性缓存的底层逻辑,再对比 Vue2 的 defineProperty 为什么被取代。你会亲手搭建一个迷你响应式系统,并搞懂高频面试里最常考的“依赖收集、派发更新、批量调度”到底是怎么回事。

岗位关联

对 AI 从业者而言,Vue 的响应式系统是“状态 → 副作用自动同步”的经典范本——无论做前端交互可视化、LLM 流式输出界面,还是 agent 状态机,理解“数据订阅-变更通知”模型都能帮你写出高性能、低心智负担的交互代码。

目录
  1. 可视化演示
  2. 组合式API:把零散工具装进功能柜
  3. 响应式Proxy:从门禁卡到智能总闸
  4. Ref与Reactive:水表盒子与整楼监控
  5. 依赖收集原理:住户登记,track 存档
  6. 派发更新机制:放水通知,trigger 执行
  7. Effect调度器:高峰期的交通指挥
  8. 计算属性缓存:蓄水池的智慧
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示Vue3 Proxy响应式系统、依赖收集(track/trigger)机制、ref与reactive对比及computed缓存与懒计算原理,含流动波形背景和发光动画,交互切换各机制视图。

本节配有交互动效(进入视口后自动加载,约 3KB)。

组合式API:把零散工具装进功能柜

从前写 Vue2 组件,像把扳手、钳子、螺丝刀全倒进一个大抽屉:data 放一起、computed 放一堆、watch 又堆一角。一个数据相关的逻辑被硬生生拆成三块,改需求时你得在抽屉里翻来翻去。组合式 API 给这个抽屉装上了“功能分隔板”——每个业务关注点(比如“用户列表”)都收进一个独立的 setup 函数块里,代码按逻辑而非选项类型组织。

setup 是组合式 API 的入口,组件创建时执行一次,返回的变量和方法直接暴露给模板。你可以把“拉取用户列表、搜索过滤、分页”这些代码写进同一个 setup 作用域,再用自定义组合式函数(如 useUserList)抽离复用。这不是什么魔法,本质上是 JavaScript 闭包与函数式组合的运用——就像把同一套水电管线按房间收纳进对应工具柜。

相比选项式 API 分散的数据、方法、侦听器,组合式 API 最大的好处是“高内聚、低耦合”。逻辑复用从 mixin 的“叠加冲突”变成函数间的“自由组合”,TypeScript 推导也更自然。

最终,组合式 API 让组件变成“一个入口、多套功能包”的结构——新增功能不需要翻动旧代码,就像在工具柜里加一个新抽屉。这种方式还被广泛用于封装可复用的业务 Hooks,是 Vue3 生态设计的第一块基石。

setup组件={stateref,  logiceffect,  computed缓存}\text{setup}_{组件} = \{ \text{state}_{ref}, \; \text{logic}_{effect}, \; \text{computed}_{缓存} \}
setup 是组件的“功能柜”:一个函数把状态、副作用与派生数据全部装进统一作用域。
  1. setup 标识$\text{setup}_{组件}$ 表示 Vue3 组件执行的核心函数,相当于工具柜的“柜门把手”。
    setup组件↦renderContext\text{setup}_{组件} \mapsto \text{renderContext}
  2. 状态引用$\text{state}_{ref}$ 是组件内用 ref/reactive 声明的响应式状态,如同柜内收纳好的管线总阀。
    stateref=ref(初始值)\text{state}_{ref} = \text{ref}(\text{初始值})
  3. 副作用逻辑$\text{logic}_{effect}$ 是用 watchEffect/effect 注册的副作用,当依赖变化时自动运行,对应柜内的电工检修流程。
    logiceffect→依赖变化自动执行\text{logic}_{effect} \xrightarrow{\text{依赖变化}} \text{自动执行}
  4. 派生数据$\text{computed}_{缓存}$ 表示计算属性,只读且带缓存,是柜内“即取即用”的便捷工具。
    computed缓存=f(stateref)\text{computed}_{缓存} = f(\text{state}_{ref})

响应式Proxy:从门禁卡到智能总闸

Vue2 用 Object.defineProperty 做响应式,如同给每栋楼的每一扇门都安装一台固定式刷卡机。装之前得知道“这栋楼有哪些门”——所以对象新增属性、删除属性,Vue2 都有 forceUpdate 也用不上的死角;数组更是老大难,改 arr[2] = x 和 arr.length-- 这种操作,刷卡机完全没记录。每装一台机器还耗内存,为了响应式你不得不给所有属性预先“装修”一遍。

Vue3 直接升级成了“楼宇智能总闸”——Proxy。它代理的是整栋楼而非每扇门,所有读写、删改、枚举操作都先经过总闸。这意味着:新增属性天然可拦截、删除属性可拦截、数组索引与 length 修改也可拦截。而且代理是“惰性”的:只有访问到嵌套对象时,才给那一层再套一个 Proxy 子代理,内存开销按需分配。

Proxy 可拦截的 trap 有 get、set、has、deleteProperty、ownKeys 等 13 种。Vue3 的 reactive 正是用这些 trap 实现了“读取时收集依赖、写入时触发更新、删除时通知清理”的完整闭环。

正因为总闸拦截的是“楼宇级别的所有操作”,Vue3 才能优雅解决 Vue2 中对象增删和数组变更检测的盲区。这不仅是一次 API 升级,更是响应式设计范式从“预埋监控”到“统一代理”的根本转变。

target→new Proxy{get:track(target,key)set:trigger(target,key)deleteProperty:trigger(target,key)\text{target} \xrightarrow{\text{new Proxy}} \begin{cases} \text{get}: \text{track}(target, key) \\ \text{set}: \text{trigger}(target, key) \\ \text{deleteProperty}: \text{trigger}(target, key) \end{cases}
Proxy 像一个总闸控制器:get 时登记“谁在用这个数据”,set/delete 时通知“这些使用者该更新了”。
  1. 代理创建$\text{target} \xrightarrow{\text{new Proxy}}$ 表示将原始对象 target 包装成代理,如同为整栋楼接入总闸。
    proxy=new Proxy(target,handler)\text{proxy} = \text{new Proxy}(\text{target}, \text{handler})
  2. get 拦载获取属性时执行 $\text{track}(target, key)$,作用是登记当前正在运行的 effect 为“该 key 的订阅者”,就像住户路过总闸签个到。
    get:value↦track(target,key)\text{get}: \text{value} \mapsto \text{track}(\text{target}, \text{key})
  3. set 拦截写入属性时执行 $\text{trigger}(target, key)$,遍历该 key 上的订阅者列表并重新执行,相当于放水通知所有登记住户。
    set:newValue↦trigger(target,key)\text{set}: \text{newValue} \mapsto \text{trigger}(\text{target}, \text{key})
  4. 删除拦截$\text{deleteProperty}$ 触发删除通知,这是 Vue2 无法做到的“退租提醒”。
    deleteProperty:key↦trigger(target,key)\text{deleteProperty}: \text{key} \mapsto \text{trigger}(\text{target}, \text{key})

Ref与Reactive:水表盒子与整楼监控

城市里有两种监控方式。ref 是给单个水表装个透明盒子——不管里面装的是数字还是对象,你都通过盒子的 .value 读取和修改;reactive 则是给整栋楼装总闸,直接操作楼里的每一处管线,不用经手任何盒子和 .value。

ref 的底层其实很妙:如果传进去的是基本类型,它用一个 { value: 原始值 } 的对象包住,再对这个对象做 reactive 代理;如果传进去的是对象,则直接调用 reactive 完成深层代理。所以 ref(0).value 触发的是 get/set trap,这和 reactive(obj) 的机制同源。

reactive 只能代理对象(含数组和 Map/Set 等),它拦截的是“对象本身的操作”。但直接解构 reactive 对象会丢失响应式——因为解构拿到的是原始值的拷贝,总闸只认楼不认你手里的复印件。解决办法是用 toRefs 把每个属性变成一个 ref 引用,解构后依然保持响应。

实践中推荐“ref 全家桶”——统一用 ref 声明数据,几乎不需要区分基本类型和对象,心智负担最小;reactive 在需要深层对象批量操作时更省代码。总闸和盒子的选择,本质是在“统一性”与“灵活性”之间找平衡。

ref(value)={reactive({value:value})对象reactive({value:value})基本类型\text{ref}(\text{value}) = \begin{cases} \text{reactive}(\{ \text{value} : \text{value} \}) & \text{对象} \\ \text{reactive}(\{ \text{value} : \text{value} \}) & \text{基本类型} \end{cases}
ref 是“盒子化”的响应式:任何类型都装入 { value } 容器;reactive 是“对象级”的响应式,直接代理整栋楼。
  1. 基本类型包装$\text{ref}(\text{数字})$ 会用对象包装原始数字,让 Proxy 能拦截它的读写,如同把水表装进一个可开合的盒子。
    ref(0)={value:0}\text{ref}(0) = \{ \text{value}: 0 \}
  2. 对象走 reactive$\text{ref}(对象)$ 内部直接复用 reactive 完成深层代理,不再额外套壳,如同盒子内嵌套了整栋楼的总闸。
    ref(obj)=reactive({value:obj})\text{ref}(\text{obj}) = \text{reactive}(\{ \text{value}: \text{obj} \})
  3. reactive 直接代理$\text{reactive}(\text{obj})$ 返回 Proxy 代理对象,访问属性自动深层懒代理,类似总闸按需为每个房间装分闸。
    reactive(obj)=new Proxy(obj,handler)\text{reactive}(\text{obj}) = \text{new Proxy}(\text{obj}, \text{handler})
  4. toRefs 解构$\text{toRefs}(\text{reactive对象})$ 把每个属性转为 ref,使解构后仍保留响应连接,确保复印件也被总闸追踪。
    toRefs(obj)⇒{key:ref(obj[key])}\text{toRefs}(\text{obj}) \Rightarrow \{ \text{key}: \text{ref}(\text{obj[key]}) \}

依赖收集原理:住户登记,track 存档

想象总闸每天要做的第一件事:当住户 A 打开水龙头,总闸记录“A 需要这条管道的水”。这里的“住户”就是 effect(副作用函数),“管道”就是某个对象的某个 key。Vue3 用全局的 targetMap 登记这些订阅关系,结构是 Map(对象) → Map(key) → Set(effect),像一座三层楼的档案柜。

track 函数在 get 拦截中调用:先确定当前是否处于某个 effect 的同步执行中(activeEffect),如果是,就把这个 effect 塞进对应 key 的 Set 集合里。同一个 effect 重复读取同一 key 时,Set 的天然去重保证了订阅只登记一次,避免派发时重复执行。

有意思的是最外层用了 WeakMap 而非 Map。WeakMap 的 key 是弱引用——当某个响应式对象不再被任何代码引用时,它连同整个依赖档案会被垃圾回收,不会造成内存泄漏。这正是“整栋楼拆掉后,档案柜里的户主信息也跟着注销”。

有了这套档案,系统在任何时候都知道“哪些 effect 依赖哪个 key”。这也解释了为什么组合式 API 中副作用函数要同步执行完——只有同步读取过的属性才会被登记,异步读取发生在 effect 退出后,无法被追踪。

targetMap:target⏟WeakMap key↦Map(key)⏟属性索引↦Set(effect)⏟订阅者集合\text{targetMap}: \underbrace{\text{target}}_{\text{WeakMap key}} \mapsto \underbrace{\text{Map}(\text{key})}_{\text{属性索引}} \mapsto \underbrace{\text{Set}(\text{effect})}_{\text{订阅者集合}}
依赖收集就是“谁在读数据,就把谁写进档案”。档案的三层结构让系统知道:某个对象的某个属性被哪些副作用依赖。
  1. 外层 WeakMap$\text{targetMap}$ 是弱引用 Map,键为原始对象 target,值是其属性索引,确保对象销毁后档案自动释放。
    targetMap=new WeakMap()\text{targetMap} = \text{new WeakMap}()
  2. 中层属性索引每个对象对应一个 $\text{Map}(\text{key})$,键为属性名,值为该属性的订阅者集合,像大楼每一层的水电线路图。
    depsMap=new Map()\text{depsMap} = \text{new Map}()
  3. 内层订阅集合每个 key 对应一个 $\text{Set}(\text{effect})$,存放所有读取过该 key 的副作用函数,Set 去重确保一个 effect 只登记一次。
    depSet=new Set()\text{depSet} = \text{new Set}()
  4. track 登记get trap 中执行 $\text{track}(\text{target}, \text{key})$,把 activeEffect 塞进 depSet,如同住户在管线登记簿上签名。
    track:depSet.add(activeEffect)\text{track}: \text{depSet.add}(\text{activeEffect})

派发更新机制:放水通知,trigger 执行

当总闸检测到某条管道的水流变化(set 被调用),就要按档案逐个通知曾在登记簿上签名的住户“水来了”——这就是 trigger 的职责:遍历 targetMap 中该 target 对应 key 的 depSet,把里面的 effect 全部重新执行一遍。

trigger 有四个精细的注意点。第一,如果新值与旧值相同(Object.is 判断),直接不触发,避免无谓的放水;第二,遍历订阅者时用 new Set(dep) 迭代副本,防止 effect 执行过程中又增删订阅导致死循环;第三,数组的 length 变化要特殊处理——不仅要通知索引订阅者,还要通知所有依赖 length 或迭代器的 effect;第四,如果读取的是迭代器方法(如 forEach、Symbol.iterator),还需要单独收集额外的迭代 key 依赖。

这套流程保证了“改一个值,所有依赖它的计算、渲染、watch 全部自动刷新”。Vue3 的 trigger 是同步遍历的,但每个 effect 本身可能是“可调度的”——它不会立刻执行,而是被交给调度器排队,从而支持批量更新。

放水通知不是简单广播,而是一次精心设计的“精准投递”:该不通知的不打扰,通知时又要覆盖数组这类特殊用户的全部依赖。理解 trigger 的细节,就掌握了 Vue3 性能优化的内功心法。

trigger(target,key)⇒∀  effect∈depSet:effect 重新执行\text{trigger}(\text{target}, \text{key}) \Rightarrow \forall \; \text{effect} \in \text{depSet}: \text{effect 重新执行}
派发更新是“逐户送水”:按档案把变化的 key 通知给每个订阅者,触发它们的重跑与重渲染。
  1. 值变更判定$\text{Object.is}(\text{旧值}, \text{新值})$ 为真则提前退出,如同送水前先看水箱是不是真的见了底。
    Object.is(old,new)⇒return\text{Object.is}(\text{old}, \text{new}) \Rightarrow \text{return}
  2. 迭代副本遍历$\text{depSet}$ 遍历前拷贝为 new Set(dep),避免 effect 中读写属性造成的“边遍历边改档案”死循环,也是派发时最经典的防御性设计。
    const runnable=new Set(depSet)\text{const runnable} = \text{new Set}(\text{depSet})
  3. 数组拦截扩展修改数组索引与 length 时,额外触发数组迭代器的依赖集合,让 v-for 等迭代视图同步更新。
    trigger(target,key∈数组迭代器)\text{trigger}(\text{target}, \text{key} \in \text{数组迭代器})
  4. 同步遍历执行$\forall \text{effect}$ 逐个执行(或交给调度器),完成从数据变化到视图更新的因果闭环。
    ∀  e∈runnable:scheduler(e)\forall \; e \in \text{runnable}: \text{scheduler}(e)

Effect调度器:高峰期的交通指挥

直接通知住户会有问题:如果一场代码里改了 10 个状态,每个状态都触发一次渲染,界面会闪 10 次。于是 Vue3 给 effect 增加了一个可选的 scheduler 选项——effect 不立刻执行,而是被交给调度器“排队等号”。

调度器的工作方式类似地铁站的限流围栏:同一个 effect 被多次触发时,只在当前批次中执行一次(通过全局队列 + Set 去重保证)。Vue3 的响应式系统在浏览器微任务队列中批量刷新这些待执行 effect,从而实现“同步改数据、异步批渲染”。这就是为什么你连续修改 10 个 ref 后,页面只更新一次。

渲染 effect 的调度更进一步:render 函数本身就是一个 effect,数据变化时它不会立刻重渲染,而是被放进任务队列,等待组件更新时机统一 flush。这也引出了 nextTick 的原理——Promise.then 回调必然排在当前批次的 effect 刷新之后。调度器还预留了 pre/post 等执行阶段,上层的 watch 回调、组件渲染、用户回调都可以按需进入不同时间片。

调度器把同步世界和异步渲染优雅地连接起来。它不改变“数据变了要通知”的本质,却决定了“什么时候通知、通知几次”——正因如此,Vue3 才能在高频交互场景下保持 60fps 的流畅体验。

scheduler(e)⇒queue加锁入队→微任务批量执行同类 effect\text{scheduler}(e) \Rightarrow \text{queue加锁入队} \xrightarrow{\text{微任务}} \text{批量执行同类 effect}
调度器是响应式系统的交通指挥:不堵车、不重复跑,把同一时间段内所有“要重跑的通知”合并成一次放行。
  1. 入队去重$\text{queue.add}(e)$ 用 Set 保证同一 effect 只入队一次,类似地铁刷码后的排队闸口防止同一乘客重复占位。
    queue:Set(effect)\text{queue} : \text{Set}(\text{effect})
  2. 微任务调度$\text{Promise.resolve()}$ 后统一 flush 队列,把同步多次修改合并为异步一次执行,避免同一帧内重复渲染。
    flush∈microtask\text{flush} \in \text{microtask}
  3. 渲染 effect 排队$\text{renderEffect}$ 也通过调度器排队,组件更新延迟到队列统一执行从而实现“批量 DOM 更新”,配合虚拟 DOM 的 diff 优化效率。
    componentUpdate⇒queue(renderEffect)\text{componentUpdate} \Rightarrow \text{queue}(renderEffect)
  4. nextTick 时序$\text{nextTick}(\text{cb})$ 在队列 flush 之后运行回调,确保回调一定能拿到最新 DOM,如同等到末班车出站后再上车。
    nextTick(cb)=Promise.then(cb)\text{nextTick}(cb) = \text{Promise.then}(cb)

计算属性缓存:蓄水池的智慧

看一个水电系统场景:几百户住户每天都要看同一块总水表的读数,如果每次有人问就重新跑一遍整个供水系统算一次,浪费巨大。计算属性就是这个“蓄水池”——它把计算结果存下来,只有上游水源(依赖的响应式数据)真的变化时才重新蓄水。

computed 的实现机制非常优雅:它内部创建一个惰性 effect(lazy),并维护一个 _dirty 脏标记。首次访问 computed.value 时执行计算方法并缓存结果,标记置为 false;此后只要依赖不变,每次读取都直接返回缓存值,不再重算。当依赖变化时,computed 内部 effect 的 scheduler 被触发——它只把 _dirty 置回 true,并不立刻重算,等下一次有人访问 .value 时才“按需重算”。

因此 computed 天然具备两个特性:惰性(不访问不算)与缓存(访问过不重算)。这正是它和普通方法调用的本质区别——普通方法每次执行都跑一遍,computed 只在依赖变化时重算一次。

在模板中频繁使用计算属性,或者数据派生链较长时,computed 的缓存机制能显著降低计算开销。它也展示了“用空间换时间、用脏标记换按需计算”的经典工程思想——这套思想在 React 的 useMemo、zustand 的 selector 中同样随处可见。

computed(f)={cache:None,  dirty:True}⇒读取:{dirty=True重算并缓存dirty=False返回 cache\text{computed}(f) = \{ \text{cache}: \text{None}, \; \text{dirty}: \text{True} \} \Rightarrow \text{读取}: \begin{cases} \text{dirty=True} & \text{重算并缓存} \\ \text{dirty=False} & \text{返回 cache} \end{cases}
计算属性是带闸门的蓄水池:闭闸时住户随取随用存水,只有上游来水变化时才重新开闸蓄水,把昂贵计算降到最低频次。
  1. 脏标记初始化$\text{dirty} : \text{True}$ 表示初次读取时蓄水池是空池,必须重算。
    dirty=True\text{dirty} = \text{True}
  2. 首次读重算访问 .value 且 dirty 为 True 时执行 $f()$,将结果存入 cache,并把 dirty 置为 False,如同完成首次蓄水并闭闸。
    cache=f(依赖),dirty=False\text{cache} = f(\text{依赖}), \quad \text{dirty} = \text{False}
  3. 依赖变更通知上游依赖 set 时,computed 的 effect 调度器仅执行 $\text{dirty} = \text{True}$,不开闸放水,实现“只标脏、不重算”。
    scheduler⇒dirty=True\text{scheduler} \Rightarrow \text{dirty} = \text{True}
  4. 惰性重算下次读取时发现 dirty=True 再重算,保证“没人要水就不重复蓄水”,这就是计算的极致懒惰。
    读取: dirty?重算:返回 cache\text{读取: } \text{dirty} ? \text{重算} : \text{返回 cache}

AI 应用场景

大型中后台的状态管理重构

阶段:前端应用开发与状态管理层

解决的问题:传统中后台项目依赖 Vuex/mobx 管理全局状态,状态分散、模块耦合,跨组件共享响应式数据时容易产生“改一处、崩多处”的问题。用组合式 API 的 ref/reactive + computed 可以把用户信息、权限、偏好设置等收敛到独立组合式函数中,天然响应式且可测试。

具体案例:某 AI 平台的控制台需要展示模型训练状态(进度、日志、指标)。团队用 useTrainStatus() 封装轮询接口与 WebSocket 更新逻辑,内部用 ref 存储 status、computed 计算进度百分比,各组件只需调用函数并读取返回值,数据自动同步。新增“训练指标图表”时,直接组合已有函数,无需改动状态层。

实时数据看板的高频更新优化

阶段:实时数据流接入与渲染优化

解决的问题:AI 推理结果的流式输出(如 LLM 打字机效果、指标监控大屏)每秒可能产生数十次数据更新,若每次更新都直接驱动 DOM,浏览器必然卡顿。Vue3 的 effect 调度器将同批次更新合并到微任务队列,配合 computed 缓存派生指标,可实现高性能流式渲染。

具体案例:一个 LLM 对话应用用 ref('') 保存流式输出的文本片段,每当收到新的 token 就追加到字符串上。由于多次 append 发生在同一个事件循环内,渲染 effect 被调度器合并,页面不会逐字闪烁,而是以帧为单位平滑刷新。对 token 进行长度、关键词高亮等派生计算时用 computed 避免重复分词。

通用交互逻辑的 Hooks 封装

阶段:跨项目组件复用与工程化

解决的问题:在多个 AI 产品中,“拖拽上传文件、轮询任务状态、倒计时等待”等交互逻辑高度相似,选项式 API 很难优雅抽取复用。组合式 API 的本质是函数组合,可以像 React Hooks 一样把可复用逻辑封装为 useXxx 组合式函数。

具体案例:团队封装了 useModelInference() 处理大模型调用的全生命周期:loading(ref)、结果(ref)、错误(ref)、重试(computed 派生剩余次数)。不同业务组件只需三行代码接入,也能与其他组合式函数自由嵌套,例如在流式输出中追加 useAutoScroll 实现对话自动滚动。

Python 演示

python125 行
# -*- coding: utf-8 -*-"""mini_vue3.py极简版 Vue3 响应式系统:实现 ref / reactive / effect / computed 核心语义"""from copy import deepcopy  class Dep:    """依赖收集器:每个属性对应一个 Dep 实例,维护订阅它的 effect 集合"""    def __init__(self):        self.subs = set()     def depend(self, effect):        if effect is not None:            self.subs.add(effect)     def notify(self):        for fn in list(self.subs):            fn()  class Reactive:    """Proxy 风格响应式对象:get 收集依赖,set 触发更新"""    def __init__(self, target, is_ref=False):        self._target = target        self._deps_map = {}        self._is_ref = is_ref     def _get_dep(self, key):        if key not in self._deps_map:            self._deps_map[key] = Dep()        return self._deps_map[key]     def get(self, key):        dep = self._get_dep(key)        dep.depend(_active_effect)        value = self._target[key]        if isinstance(value, dict) and not isinstance(value, Reactive):            nested = Reactive(value)            self._target[key] = nested            return nested        if isinstance(value, Reactive):            return value        return value     def set(self, key, obj_value):        old = self._target.get(key)        if old == obj_value:            return        self._target[key] = obj_value        dep = self._get_dep(key)        dep.notify()  _active_effect = None  class Ref:    """ref 的 Python 模拟:维护一个 _reactive 对象,value 属性走 get/set"""    def __init__(self, raw_value):        self._reactive = Reactive({"value": raw_value}, is_ref=True)     @property    def value(self):        return self._reactive.get("value")     @value.setter    def value(self, new_val):        self._reactive.set("value", new_val)  def reactive(obj):    return Reactive(obj)  def ref(obj_value):    return Ref(obj_value)  def _run_effect(fn):    global _active_effect    _active_effect = fn    try:        fn()    finally:        _active_effect = None  def effect(fn):    _run_effect(fn)    return fn  def computed(fn):    _cache = {"value": None, "dirty": True}     def runner():        _cache["value"] = fn()        _cache["dirty"] = False     wrapped = lambda: None    effect(lambda: (_cache.__setitem__("dirty", True),                    wrapped()))    return {        "get_value": lambda: (runner() if _cache["dirty"] else None,                              _cache["value"])[1],    }  if __name__ == "__main__":    count = ref(0)    double = computed(lambda: count.value * 2)    log = []     def watch_count():        log.append(count.value)     effect(watch_count)     count.value = 5    print("double cached:", double["get_value"]())    count.value = 10    print("double after change:", double["get_value"]())    print("watch log:", log)
目的:用纯 Python 复刻 Vue3 的响应式核心——Dep 收集依赖、Reactive 的 get/set 拦截、effect 自动订阅、computed 脏标记缓存,让你在无浏览器环境下也能直观体验响应式原理。
  1. class Dep类比依赖集合 Set(effect):每个属性一个 Dep,depend 登记订阅者,notify 派发更新。
  2. _active_effect全局标记“当前正在执行的 effect”,这就是 Vue3 中 activeEffect 的实现方式。
  3. def get(self, key)读取属性时调用 dep.depend(_active_effect),完成依赖收集,相当于 track。
  4. def set(self, key, obj_value)写入属性时对比旧值并 dep.notify(),相当于 trigger。
  5. count = ref(0)ref 内部把值包进 {"value": ...},再交给 Reactive 代理,和 Vue3 底层如出一辙。
  6. effect(watch_count)同步执行一次用于收集依赖,之后数据变化自动重跑。
  7. count.value = 5setter 触发通知,watch_count 被重新执行,log 中追加 5。
  8. double["get_value"]()computed 首次读取时重算并缓存,依赖不变时后续读取直接命中缓存。
  9. _cache["dirty"]脏标记:依赖变化时仅置脏不重算,下次读取才惰性重算。
  10. effect(lambda: ...)computed 内部通过一个 effect 监听依赖,当依赖变化时把 dirty 置为 True。

常见误区

误区“ref 是给数字、字符串用的,普通对象必须用 reactive,否则会报错或丢失响应式。”

正解ref 内部其实不分类型:传基本类型就包装成 { value: 原值 },传对象就直接调用 reactive 做深层代理。所以 ref({ count: 0 }) 完全可以正常工作,且 state.value.count = 1 也是响应式的。唯一的差别是访问方式——ref 需要 .value。工程上“全部用 ref”是完全可行的统一方案,不会丢响应式。

为何常见:初学时看教程总说“ref 基本类型、reactive 对象类型”,很容易把“使用惯例”误解为“底层限制”。

误区“const { name } = reactive({ name: '张三' }) 之后 name 还是响应式的,改它会更新视图。”

正解解构拿到的是原始值的拷贝或原始引用,只有通过对象本身访问才能触发 Proxy 的 get/set trap。解构后的 name 已经脱离了代理对象,所有读写都绕过总闸,自然丢失响应式。需要用 toRefs(state) 把每个属性转成 ref 后再解构,才能保持连接。

为何常见:JS 解构太常用,直觉上会觉得“从响应式对象里拿出来的东西也是响应式的”,忽略了 Proxy 拦截依赖“引用关系”这一事实。

误区“effect 注册后不会立即执行,只有数据变化时才触发。”

正解恰好相反,effect(fn) 注册后会立即同步执行一次,目的就是“跑一遍函数体,触发所有属性读取,从而完成依赖收集”。之后数据变化时才会再执行。Vue3 中 watchEffect、computed 内部都有这个首次执行收集依赖的阶段。如果首执行不跑,系统根本不知道这个 effect 依赖哪些数据。

为何常见:从“监听”的字面意思出发,容易类比 setTimeout 的“延迟回调”,但副作用函数必须“先跑一次暴露依赖”,这正是响应式系统设计的关键。

误区“Vue2 里数组 push 需要特殊处理,Vue3 虽然用 Proxy,但 push 这种原型方法应该也拦不住吧?”

正解Vue3 的 Proxy 不仅能拦截 arr[2] = x 这样的索引写入,还能拦截 push、pop、shift、splice 这类调用——因为 get trap 会先拦截到 arr.push 的读取,返回一个被包裹的方法,方法内部再经过 set 和 length 修改的 trap,最终都能触发依赖更新。数组在 Vue3 中完全不需要 hack。

为何常见:被 Vue2 的数组缺陷“创伤”后,容易惯性认为数组原生方法永远是个问题,没意识到 Proxy 对“原型链方法调用”同样有拦截能力。

误区“computed 就是带缓存的 getter,所以每次读取它都会先检查依赖有没有变,变了就重算,没变就返回缓存。”

正解这种理解方向是对的,但漏掉了“惰性”这个关键细节。computed 在依赖变化时不会立即重算,只是把脏标记置为 true;只有下一次访问 .value 时才会触发重算并更新缓存。也就是说计算被推迟到真正“被需要”的瞬间,这才是 computed 高效的本质。

为何常见:接触“缓存失效”机制时,容易默认“失效 = 立即重建”,而 Vue3 选择的是“失效 = 标记待重建”,把重算推迟到读取时,省掉了大量无用户访问的无效计算。

面试题

Q1. Vue3 中创建基本类型响应式引用应该用哪个 API?

单选题 · easy

  • A.ref← 正确答案
  • B.reactive
  • C.computed
  • D.watch

答案:A。ref 专为包装基本类型设计(内部也支持对象),返回带 .value 的响应式引用;reactive 只能代理对象;computed 是派生数据;watch 是侦听器,都不负责创建基本类型响应式。

Q2. Vue3 的响应式底层基于哪个 JS 特性实现?

单选题 · easy

  • A.Proxy← 正确答案
  • B.defineProperty
  • C.Object.observe
  • D.MutationObserver

答案:A。Vue3 用 Proxy 代理整个对象,无需预声明属性即可拦截增删改;defineProperty 是 Vue2 的方案;Object.observe 已被废弃;MutationObserver 用于监听 DOM 变化而非数据。

Q3. 关于 Vue3 的 Proxy 与 Vue2 的 defineProperty 对比,正确的是哪些?

多选题 · easy

  • A.Proxy 可监听新增属性← 正确答案
  • B.Proxy 可监听删除属性← 正确答案
  • C.defineProperty 可监听数组索引
  • D.Proxy 支持深层惰性代理← 正确答案

答案:ABD。Proxy 的 deleteProperty、set trap 天然覆盖新增和删除属性;defineProperty 对数组索引和 length 的监听存在盲区——严格说 defineProperty 无法拦截 arr[3]=x 这类新增索引赋值,因此 C 错误;Proxy 在访问嵌套对象时才递归代理,是惰性深层代理。

Q4. 从 reactive 对象解构出的属性为什么会丢失响应性?

单选题 · mid

  • A.解构是值拷贝操作,绕过 Proxy← 正确答案
  • B.reactive 不支持解构语法
  • C.解构会销毁原代理对象
  • D.解构返回的是 ref 引用

答案:A。解构提取的是属性当前值或原始引用,之后对解构变量的读写不再经过 Proxy 的 get/set trap,响应连接断裂;reactive 当然支持解构语法(JS 层面合法);解构不会销毁原对象;解构返回原始值而非 ref。用 toRefs 可以修复。

Q5. Vue3 派发更新(trigger)时为什么用迭代副本遍历依赖?

单选题 · mid

  • A.避免 effect 执行中修改依赖导致死循环← 正确答案
  • B.提升遍历性能
  • C.确保依赖按顺序执行
  • D.减少内存占用

答案:A。effect 执行时可能再次读取或写入同一属性,导致订阅集合在遍历过程中被增删;先拷贝一份 new Set(dep) 再遍历,保证原始集合变化不影响当轮迭代,避免死循环或漏执行。与性能、顺序、内存无直接关系。

Q6. 关于 effect 调度器(scheduler)的作用,正确的是哪些?

多选题 · mid

  • A.批量合并同一 effect 的多次触发← 正确答案
  • B.将 effect 放到微任务队列统一执行← 正确答案
  • C.让所有 effect 变成异步的
  • D.实现 nextTick 等待 DOM 更新← 正确答案

答案:ABD。调度器用 Set 去重,把同一 effect 多次触发合并为一次;渲染 effect 等通过 Promise 微任务统一 flush,因此 nextTick 可以保证拿到更新后的 DOM;但并非所有 effect 都是异步的——没有配置 scheduler 的 effect 仍同步执行,C 错误。

Q7. computed 的脏标记(dirty)在什么情况下会被置为 true?

单选题 · mid

  • A.依赖的响应式数据发生变化时← 正确答案
  • B.每次访问 computed.value 时
  • C.computed 内部 effect 首次执行时
  • D.组件销毁时自动置脏

答案:A。computed 内部 effect 的 scheduler 在依赖变化时只执行 dirty = true,不做重算;下次访问 .value 才惰性重算。首次执行 effect 是收集依赖,与置脏无关;同样组件销毁也不会置脏。

Q8. 修改数组的 length 属性时,Vue3 的 trigger 会额外通知哪些依赖?

单选题 · hard

  • A.依赖数组迭代器的 effect← 正确答案
  • B.依赖数组首个元素的所有 effect
  • C.仅依赖 length 本身的 effect
  • D.所有对象类型的依赖集合

答案:A。length 变化影响数组遍历结果,因此依赖 forEach/map 或 Symbol.iterator 的 effect 必须重新执行;只通知 length 自身的订阅者会漏掉 v-for 等迭代渲染。B、D 范围不准确。

Q9. 关于 ref 的底层实现,下列说法正确的是哪些?

多选题 · hard

  • A.ref 传基本类型时会包装成 { value } 对象← 正确答案
  • B.ref 传对象时内部调用 reactive 处理← 正确答案
  • C.ref 与 reactive 的响应式机制完全无关
  • D.ref 的 .value 读写同样经过 Proxy← 正确答案

答案:ABD。ref 统一把值包进 { value: ... } 再交给 reactive,因此基本类型被包装、对象直接走深层代理,.value 读写经过 Proxy 的 get/set trap;所以说 ref 与 reactive 机制同源,C 错误。

Q10. 嵌套对象在 Vue3 的 reactive 中何时被代理为深层 Proxy?

单选题 · extreme

  • A.对象创建时立即递归代理
  • B.首次访问该嵌套对象属性时← 正确答案
  • C.首次 render 完成后统一代理
  • D.手动调用 toRaw 时才会代理

答案:B。Vue3 采用惰性深层代理:只有 get trap 访问到嵌套对象时才用 reactive 递归包装并缓存,避免创建时对所有深层属性做无谓代理,节省初始化的内存和时间开销。这也使 Vue3 在深层大对象上远比 Vue2 高效。