Skip to main content
响应式我从 Proxy 和 defineProperty 的差异入手,慢慢补上数组索引、新增属性、ref 解包和 track/trigger 这几块,才看清依赖是按属性收集的。

Q1: Vue 2 的 defineProperty 和 Vue 3 的 Proxy 有什么区别?

1

Vue 2 时代: Object.defineProperty

Vue 2 用 Object.defineProperty 给对象的每个属性设置 getter/setter。读取属性时收集依赖,修改属性时触发更新。这套方案支撑了 Vue 2 全系列的响应式实现。
2

致命痛点: 无法拦截新增属性

defineProperty 是针对已有属性的——你必须预先知道对象有哪些属性。新增属性(obj.newProp = 'x')不在预设列表中,自然拦截不到。Vue 2 不得不提供 $set、$delete 这些补丁 API。数组下标变化同理。
3

Vue 3 的方案: Proxy

Vue 3 用 Proxy 代理整个对象。Proxy 拦截的是对象级别的操作——不管访问什么属性、新增什么属性、删除什么属性、遍历对象,全部能拦截到。
4

代价与权衡

Proxy 不能代理原始类型(number、string、boolean),所以 Vue 3 需要用 ref() 来包装原始值。这是 Vue 3 的设计权衡:用 ref 的 .value 语法换取了更强大的拦截能力。
5

关键洞察: 深层代理是递归的

Proxy 只代理第一层属性。Vue 3 在 get trap 中检查返回值,如果是对象就递归调用 reactive() 包装——这就是”惰性代理”,只有访问到的嵌套对象才会被代理,避免一次性代理整棵对象树的性能开销。
defineProperty vs Proxy 解决的核心问题:
defineProperty 是”逐个属性上锁”,Proxy 是”给整个保险箱上锁”——Vue 3 用对象级拦截替代属性级拦截,彻底消除了响应式盲区。

Vue 2 的 defineProperty

只能拦截已有属性的读写。新增属性需要 $set,删除属性需要 $delete,数组下标赋值不触发更新。就像你给家里的每扇门单独装了锁,但有人从窗户爬进来你完全不知道。

Vue 3 的 Proxy

拦截整个对象的所有操作,包括新增、删除、遍历。就像你给整栋房子装了一个智能门禁系统,不管谁从哪个门进、带了什么东西出去,系统都能感知。
defineProperty 就像给家里的每扇门单独装锁——你必须提前知道有哪些门,新装的门(新增属性)没有锁,窗户(数组下标)也管不住。Proxy 就像给整栋房子装了一套智能门禁系统——不管是门、窗还是天窗,任何进出操作都能被感知到。Vue 3 换上这套”门禁系统”后,$set、$delete 这些补丁工具就光荣退休了。
回答时先说 defineProperty 的三个缺陷(新增属性、删除属性、数组下标),再说 Proxy 如何一招解决所有问题,最后提一句 Proxy 不能代理原始类型所以需要 ref。这三步说完,对方就知道你真正理解了这次架构升级的动机。

数据响应式的本质:track、trigger 与响应式粒度

很多人能说出”Vue 用 Proxy 实现响应式”,但被追问”响应式的本质到底是什么”时就卡壳了。这道冷门题考的是你是否理解响应式系统的三个核心机制:依赖收集(track)、触发更新(trigger)、响应式粒度(组件级 vs 属性级)。 响应式的本质是一个发布-订阅模式——数据是”发布者”,视图是”订阅者”。当数据被读取时,当前正在执行的副作用函数(effect)自动订阅这个数据(track);当数据被修改时,所有订阅了这个数据的副作用函数被通知执行(trigger)。

Proxy vs defineProperty 对响应式粒度的影响

响应式粒度是指”数据变化时,更新范围有多精确”。Vue 2 的 defineProperty 和 Vue 3 的 Proxy 在粒度上有本质区别:
Vue 的响应式粒度是属性级别——修改 state.name 只会触发依赖 name 的 effect,不会触发依赖 state.age 的 effect。这得益于三层 Map 的依赖收集结构(WeakMap → Map → Set)。Vue 2 的 defineProperty 理论上也是属性级粒度,但因为无法拦截新增属性,实际粒度是”预定义属性级”。Vue 3 的 Proxy 是真正的”对象级拦截 + 属性级粒度”——拦截所有操作,但依赖仍然按属性分开收集。

Q2: 依赖收集的三层 Map 是怎么设计的?

Vue 3 的依赖收集系统用三层嵌套的数据结构来存储”谁依赖了什么数据”的关系:
  • 第一层 WeakMap:以目标对象为 key。WeakMap 的弱引用保证对象被 GC 时依赖自动清理,不会内存泄漏。
  • 第二层 Map:以属性名为 key。O(1) 的查找效率,一个对象的每个属性有独立的依赖集合。
  • 第三层 Set:存储所有依赖此属性的 effect。Set 自动去重(同一个 effect 不会重复加入),且删除效率 O(1)。
如果只用两层(WeakMap + Set),所有属性共享一个依赖集合,修改 name 会触发依赖 age 的 effect,产生大量不必要的更新。
三层 Map 的设计保证了属性级粒度的精确依赖收集——WeakMap 防内存泄漏、Map 保证 O(1) 查找、Set 自动去重。
三层 Map 就像医院的挂号系统。第一层 WeakMap 是”医院大楼”(哪个对象),第二层 Map 是”科室”(哪个属性),第三层 Set 是”挂号名单”(哪些医生/effect 关注了这个科室)。当某个科室的检查结果更新时(属性变化),只有挂了这个科室号的医生(依赖该属性的 effect)会被通知,其他科室的医生完全不受影响。
reactive 就像一个保险柜——你把贵重物品(对象)放进去后,每次取东西都要开柜门(直接访问属性),但你不能把整个保险柜搬走(解构会丢失响应式)。ref 就像一个快递盒——不管里面装的是文件(对象)还是钥匙扣(原始值),你都要拆开包装(.value)才能拿到东西,但盒子本身可以随意搬动(解构不丢失响应式)。computed 就像自动售货机——你投币(读取 .value)时它才出货(计算),没人买的时候它就静静待着(惰性求值),而且同一瓶饮料不会重复出货(缓存)。

Q3: reactive vs ref vs computed 有什么区别?

基础梳理

专家视角深度剖析

很多人能背出三者的 API 区别,但实际更在意的是:为什么 Vue 3 要同时设计 reactive 和 ref 两个 API?只用一个不行吗? 核心原因是 Proxy 无法代理原始类型。new Proxy(0, handlers) 会报错,因为 Proxy 的第一个参数必须是对象。所以 Vue 3 必须用一个对象把原始值包起来——这就是 ref。 但 ref 的 .value 语法确实不如 reactive 的直接访问直观。为什么 Vue 3 不把所有东西都做成 reactive 呢?因为 reactive 返回的是 Proxy 对象,解构赋值会丢失响应式:
computed 的设计哲学是惰性求值 + 缓存。依赖变化时只标记 dirty(推),有人读取时才计算(拉)。这比 eager 模式(每次依赖变化立即计算)更高效——如果 computed 没有被读取,计算就完全省掉了。

reactive 的解构陷阱与 toRefs 的设计

reactive 返回的是 Proxy 对象,解构赋值是值复制,脱离了 Proxy 代理。这是面试中最常被追问的点:
toRefs 的源码实现(packages/reactivity/src/ref.ts)遍历 reactive 对象的每个属性,为每个属性创建一个 getter/setter 包装的 ref——读取时走 reactive 的 get trap,写入时走 reactive 的 set trap,从而保持响应式连接。

Q4: 解构 reactive 对象为什么会丢失响应式?

原因:解构赋值是值复制,脱离了 Proxy 代理。
关键区别:
reactive 对象就像一串钥匙串——所有钥匙(属性)都挂在同一个环(Proxy)上,你从环上取下一把钥匙(解构),它就变成了普通的金属片(脱离 Proxy),再也打不开门了。toRefs 就像给每把钥匙配了一个智能钥匙扣(Ref 对象)——即使从串上取下来,钥匙扣里的芯片仍然记得它属于哪扇门(保持响应式连接)。
从 reactive 对象中取值时,如果需要解构,必须用 toRefs 或 toRef 转换。如果只是在模板中使用({{ state.count }}),直接访问属性即可,不需要解构。

Q5: Vue 的批量更新是怎么实现的?(queueJob + 微任务)

Vue 的批量更新核心机制是:同一事件循环内的多次数据修改,只触发一次组件更新。
微任务(Promise.resolve().then())在当前同步代码执行完毕后、浏览器渲染前执行。这意味着:
  • 同步代码中的所有数据修改都收集完毕后,才统一触发一次更新
  • 更新发生在浏览器渲染前,确保视图是最新状态
  • nextTick 回调复用了同一个 flushPromise,所以总是在所有 queueJob 之后执行
如果用 setTimeout(宏任务),更新要等到浏览器渲染之后才执行,会导致视图闪烁——先渲染旧状态,再更新为新状态。
queueJob 用微任务实现批量更新——同一事件循环内的多次修改只触发一次组件更新,确保更新发生在浏览器渲染前。
批量更新就像餐厅的后厨系统。客人的多道菜(多次数据修改)被一起送到后厨(queueJob 去重入队),后厨不会每收到一道菜就开一次火(不每次修改都更新组件),而是等这一桌的菜全部下单后,统一安排炒菜(微任务中 flushJobs),一次性出餐(组件更新一次)。如果客人中途又加了菜(微任务中新的修改触发新的 queueJob),后厨也会在这轮出餐中一并处理。

Q6: nextTick 的原理和使用场景?

1

数据变化后 DOM 不会立即更新

Vue 的响应式系统在数据变化时不会立即同步更新 DOM,而是把更新任务推入一个异步队列(queueJob)。同一事件循环内的多次数据修改会被合并,只触发一次 DOM 更新。这意味着在同步代码中读取 DOM,拿到的还是旧值。
2

致命痛点: 怎么拿到更新后的 DOM?

很多场景需要在 DOM 更新后执行逻辑——比如获取元素尺寸、操作滚动位置、初始化第三方库。但因为 DOM 更新是异步的,同步代码中读取 DOM 永远是旧值。
3

nextTick: 等 DOM 更新完再执行

nextTick 接受一个回调函数,把它推入一个微任务队列。当 DOM 更新完成后(queueJob 的 flushJobs 执行完毕),nextTick 的回调才会执行——此时读取 DOM 就是最新值。
4

底层实现: 复用同一个 Promise

Vue 3 的 nextTick 返回一个 Promise,内部用 Promise.resolve() 创建微任务。多个 nextTick 调用会复用同一个 resolvedPromise,保证回调在同一个微任务批次中按顺序执行。
5

关键洞察: nextTick 是批量更新的出口

nextTick 的本质是”批量更新完成后的一个通知机制”。它和 queueJob 共享同一个微任务调度——queueJob 负责安排更新,nextTick 负责在更新完成后执行用户回调。
nextTick 解决的核心问题:
一句话总结:nextTick 是”等 DOM 更新完再执行”的微任务回调机制,本质是批量更新的出口。
nextTick 就像洗衣机洗完衣服后的那声”滴滴”提示。你把脏衣服(数据修改)扔进洗衣机(queueJob),但你不会每放一件就打开盖子看一次(不会每次数据变化都读 DOM)。你等洗衣机发出”洗涤完成”的提示音(nextTick 回调),那时候衣服肯定已经洗好了(DOM 已经更新),你再打开盖子取衣服(读取 DOM)就是最新状态。

没有 nextTick

数据变化后立刻读 DOM,拿到的是旧值。就像你刚发出一封快递,立刻去查物流信息,系统还没来得及更新,显示的还是”未揽收”。

引入 nextTick

用 nextTick 等 DOM 更新完成后再读取。就像你等快递员扫码揽收后(DOM 更新完成),再去查物流信息(nextTick 回调),就能看到最新状态。
回答时先说”为什么需要”(DOM 异步更新的机制),再说”怎么实现的”(Promise.resolve 微任务),最后说”典型场景”(获取元素尺寸、初始化第三方库)。想听的是你对 nextTick 和批量更新机制关系的理解,而不是背 API。

Q7: watch 和 watchEffect 有什么区别?

watch 和 watchEffect 都是 Vue 3 的侦听器 API,但设计理念完全不同:
watch 侦听 reactive 对象时,Vue 3 会隐式开启深层侦听;而侦听返回对象的 getter 时默认是浅层的:
deep: true 会递归遍历对象的所有属性,性能开销较大。优先用 getter 函数精确指定侦听路径。
watch 是显式侦听源的惰性副作用,watchEffect 是自动追踪依赖的立即执行副作用——前者能拿旧值,后者不能。
watch 就像门口的保安——你告诉他”盯住这个人”(指定侦听源),他只在那个人进出时才报告(惰性执行),而且能告诉你”之前是张三,现在换成了李四”(旧值/新值)。watchEffect 就像自动保洁机器人——你不需要指定它打扫哪里,它会自己扫描整个房间(自动收集依赖),发现脏了就立刻打扫(立即执行),但它不会告诉你”之前有多脏”(无法拿到旧值)。

Q8: computed 的惰性求值是怎么实现的?

computed 的核心设计是**推拉结合(push-pull)**模型——依赖变化时只标记 dirty(推),有人读取时才计算(拉)。
惰性求值(lazy evaluation)的含义是:不到真正需要结果的时候,就不计算。
  • eager 模式(非惰性):依赖变了立刻计算新值。如果依赖连续变化 10 次,就计算 10 次——即使没有人在读取结果。
  • lazy 模式(惰性):依赖变了只标记 dirty。只有有人读取 .value 时,如果 dirty 为 true 才计算。依赖连续变化 10 次,只在最终读取时计算 1 次。
这就是 computed 比在 watch 中手动计算更高效的原因——computed 会自动跳过无用的中间计算。
computed 的核心是推拉结合——依赖变化只标记 dirty(推),读取时才计算(拉),无人读取则完全省掉计算。
computed 的惰性求值就像学校的成绩单系统。每次考试成绩录入时(依赖变化),系统不会立刻打印每个学生的成绩单(不立即计算)。只有当家长来问”我孩子考了多少分”时(读取 .value),系统才去汇总计算并打印(执行 getter)。如果家长一直没来问,成绩单就永远不会打印(计算省掉了)。而且如果成绩录入了 5 次,家长只在最后问一次,系统只计算最终的那次结果。

Q9: effect 和 ReactiveEffect 是什么关系?

ReactiveEffect 是 Vue 3 响应式系统的底层核心类,effect 函数是创建 ReactiveEffect 实例的工厂函数。
谁在用 ReactiveEffect?
Vue 3 用一个全局变量 activeEffect 来标记”当前正在执行的 effect”。当 track() 被调用时,它把 activeEffect 加入属性的依赖集合。这就是为什么响应式数据的读取必须在 effect 函数内部——只有此时 activeEffect 不为 null,依赖才能被收集。如果在 effect 函数外部读取响应式数据,activeEffect 为 null,track() 直接返回,不收集依赖。
ReactiveEffect 是响应式系统的底层核心类——组件渲染、computed、watch/watchEffect 都是它的实例,区别只在 scheduler 的实现。
ReactiveEffect 就像快递员的揽件流程。run() 就是”揽件”——快递员(effect)去客户家(执行 fn),顺便记录客户地址(track 收集依赖)。下次客户有新包裹(数据变化),快递公司就知道派哪个快递员去(触发更新)。stop() 就是”离职”——从所有客户的派件名单中移除(清理依赖)。全局变量 activeEffect 就像快递员的工作证——只有在岗的快递员才能揽件,不在岗的揽不到件。

底层源码:Vue 3 的响应式是如何实现的?

Vue 3 为不同类型的对象定义了不同的 handler(详见 baseHandlers.ts#L38):
详见 effect.ts#L209:

十、及格回答 vs 满分回答

问题:“说说 Vue 3 的响应式原理”

及格回答(60-70分):
“Vue 3 用 Proxy 代理对象,读取属性时 track 收集依赖,修改属性时 trigger 触发更新。ref 包装原始类型,computed 是惰性求值加缓存。”
问题出在:知道机制但不理解设计。 满分回答(90-100分):
“Vue 3 的响应式系统有三个核心设计决策。第一,用 Proxy 替代 defineProperty。defineProperty 只能拦截已有属性,新增属性、删除属性、数组下标赋值都需要 $set 补丁。Proxy 拦截对象级别的操作,包括 has、ownKeys、deleteProperty,一招解决所有响应式盲区。代价是不能代理原始类型,所以需要 ref 包装。[1]”
“第二,三层 Map 的依赖收集。targetMap(WeakMap) → depsMap(Map) → dep(Set)。WeakMap 以对象为 key,对象被 GC 时依赖自动清理,避免内存泄漏。Map 以属性名为 key,O(1) 查找。Set 存储 effect,自动去重。三层结构确保了修改 name 不会触发依赖 age 的 effect。[2]”
“第三,computed 的推拉结合模型。依赖变化时只标记 dirty(推),有人读取时才计算(拉)。这比 eager 模式高效得多——如果 computed 没有被读取,计算完全省掉。queueJob 的批量更新机制保证同一事件循环内的多次修改只触发一次组件更新,用微任务调度确保更新发生在浏览器渲染前。[3]“

常见追问

题目来源于真实面经(字节/腾讯/阿里前端面试、牛客网、haizlin/fe-interview),按难度递进。
答题要点: 解构赋值是值复制,变量脱离了 Proxy 代理。高分回答: “reactive 返回的是 Proxy 对象,属性访问通过 get trap 收集依赖。JavaScript 的解构赋值 let { count } = state 本质上是值复制——count 被赋值为 state.count 的返回值(一个普通数字),它和 Proxy 没有任何关系了。解决方案有三种:1)不解构,直接用 state.count 访问;2)用 toRefs(state) 把每个属性转为 ref,解构出来的是 Ref 对象,.value 访问仍然走响应式;3)用 toRef(state, 'count') 单独转一个属性。模板中直接用 {{ state.count }} 不需要解构,这是最常见的场景。”
答题要点: 从 Proxy 的限制出发解释设计权衡。高分回答: “根本原因是 Proxy 只能代理对象,不能代理原始类型。new Proxy(0, handlers) 会报错。所以 Vue 3 必须用一个对象把原始值包起来——RefImpl 就是这个包装对象。访问 .value 才能触发 getter 进行依赖收集,修改 .value 才能触发 setter 进行更新。reactive 不需要 .value 是因为它返回的是 Proxy 对象本身,直接访问属性就经过了 get trap。但 reactive 也有代价:解构丢失响应式、不能用于原始类型。Vue 团队内部讨论过”编译器自动脱 ref”方案(ref sugar),让编译器自动把 .value 去掉,但最终因为容易引起混淆而废弃了。目前的共识是:.value 是一个可接受的代价,换取了完整的类型安全和明确的语义。”
答题要点: 核心区别是”惰性求值”vs”立即执行”以及 scheduler 的不同。高分回答: “computed 和 watch 底层都是 ReactiveEffect,区别在于 scheduler 的实现。computed 的 scheduler 只设置 dirty = true(标记需要重新计算),不立即执行 getter。只有当有人读取 .value 时,如果 dirty 为 true 才执行计算并缓存结果。这是’推拉结合’——依赖变了推一个 dirty 标记,读取时拉取新值。watch 的 scheduler 是立即调度一个 job(通过 queueJob)。依赖变化时,watch 的回调函数会在微任务中被调用。watch 还支持 flush: 'pre'/'post'/'sync' 来控制回调的执行时机——pre 在组件更新前,post 在更新后,sync 是同步立即执行。一句话总结:computed 是’被动的、缓存的派生值’,watch 是’主动的、有副作用的响应’。”
答题要点: effectScope 解决的是 effect 的批量管理问题,尤其在组合式函数和组件卸载场景。高分回答: “effectScope 是 Vue 3.2 引入的一个 API,用来收集和批量管理 effect(包括 computed、watch、watchEffect)。它的核心场景是:当你写一个组合式函数(composable),内部创建了多个 watch/effect,组件卸载时需要全部清理。没有 effectScope 的话,你需要手动 return 一个 stop 函数,在 onUnmounted 中逐个调用。有了 effectScope,只需要在 scope 内创建 effect,调用 scope.stop() 就能一次性清理所有。Vue 的 setup() 函数内部就隐式创建了一个 effectScope——所以 setup 中创建的 watch/computed 会在组件卸载时自动清理。一些状态管理库(如 Pinia)也用 effectScope 来管理 store 的响应式依赖。”
答题要点: nextTick 用 Promise.resolve 创建微任务,和 queueJob 共享调度。高分回答: “Vue 3 的 nextTick 内部用 Promise.resolve() 创建微任务,返回一个 Promise。它和 queueJob(组件更新的调度器)共享同一个微任务批次——先执行 queueJob 中的更新任务,再执行 nextTick 的回调。多个 nextTick 调用会复用同一个 resolvedPromise,保证回调按顺序执行。为什么用微任务而不是宏任务(setTimeout)?因为微任务在当前同步代码执行完毕后、浏览器渲染前执行。这意味着:1)同步代码中的所有数据修改都收集完毕后,才统一更新 DOM;2)nextTick 回调在 DOM 更新后、浏览器渲染前执行。如果用 setTimeout,更新要等到浏览器渲染之后,会导致先渲染旧状态再更新为新状态的闪烁问题。”
答题要点: watch 显式指定侦听源,可拿旧值;watchEffect 自动追踪依赖,立即执行。高分回答: “watch 和 watchEffect 底层都是 doWatch 函数(packages/runtime-core/src/apiWatch.ts),区别在于三个维度:1)侦听源:watch 需要明确指定侦听源(ref、reactive 或 getter 函数),watchEffect 不需要——它自动追踪回调中访问的所有响应式数据。 2)首次执行:watch 默认惰性(只在源变化时执行),加 immediate: true 可首次执行。watchEffect 立即执行一次。 3)旧值:watch 的回调可以拿到 (newVal, oldVal),watchEffect 拿不到。选型建议:需要对比新旧值或有条件地执行副作用时用 watch(如监听路由参数变化重新请求数据);需要自动收集依赖并立即执行副作用时用 watchEffect(如根据响应式数据动态设置 document.title)。”
答题要点: effect 是工厂函数,ReactiveEffect 是核心类,组件渲染是特殊的 ReactiveEffect。高分回答: “effect() 是一个工厂函数(packages/reactivity/src/effect.ts),内部创建 ReactiveEffect 实例并立即执行一次。ReactiveEffect 是响应式系统的核心类,有 run()、stop() 等方法,run() 执行副作用函数并收集依赖。组件渲染也是 ReactiveEffect——在 setupRenderEffect(packages/runtime-core/src/renderer.ts)中创建,但它的 scheduler 不是直接执行,而是调用 queueJob 把组件更新推入队列。这就是为什么数据变化不会立即更新 DOM,而是等微任务中批量更新。computed 的 ReactiveEffect 更特殊——它的 scheduler 只设置 dirty = true,不触发更新。watch/watchEffect 的 ReactiveEffect 通过 doWatch 创建,scheduler 调用 queueJob 来调度回调执行。”
答题要点: computed 用 dirty 标志位实现惰性求值,连续修改只在读取时计算一次。高分回答: “computed 的核心是 dirty 标志位。当依赖变化时,scheduler 只设置 dirty = true(推),不重新计算。只有当有人读取 .value 时,如果 dirty 为 true 才执行 getter 计算新值,并设置 dirty = false(拉)。所以连续修改 10 次:依赖变化 10 次 → dirty 被设置为 true 10 次(但每次都是同一个标志位)→ 只有在最终读取 .value 时才计算 1 次。这比 eager 模式(每次变化都计算)高效 10 倍。但有一个陷阱:如果模板中有多个地方使用了同一个 computed,Vue 的模板编译器会缓存读取结果——在同一个渲染 effect 中,computed 只计算一次。这就是为什么 computed 比在 methods 中写同样的逻辑更高效——methods 每次渲染都重新执行,computed 只在依赖变化时重新计算。“

Q10: reactive() 底层源码详解

reactive() 是 Vue 3 响应式系统的入口函数。它的核心逻辑是:创建一个 Proxy 对象,拦截所有操作,在 get 时收集依赖、在 set 时触发更新。

源码流程

handlers 的核心 trap

惰性代理的精妙设计

defineProperty 在初始化时必须递归遍历整个对象树,为每个属性设置 getter/setter。Proxy 只代理第一层,嵌套对象在 get trap 中按需递归——这就是”惰性代理”。
Proxy 的惰性代理就像快递驿站的”按需取件”模式。defineProperty 是”全量派送”——快递员(初始化时)把所有包裹(所有嵌套属性)一次性送到你家门口,不管你用不用。Proxy 是”按需取件”——你只在驿站存了第一层的快递柜(代理第一层),只有你去取某个包裹时(访问嵌套属性),驿站才会把那个包裹从仓库里调出来(递归代理)。包裹越多,按需取件越省资源。
reactive 只能代理对象,不能代理原始类型。reactive(0) 会报错。原始类型必须用 ref() 包装。另外,reactive 返回的 Proxy 对象解构会丢失响应式——因为解构是值复制,脱离了 Proxy 代理。

Q11: 解构 reactive 为什么丢失响应式?

解构 reactive 就像用拍立得给朋友拍了一张照片——照片(解构出来的变量)是那个瞬间的静态快照,朋友后来换了发型(数据变化),照片不会自动更新。toRefs 就像给朋友装了一个实时视频通话——即使你把手机拿走(解构),画面仍然是实时的,因为数据流没有断开。

根本原因:解构是值复制

JavaScript 的解构赋值 let { count } = state 本质上是:

ref 解构会丢失吗?

会。ref 只是把值包在 RefImpl 对象里,解构 .value 仍然是值复制:

toRefs 的原理

toRefs 把 reactive 对象的每个属性转为一个 ref,解构出来的是 Ref 对象:

Q12: ref vs reactive 的核心区别

ref 就像一个手提箱——不管是小首饰(原始值)还是大件行李(对象),都能装进去,但你每次拿东西都要打开箱子(.value)。reactive 就像一个嵌入墙壁的保险箱——直接拉开抽屉就能拿东西(直接访问属性),但你不能把整面墙搬走(解构丢失响应式),而且保险箱只能放大件物品(只能代理对象,不能代理原始值)。

为什么需要两个 API?

根本原因是 Proxy 只能代理对象,不能代理原始类型。

ref 的内部实现

详细对比

什么时候用 ref,什么时候用 reactive?

原始值用 ref,对象用 reactive。如果需要整体替换(如列表数据从 API 返回后整体赋值),用 ref。模板中使用 ref 不需要写 .value,编译器会自动脱 ref。

Q13: watch 的原理

watch 的原理就像安保监控室的工作模式。doWatch 函数是监控室主任,他先确认”要监控哪个摄像头”(解析 source),然后安排一个保安(ReactiveEffect)盯着屏幕。保安发现画面变化时不会立刻行动(scheduler 调度),而是写一份报告(job)交给调度中心(queueJob)。调度中心根据优先级安排处理时间——flush: 'pre' 是在开门前处理,flush: 'post' 是在关门后处理,flush: 'sync' 是发现异常立刻出动。

watch 的底层实现

watch 底层也是 ReactiveEffect,但和组件渲染的 effect 不同——它的 scheduler 是调度回调函数执行,而不是调度组件更新。

watch 的 deep 选项

deep: true 会递归遍历对象的所有属性,对大对象有显著性能开销。优先用 getter 函数精确指定侦听路径。

Q14: computed 的原理

computed 就像一个”懒人计算器”。你把计算公式(getter)告诉它,但它不会每时每刻都在算——只有你按下”等号”(读取 .value)时,它才检查”输入有没有变过”(dirty 标志位)。如果没变,直接显示上次的结果(缓存);如果变了,才算一次新结果。最妙的是:如果从来没人按”等号”,计算器就永远在睡觉,连电都不耗(计算完全省掉)。

computed 的推拉结合模型

computed 的核心设计是惰性求值 + 缓存。依赖变化时只标记 dirty(推),有人读取时才计算(拉)。

computed vs method 的区别


Q15: effect 的原理

ReactiveEffect 的原理就像办公室的”便签纸追踪系统”。每次你打开一份文件(执行 fn),就在文件上贴一张便签”某某在看”(track 收集依赖)。当你修改了文件内容(数据变化),所有贴了便签的人(依赖该数据的 effect)都会收到通知。run() 就是”开始阅读 + 贴便签”的过程,stop() 就是”撕掉所有便签、退出追踪”。全局变量 activeEffect 就像”当前正在阅读的人的工牌”——只有拿着工牌的人贴的便签才有效,没拿工牌的人读文件不会被追踪。

ReactiveEffect 的核心设计

ReactiveEffect 是 Vue 3 响应式系统的底层核心类。组件渲染、computed、watch/watchEffect 都是 ReactiveEffect 的实例。

activeEffect 的工作原理

谁在创建 ReactiveEffect?


延伸阅读与引用参考