Vue响应式原理(下)

前言

在上一篇文章中,我们实现了基础的响应式系统:通过 Proxy 拦截对象的读写操作,在 get 时自动收集依赖(track),在 set 时自动派发更新(trigger),完成了数据驱动视图的闭环。

然而这个系统仍有明显的缺陷:effect 是一个全局硬编码的变量,无法处理多个副作用函数共存的场景;每次数据变动都会立即执行更新,无法做到批量调度;也不支持 computedwatch 这些 Vue 中最常用的响应式 API。本文将逐一解决这些问题。

一、activeEffect ------ 摆脱全局硬编码

回顾上篇的代码,我们的 track 函数直接把一个全局的 effect 变量存入 dep 集合:

javascript 复制代码
// 上篇的写法:硬编码全局 effect
function track(target, key) {
  // ...
  dep.add(effect) // ← 问题:effect 是谁?
}

这种写法只能处理一个副作用函数。如果存在两个 effect,第二个就会覆盖第一个,依赖收集就会出错。我们需要一种机制,让 track 知道"当前正在运行的是哪个副作用函数"。

解决方案是引入 activeEffect 变量,配合一个 effect 注册函数来管理它:

javascript 复制代码
let activeEffect = null

function effect(fn) {
  activeEffect = fn
  fn() // 执行副作用函数,触发 Proxy 的 get → track 会读取 activeEffect
  activeEffect = null
}

然后修改 track,用 activeEffect 替换硬编码的 effect

javascript 复制代码
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) // ← 改为 activeEffect
}

来测试一下:

javascript 复制代码
const product = reactive({ price: 5, quantity: 2 })

effect(() => {
  console.log('副作用1:', product.price * product.quantity)
})

effect(() => {
  console.log('副作用2:', product.quantity)
})

product.quantity = 3
// 输出:
// 副作用1: 10(首次执行)
// 副作用2: 2(首次执行)
// 副作用2: 3(quantity 变化触发)
// 副作用1: 15(quantity 变化触发)

两个副作用函数都被正确收集和触发了。

二、effectStack ------ 处理嵌套副作用

activeEffect 解决了单层 effect 的问题,但遇到了新场景:嵌套 effect

javascript 复制代码
effect(() => {
  // 外层 effect
  console.log('外层:', product.price)
  effect(() => {
    // 内层 effect
    console.log('内层:', product.quantity)
  })
})

执行流程是这样的:

  1. 外层 effect 开始执行,activeEffect 被设为外层函数
  2. 外层函数读取 product.pricetrack 收集了外层函数
  3. 内层 effect 开始执行,activeEffect 被覆盖为内层函数
  4. 内层函数读取 product.quantitytrack 收集了内层函数
  5. 内层 effect 执行完毕,activeEffect 被设为 null
  6. 问题来了 :此时外层函数还没执行完,但 activeEffect 已经是 null,后续的依赖收集全部丢失

这和函数调用栈的道理是一样的------内层函数执行完毕后,需要回到外层继续执行。所以我们引入 effect 栈

javascript 复制代码
let activeEffect = null
const effectStack = []

function effect(fn) {
  const effectFn = () => {
    effectStack.push(effectFn)  // 入栈
    activeEffect = effectFn     // 设为当前活跃 effect
    fn()                        // 执行副作用
    effectStack.pop()           // 出栈
    activeEffect = effectStack[effectStack.length - 1] // 恢复上一层
  }
  effectFn()
}

现在当内层 effect 执行完毕后,activeEffect 会自动恢复为外层 effect,确保外层的后续依赖收集不会丢失。

三、避免无限循环 ------ 同一个 effect 不重复触发

还有一个边界情况需要处理。考虑这段代码:

javascript 复制代码
effect(() => {
  product.count++ // 等价于 product.count = product.count + 1
})

在执行这个副作用函数时,先读取 product.count(触发 track,收集当前 effect),然后设置 product.count(触发 trigger,执行当前 effect),于是又读取又设置......无限循环

解决方案很简单:在 trigger 执行副作用时,跳过当前正在执行的 effect 本身。

javascript 复制代码
function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (!dep) return

  // 复制一份再遍历,避免遍历时修改 Set 导致的问题
  const effectsToRun = new Set(dep)
  effectsToRun.forEach(effectFn => {
    if (effectFn !== activeEffect) { // ← 跳过自身
      effectFn()
    }
  })
}

四、scheduler ------ 调度执行,控制时机

目前我们的响应式系统在数据变化时会同步执行所有副作用函数。但在实际场景中,这会导致性能问题:

javascript 复制代码
product.price = 6
product.quantity = 3
// trigger 执行了两次副作用,但最终结果只需要最后一次

Vue 的做法是:数据变了不立即执行,而是把副作用函数放进一个队列,在"合适的时机"统一执行 。这个"合适的时机"通常是微任务(Promise.then)。

我们给 effect 增加一个 scheduler(调度器)选项:

javascript 复制代码
function effect(fn, options = {}) {
  const effectFn = () => {
    effectStack.push(effectFn)
    activeEffect = effectFn
    const result = fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
    return result
  }

  effectFn.scheduler = options.scheduler // 挂载调度器
  effectFn()
  return effectFn
}

然后修改 trigger,如果副作用函数有 scheduler,就调用调度器而不是直接执行:

javascript 复制代码
function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (!dep) return

  const effectsToRun = new Set(dep)
  effectsToRun.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      if (effectFn.scheduler) {
        effectFn.scheduler(effectFn) // 有调度器,交给调度器处理
      } else {
        effectFn() // 没有调度器,直接执行
      }
    }
  })
}

有了 scheduler,我们就能实现任务队列去重------多次修改只执行一次:

javascript 复制代码
const jobQueue = new Set()
const isFlushing = false

function flushJobs() {
  if (isFlushing) return
  isFlushing = true
  Promise.resolve().then(() => {
    jobQueue.forEach(job => job())
    jobQueue.clear()
    isFlushing = false
  })
}

effect(() => {
  console.log(product.price * product.quantity)
}, {
  scheduler(effectFn) {
    jobQueue.add(effectFn)
    flushJobs()
  }
})

product.price = 6
product.quantity = 3
// 虽然修改了两次,但副作用只执行一次,输出 18

Set 天然去重,Promise.then 保证在微任务中执行------这正是 Vue 组件异步更新的核心思路。

五、computed ------ 懒求值的响应式计算

Vue 中最常用的 API 之一是 computed,它具有两个特点:

  1. 懒求值:只有真正读取时才计算,没有读取就不执行
  2. 缓存:依赖不变时,多次读取返回同一个值,不重复计算

我们可以基于 effect + scheduler 来实现:

javascript 复制代码
function computed(getter) {
  let value
  let dirty = true // "脏"标记:为 true 时需要重新计算

  const effectFn = effect(getter, {
    scheduler() {
      if (!dirty) {
        dirty = true // 依赖变化时标记为"脏"
        trigger(obj, 'value') // 通知依赖 computed 的副作用
      }
    }
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn() // 重新计算
        dirty = false      // 标记为"干净"
      }
      track(obj, 'value') // 收集依赖 computed 的副作用
      return value
    }
  }

  return obj
}

来分析这个精巧的设计:

  • 首次读取dirtytrue,执行 effectFn()(即 getter),计算值,标记为"干净"
  • 再次读取 (依赖没变):dirtyfalse,直接返回缓存的 value
  • 依赖变化scheduler 被触发,dirty 重新设为 true,但不立即计算
  • 下次读取 :发现 dirtytrue,才重新计算

这就是 computed 的精髓------惰性求值 + 缓存

javascript 复制代码
const product = reactive({ price: 5, quantity: 2 })
const total = computed(() => product.price * product.quantity)

console.log(total.value) // 10,首次读取时计算
console.log(total.value) // 10,直接返回缓存值,不重新计算

product.price = 10
// 此时不重新计算,dirty 被标记为 true
console.log(total.value) // 20,读取时发现 dirty 为 true,重新计算

六、watch ------ 侦听数据变化

watch 是另一个核心 API。它的本质是:观测一个数据源,当数据变化时执行回调函数

最简单的实现,仍然是基于 effect + scheduler

javascript 复制代码
function watch(source, callback) {
  let getter
  if (typeof source === 'function') {
    getter = source
  } else {
    // 如果传入的是 reactive 对象,递归读取所有属性来建立依赖
    getter = () => traverse(source)
  }

  let oldValue, newValue

  const effectFn = effect(
    () => getter(),
    {
      scheduler() {
        newValue = effectFn()
        callback(newValue, oldValue)
        oldValue = newValue
      }
    }
  )

  oldValue = effectFn() // 首次执行,获取初始值
}

traverse 函数递归遍历 reactive 对象的所有属性,确保深层属性的变化也能被侦测到:

javascript 复制代码
function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  seen.add(value)
  for (const key of Object.keys(value)) {
    traverse(value[key], seen) // 递归访问每个属性,触发 get → track
  }
  return value
}

使用示例:

javascript 复制代码
const product = reactive({ price: 5, quantity: 2 })

watch(
  () => product.price,
  (newVal, oldVal) => {
    console.log(`price 从 ${oldVal} 变为 ${newVal}`)
  }
)

product.price = 10
// 输出:price 从 5 变为 10

七、ref ------ 让原始值也响应式

到目前为止,我们的 reactive 只能处理对象类型,因为它依赖 Proxy,而 Proxy 只能代理对象。对于原始值(数字、字符串、布尔值),需要另一种方案。

Vue 3 使用 ref 来解决这个问题。ref 的思路很朴素:把原始值包装成一个对象,通过 .value 读写 ,然后在 get valueset value 上做 tracktrigger

javascript 复制代码
function ref(initialValue) {
  const r = {
    get value() {
      track(r, 'value')
      return initialValue
    },
    set value(newValue) {
      if (initialValue !== newValue) {
        initialValue = newValue
        trigger(r, 'value')
      }
    }
  }
  return r
}

使用示例:

javascript 复制代码
const count = ref(0)

effect(() => {
  console.log('count =', count.value)
})

count.value++
// 输出:
// count = 0(首次执行)
// count = 1(变化触发)

不过上面的实现有个问题:initialValue 用闭包变量保存,如果 ref 的值本身是对象,需要用 reactive 包裹。Vue 3 的实际实现会判断传入值的类型:

javascript 复制代码
function ref(initialValue) {
  // 如果是对象,交给 reactive 处理
  const value = isObject(initialValue) ? reactive(initialValue) : initialValue

  const r = {
    get value() {
      track(r, 'value')
      return value
    },
    set value(newValue) {
      if (newValue !== value) {
        // 如果新值是对象,也转为 reactive
        value = isObject(newValue) ? reactive(newValue) : newValue
        trigger(r, 'value')
      }
    }
  }
  return r
}

八、整体架构回顾

到这里,我们已经构建了一个完整的响应式系统。回顾整个架构:

复制代码
                    ┌─────────────────────┐
                    │      targetMap       │
                    │     (WeakMap)        │
                    │  target → depsMap    │
                    └──────────┬──────────┘
                               │
                    ┌──────────▼──────────┐
                    │      depsMap         │
                    │       (Map)          │
                    │    key → dep         │
                    └──────────┬──────────┘
                               │
                    ┌──────────▼──────────┐
                    │        dep           │
                    │       (Set)          │
                    │   effectFn 集合      │
                    └─────────────────────┘

核心函数之间的关系:

API 底层机制 核心特点
reactive Proxy 拦截 get/set 对象类型响应式
ref 对象的 get value/set value 原始值响应式
effect activeEffect + effectStack 副作用注册与执行
computed effect + lazy + dirty 懒求值 + 缓存
watch effect + scheduler 侦听变化 + 回调

数据流动的全链路:

复制代码
读取数据 → Proxy.get / ref.value get → track(activeEffect)
修改数据 → Proxy.set / ref.value set → trigger → 执行 effectFn
                                                ↓
                                         有 scheduler?
                                         ├── 是 → 调度器处理(异步/去重)
                                         └── 否 → 直接同步执行

总结

从最简单的 Set 收集依赖,到 WeakMap + Map + Set 三层结构精确追踪,再到 Proxy 实现自动化,最后演进到 activeEffecteffectStackschedulercomputedwatchref------这就是 Vue 3 响应式系统的核心脉络。

每一步的演进都不是凭空设计,而是为了解决前一步暴露的问题:

  • 全局 effect 无法多函数共存 → 引入 activeEffect
  • 嵌套 effect 导致依赖丢失 → 引入 effectStack
  • 自增操作无限循环trigger 跳过当前 activeEffect
  • 同步执行性能浪费 → 引入 scheduler 异步调度
  • 多次计算浪费computeddirty 标记实现缓存
  • 原始值无法 Proxyref 包装为对象的 .value

Vue 的响应式系统看起来精巧复杂,但拆开来看,每一层都是在解决一个具体的问题。理解了问题,也就理解了设计。

完整代码

javascript 复制代码
// ==================== 核心仓库 ====================
const targetMap = new WeakMap()
let activeEffect = null
const effectStack = []

// ==================== track & trigger ====================
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()))
  }
  dep.add(activeEffect)
}

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const dep = depsMap.get(key)
  if (!dep) return
  const effectsToRun = new Set(dep)
  effectsToRun.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      if (effectFn.scheduler) {
        effectFn.scheduler(effectFn)
      } else {
        effectFn()
      }
    }
  })
}

// ==================== effect ====================
function effect(fn, options = {}) {
  const effectFn = () => {
    effectStack.push(effectFn)
    activeEffect = effectFn
    const result = fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1] || null
    return result
  }
  effectFn.scheduler = options.scheduler
  effectFn()
  return effectFn
}

// ==================== reactive ====================
function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const oldValue = target[key]
      const result = Reflect.set(target, key, value, receiver)
      if (oldValue !== value) {
        trigger(target, key)
      }
      return result
    }
  }
  return new Proxy(target, handler)
}

// ==================== ref ====================
function ref(initialValue) {
  const r = {
    get value() {
      track(r, 'value')
      return initialValue
    },
    set value(newValue) {
      if (initialValue !== newValue) {
        initialValue = newValue
        trigger(r, 'value')
      }
    }
  }
  return r
}

// ==================== computed ====================
function computed(getter) {
  let value
  let dirty = true
  const effectFn = effect(getter, {
    scheduler() {
      if (!dirty) {
        dirty = true
        trigger(obj, 'value')
      }
    }
  })
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      track(obj, 'value')
      return value
    }
  }
  return obj
}

// ==================== watch ====================
function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value)) return
  seen.add(value)
  for (const key of Object.keys(value)) {
    traverse(value[key], seen)
  }
  return value
}

function watch(source, callback) {
  let getter = typeof source === 'function' ? source : () => traverse(source)
  let oldValue, newValue
  const effectFn = effect(() => getter(), {
    scheduler() {
      newValue = effectFn()
      callback(newValue, oldValue)
      oldValue = newValue
    }
  })
  oldValue = effectFn()
}
相关推荐
ZC跨境爬虫10 小时前
跟着 MDN 学 HTML day_9:(信件语义标记)
前端·css·笔记·ui·html
前端老石人10 小时前
HTML 字符引用完全指南
开发语言·前端·html
matlab_xiaowang11 小时前
Redux 入门:JavaScript 可预测状态管理库
开发语言·javascript·其他·ecmascript
幼儿园技术家11 小时前
前端如何设计权限系统(RBAC / ABAC)?
前端
前端摸鱼匠12 小时前
Vue 3 的v-bind合并行为:讲解v-bind与普通属性合并的规则
前端·javascript·vue.js·前端框架·ecmascript
REDcker13 小时前
浏览器端Web程序性能分析与优化实战 DevTools指标与工程清单
开发语言·前端·javascript·vue·ecmascript·php·js
donecoding14 小时前
一个 sudo 引发的血案:npm 全局包权限错乱彻底修复
前端·node.js·前端工程化
风骏时光牛马14 小时前
Raku正则匹配与数据批量处理实操案例
前端
nbwenren14 小时前
2026实测:Gemini 3 镜像站视觉能力实践——拍照原型图,一键生成 HTML+CSS 代码
前端·css·html