Appearance
Day5 Vue3 核心原理:响应式、Proxy、track/trigger、生命周期
📖 基础知识讲解【基础原理 + 高级深挖知识点】
基础原理
1. Vue2 与 Vue3 响应式的本质差异
Vue2:Object.defineProperty 逐属性劫持
js
function defineReactive(obj, key, val) {
const dep = new Dep(); // 每个属性一个依赖收集器
Object.defineProperty(obj, key, {
get() {
dep.depend(); // 收集当前 Watcher
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
dep.notify(); // 通知所有 Watcher 更新
},
});
}
// 初始化时必须递归遍历,给每个属性都加上 getter/setter
function observe(obj) {
Object.keys(obj).forEach((key) => defineReactive(obj, key, obj[key]));
}Vue2 的四个硬伤:
| 问题 | 表现 | Vue2 的妥协方案 |
|---|---|---|
| 新增属性监听不到 | obj.newKey = 1 不触发更新 | Vue.set / this.$set |
| 删除属性监听不到 | delete obj.key 不触发更新 | Vue.delete / this.$delete |
| 数组下标与 length 无效 | arr[0] = 1、arr.length = 0 不触发 | 重写 7 个数组方法(push/pop/shift/unshift/splice/sort/reverse) |
| 初始化性能差 | 必须递归遍历整个对象,深层嵌套开销大 | 无解,只能少写深层数据 |
Object.defineProperty的粒度是「属性」,劫持前必须先知道有哪些 key,所以天然管不了「新增」和「删除」。
Vue3:Proxy 代理整个对象
js
const state = new Proxy(target, {
get(target, key, receiver) {
/* 读取拦截 → track */
},
set(target, key, value, receiver) {
/* 设置拦截 → trigger */
},
deleteProperty(target, key) {
/* 删除拦截 → trigger */
},
has(target, key) {
/* in 操作符拦截 */
},
ownKeys(target) {
/* for...in / Object.keys 拦截 */
},
});对比总表(面试直接背):
| 维度 | Vue2 Object.defineProperty | Vue3 Proxy |
|---|---|---|
| 劫持粒度 | 属性级,需遍历每个 key | 对象级,代理整个对象 |
| 新增 / 删除属性 | ❌ 需 $set / $delete | ✅ 原生支持 |
| 数组索引 / length | ❌ 需重写 7 个方法 | ✅ 原生支持 |
| 初始化性能 | 递归遍历所有属性,深对象慢 | 惰性代理,访问到才递归代理子对象 |
| Map / Set | ❌ 不支持 | ✅ 支持(需单独写 handler) |
| 可拦截操作 | 只有 get / set | 13 种(Vue 用到 5 种) |
| 兼容性 | IE9+ | 不支持 IE11 及以下,无法 polyfill |
加分点: Vue3 的
reactive是**惰性(lazy)**的 —— 只有真正读取到某个嵌套对象时,才在get里对返回值再包一层reactive。所以初始化一个 1000 层的深对象,Vue3 只代理第一层,Vue2 要递归到底。
2. 核心响应式 API 速查
| API | 作用 | 返回值 | 适用场景 |
|---|---|---|---|
reactive(obj) | 深层代理对象 | Proxy 对象 | 对象、数组、Map、Set |
ref(val) | 包装任意值 | RefImpl 实例(.value 访问) | 基础类型、需要整体替换的对象 |
shallowReactive | 只代理第一层 | Proxy | 大型数据,只关心顶层变化 |
shallowRef | 只有 .value 替换才触发 | RefImpl | 大型不可变数据、第三方实例 |
readonly | 深层只读代理 | Proxy | props 保护、全局配置 |
computed | 带缓存的派生值 | ComputedRefImpl(.value) | 派生状态,依赖不变不重算 |
toRef(obj, key) | 单个属性转 ref,保持引用 | ObjectRefImpl | 解构单个属性 |
toRefs(obj) | 批量转 ref | 普通对象(值为 ref) | 解构整个 reactive |
toRaw(proxy) | 拿回原始对象 | 原始对象 | 性能优化、避免误触发 |
markRaw(obj) | 标记永不被代理 | 原对象 | 图表实例、Map 地图实例等 |
unref(x) | isRef(x) ? x.value : x | 值 | 工具函数里兼容 ref 与普通值 |
ref 的内部实现其实很简单:
js
class RefImpl {
constructor(value) {
this._rawValue = value;
// 对象类型内部直接转交给 reactive,所以 ref 也能放对象
this._value = isObject(value) ? reactive(value) : value;
}
get value() {
track(this, "value"); // 读取时收集依赖
return this._value;
}
set value(newVal) {
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal;
this._value = isObject(newVal) ? reactive(newVal) : newVal;
trigger(this, "value"); // 修改时触发更新
}
}
}为什么
ref要写.value? 因为 JS 无法代理一个原始值(Proxy只能代理对象)。把值包进一个对象的value属性,才能通过 getter/setter 拦截到读写。模板里不用写.value是因为编译时自动做了解包(仅限顶层 ref)。
3. 依赖收集全景:targetMap → depsMap → dep
Vue3 用三层嵌套的数据结构保存「哪个对象的哪个属性,被哪些副作用函数依赖」:
targetMap (WeakMap)
│
├── key: 原始对象 state1 ──► depsMap (Map)
│ ├── key: "count" ──► dep (Set) { effect1, effect2 }
│ └── key: "name" ──► dep (Set) { effect3 }
│
└── key: 原始对象 state2 ──► depsMap (Map)
└── key: "list" ──► dep (Set) { effect1 }| 层级 | 数据结构 | 为什么选它 |
|---|---|---|
targetMap | WeakMap | key 是对象,弱引用,对象被回收时依赖自动释放,防内存泄漏 |
depsMap | Map | key 可以是 string / symbol(Symbol.iterator、ITERATE_KEY) |
dep | Set | 自动去重,同一个 effect 多次读取同一属性只存一份 |
track(依赖收集)与 trigger(派发更新):
js
let activeEffect = null; // 当前正在执行的副作用函数
const targetMap = new WeakMap();
// 读取属性时调用
function track(target, key) {
if (!activeEffect) return; // 不在 effect 中读取,不需要收集
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
dep.add(activeEffect); // 正向:属性 → effect
activeEffect.deps.push(dep); // 反向:effect → 属性集合(用于清理)
}
// 修改属性时调用
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return; // 该对象从未被依赖
const dep = depsMap.get(key);
if (!dep) return;
// 复制一份再遍历,避免边删边加导致死循环
[...dep].forEach((effect) => {
if (effect === activeEffect) return; // 防止自增类操作无限递归
effect.scheduler ? effect.scheduler() : effect.run();
});
}完整链路(一次 state.count++ 发生了什么):
state.count++
│
├─ 1. 读 state.count → Proxy.get → Reflect.get → track(target,'count')
│ 把当前 activeEffect 存进 dep
│
└─ 2. 写 state.count → Proxy.set → Reflect.set → trigger(target,'count')
取出 dep 里的所有 effect
│
├─ 有 scheduler → 推入微任务队列(组件更新走这条)
└─ 无 scheduler → 立即 effect.run()为什么
effect.deps要反向存一份? 因为每次 effect 重新执行前要先cleanupEffect,把自己从所有旧的 dep 里删掉,再重新收集。否则v-if切换分支后,已经不再读取的属性变化依旧会触发更新(无效依赖)。
4. 生命周期:选项式 vs 组合式
| 选项式 API | 组合式 API | 触发时机 | 常见用途 |
|---|---|---|---|
beforeCreate | setup() 本身 | 实例初始化后,data/methods 未注入 | — |
created | setup() 本身 | 响应式数据已就绪,DOM 未生成 | 发请求、读路由参数 |
beforeMount | onBeforeMount | 挂载前,render 已编译未执行 | 极少用 |
mounted | onMounted | DOM 已插入页面 | 操作 DOM、初始化图表、绑定第三方库 |
beforeUpdate | onBeforeUpdate | 数据变了,DOM 未更新 | 获取更新前的 DOM 状态(如滚动位置) |
updated | onUpdated | DOM 已更新完成 | ⚠️ 此处改数据会死循环 |
beforeUnmount | onBeforeUnmount | 卸载前,实例仍完全可用 | 清定时器、解绑事件、取消请求 |
unmounted | onUnmounted | 卸载完成,子组件也已卸载 | 最终清理 |
activated | onActivated | <KeepAlive> 缓存组件被激活 | 刷新数据 |
deactivated | onDeactivated | <KeepAlive> 缓存组件被停用 | 暂停轮询 |
errorCaptured | onErrorCaptured | 捕获后代组件的错误 | 错误上报、降级 UI |
renderTracked | onRenderTracked | 响应式依赖被收集时(dev only) | 调试「为什么重渲染」 |
renderTriggered | onRenderTriggered | 响应式依赖触发重渲染时(dev only) | 调试「谁触发了更新」 |
Vue3 没有
beforeDestroy/destroyed,改名为beforeUnmount/unmounted(Vue3 里旧名仍可用但会告警,Vue3.3+ 仍兼容,不建议用)。
父子组件生命周期执行顺序(必背):
挂载:
父 setup → 父 beforeMount → 子 setup → 子 beforeMount → 子 mounted → 父 mounted
(父先开始,子先完成 —— 洋葱模型)
更新(父传的 props 变化):
父 beforeUpdate → 子 beforeUpdate → 子 updated → 父 updated
卸载:
父 beforeUnmount → 子 beforeUnmount → 子 unmounted → 父 unmounted结论一句话: 挂载和卸载都是「父开始 → 子完成 → 父完成」。因为父组件的
mounted要等所有子树 DOM 都插入完毕才算真正挂载完成。
组合式 API 写法:
js
import { onMounted, onUnmounted, onErrorCaptured } from "vue";
setup() {
let timer = null;
onMounted(() => {
timer = setInterval(fetchData, 5000);
});
// 可以注册多个同名钩子,按注册顺序依次执行
onUnmounted(() => clearInterval(timer));
onErrorCaptured((err, instance, info) => {
report(err, info);
return false; // 返回 false 阻止错误继续向上冒泡
});
}注意: 生命周期钩子必须在
setup的同步执行阶段注册。写在await之后或setTimeout里会失效,因为 Vue 是靠「当前活跃实例currentInstance」这个全局变量来关联钩子和组件的,异步后该变量已被置空。
✨ 高级深挖知识点
1. Proxy 为什么必须搭配 Reflect
反面写法(不用 Reflect):
js
const handler = {
get(target, key, receiver) {
track(target, key);
return target[key]; // ❌ 这里的 this 指向 target,不是 proxy
},
};
const obj = {
_name: "vue",
get name() {
return this._name; // this 是 target → 读取 _name 不走 Proxy → 收集不到依赖
},
};
const p = new Proxy(obj, handler);
effect(() => console.log(p.name)); // _name 的依赖丢了!
obj._name = "vue3"; // 不触发更新正解:
js
get(target, key, receiver) {
track(target, key);
// receiver 就是 proxy 本身,Reflect.get 会把 getter 里的 this 绑定成 receiver
return Reflect.get(target, key, receiver); // ✅ 访问 this._name 时再次走 Proxy.get
}| 要点 | 说明 |
|---|---|
receiver 是什么 | 触发本次操作的那个代理对象(通常就是 proxy 自身) |
| 为什么要传 | 把 getter / setter 里的 this 从 target 纠正为 proxy,让嵌套访问也被拦截 |
| Reflect 的第二个好处 | 返回值语义统一:Reflect.set 返回布尔值,Reflect.deleteProperty 返回布尔值,便于在 set 拦截器里 return |
| 第三个好处 | Reflect.ownKeys 能同时拿到 string key 和 symbol key,Object.keys 拿不到 symbol |
2. 13 种拦截器 与 Vue 实际用到的 5 种
Proxy 共支持 13 种 trap,Vue3 的 baseHandlers 只用了 5 种:
| 拦截器 | 触发时机 | Vue3 是否使用 | 对应操作 |
|---|---|---|---|
get | 读取属性 | ✅ | track(target, key, 'get') |
set | 设置属性 | ✅ | trigger,并区分 ADD / SET |
deleteProperty | delete obj.key | ✅ | trigger(..., 'delete') |
has | key in obj | ✅ | track(target, key, 'has') |
ownKeys | Object.keys / for...in / 展开 | ✅ | track(target, ITERATE_KEY) |
getPrototypeOf 等其余 8 种 | — | ❌ | 与响应式无关 |
set 里为什么要区分 ADD 和 SET?
js
set(target, key, value, receiver) {
const hadKey = Array.isArray(target)
? Number(key) < target.length // 数组看索引是否越界
: Object.prototype.hasOwnProperty.call(target, key);
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (!hadKey) {
// 新增属性:要额外触发 ITERATE_KEY(for...in / Object.keys 的依赖)
trigger(target, key, "add", value);
} else if (hasChanged(value, oldValue)) {
trigger(target, key, "set", value);
}
return result;
}如果不区分,
for (const k in state)这种依赖「key 的数量」的 effect,在新增属性时就不会被触发。Vue 用一个特殊的ITERATE_KEY = Symbol('iterate')专门收集这类依赖。
数组 length 的特殊处理: arr.length = 0 会触发 set(target, 'length'),Vue 会遍历 depsMap,把所有「索引 ≥ 新 length」的 key 对应的 effect 一并触发,这样 arr[3] 的依赖才能感知到数组被截断。
3. 集合类型(Map / Set / WeakMap / WeakSet)为什么要单独 handler
js
const m = reactive(new Map([["a", 1]]));
// ❌ 如果用普通 baseHandlers:
m.get("a"); // TypeError: Method Map.prototype.get called on incompatible receiver原因: Map / Set 的方法内部要访问内部槽(internal slot)[[MapData]],而 Proxy 没有这个槽。m.get('a') 时 this 是 proxy,取不到 [[MapData]] 就直接抛错。
Vue3 的解法 —— collectionHandlers: 只保留 get 拦截器,把所有方法替换成自己实现的「插桩函数」:
js
const mutableInstrumentations = {
get(key) {
const target = this["__v_raw"]; // 拿回原始 Map
track(target, key);
return wrap(target.get(key)); // 返回值继续包装成响应式
},
set(key, value) {
const target = this["__v_raw"];
const had = target.has(key);
const oldValue = target.get(key);
target.set(key, toRaw(value)); // 用原始对象调用,避免 receiver 问题
if (!had) trigger(target, key, "add");
else if (hasChanged(value, oldValue)) trigger(target, key, "set");
},
get size() {
const target = this["__v_raw"];
track(target, ITERATE_KEY); // size 依赖的是「元素数量」
return Reflect.get(target, "size", target);
},
// forEach / keys / values / entries / [Symbol.iterator] 同理插桩
};| 坑点 | 说明 |
|---|---|
this.__v_raw | 必须用原始对象调用原生方法,否则内部槽访问失败 |
size 用 getter | size 是访问器属性,必须 Reflect.get(target,'size',target),第三参不能传 proxy |
set(key, toRaw(value)) | 防止「数据污染」 —— 把响应式对象存进原始 Map,会让原始数据也带上响应式 |
| 遍历方法 | forEach 的回调参数也要 wrap 成响应式,否则遍历出来的对象改了不触发更新 |
4. effect / scheduler / 批量更新队列
Vue3 不会每改一次数据就更新一次 DOM,而是把更新任务推进队列、去重、在微任务里批量执行:
js
// 简化版调度器
const queue = []; // 任务队列
let isFlushing = false;
const p = Promise.resolve();
function queueJob(job) {
if (!queue.includes(job)) queue.push(job); // ① 去重:同一组件只更新一次
if (!isFlushing) {
isFlushing = true;
p.then(() => {
// ② 微任务中批量执行
try {
// ③ 按 id 升序:保证父组件先于子组件更新
queue.sort((a, b) => a.id - b.id);
queue.forEach((job) => job());
} finally {
isFlushing = false;
queue.length = 0;
}
});
}
}所以这段代码只会触发一次 DOM 更新:
js
state.a = 1;
state.b = 2;
state.c = 3; // 三次 trigger,但只入队一次组件更新任务
console.log(dom.textContent); // 旧值!DOM 还没更新
await nextTick();
console.log(dom.textContent); // 新值
nextTick的本质就是return p.then(fn)—— 把你的回调挂到同一个 Promise 链上。因为更新任务已经先.then进去了,所以你的回调一定在 DOM 更新之后执行。
三类 flush 时机(watch 的 flush 选项):
| flush | 执行时机 | 对应队列 | 使用场景 |
|---|---|---|---|
'pre'(默认) | 组件更新前 | pre 队列(微任务) | 默认,拿到的是更新前 DOM |
'post' | 组件更新后 | post 队列(微任务) | 需要访问更新后的 DOM |
'sync' | 同步立即执行 | 不入队 | 需要精确控制时序,慎用(性能差) |
5. watch vs watchEffect vs computed
| 维度 | computed | watch | watchEffect |
|---|---|---|---|
| 依赖来源 | 自动收集 | 手动指定第一个参数 | 自动收集 |
| 立即执行 | 惰性(被读取才算) | 默认不立即执行 | 立即执行一次 |
| 能拿新旧值 | ❌ | ✅ (newVal, oldVal) | ❌ |
| 缓存 | ✅ 依赖不变不重算 | — | — |
| 深层监听 | — | 需 deep: true | 自动(读到就收集) |
| 可有副作用 | ❌ 不应有 | ✅ | ✅ |
| 典型场景 | 派生数据、模板展示 | 监听特定字段发请求、路由变化 | 多依赖联动、自动同步 localStorage |
computed 的 lazy + dirty 缓存机制:
js
class ComputedRefImpl {
constructor(getter) {
this._dirty = true; // 脏标记
this.effect = new ReactiveEffect(getter, () => {
// scheduler:依赖变化时不重算,只把脏标记打开
if (!this._dirty) {
this._dirty = true;
trigger(this, "value"); // 通知依赖该 computed 的 effect
}
});
}
get value() {
track(this, "value");
if (this._dirty) {
this._dirty = false;
this._value = this.effect.run(); // 只有脏了才真正重算
}
return this._value;
}
}面试高频:
computed的缓存靠的不是「比较值」,而是 scheduler +_dirty标记。依赖变化时只把_dirty置 true,真正的计算推迟到下一次读取.value。
watch 的几种源与常见写法:
js
// ① ref → 直接传
watch(count, (n, o) => {});
// ② reactive 对象的属性 → 必须用 getter 函数包裹
watch(
() => state.count,
(n, o) => {}
);
// ③ 整个 reactive 对象 → 自动 deep,但新旧值是同一个引用
watch(state, (n, o) => console.log(n === o)); // true ⚠️
// ④ 多个源 → 数组
watch([count, () => state.name], ([c, n], [oc, on]) => {});
// ⑤ 立即执行 + 清理副作用(防竞态)
watch(
id,
async (newId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort()); // 下次触发前取消上一次请求
const res = await fetch(`/api/${newId}`, { signal: controller.signal });
},
{ immediate: true }
);为什么监听整个 reactive 对象时
newVal === oldVal? 因为 Proxy 对象的引用从未改变,Vue 只能把同一个代理对象同时作为新旧值传给你。想拿到真正的旧值,要用() => ({ ...state })返回浅拷贝(或cloneDeep)。
6. reactive 丢失响应式的三大场景
| 场景 | 反面代码 | 为什么丢 | 正解 |
|---|---|---|---|
| 解构 | const { count } = reactive({count:0}) | 解构出的是基础类型值的拷贝,脱离 Proxy | const { count } = toRefs(state) |
| 整体替换 | state = { ...newData } | 变量指向了新的普通对象,原 Proxy 被丢弃 | Object.assign(state, newData) 或改用 ref + .value = |
| 传参 / 展开 | useFoo(state.count)、{...state} | 传进去的是快照值 | 传 getter:useFoo(() => state.count),或整个 state 传入 |
js
// ❌ 丢响应式
const state = reactive({ count: 0 });
let { count } = state;
count++; // 跟 state 毫无关系
// ✅ 保持响应式
const { count } = toRefs(state);
count.value++; // state.count 同步变化
// ✅ toRef 原理:存的是「对象 + key」,而非值
class ObjectRefImpl {
constructor(target, key) {
this._object = target;
this._key = key;
this.__v_isRef = true; // 标记,模板解包与 unref 依赖它
}
get value() {
return this._object[this._key]; // 每次都去 Proxy 上取
}
set value(v) {
this._object[this._key] = v; // 每次都往 Proxy 上写
}
}props 解构的版本差异: Vue 3.5+ 引入「响应式 props 解构」,
const { foo } = defineProps(...)会被编译成props.foo的访问,不再丢响应式;3.5 之前必须用toRefs(props)或() => props.foo。面试时主动说清版本差异是加分项。
7. 组件级更新粒度 + 编译期优化
Vue3 的更新粒度是「组件级」: 一个响应式数据变化,只会让依赖它的那个组件的 render effect 重新执行,再 diff 它自己的 vnode 树。而 React 默认会重渲染整棵子树(除非手动 memo)。
Vue3 在编译期又把 diff 范围进一步缩小到「动态节点」:
模板:
<div class="static">
<span>固定文案</span>
<p>{{ msg }}</p>
</div>
编译产物(概念示意):
const _hoisted_1 = createVNode('span', null, '固定文案') ← 静态提升,只创建一次
render() {
return (openBlock(), createBlock('div', {class:'static'}, [
_hoisted_1, ← 直接复用,不参与 diff
createVNode('p', null, _ctx.msg, 1 /* TEXT */) ← PatchFlag = 1
]))
}
Block 的 dynamicChildren = [ <p> ] ← diff 时只遍历这个数组,跳过整棵树| 优化手段 | 做了什么 | 收益 |
|---|---|---|
| PatchFlag | 给动态 vnode 打标记(1=TEXT,2=CLASS,4=STYLE,8=PROPS,16=FULL_PROPS…) | diff 时只比对标记指明的部分,不做全量 props 遍历 |
| 静态提升 hoistStatic | 把纯静态 vnode 提到 render 函数外层 | 每次 render 复用同一对象,减少 GC |
| 预字符串化 | 连续多个静态节点编译成一整段 HTML 字符串 | 一次性插入,创建开销骤降 |
| Block Tree | 每个 Block 收集后代动态节点到 dynamicChildren | diff 从「树递归遍历」降为「数组线性遍历」 |
| cacheHandlers | 内联事件处理函数缓存到 _cache | 避免每次 render 生成新函数导致子组件无谓更新 |
| SSR 优化 | 静态内容直接拼字符串输出 | SSR 渲染速度提升明显 |
为什么
v-if/v-for会产生新的 Block? 因为它们让节点结构本身不稳定(数量、顺序会变),无法用固定索引对应dynamicChildren,所以各自作为新的 Block 边界,内部再做结构化 diff。
8. 编译宏:defineProps / defineEmits / defineModel
| 宏 | 作用 | 关键点 |
|---|---|---|
defineProps | 声明 props | 编译期宏,无需 import,不能用在普通 setup() 函数内 |
defineEmits | 声明自定义事件 | 返回 emit 函数 |
defineExpose | <script setup> 默认封闭 | 父组件通过 ref 访问子组件方法前必须显式暴露 |
defineModel(3.4+) | 简化 v-model 双向绑定 | 替代手写 props + emit('update:xxx') |
defineOptions(3.3+) | 声明 name、inheritAttrs 等 | 免去再写第二个 <script> 块 |
vue
<script setup>
// 宏在编译后被完全移除,参数必须是「字面量」,不能引用外部变量
const props = defineProps({ count: { type: Number, default: 0 } });
const emit = defineEmits(["update:count"]);
// 3.4+ 写法:一行实现 v-model
const model = defineModel({ default: 0 });
// 等价于 props.modelValue + emit('update:modelValue')
</script>为什么宏的参数必须是字面量? 因为
@vue/compiler-sfc在编译时静态分析这段代码来生成props选项,此时没有运行时环境,拿不到变量的值。写defineProps(myProps)会直接编译报错。
❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】
题 1:Vue3 为什么用 Proxy 替代 Object.defineProperty?
精简口述答案:
核心是劫持粒度不同。
Object.defineProperty劫持的是「属性」,必须先遍历已有的 key 才能加 getter/setter,所以天生管不了新增属性、删除属性,数组的索引赋值和改 length 也拦不住,Vue2 只能靠$set、$delete和重写 7 个数组方法来打补丁;而且初始化时要递归遍历整个对象,深层数据性能很差。Proxy 代理的是「整个对象」,13 种 trap 能拦截到增删改查、in、for...in全部操作,还能支持 Map/Set,并且是惰性代理——只有真正访问到嵌套对象时才递归包一层,初始化开销显著降低。代价是 Proxy 无法被 polyfill,所以 Vue3 彻底放弃了 IE11。
连环追问链:
- 追问:Proxy 有什么缺点?
- 答:① 完全不支持 IE11 且无法 polyfill(因为它是语言层面的行为拦截);② 单次属性访问的性能其实比原生 getter 略慢,Vue3 的性能优势主要来自惰性代理和编译优化,不是 Proxy 本身快;③
Map/Set这类带内部槽的对象必须单独写 handler 插桩,否则直接报错。
- 答:① 完全不支持 IE11 且无法 polyfill(因为它是语言层面的行为拦截);② 单次属性访问的性能其实比原生 getter 略慢,Vue3 的性能优势主要来自惰性代理和编译优化,不是 Proxy 本身快;③
- 追问:Proxy 为什么必须配 Reflect?直接
target[key]不行吗?- 答:不行。如果对象里有 getter 用到了
this,target[key]会让this指向原始对象,嵌套访问就绕过了 Proxy,依赖收集不到。Reflect.get(target, key, receiver)把第三个参数receiver传成 proxy,getter 里的this就变成了 proxy,嵌套访问继续被拦截。
- 答:不行。如果对象里有 getter 用到了
- 追问:Vue3 里
obj.newKey = 1真的不用$set了吗?- 答:对象确实不用了,
settrap 会拦到并把它识别成 ADD 操作,额外触发ITERATE_KEY的依赖,所以for...in、Object.keys的 effect 也能更新。但注意用ref包的对象整体替换和解构出来的值依旧会丢响应式,这是另一类问题。
- 答:对象确实不用了,
- 追问:那 Vue3 还需要重写数组方法吗?
- 答:响应式层面不需要了。但 Vue3 对
includes、indexOf、lastIndexOf这三个查找方法做了插桩——因为数组里存的是响应式代理对象,而你传进去查找的可能是原始对象,直接比较会找不到,所以插桩里会先用 proxy 比一遍,找不到再用toRaw比一遍。另外push/pop这类方法会同时读写length,可能导致无限递归,Vue 用pauseTracking临时暂停依赖收集来规避。
- 答:响应式层面不需要了。但 Vue3 对
✅ 项目关联话术(按真实经历替换):
我们项目有一个 3000+ 行配置数据的表单页,Vue2 时代首屏初始化
Observer递归就要花 200ms 左右。迁移到 Vue3 后,因为reactive是惰性代理,只有用户展开某个折叠面板时才代理那一层数据,初始化时间降到 30ms 以内。另外我们之前为了绕$set写了很多this.$set(this.form, key, val),迁移后全部简化成直接赋值,代码可读性也好了很多。
题 2:讲一下 track 和 trigger 的完整流程
精简口述答案:
Vue3 用一个三层结构
targetMap存依赖:最外层是 WeakMap,key 是原始对象,value 是depsMap;depsMap是 Map,key 是属性名,value 是dep;dep是 Set,存所有依赖这个属性的 effect。读属性时走gettrap 调track,把当前全局的activeEffect加进对应的dep,同时在 effect 上反向存一份deps用于后续清理;写属性时走settrap 调trigger,取出dep里的 effect,有scheduler就走调度器(组件更新走这条,推进微任务队列批量执行),没有就立即run。用 WeakMap 是因为 key 是对象且需要弱引用防内存泄漏,用 Set 是为了自动去重。
连环追问链:
- 追问:
activeEffect为什么要用栈管理?- 答:因为 effect 可能嵌套。父组件的 render effect 里渲染子组件,子组件又有自己的 render effect。如果只用一个变量赋值,子 effect 执行完后
activeEffect会被置空或残留,父 effect 后续读取的属性就收集错了对象。用栈(或者 Vue3.2+ 的parent指针链)在 effect 执行前入栈、执行后出栈并恢复上一个,才能保证依赖归属正确。
- 答:因为 effect 可能嵌套。父组件的 render effect 里渲染子组件,子组件又有自己的 render effect。如果只用一个变量赋值,子 effect 执行完后
- 追问:effect 为什么每次执行前要清理依赖?
- 答:为了避免无效依赖。比如
effect(() => { show.value ? a.value : b.value }),第一次show为 true 收集了a,之后show改成 false,effect 重新执行只读b。如果不清理,a的 dep 里还留着这个 effect,改a还会触发一次无意义的更新。cleanupEffect就是遍历effect.deps把自己从每个 Set 里删掉,然后重新收集。
- 答:为了避免无效依赖。比如
- 追问:
trigger时为什么要复制一份 dep 再遍历?- 答:因为 effect 执行时会先
cleanup(从 Set 里 delete 自己)再重新track(往 Set 里 add 自己)。直接遍历原 Set 会出现「边删边加」,JS 的Set.forEach对本次遍历中新加入的元素还会再访问一次,导致无限循环。复制成数组再遍历就隔离了这个问题。
- 答:因为 effect 执行时会先
- 追问:
state.count++为什么不会死循环?- 答:
++是先读后写,读的时候正在执行的 effect 被收集进 dep,写的时候trigger又会取出这个 effect。Vue 在trigger里加了if (effect !== activeEffect)的判断,跳过「正在执行中的那个 effect 自己」,避免自我递归。
- 答:
✅ 项目关联话术:
我排查过一个「列表莫名刷新」的性能问题,用
onRenderTriggered打印出触发源,发现是一个已经被v-if隐藏的分支里读过的字段在变。本质就是我自己手写的一个watchEffect里混了条件分支,而我误以为隐藏后就不会再触发。后来理解了 effect 的依赖清理机制,改成把条件判断提到 effect 外层、用watch精确指定依赖源,无效渲染从每秒 6 次降到 0。
题 3:Vue3 的生命周期有哪些?父子组件执行顺序是怎样的?
精简口述答案:
Vue3 组合式 API 把
beforeCreate和created合并进了setup本身,其余钩子加on前缀:onBeforeMount、onMounted、onBeforeUpdate、onUpdated、onBeforeUnmount、onUnmounted,另外还有onActivated/onDeactivated(配合KeepAlive)、onErrorCaptured(捕获后代错误)、以及开发环境专用的onRenderTracked/onRenderTriggered。Vue2 的beforeDestroy/destroyed改名成了beforeUnmount/unmounted。父子顺序上,挂载和卸载都是「父开始 → 子完成 → 父完成」的洋葱模型,因为父组件要等整棵子树 DOM 就绪才算挂载完;更新则是父beforeUpdate→ 子beforeUpdate→ 子updated→ 父updated。
连环追问链:
- 追问:请求数据应该放
created(setup)还是mounted?- 答:如果不依赖 DOM,就放
setup,能更早发出请求,减少首屏等待;如果需要拿到 DOM 尺寸再决定请求参数(比如按容器宽度请求不同尺寸的图),才放onMounted。SSR 场景下只有setup和beforeCreate/created会在服务端执行,onMounted不会,所以 SSR 需要同构的数据请求必须放setup。
- 答:如果不依赖 DOM,就放
- 追问:
onMounted里能保证子组件 DOM 已经渲染好吗?- 答:能,子组件的
mounted早于父组件。但如果子组件是异步组件(defineAsyncComponent)或包在<Suspense>里,父组件的onMounted不会等它,这时要靠nextTick或Suspense的resolve事件。
- 答:能,子组件的
- 追问:为什么生命周期钩子不能写在
await之后?- 答:Vue 内部靠一个全局变量
currentInstance来知道「现在注册的钩子属于哪个组件」,这个变量只在setup同步执行期间有值,await之后微任务恢复执行时它已经被置空了,钩子就注册不上去(dev 环境会告警)。要在异步后注册,得先把实例存下来再手动setCurrentInstance,但一般不推荐。
- 答:Vue 内部靠一个全局变量
- 追问:
onUpdated里改数据会怎样?- 答:会死循环。改数据触发
trigger→ 组件更新 → 再次触发onUpdated→ 又改数据。Vue 在开发环境有递归更新次数检测,超过 100 次会抛Maximum recursive updates exceeded。需要「更新后同步状态」应该用watch配flush: 'post',或者加条件守卫。
- 答:会死循环。改数据触发
- 追问:
KeepAlive缓存的组件,切换时走哪些钩子?- 答:首次进入走完整挂载流程并额外触发
onActivated;离开时不走unmounted,只走onDeactivated;再次进入也不走mounted,只走onActivated。所以需要每次进入都刷新的数据必须放onActivated,需要暂停的轮询要在onDeactivated里清掉,否则缓存组件会在后台一直发请求。
- 答:首次进入走完整挂载流程并额外触发
✅ 项目关联话术:
我们有个带
KeepAlive的列表页,最早轮询写在onMounted/onUnmounted里,结果用户跳到详情页后列表被缓存、onUnmounted不触发,轮询一直在后台跑,QA 测出来接口 QPS 异常。后来改成在onActivated里起轮询、onDeactivated里清掉,同时保留onUnmounted做兜底清理,问题解决。这件事让我记住了KeepAlive会「吃掉」卸载钩子。
题 4:ref 和 reactive 有什么区别?工作中怎么选?
精简口述答案:
reactive用 Proxy 代理对象,只能接对象/数组/Map/Set,访问时不用.value,但不能整体替换也不能解构,否则丢响应式。ref是用一个类包一层,通过.value的 getter/setter 拦截,能接任意类型;传对象时内部会转交给reactive,所以ref({})的深层属性也是响应式的。实践中我倾向统一用ref:心智负担小、能整体替换(接口返回的数据直接data.value = res)、作为返回值从 composable 里传出去也不丢响应式;只有在一组强关联的本地状态(比如表单字段)时才用reactive,少写一堆.value。
连环追问链:
- 追问:
ref里套reactive会怎样?reactive里套ref呢?- 答:
ref(reactive(obj))不会二次包装,.value直接拿到那个 Proxy。reactive({ count: ref(0) })时,访问state.count会自动解包,直接拿到数字 0,不用写.value;但如果放在数组或 Map 里就不解包,arr[0].value才行。这个不一致性是面试爱考的坑点。
- 答:
- 追问:模板里为什么不用写
.value?- 答:
<script setup>编译时会分析出哪些变量是 ref,在模板编译产物里自动加上.value(运行时也有proxyRefs做兜底解包)。但只对顶层 ref 生效,obj.someRef这种嵌套的 ref 在模板里仍要写.value。
- 答:
- 追问:
shallowRef什么时候用?- 答:存大型不可变数据或第三方实例时。比如 ECharts 实例、地图实例、Monaco Editor 实例,用
ref会把整个实例深度代理一遍,既慢又可能因为代理破坏内部逻辑。用shallowRef只在.value整体替换时触发更新,配合markRaw效果更好。
- 答:存大型不可变数据或第三方实例时。比如 ECharts 实例、地图实例、Monaco Editor 实例,用
- 追问:
toRaw和markRaw的使用场景?- 答:
toRaw用来在不触发依赖收集的情况下读数据(比如把数据传给非响应式的第三方库、或做大量循环计算时避免 Proxy 开销);markRaw用来永久标记某个对象不被代理,内部是加了个__v_skip标记,reactive看到就直接返回原对象。
- 答:
✅ 项目关联话术:
我们接 ECharts 时踩过坑:实例用
ref存,结果setOption偶发报错、图表交互卡顿。原因是ref把 ECharts 实例深度 Proxy 化了,实例内部的this和一些缓存判断全乱了。换成shallowRef(markRaw(chart))之后彻底正常。后来团队就定了规范:所有第三方实例一律shallowRef+markRaw。
题 5:Vue3 的性能提升体现在哪些方面?
精简口述答案:
我会分四层说。响应式层:Proxy 替代
defineProperty,惰性递归代理,初始化不用遍历整个对象。编译层:这是提升最大的一块——PatchFlag 让 diff 只比对标记过的动态部分;静态提升把不变的 vnode 提到 render 外复用;Block Tree 把动态节点收集到扁平数组,diff 从树遍历降为线性遍历;再加上事件缓存和静态节点预字符串化。运行时层:diff 算法用最长递增子序列优化节点移动,Fragment支持多根节点减少无用 DOM 层级。打包层:全部 API 支持 Tree-shaking,没用到的Transition、KeepAlive都不会进包,最小运行时体积比 Vue2 小一半左右。
连环追问链:
- 追问:PatchFlag 具体怎么省时间?
- 答:没有 PatchFlag 时,diff 两个 vnode 的 props 要把新旧 props 对象的所有 key 都遍历比对一遍。有了 PatchFlag,比如标记是
TEXT,就只调setElementText更新文本,props 一个都不比;标记是CLASS就只更新 class。本质是把「运行时的猜测」变成「编译时的确定信息」。
- 答:没有 PatchFlag 时,diff 两个 vnode 的 props 要把新旧 props 对象的所有 key 都遍历比对一遍。有了 PatchFlag,比如标记是
- 追问:Block Tree 是怎么做到跳过静态节点的?
- 答:编译时在
createBlock开始前调openBlock()开一个收集数组,所有带 PatchFlag 的动态 vnode 在createVNode时会自动把自己 push 进当前 block 的收集数组,最终挂在 block vnode 的dynamicChildren上。patch 时如果发现有dynamicChildren,就走patchBlockChildren直接遍历这个数组,完全不碰children里的静态节点。
- 答:编译时在
- 追问:Vue3 的 diff 和 Vue2 有什么区别?
- 答:Vue2 是双端比较(头头、尾尾、头尾、尾头四次尝试),Vue3 保留了前后预处理(先同步头部、再同步尾部),中间乱序部分改用 最长递增子序列(LIS) 求出不需要移动的最大子序列,只移动剩下的节点,把 DOM 移动次数降到最少。
- 追问:Vue3 比 Vue2 快,是因为 Proxy 比 defineProperty 快吗?
- 答:不是。单次属性读写上 Proxy 甚至比原生 getter 略慢。Vue3 的快主要来自三点:惰性代理省掉了初始化的递归遍历、编译期优化大幅缩小 diff 范围、以及更好的 Tree-shaking 和更轻的运行时。这个点答对了很能体现对原理的理解深度。
✅ 项目关联话术:
我们一个管理后台从 Vue2 迁到 Vue3 + Vite,首屏 JS 体积从 480KB 降到 290KB(Tree-shaking + 按需引入组件库),FCP 从 2.1s 降到 1.3s。但我也发现迁移本身不会自动变快——我们有个长列表页迁移后依然卡,排查发现是我们自己在
v-for里写了大量内联箭头函数和复杂表达式,编译优化帮不上忙。后来抽成 computed + 虚拟列表才真正解决。所以我的结论是:框架升级带来的是上限提升,具体性能还得靠自己的写法。
✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】
题 1:手写 reactive(含依赖收集与派发更新)
核心实现:
js
const isObject = (v) => typeof v === "object" && v !== null;
const hasChanged = (a, b) => !Object.is(a, b);
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
const ITERATE_KEY = Symbol("iterate"); // for...in / Object.keys 的专用 key
const targetMap = new WeakMap();
const reactiveMap = new WeakMap(); // 缓存:同一对象多次 reactive 返回同一个 proxy
function reactive(target) {
if (!isObject(target)) return target; // 基础类型原样返回
if (target.__v_raw) return target; // 已经是 proxy,直接返回
if (reactiveMap.has(target)) return reactiveMap.get(target); // 命中缓存
const proxy = new Proxy(target, {
get(target, key, receiver) {
if (key === "__v_raw") return target; // 提供 toRaw 的出口
track(target, key);
const res = Reflect.get(target, key, receiver);
// 惰性深层代理:读到对象才包一层
return isObject(res) ? reactive(res) : res;
},
set(target, key, value, receiver) {
const oldValue = target[key];
// 区分新增还是修改:数组看索引是否越界,对象看是否自有属性
const hadKey = Array.isArray(target)
? Number(key) < target.length
: hasOwn(target, key);
const result = Reflect.set(target, key, value, receiver);
// 防止原型链上的 set 被重复触发:receiver 必须是自己的 proxy
if (target === receiver.__v_raw) {
if (!hadKey) trigger(target, key, "add");
else if (hasChanged(value, oldValue)) trigger(target, key, "set");
}
return result;
},
deleteProperty(target, key) {
const hadKey = hasOwn(target, key);
const result = Reflect.deleteProperty(target, key);
if (hadKey && result) trigger(target, key, "delete");
return result;
},
has(target, key) {
track(target, key); // key in obj 也要收集依赖
return Reflect.has(target, key);
},
ownKeys(target) {
// for...in / Object.keys:数组依赖 length,对象依赖 ITERATE_KEY
track(target, Array.isArray(target) ? "length" : ITERATE_KEY);
return Reflect.ownKeys(target);
},
});
reactiveMap.set(target, proxy);
return proxy;
}
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
if (!dep.has(activeEffect)) {
dep.add(activeEffect);
activeEffect.deps.push(dep); // 反向记录,便于 cleanup
}
}
function trigger(target, key, type = "set") {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = new Set();
const add = (dep) => dep && dep.forEach((e) => effects.add(e));
add(depsMap.get(key));
// 新增 / 删除属性 → 影响 for...in、Object.keys
if (type === "add" || type === "delete") {
add(depsMap.get(Array.isArray(target) ? "length" : ITERATE_KEY));
}
// 数组新增索引 → length 变了
if (type === "add" && Array.isArray(target)) {
add(depsMap.get("length"));
}
// 直接改 length → 所有 index >= newLength 的依赖都要触发
if (Array.isArray(target) && key === "length") {
const newLen = Number(target.length);
depsMap.forEach((dep, k) => {
if (k !== "length" && Number(k) >= newLen) add(dep);
});
}
effects.forEach((effect) => {
if (effect === activeEffect) return; // 避免自我递归
effect.scheduler ? effect.scheduler() : effect.run();
});
}✅ 边界 Case 自测:
reactive(1)→ 原样返回1,不报错reactive(obj) === reactive(obj)→true(缓存生效)reactive(reactive(obj)) === reactive(obj)→true(不重复代理)state.a = 1(新增)→for (const k in state)的 effect 被触发delete state.a→ 对应 effect 被触发,'a' in state的 effect 也被触发arr.push(1)→ 不死循环(push读 length 再写 length)arr.length = 0→ 依赖arr[2]的 effect 被触发state.count = state.count→ 值没变,不触发更新state.n = NaN再赋一次NaN→ 用Object.is判断,不触发更新state.deep.a.b.c→ 逐层都是 Proxy,且只在访问时才创建
🔁 变种拓展题:
js
// ① shallowReactive:只代理第一层
function shallowReactive(target) {
return new Proxy(target, {
get(t, k, r) {
track(t, k);
return Reflect.get(t, k, r); // 不再递归 reactive
},
set(t, k, v, r) {
const old = t[k];
const res = Reflect.set(t, k, v, r);
if (hasChanged(v, old)) trigger(t, k);
return res;
},
});
}
// ② readonly:拦截所有写操作并告警
function readonly(target) {
return new Proxy(target, {
get(t, k, r) {
const res = Reflect.get(t, k, r);
return isObject(res) ? readonly(res) : res; // 只读不需要 track
},
set(t, k) {
console.warn(
`Set operation on key "${String(k)}" failed: target is readonly.`
);
return true; // 返回 true 避免严格模式抛 TypeError
},
deleteProperty(t, k) {
console.warn(
`Delete operation on key "${String(k)}" failed: target is readonly.`
);
return true;
},
});
}
// ③ 数组查找方法插桩:解决「存的是 proxy,查的是原始对象」
const arrayInstrumentations = {};
["includes", "indexOf", "lastIndexOf"].forEach((method) => {
const origin = Array.prototype[method];
arrayInstrumentations[method] = function (...args) {
const raw = this.__v_raw;
// 先用响应式版本查
let res = origin.apply(this, args);
if (res === false || res === -1) {
// 没查到,再用原始对象查一遍
res = origin.apply(raw, args.map(toRaw));
}
return res;
};
});题 2:手写 effect(含嵌套、清理、scheduler、stop)
核心实现:
js
let activeEffect = null;
const effectStack = []; // 用栈管理嵌套 effect
class ReactiveEffect {
constructor(fn, scheduler = null) {
this.fn = fn;
this.scheduler = scheduler;
this.deps = []; // 反向依赖:我被哪些 dep 收集了
this.active = true; // 是否存活(stop 后为 false)
}
run() {
if (!this.active) return this.fn(); // 已 stop,只执行不收集
try {
effectStack.push(this);
activeEffect = this;
cleanupEffect(this); // ⭐ 执行前清理旧依赖,避免无效依赖
return this.fn(); // 执行过程中触发 get → track
} finally {
effectStack.pop();
// 恢复上一层 effect,保证嵌套场景依赖归属正确
activeEffect = effectStack[effectStack.length - 1] || null;
}
}
stop() {
if (this.active) {
cleanupEffect(this);
this.onStop && this.onStop();
this.active = false;
}
}
}
function cleanupEffect(effect) {
effect.deps.forEach((dep) => dep.delete(effect));
effect.deps.length = 0;
}
function effect(fn, options = {}) {
const _effect = new ReactiveEffect(fn, options.scheduler);
Object.assign(_effect, options);
if (!options.lazy) _effect.run(); // 默认立即执行一次以收集依赖
const runner = _effect.run.bind(_effect);
runner.effect = _effect; // 暴露实例,方便外部调 stop
return runner;
}✅ 边界 Case 自测:
- 嵌套 effect:
effect(() => { effect(() => b.value); a.value })→ 改a只触发外层,改b只触发内层 - 分支切换:
effect(() => show.value ? a.value : b.value)→show置 false 后,改a不再触发 - 自增不死循环:
effect(() => state.count++)→ 只执行一次 - scheduler 生效:传了
scheduler后,依赖变化只调 scheduler,不调run - lazy 生效:
effect(fn, { lazy: true })→ 不立即执行,返回的 runner 手动调才执行 - stop 后:改依赖不再触发,但手动调 runner 仍能执行;重复
stop不报错 - 返回值:
runner()能拿到fn的返回值(computed 依赖这一点)
🔁 变种拓展题:
js
// ① 手写 nextTick + 批量更新队列
const queue = [];
let isFlushing = false;
const resolvedPromise = Promise.resolve();
let currentFlushPromise = null;
function queueJob(job) {
if (!queue.includes(job)) queue.push(job);
queueFlush();
}
function queueFlush() {
if (isFlushing) return;
isFlushing = true;
currentFlushPromise = resolvedPromise.then(flushJobs);
}
function flushJobs() {
try {
queue.sort((a, b) => (a.id ?? Infinity) - (b.id ?? Infinity)); // 父先于子
queue.forEach((job) => job());
} finally {
queue.length = 0;
isFlushing = false;
currentFlushPromise = null;
}
}
function nextTick(fn) {
const p = currentFlushPromise || resolvedPromise;
return fn ? p.then(fn) : p; // 挂到同一条 Promise 链上,所以在更新之后
}
// ② 用 effect + scheduler 实现 watch
function watch(source, cb, { immediate = false } = {}) {
const getter = typeof source === "function" ? source : () => traverse(source);
let oldValue;
const job = () => {
const newValue = _effect.run();
if (hasChanged(newValue, oldValue)) {
cb(newValue, oldValue);
oldValue = newValue;
}
};
const _effect = new ReactiveEffect(getter, job);
if (immediate) job();
else oldValue = _effect.run(); // 先跑一次拿到初始值并收集依赖
return () => _effect.stop(); // 返回停止函数
}
// 递归读取每个属性,实现 deep 监听
function traverse(value, seen = new Set()) {
if (!isObject(value) || seen.has(value)) return value;
seen.add(value); // 防循环引用
for (const k in value) traverse(value[k], seen);
return value;
}题 3:手写 ref / computed / toRefs
核心实现:
js
// ---------- ref ----------
class RefImpl {
constructor(value, shallow = false) {
this.__v_isRef = true; // 标记位:isRef / unref / 模板自动解包都靠它
this._shallow = shallow;
this._rawValue = value;
this._value = shallow || !isObject(value) ? value : reactive(value);
this.dep = new Set(); // ref 只有一个 key,直接挂一个 dep 即可
}
get value() {
trackRefValue(this);
return this._value;
}
set value(newVal) {
if (!hasChanged(newVal, this._rawValue)) return; // 值没变直接退出
this._rawValue = newVal;
this._value =
this._shallow || !isObject(newVal) ? newVal : reactive(newVal);
triggerRefValue(this);
}
}
function trackRefValue(ref) {
if (!activeEffect) return;
if (!ref.dep.has(activeEffect)) {
ref.dep.add(activeEffect);
activeEffect.deps.push(ref.dep);
}
}
function triggerRefValue(ref) {
[...ref.dep].forEach((e) => (e.scheduler ? e.scheduler() : e.run()));
}
const ref = (v) => (isRef(v) ? v : new RefImpl(v));
const shallowRef = (v) => new RefImpl(v, true);
const isRef = (v) => !!(v && v.__v_isRef);
const unref = (v) => (isRef(v) ? v.value : v);
// ---------- computed ----------
class ComputedRefImpl {
constructor(getter, setter) {
this.__v_isRef = true;
this._setter = setter;
this._dirty = true; // ⭐ 脏标记,缓存的核心
this.dep = new Set();
this.effect = new ReactiveEffect(getter, () => {
// 依赖变了不立即重算,只打脏标记 + 通知下游
if (!this._dirty) {
this._dirty = true;
triggerRefValue(this);
}
});
}
get value() {
trackRefValue(this); // computed 自己也可以被别的 effect 依赖
if (this._dirty) {
this._dirty = false;
this._value = this.effect.run();
}
return this._value;
}
set value(v) {
this._setter
? this._setter(v)
: console.warn("Write operation failed: computed value is readonly");
}
}
function computed(getterOrOptions) {
const isGetter = typeof getterOrOptions === "function";
const getter = isGetter ? getterOrOptions : getterOrOptions.get;
const setter = isGetter ? null : getterOrOptions.set;
return new ComputedRefImpl(getter, setter);
}
// ---------- toRef / toRefs / proxyRefs ----------
class ObjectRefImpl {
constructor(object, key, defaultValue) {
this.__v_isRef = true;
this._object = object;
this._key = key;
this._default = defaultValue;
}
get value() {
const val = this._object[this._key]; // 每次都去源对象上取 → 保持响应式
return val === undefined ? this._default : val;
}
set value(v) {
this._object[this._key] = v;
}
}
const toRef = (obj, key, def) =>
isRef(obj[key]) ? obj[key] : new ObjectRefImpl(obj, key, def);
function toRefs(object) {
const res = Array.isArray(object) ? new Array(object.length) : {};
for (const key in object) res[key] = toRef(object, key);
return res;
}
// 自动解包:setup 返回值会过一层它,所以模板里不用写 .value
function proxyRefs(objectWithRefs) {
return new Proxy(objectWithRefs, {
get: (t, k, r) => unref(Reflect.get(t, k, r)),
set(t, k, v, r) {
const oldValue = t[k];
// 旧值是 ref 且新值不是 ref → 改 .value,而不是整个替换掉 ref
if (isRef(oldValue) && !isRef(v)) {
oldValue.value = v;
return true;
}
return Reflect.set(t, k, v, r);
},
});
}✅ 边界 Case 自测:
ref(ref(1))→ 不二次包装,返回同一个 refref({a:1})→r.value.a = 2能触发更新(内部转reactive)r.value = NaN连续两次 → 用Object.is判断,只触发一次shallowRef({a:1})→ 改.value.a不触发;整体替换.value = {...}才触发computed连续读 10 次,getter 只执行 1 次computed依赖未被任何 effect 使用时,是惰性的,不读.value永远不计算computed链式依赖:c2依赖c1,改源数据后读c2能拿到最新值- 写只读
computed→ 控制台告警且不生效 toRefs(state).count.value = 5→state.count同步变为 5toRef(obj, 'notExist', 'default')→ 返回默认值,赋值后仍写回源对象proxyRefs包裹后,obj.count = 5(count 是 ref)→ 正确改到.value,而不是把 ref 覆盖掉
🔁 变种拓展题:
js
// ① 可写 computed
const firstName = ref("San");
const lastName = ref("Zhang");
const fullName = computed({
get: () => `${lastName.value} ${firstName.value}`,
set(v) {
[lastName.value, firstName.value] = v.split(" ");
},
});
fullName.value = "Li Si"; // lastName='Li', firstName='Si'
// ② customRef:手写一个带防抖的 ref
function debounceRef(value, delay = 300) {
let timer;
return customRef((track, trigger) => ({
get() {
track();
return value;
},
set(newVal) {
clearTimeout(timer);
timer = setTimeout(() => {
value = newVal;
trigger(); // 延迟后才通知更新
}, delay);
},
}));
}
// 用法:搜索框 v-model="keyword",输入停止 300ms 后才触发请求
const keyword = debounceRef("");
// ③ 一个常用 composable:localStorage 同步的 ref
function useStorage(key, initial) {
const data = ref(JSON.parse(localStorage.getItem(key)) ?? initial);
watchEffect(() => localStorage.setItem(key, JSON.stringify(data.value)));
return data; // 返回 ref,外部解构也不丢响应式
}⚠️ 当日踩坑实录
| 常见坑 | 解决方案 |
|---|---|
❌ const { count } = reactive({count:0}) 解构后修改无反应 | ✅ 用 toRefs(state) 解构,或直接整体用 ref;原理是解构拿到的是值拷贝,脱离了 Proxy |
❌ state = { ...newData } 整体替换后页面不更新 | ✅ 用 Object.assign(state, newData) 保留原 Proxy 引用;或改用 ref,data.value = newData |
❌ 忘写 .value,count++ 变成 NaN 或静默失效 | ✅ 开启 Volar + ESLint 规则 vue/no-ref-as-operand;模板里才自动解包,JS 里必须写 |
❌ reactive 里数组元素是 ref,模板里拿不到值 | ✅ 数组/Map 中的 ref 不会自动解包,必须写 arr[0].value |
❌ watch(state, cb) 回调里 newVal === oldVal | ✅ Proxy 引用未变。需要旧值就监听 () => ({...state}) 或 cloneDeep(state) |
❌ watch(() => state.obj, cb) 改深层属性不触发 | ✅ getter 返回的是同一引用。加 { deep: true },或精确监听 () => state.obj.name |
❌ deep: true 监听大对象导致页面卡顿 | ✅ deep 会递归读取所有属性来收集依赖,开销大。改成只监听真正关心的字段 |
| ❌ 改完数据立刻读 DOM,拿到的是旧内容 | ✅ Vue 的更新是异步批量的,用 await nextTick() 或 watch(..., {flush:'post'}) |
❌ 生命周期钩子写在 await 之后不生效 | ✅ 必须在 setup 同步阶段注册,Vue 靠 currentInstance 全局变量关联组件 |
❌ KeepAlive 缓存的组件轮询停不掉 | ✅ 缓存组件不走 onUnmounted,要在 onDeactivated 里清理,onActivated 里重启 |
❌ onUpdated 里改数据导致 Maximum recursive updates exceeded | ✅ 改用 watch + flush:'post',或加条件守卫阻断循环 |
❌ ECharts / 地图实例用 ref 存后交互异常、卡顿 | ✅ 第三方实例一律 shallowRef(markRaw(instance)),避免被深度 Proxy 化 |
❌ props 解构后失去响应式(Vue < 3.5) | ✅ 用 toRefs(props) 或 () => props.foo;3.5+ 支持响应式解构但需确认版本 |
❌ 子组件直接修改 props 报警告 | ✅ props 单向数据流,用 emit('update:xxx') 配合 v-model,或 3.4+ 的 defineModel |
❌ v-for 用 index 作 key,列表增删后状态错乱 | ✅ 用稳定唯一 id 作 key;index 会让 diff 复用错节点,导致输入框内容串位 |
❌ v-if 和 v-for 写在同一元素上 | ✅ Vue3 中 v-if 优先级高于 v-for(与 Vue2 相反),会取不到循环变量。用 <template v-for> 包一层再写 v-if |
❌ reactive(new Map()) 调 get 报 incompatible receiver | ✅ Vue3 已为集合类型做了插桩;自己手写 Proxy 时必须用 this.__v_raw 调原生方法 |
| ❌ 把响应式对象存进原始 Map / 发给后端,数据被"污染" | ✅ 存入前 toRaw(value),传给后端前 JSON.parse(JSON.stringify()) 或 toRaw |
❌ markRaw 后又包 reactive,发现改了没反应 | ✅ 这是预期行为,markRaw 加了 __v_skip 标记,reactive 会直接返回原对象 |
❌ 大数组每次 push 都触发渲染,长列表卡死 | ✅ 先操作原始数组再整体赋值;或用 shallowRef + 手动 triggerRef;长列表上虚拟滚动 |
📌 当日复盘任务
- [ ] 能画出
targetMap → depsMap → dep三层结构图,并说清每层为什么选 WeakMap / Map / Set - [ ] 不看资料手写
reactive,覆盖get/set/has/deleteProperty/ownKeys五个拦截器 - [ ] 手写
effect,能处理嵌套(栈)、依赖清理(cleanup)、scheduler、stop 四个点 - [ ] 手写
ref、computed(lazy + dirty)、toRef/toRefs(__v_isRef标记) - [ ] 手写
nextTick+ 批量更新队列,并解释「为什么改数据后立刻读 DOM 是旧值」 - [ ] 说清 Proxy 为什么必须配 Reflect,
receiver到底解决了什么问题 - [ ] 默写 Vue3 全部生命周期钩子及其对应的选项式名称
- [ ] 默写父子组件挂载 / 更新 / 卸载的钩子执行顺序
- [ ] 对比说明
computed/watch/watchEffect的 5 点差异与选型依据 - [ ] 列举
reactive丢失响应式的 3 种场景及各自解法 - [ ] 解释 PatchFlag、静态提升、Block Tree 三项编译优化分别优化了什么
- [ ] 用
onRenderTriggered在自己项目里抓一次「意外重渲染」并定位原因 - [ ] 准备 2 段真实项目话术:一段性能优化、一段踩坑排查,各控制在 60 秒内讲完