← 全部材料 Vue 3
VUE 3 · 16 个高频点

每一项改动,都是在还债

Vue3 不是推倒重来,它是在解决 Vue2 留下的具体问题: Proxy 还的是响应式的债,编译优化还的是运行时全量 diff 的债, Composition API 还的是按选项组织代码的债。 看懂这个对应关系,面试问到"为什么",你就有话可说了。

01先猜再看看到代码先自己在心里跑一遍输出,再看下面的结果。
02解释"为什么"能说出机制,才算真的会,光记住现象很快会忘。
03连上 Vue2没看过 Vue2 那份的话,先看它,这里的对照会更有意义。
01

Proxy:一次解决四个缺陷★

intercept the object, not the key
Vue2 是给每个属性装钩子,所以"新加的属性"装不上。 Vue3 改成给整个对象套一层 Proxy,所有操作都要穿过它, 新增、删除、索引赋值全都能拦到。

✕ Vue2 拦截不到(这四件事)

  • state.newKey = 1
  • delete state.key
  • list[0] = 'x'
  • list.length = 0

✓ Vue3 全部拦截到

  • set 拦截新增与索引赋值
  • deleteProperty 拦截删除
  • length 变化也走 set
  • 不需要 $set,也不需要重写数组方法

为什么一定要用 Reflect? 三个原因,最后一个最容易被忽略:

通过 proxy 访问state.user
→
get 拦截track 记录"谁用了 user"
→
通过 proxy 修改state.user = {}
→
set 拦截trigger 找出依赖并更新
面试这样答
Vue3 用 Proxy 代理整个对象,而不是给每个属性定义 getter/setter。因为所有读写删除操作都必须经过代理,所以新增属性、删除属性、数组索引赋值和 length 修改都能被拦截,不需要 Vue.set 和重写数组方法。配合 Reflect 是为了正确传递 receiver,让 getter/setter 里的 this 指向代理对象,同时保证返回值语义正确。代价是 Proxy 无法被 polyfill,所以 Vue3 不支持 IE。
02

惰性代理:启动速度从哪来

lazy, cached by WeakMap
Vue2 初始化时要递归遍历整个 data,把所有属性一次性装好钩子。 Vue3 只代理第一层,访问到哪个嵌套对象才代理哪个,并且用 WeakMap 缓存, 不会重复创建。
这意味着什么 启动更快:初始化成本只跟"顶层有几个 key"有关,跟对象有多深无关
内存更省:没访问到的深层对象不会产生 proxy
注意:用 WeakMap 而不是 Map,是为了让原对象被回收时缓存能自动释放
面试这样答
Vue2 在 initData 阶段递归遍历整个 data 树,为每个属性 defineProperty,所以初始化开销和对象深度成正比,深层大对象会明显拖慢启动。Vue3 改成惰性代理:reactive 只代理最外层,在 get 拦截里发现取到的是对象才递归创建代理,并用 WeakMap 缓存,保证同一个原对象只会有一个代理实例。这样初始化的代价只跟顶层属性数量有关,深层属性等到真被访问时才付出成本。
03

ref 和 reactive 到底怎么选★

value 访问器 vs Proxy
区别来自实现方式:ref 是把值包进 { value },用 get/set 访问器拦截; reactive 是直接套 Proxy。 Proxy 只能代理对象,所以原始值必须走 ref。
模板里的差异(容易踩) 模板中 顶层 ref 会自动解包:{{ count }} 不用写 .value
但嵌套在对象里不会:{{ obj.count }} 里如果 count 是 ref,需要 obj.count.value
在 JS 代码里永远要写 .value,模板的自动解包只发生在编译后的渲染上下文

用 ref 更稳的场景

  • 原始值:数字、字符串、布尔
  • 需要整体替换的数据:list.value = res.data
  • 需要传给 composable 并解构返回的

用 reactive 更顺的场景

  • 一组相关的表单字段
  • 不需要整体替换、只做属性级修改
  • 对象层级的逻辑封装(避免满屏 .value)
面试这样答
ref 和 reactive 的差别来自底层实现:Proxy 只能代理对象,处理不了原始值,所以 ref 把值包成一个带 value 访问器的对象,通过 get value / set value 实现拦截;reactive 直接返回原对象的 Proxy,因此只能接收对象。使用上 ref 可以装任何类型、可以整体替换,但要写 .value,模板顶层会自动解包;reactive 访问更自然但不能整体替换,解构会丢响应性。实际项目里为了统一心智模型,多数场景用 ref 就够了。
04

解构为什么丢响应性★

get trap is the whole point
reactive 的响应性全部在 Proxy 的 get 拦截里。 解构就是"走一次 get 把值取出来存进普通变量",之后那个变量和原对象 再无关系。要保住关系,用 toRefs。

所以 toRefs 干的不是"深拷贝一份",而是 给你一个通往原属性的管道。读它 = 读原对象,写它 = 写原对象, 但经过了 ref 的访问器,所以响应性还在。

另一个同样隐蔽的坑:整体替换 let state = reactive({ count: 0 });
state = reactive({ count: 1 }); ← 组件里引用的还是旧的 proxy
正确做法:Object.assign(state, newObj) 或改用 ref
△ 可交互:解构 vs toRefs
直接解构 count
0
点 +1,界面不会动
toRefs 后的 count
0
点 +1,界面会跟着变
state(原对象)
state.count = 0
面试这样答
reactive 的响应式依赖 Proxy 的 get 拦截来做依赖收集,解构相当于执行一次 get 把值取出来赋给普通变量,这个变量和原对象就没有任何关系了,后续修改不会触发更新。解决方式是用 toRefs 把每个属性转成 ref,ref 的 get value 会去原对象上读取、set value 会写回原对象,相当于保留了一条通路。同样的道理,直接给 reactive 变量重新赋值也会丢响应性,应该用 Object.assign 合并。
05

Composition API 到底解决了什么

by feature, not by option
它不是一个"新语法",而是把代码的组织维度从"按选项"换成"按功能"。 同一个功能的状态、计算、监听、生命周期,终于能写在一起了。
面试这样答
Options API 是按选项组织代码的,一个功能的数据在 data、逻辑在 methods、监听在 watch、初始化在 mounted,功能复杂后这些代码被拆得支离破碎。Composition API 改成按功能组织,把同一个功能用到的 ref、computed、watch、生命周期都收敛到一个函数里,通过 return 显式暴露。它解决了 mixins 的三个问题:来源不透明、命名冲突、隐式依赖。而且普通函数更容易做类型推导,也更容易嵌套组合。
06

setup 和生命周期的变化

created 被吃掉了
setup 在 beforeCreate 之前执行, 所以它替代了 beforeCreate 和 created。 而且 setup 里没有 this。
setup替代 beforeCreate + created
→
onBeforeMount原 beforeMount
→
onMounted原 mounted
→
onBeforeUpdate原 beforeUpdate
↓
onUpdated原 updated
→
onBeforeUnmount原 beforeDestroy
→
onUnmounted原 destroyed
面试这样答
setup 在 beforeCreate 之前执行,所以它取代了 beforeCreate 和 created 这两个钩子,组件初始化相关的逻辑都写在 setup 里。setup 接收 props 和 context 两个参数,context 包含 attrs、slots、emit。setup 里没有 this,因为函数组件不再依赖实例上下文,需要访问实例可以用 getCurrentInstance。Vue3 还把 beforeDestroy 和 destroyed 改名为 onBeforeUnmount 和 onUnmounted,因为组件实际上是被卸载而不是销毁。
07

watch、watchEffect、computed

declare the source, or let it find it
computed 算出一个值;watch 你告诉它看什么; watchEffect 让它自己去找——执行过程中读到了谁,就依赖谁。

watch 的优势

  • 能拿到 oldVal
  • 可以 immediate / deep
  • 可以控制 flush 时机
  • 依赖关系是显式的,一眼看得懂

watchEffect 的优势

  • 依赖自动收集,不用列数组
  • 写起来短,适合"一堆依赖做一件事"
  • 立即执行一次,天然像"初始化"
  • 但依赖不显式,复杂逻辑里容易过度追踪
面试这样答
computed 用于派生值,有缓存、必须同步返回;watch 用于副作用,需要显式指定监听源,能拿到新旧值,支持 immediate、deep 和 flush 配置;watchEffect 会立即执行一次,并在执行过程中自动收集所有被读到的响应式依赖,之后依赖变化就重新运行,但拿不到旧值,也可以返回 stop 手动停止。选择上,计算值用 computed,需要旧值或精确控制依赖用 watch,依赖多又懒得列举时用 watchEffect。
08

Patch Flag:把"比什么"编译进去★

runtime diff → compile-time hint
Vue2 在运行时对比两个 VNode 的全部属性,但它其实不可能知道哪些会变。 Vue3 在编译时就知道——于是把"这个节点只有文本会变"直接写成数字标记, 运行时按标记跳过无关比较。
常见 PatchFlag 值(记几个就够) 1 TEXT —— 只有文本内容会变
8 PROPS —— 有动态属性,并给出了具体是哪些
16 FULL_PROPS —— 动态 key 的属性(比如 v-bind="obj"),要全比
64 STABLE_FRAGMENT —— 顺序稳定的 fragment
-1 HOISTED —— 静态提升,patch 时直接跳过
-2 BAIL —— 放弃优化(比如手写 render 函数),退回全量 diff
面试这样答
Vue2 的 diff 在运行时对比 VNode 的所有属性,但它无法区分哪个属性是动态的,只能全比。Vue3 在编译模板时就能分析出每个节点的动态部分:纯静态节点直接提升到 render 函数外面只创建一次,带动态文本的标 PatchFlag 1,带动态属性的标 8 并附上具体是哪些 prop。运行时 diff 看到标记就知道只需要比较那一小块,静态部分完全跳过。本质是把运行时的计算成本转移到了编译期。
09

Block Tree:只遍历动态节点

flat list of dynamic children
光有 PatchFlag 还不够——你还是得把整棵树走一遍才能找到这些标记。 Block Tree 解决的就是这个:每个 Block 额外维护一个 dynamicChildren 数组,直接把动态节点拍平收集起来。
div BLOCK
p 静态文本 不进数组
p {{ msg }} 1 TEXT
div BLOCK
span :class="cls" 8 PROPS
dynamicChildren = [ p(msg), span(:class) ]
→ diff 只遍历这 2 个,静态的 p 和层级结构完全不碰

原因很直接:Block 的前提是"子节点的结构稳定",这样拍平的数组顺序才不会错乱。 v-if 会整段出现或消失,v-for 的数量会变, 结构不稳定 → 所以它们内部要开新 Block,父 Block 只把"这个 Block 本身"记进数组。

面试这样答
PatchFlag 只标记了节点哪里会变,但如果不对树做处理,diff 还是得递归遍历整棵树才能找到这些标记。Block Tree 的思路是在编译时把动态节点拍平收集到当前 Block 的 dynamicChildren 数组里,diff 时只遍历这个数组,静态结构和静态节点完全跳过,复杂度从"树的节点数"降到"动态节点数"。v-if、v-for 这类结构不稳定的地方会单独开启新的 Block,保证拍平数组的顺序可靠。
10

最长递增子序列:少挪几次 DOM★

minimize real DOM moves
列表 diff 真正的成本不是"比较",而是"移动真实 DOM 节点"。 Vue3 先用 key 建映射,再求最长递增子序列——这一串节点顺序本来就是对的, 不用动;只移动剩下的那些。
① 掐头从头找相同前缀
→
② 去尾从尾找相同后缀
→
③④ 增删处理长度差异
→
⑤ LIS只移动不在递增序列里的

✓ 为什么这个思路对

LIS 里的节点相对顺序本身就正确,把它们当作"锚点"不动,其他节点插到它们中间即可。DOM 移动次数因此最少。

✕ Vue2 的双端比较做不到

它靠四个指针的局部匹配,遇到"中间乱序 + 跨位置移动"时可能多做几次 insertBefore。功能对,但不是最优。

面试这样答
Vue3 的列表 diff 分五步:先从头部同步相同前缀,再从尾部同步相同后缀,这样能 O(1) 处理掉头尾增删;然后处理长度差异带来的新增和卸载;最后只剩中间乱序部分时,用 key 建立旧节点索引映射,求新顺序在旧数组中的最长递增子序列。LIS 代表"相对顺序已经正确的那批节点",把它们固定住,只移动不在序列里的节点,就能把真实 DOM 的移动次数降到最少。整个算法是 O(n log n),其中二分查找是关键。
11

Fragment / Teleport / Suspense

three new built-ins
三个都是"Vue2 里做起来别扭、Vue3 变成内建能力"的东西: 多根节点、把内容传送到别处、异步加载的占位。

代价是:多根组件无法通过 $attrs 自动透传属性, 因为 Vue 不知道该把 class 加到哪个根上,会给出警告。 需要手动指定:<Header v-bind="$attrs" />。

解决的是层叠上下文问题:弹窗如果写在深层嵌套的容器里, 父级的 overflow: hidden、transform、 z-index 都会把它裁掉或压住。Teleport 让 DOM 位置和组件层级解耦, 但事件冒泡、props、provide/inject 仍然按组件树走。

面试这样答
Fragment 让组件支持多根节点,不再需要额外包一层 div,代价是多根组件无法自动透传 attrs。Teleport 把内容的 DOM 渲染到指定容器,常用于弹窗、toast,解决深层嵌套导致的 overflow 裁剪和 z-index 问题,但组件逻辑关系不变,事件冒泡和 provide/inject 仍按组件树走。Suspense 用来包裹异步依赖,配合 async setup 在数据返回前显示 fallback,避免手动维护 loading 状态。
12

v-model 和 .sync 的合并

modelValue + update:modelValue
Vue2 里一个组件只能有一个 v-model,想多个就得用 .sync。Vue3 把 .sync 删了,统一成 v-model:propName。
面试这样答
Vue2 的 v-model 在组件上默认展开成 value prop 加 input 事件,一个组件只能有一个,多值绑定要用 .sync 修饰符。Vue3 把默认 prop 改成 modelValue、事件改成 update:modelValue,同时支持带参数的 v-model:xxx,用参数区分多个绑定,所以 .sync 被移除了。这个改动也统一了语法:不管多少个双向绑定,都是 v-model 加参数的形式。
13

Tree-shaking:不用的就别打包

esm + on-demand built-ins
Vue2 是一个完整的构造函数,你只用 .data(), 但 keep-alive、transition、编译器全都在包里。 Vue3 全部模块化,没 import 的就被摇掉。
体积大致情况(gzip 后,基线对比) Vue 2 完整版:约 23 KB
Vue 3 运行时(不含编译器):约 10 KB 出头
具体数字随版本变化,面试说量级就够了,不要背精确值
面试这样答
Vue2 是以完整构造函数的形式提供的,不管项目用不用,Transition、KeepAlive、模板编译器都在包里。Vue3 用 ESM 重写并做了彻底模块化,所有 API 和内置组件都是独立导出的,配合 sideEffects 标记,打包工具能把没用到的部分摇掉。另外 .vue 文件在构建时就编译成 render 函数,运行时可以只引入 runtime 版本,把编译器排除掉,所以基座体积从 20 多 KB 降到 10 KB 左右。
14

shallowRef 和 markRaw:主动放弃深度响应

when deep reactivity is a cost
深度响应是有代价的。有些数据你不需要它内部变化被追踪—— 比如一个图表实例、一份只读的大列表。这时候主动降级,反而更快。

适合用的场景

  • 第三方实例:ECharts、地图、编辑器 —— 被代理会出各种怪问题
  • 一次性加载、之后只读的大列表
  • 只关心整体替换、不关心内部变化的配置对象

✕ 用了会踩坑

  • 以为它和 ref 一样用 → 改了内部数据界面不动
  • 把 markRaw 过的对象放进 v-model
  • 忘了 markRaw 是不可逆的,之后再也代理不了
面试这样答
Vue3 的响应式是可以按层降级的。shallowRef 只在 .value 被整体替换时触发更新,不追踪内部;shallowReactive 只让第一层属性响应式;markRaw 则标记一个对象永远不被代理,且不可逆。使用场景主要是两类:一是第三方类实例,比如图表、地图,被 Proxy 包裹后内部逻辑可能失效;二是性能优化,比如一份几万条只读数据用 shallowRef 存,避免为每个字段建立依赖追踪。
15

script setup 多做了什么

compile-time sugar
它是编译时的语法糖,不是新的运行时特性。 编译器把顶层声明直接塞进 setup() 的 return, 所以少写了样板,但运行时没有额外开销。
编译器大致做了什么 把 count、double、add 自动加入 setup 的返回值
把 defineProps / defineEmits 编译成真正的 props / emits 选项
把模板里用到的导入(如子组件)自动注册
运行时没有任何魔法,产物就是一个普通的 setup 函数
面试这样答
script setup 是编译期语法糖。编译器会把顶层声明的变量、函数、导入自动加入 setup 的返回,把 defineProps、defineEmits 这些宏编译成组件选项,把模板里用到的组件自动注册,所以模板可以直接使用不需要 return。它没有运行时开销,产物就是一个普通组件。需要注意两点:defineProps 解构会丢响应性,以及组件默认是关闭的,父组件要用 ref 访问内部方法得通过 defineExpose 显式暴露。
16

速答清单

last 30 minutes before the interview

考前扫一遍。卡住的回去看对应那节。

QVue3 响应式原理一句话概括?
用 Proxy 代理对象,在 get 里通过 track 收集依赖,在 set / deleteProperty 里通过 trigger 派发更新。依赖关系存在 WeakMap → Map → Set 的三层结构里:target → key → 副作用集合。
Q为什么 Vue3 不支持 IE?
因为 Proxy 在语言层面无法 polyfill。Vue2 用的 Object.defineProperty 是可以降级的,Proxy 不行——它拦截的是语言内部操作,没有等价的替代实现。
Q依赖收集的三层结构是什么?
WeakMap(target → depsMap)、Map(key → dep)、Set(effect 集合)。用 WeakMap 是为了 target 被回收时依赖表能自动释放,避免内存泄漏。
QVue3 比 Vue2 快在哪三个层面?
① 响应式:Proxy 惰性代理,初始化不再递归全量 defineProperty;② 编译:静态提升、PatchFlag、Block Tree,运行时 diff 只遍历动态节点;③ 体积:ESM + Tree-shaking,用不到的不进包。
Qref 和 reactive 最本质的区别?
ref 用 get/set 访问器包装一个 { value } 盒子,所以能装原始值;reactive 用 Proxy 包整个对象,所以只能装对象。响应式原理不同,导致解构行为、整体替换能力都不一样。
Q为什么解构 reactive 会丢响应性,解构 ref 不会?
reactive 的响应性在 Proxy 的 get 拦截里,解构等于取一次值赋给普通变量,之后跟原对象无关。ref 本身就是对象,解构出来还是那个 ref,读 .value 仍然走访问器,所以链路没断。要解构 reactive 用 toRefs。
QBlock Tree 解决的是什么问题?
PatchFlag 只标记了"哪里会变",但没解决"怎么快速找到这些节点"。Block Tree 在编译期把动态节点拍平收进 dynamicChildren,diff 只遍历这个数组,跳过所有静态结构。v-if、v-for 会开新 Block 以保证结构稳定。
Q最长递增子序列在 diff 里干嘛用?
用来找出"相对顺序已经正确、不需要移动"的那批节点。把它们固定住,只移动其余的,就能让真实 DOM 的 insertBefore 次数最少。时间复杂度 O(n log n),用贪心加二分实现。
QVue3 的生命周期改了哪些?
beforeCreate 和 created 被 setup 取代;beforeDestroy → onBeforeUnmount,destroyed → onUnmounted;新增 onRenderTracked / onRenderTriggered 用于调试。其余只是加了 on 前缀。
Qwatch 和 watchEffect 怎么选?
需要旧值、需要精确控制依赖、需要 immediate/deep/flush 时用 watch;依赖多、懒得列举、想立即执行时用 watchEffect。watchEffect 的依赖是自动收集的,但也因此容易追踪到不需要的数据,复杂逻辑里反而要小心。
Q为什么第三方实例要用 markRaw?
Proxy 会拦截所有属性访问,某些库内部依赖 instanceof、私有字段或 this 的严格相等,被包一层代理后行为会变,可能报错或性能骤降。markRaw 标记后这个对象永不被代理,且不可逆。