那些说熟练掌握Vue 3的能真的讲明白响应式吗?

zijieLeo
30 分钟
10 次浏览
那些说熟练掌握Vue 3的能真的讲明白响应式吗?

Vue 3 响应式到底是什么水平:从“只会说 Proxy”到能把依赖图讲明白

写 Vue 3 写久了会发现一个很现实的现象:同样叫“懂响应式”,不同人脑子里的东西完全不是同一个层次。有人停留在“Vue3 用 Proxy 了”;有人能把依赖收集、触发更新、effect 栈、cleanup 讲清楚;再往上,能把那套数据结构为什么要用 WeakMap → Map → Set 解释得很顺,还能指出哪些地方是性能/边界的坑。

这篇按不同水平,给一套“应该能回答到的点”,再用一段接近源码的实现骨架把关键路径串起来。


0)先定一句话:Vue 3 响应式做的事

把“读取”和“写入”变成可观测的事件

  • 读取(get)的时候:记录“谁(effect)在读哪个对象的哪个 key”
  • 写入(set/delete/add)的时候:找到“依赖这个 key 的 effect 集合”,把它们重新跑一遍(或交给调度器)

这两句话是主线,后面所有细节都在为它服务。


1)初级:别只说 Proxy,起码把“为什么 Proxy + 它解决了什么”讲完整

能说清楚的点

  • Vue 3 用 Proxy 拦截对象的 get/set,从而实现依赖收集和触发更新。
  • 对比 Vue 2:
    • Vue 2 用 Object.defineProperty 劫持属性,新增/删除属性有天然限制(需要 Vue.set 之类的补丁),数组索引也麻烦。
    • Vue 3 的 Proxy 能拦截更多操作(inownKeys、delete 等),新增/删除属性也能被捕获。

别说得太虚

“Proxy 更强大所以 Vue3 更快”这种容易被追问。更稳的说法是:

  • Proxy 让拦截的覆盖面更完整,减少补丁式 API;
  • 真正的性能来自按需追踪更精细的触发策略(后面中高级部分)。

2)中级:讲得出“依赖收集/触发更新”是一条链,知道 activeEffect 是啥

到这个层次,至少能把下面这条链串起来:

  1. reactive(obj) 返回一个 Proxy
  2. 组件渲染 / computed / watch 本质都会创建 effect(可以理解为“带依赖的函数”)
  3. effect 执行时把自己挂成 activeEffect
  4. Proxy get 触发 track(target, key)
  5. Proxy set 触发 trigger(target, key)
  6. trigger 找到依赖集合,把 effect 重新执行(或 scheduler 调度)

依赖收集:track

“谁依赖谁”的信息要存起来。Vue 3 的核心结构非常经典:

  • targetMap: WeakMap<object, Map<key, Dep>>
  • Dep 通常是 Set<ReactiveEffect>

也就是:

  • 第一层 WeakMap:按“目标对象 target”分桶(避免内存泄漏,target 没引用了能 GC)
  • 第二层 Map:按“属性 key”分桶(同对象不同 key 的依赖分开)
  • 第三层 Set:同一个 key 被多个 effect 依赖,去重 + 可迭代触发

触发更新:trigger

set/delete/add 时,通过 target+key 找到 Dep(Set),遍历其中的 effect:

  • 如果 effect 有 scheduler,交给 scheduler(比如组件更新进队列、computed 标记脏)
  • 否则直接 effect.run()

3)中高级分水岭:为什么要“双向记录 + cleanup”,否则会脏依赖

很多“能讲依赖收集”的人,到了这里就开始含糊:为什么 effect 要 cleanup?

因为依赖关系不是静态的。一个 effect 里经常有分支:

effect(() => {
  if (state.flag) {
    state.a
  } else {
    state.b
  }
})

第一次 flag=true,收集了 a;如果后面 flag=false,这次执行应该收集 b,并且把之前对 a 的依赖移除,否则:

  • a 变了也会触发 effect(明明现在不读 a 了)
  • 依赖集合越来越大,性能和语义都脏

所以 Vue 3 的实现里,通常会让 effect 持有一个 deps: Dep[] 列表,做到双向引用

  • Dep(Set)里有很多 effect
  • effect.deps 里也记录自己加入过哪些 Dep

每次 effect.run 前先 cleanup:从所有 Dep 中把自己删除,清空 deps,再重新收集。


4)能“看懂源码”的水平:effect 包装、栈、WeakMap→Map→Dep、调度器

下面是一段“接近 Vue 3 实现骨架”的伪源码(变量名和结构尽量贴近真实实现,但我做了删减,便于读主线)。

4.1 依赖容器:WeakMap → Map → Dep(Set)

type Key = string | symbol
type Dep = Set<ReactiveEffect>

const targetMap = new WeakMap<object, Map<Key, Dep>>()

let activeEffect: ReactiveEffect | undefined
const effectStack: ReactiveEffect[] = []

4.2 effect:run / cleanup / scheduler

class ReactiveEffect {
  fn: Function
  scheduler?: () => void
  deps: Dep[] = []
  active = true

  constructor(fn: Function, scheduler?: () => void) {
    this.fn = fn
    this.scheduler = scheduler
  }

  run() {
    if (!this.active) return this.fn()

    cleanupEffect(this)
    try {
      effectStack.push(this)
      activeEffect = this
      return this.fn()
    } finally {
      effectStack.pop()
      activeEffect = effectStack[effectStack.length - 1]
    }
  }

  stop() {
    if (this.active) {
      cleanupEffect(this)
      this.active = false
    }
  }
}

function cleanupEffect(effect: ReactiveEffect) {
  for (const dep of effect.deps) dep.delete(effect)
  effect.deps.length = 0
}

这里有三个关键点:

  • effectStack:嵌套 effect 的时候,activeEffect 必须正确恢复(computed 里跑 effect、watch 里跑 effect 都会嵌套)
  • cleanupEffect:解决分支依赖
  • scheduler:把“立即执行”变成“可调度”(组件更新批处理、computed 懒执行)

4.3 track:get 时收集

function track(target: object, key: Key) {
  if (!activeEffect) return

  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }

  let dep = depsMap.get(key)
  if (!dep) {
    dep = new Set()
    depsMap.set(key, dep)
  }

  // 双向记录
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

4.4 trigger:set/delete/add 时触发

function trigger(target: object, key: Key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const dep = depsMap.get(key)
  if (!dep) return

  // 拷贝一份,避免触发过程中 dep 结构变化
  const effects = new Set(dep)
  effects.forEach((effect) => {
    if (effect.scheduler) effect.scheduler()
    else effect.run()
  })
}

4.5 reactive:Proxy 入口(只看主线 get/set)

function reactive<T extends object>(raw: T): T {
  return new Proxy(raw, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver)
      track(target, key as any)
      return typeof res === "object" && res !== null ? reactive(res) : res
    },
    set(target, key, value, receiver) {
      const old = (target as any)[key]
      const result = Reflect.set(target, key, value, receiver)
      if (old !== value) trigger(target, key as any)
      return result
    },
  })
}

真实 Vue 3 会更复杂:要区分新增属性/修改属性、数组 length、Map/Set 的迭代 key、readonly/shallow、ref 解包、toRaw/markRaw 等等。但主线就是这几步。


5)再往上:computed / watch 为什么“不只是 effect.run()”

computed 的本质:一个带缓存的 effect

computed 之所以“只在依赖变了才重新算”,靠的是:

  • effect 不直接触发重新执行,而是 scheduler 把 computed 标记为 dirty
  • 下一次读取 computed.value 时,如果 dirty 才执行 effect.run 重新计算

这就是 scheduler 存在的价值:把 trigger 的动作从“重跑”改成“标脏 + 延迟重跑”。

watch 的本质:一个 effect + 对比 + 回调

watch 关心的是“值变化”而不是“渲染”,所以会:

  • effect.run 得到新值
  • 和旧值比较
  • 触发回调,并处理 cleanup(onInvalidate)

6)容易被忽略的工程细节(能说出来就很加分)

  • 为什么 WeakMap 放最外层:target 没人引用了,依赖表不应该阻止 GC,否则长期页面会泄漏。
  • 为什么 Set 要拷贝再遍历:触发过程中 effect 可能会 cleanup / 重新 track,直接遍历原 Set 容易边遍历边变结构。
  • 为什么要暂停追踪/恢复追踪:某些内部读取不应收集依赖(例如数组方法内部访问 length),否则会造成多余依赖和死循环风险。
  • 数组、Map/Set、for...in/ownKeys 的特殊 track key:迭代本身也需要依赖(比如 length 变化会影响遍历结果)。
  • ref 与 reactive 的关系:ref 通过 getter/setter 在 .value 上做 track/trigger;在模板/代理里会做解包(unwrapping)。

7)面试式总结(但不做问答腔):不同级别一句话能看出差距

  • 初级:能说明“Vue 3 用 Proxy 拦截 get/set,解决 Vue 2 defineProperty 的新增/删除问题”
  • 中级:能把“track/trigger + activeEffect + 依赖集合”串起来,并解释依赖图是怎么存的
  • 高级:能讲清楚 WeakMap→Map→Set 的原因、cleanup 的必要性、effect 栈、scheduler 的意义,以及 computed/watch 的差异

评论

登录后即可发表评论

暂无评论,快来发表第一条评论吧!