Skip to content

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.definePropertyVue3 Proxy
劫持粒度属性级,需遍历每个 key对象级,代理整个对象
新增 / 删除属性❌ 需 $set / $delete✅ 原生支持
数组索引 / length❌ 需重写 7 个方法✅ 原生支持
初始化性能递归遍历所有属性,深对象慢惰性代理,访问到才递归代理子对象
Map / Set❌ 不支持✅ 支持(需单独写 handler)
可拦截操作只有 get / set13 种(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深层只读代理Proxyprops 保护、全局配置
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 }
层级数据结构为什么选它
targetMapWeakMapkey 是对象,弱引用,对象被回收时依赖自动释放,防内存泄漏
depsMapMapkey 可以是 string / symbol(Symbol.iterator、ITERATE_KEY)
depSet自动去重,同一个 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触发时机常见用途
beforeCreatesetup() 本身实例初始化后,data/methods 未注入—
createdsetup() 本身响应式数据已就绪,DOM 未生成发请求、读路由参数
beforeMountonBeforeMount挂载前,render 已编译未执行极少用
mountedonMountedDOM 已插入页面操作 DOM、初始化图表、绑定第三方库
beforeUpdateonBeforeUpdate数据变了,DOM 未更新获取更新前的 DOM 状态(如滚动位置)
updatedonUpdatedDOM 已更新完成⚠️ 此处改数据会死循环
beforeUnmountonBeforeUnmount卸载前,实例仍完全可用清定时器、解绑事件、取消请求
unmountedonUnmounted卸载完成,子组件也已卸载最终清理
activatedonActivated<KeepAlive> 缓存组件被激活刷新数据
deactivatedonDeactivated<KeepAlive> 缓存组件被停用暂停轮询
errorCapturedonErrorCaptured捕获后代组件的错误错误上报、降级 UI
renderTrackedonRenderTracked响应式依赖被收集时(dev only)调试「为什么重渲染」
renderTriggeredonRenderTriggered响应式依赖触发重渲染时(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
deletePropertydelete obj.key✅trigger(..., 'delete')
haskey in obj✅track(target, key, 'has')
ownKeysObject.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 用 gettersize 是访问器属性,必须 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 ​

维度computedwatchwatchEffect
依赖来源自动收集手动指定第一个参数自动收集
立即执行惰性(被读取才算)默认不立即执行立即执行一次
能拿新旧值❌✅ (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})解构出的是基础类型值的拷贝,脱离 Proxyconst { 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 收集后代动态节点到 dynamicChildrendiff 从「树递归遍历」降为「数组线性遍历」
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。

连环追问链:

  1. 追问:Proxy 有什么缺点?
    • 答:① 完全不支持 IE11 且无法 polyfill(因为它是语言层面的行为拦截);② 单次属性访问的性能其实比原生 getter 略慢,Vue3 的性能优势主要来自惰性代理和编译优化,不是 Proxy 本身快;③ Map/Set 这类带内部槽的对象必须单独写 handler 插桩,否则直接报错。
  2. 追问:Proxy 为什么必须配 Reflect?直接 target[key] 不行吗?
    • 答:不行。如果对象里有 getter 用到了 this,target[key] 会让 this 指向原始对象,嵌套访问就绕过了 Proxy,依赖收集不到。Reflect.get(target, key, receiver) 把第三个参数 receiver 传成 proxy,getter 里的 this 就变成了 proxy,嵌套访问继续被拦截。
  3. 追问:Vue3 里 obj.newKey = 1 真的不用 $set 了吗?
    • 答:对象确实不用了,set trap 会拦到并把它识别成 ADD 操作,额外触发 ITERATE_KEY 的依赖,所以 for...in、Object.keys 的 effect 也能更新。但注意用 ref 包的对象整体替换和解构出来的值依旧会丢响应式,这是另一类问题。
  4. 追问:那 Vue3 还需要重写数组方法吗?
    • 答:响应式层面不需要了。但 Vue3 对 includes、indexOf、lastIndexOf 这三个查找方法做了插桩——因为数组里存的是响应式代理对象,而你传进去查找的可能是原始对象,直接比较会找不到,所以插桩里会先用 proxy 比一遍,找不到再用 toRaw 比一遍。另外 push/pop 这类方法会同时读写 length,可能导致无限递归,Vue 用 pauseTracking 临时暂停依赖收集来规避。

✅ 项目关联话术(按真实经历替换):

我们项目有一个 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。读属性时走 get trap 调 track,把当前全局的 activeEffect 加进对应的 dep,同时在 effect 上反向存一份 deps 用于后续清理;写属性时走 set trap 调 trigger,取出 dep 里的 effect,有 scheduler 就走调度器(组件更新走这条,推进微任务队列批量执行),没有就立即 run。用 WeakMap 是因为 key 是对象且需要弱引用防内存泄漏,用 Set 是为了自动去重。

连环追问链:

  1. 追问:activeEffect 为什么要用栈管理?
    • 答:因为 effect 可能嵌套。父组件的 render effect 里渲染子组件,子组件又有自己的 render effect。如果只用一个变量赋值,子 effect 执行完后 activeEffect 会被置空或残留,父 effect 后续读取的属性就收集错了对象。用栈(或者 Vue3.2+ 的 parent 指针链)在 effect 执行前入栈、执行后出栈并恢复上一个,才能保证依赖归属正确。
  2. 追问:effect 为什么每次执行前要清理依赖?
    • 答:为了避免无效依赖。比如 effect(() => { show.value ? a.value : b.value }),第一次 show 为 true 收集了 a,之后 show 改成 false,effect 重新执行只读 b。如果不清理,a 的 dep 里还留着这个 effect,改 a 还会触发一次无意义的更新。cleanupEffect 就是遍历 effect.deps 把自己从每个 Set 里删掉,然后重新收集。
  3. 追问:trigger 时为什么要复制一份 dep 再遍历?
    • 答:因为 effect 执行时会先 cleanup(从 Set 里 delete 自己)再重新 track(往 Set 里 add 自己)。直接遍历原 Set 会出现「边删边加」,JS 的 Set.forEach 对本次遍历中新加入的元素还会再访问一次,导致无限循环。复制成数组再遍历就隔离了这个问题。
  4. 追问: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。

连环追问链:

  1. 追问:请求数据应该放 created(setup)还是 mounted?
    • 答:如果不依赖 DOM,就放 setup,能更早发出请求,减少首屏等待;如果需要拿到 DOM 尺寸再决定请求参数(比如按容器宽度请求不同尺寸的图),才放 onMounted。SSR 场景下只有 setup 和 beforeCreate/created 会在服务端执行,onMounted 不会,所以 SSR 需要同构的数据请求必须放 setup。
  2. 追问:onMounted 里能保证子组件 DOM 已经渲染好吗?
    • 答:能,子组件的 mounted 早于父组件。但如果子组件是异步组件(defineAsyncComponent)或包在 <Suspense> 里,父组件的 onMounted 不会等它,这时要靠 nextTick 或 Suspense 的 resolve 事件。
  3. 追问:为什么生命周期钩子不能写在 await 之后?
    • 答:Vue 内部靠一个全局变量 currentInstance 来知道「现在注册的钩子属于哪个组件」,这个变量只在 setup 同步执行期间有值,await 之后微任务恢复执行时它已经被置空了,钩子就注册不上去(dev 环境会告警)。要在异步后注册,得先把实例存下来再手动 setCurrentInstance,但一般不推荐。
  4. 追问:onUpdated 里改数据会怎样?
    • 答:会死循环。改数据触发 trigger → 组件更新 → 再次触发 onUpdated → 又改数据。Vue 在开发环境有递归更新次数检测,超过 100 次会抛 Maximum recursive updates exceeded。需要「更新后同步状态」应该用 watch 配 flush: 'post',或者加条件守卫。
  5. 追问: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。

连环追问链:

  1. 追问:ref 里套 reactive 会怎样?reactive 里套 ref 呢?
    • 答:ref(reactive(obj)) 不会二次包装,.value 直接拿到那个 Proxy。reactive({ count: ref(0) }) 时,访问 state.count 会自动解包,直接拿到数字 0,不用写 .value;但如果放在数组或 Map 里就不解包,arr[0].value 才行。这个不一致性是面试爱考的坑点。
  2. 追问:模板里为什么不用写 .value?
    • 答:<script setup> 编译时会分析出哪些变量是 ref,在模板编译产物里自动加上 .value(运行时也有 proxyRefs 做兜底解包)。但只对顶层 ref 生效,obj.someRef 这种嵌套的 ref 在模板里仍要写 .value。
  3. 追问:shallowRef 什么时候用?
    • 答:存大型不可变数据或第三方实例时。比如 ECharts 实例、地图实例、Monaco Editor 实例,用 ref 会把整个实例深度代理一遍,既慢又可能因为代理破坏内部逻辑。用 shallowRef 只在 .value 整体替换时触发更新,配合 markRaw 效果更好。
  4. 追问: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 小一半左右。

连环追问链:

  1. 追问:PatchFlag 具体怎么省时间?
    • 答:没有 PatchFlag 时,diff 两个 vnode 的 props 要把新旧 props 对象的所有 key 都遍历比对一遍。有了 PatchFlag,比如标记是 TEXT,就只调 setElementText 更新文本,props 一个都不比;标记是 CLASS 就只更新 class。本质是把「运行时的猜测」变成「编译时的确定信息」。
  2. 追问:Block Tree 是怎么做到跳过静态节点的?
    • 答:编译时在 createBlock 开始前调 openBlock() 开一个收集数组,所有带 PatchFlag 的动态 vnode 在 createVNode 时会自动把自己 push 进当前 block 的收集数组,最终挂在 block vnode 的 dynamicChildren 上。patch 时如果发现有 dynamicChildren,就走 patchBlockChildren 直接遍历这个数组,完全不碰 children 里的静态节点。
  3. 追问:Vue3 的 diff 和 Vue2 有什么区别?
    • 答:Vue2 是双端比较(头头、尾尾、头尾、尾头四次尝试),Vue3 保留了前后预处理(先同步头部、再同步尾部),中间乱序部分改用 最长递增子序列(LIS) 求出不需要移动的最大子序列,只移动剩下的节点,把 DOM 移动次数降到最少。
  4. 追问: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)) → 不二次包装,返回同一个 ref
  • ref({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 同步变为 5
  • toRef(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 秒内讲完