Skip to content

Vue 2 响应式原理

大白话解释: Vue 2 的响应式就像给每个数据属性"装监控"。当你读取数据时(getter),Vue 记录"谁在用这个数据";当你修改数据时(setter),Vue 通知所有用到这个数据的地方"数据变了,该更新了"。

为什么要理解响应式原理?

  • 排查"数据变了页面没更新"的问题:比如直接给对象添加新属性、修改数组下标,Vue 检测不到
  • 避免常见踩坑:知道哪些操作能触发更新,哪些不能
  • 性能优化:理解依赖收集机制,避免不必要的更新

Object.defineProperty 的局限:

  • 只能监听已有的属性:新增属性需要 $set,删除需要 $delete
  • 无法监听数组下标arr[0] = 'new' 不触发更新
  • 无法监听数组长度arr.length = 0 不触发更新

Vue 2 基于 Object.defineProperty 实现数据响应式。理解其工作原理和局限性,是排查「数据变了页面没更新」问题的关键。

💡 Vue 3 改用 Proxy。Vue 3 的响应式系统基于 ES6 的 Proxy 代理对象,无需递归遍历属性,能直接监听属性的新增/删除、数组索引变化等,彻底解决了 Object.defineProperty 的所有局限。核心 API 变为 reactive()ref()


核心机制

整体流程

new Vue() 初始化

遍历 data 中所有属性

Object.defineProperty 劫持每个属性的 getter/setter

getter 中:收集依赖(Dep.depend)—— 谁在用这个数据
setter 中:触发更新(Dep.notify)—— 通知所有依赖重新渲染

Dep(依赖管理器)

每个响应式属性都有一个 Dep 实例,负责收集和通知依赖。

js
// 简化版 Dep
class Dep {
  constructor() {
    this.subs = [] // 订阅者列表
  }

  addSub(sub) {
    this.subs.push(sub) // 将 watcher 加入订阅列表
  }

  depend() {
    if (Dep.target) {
      Dep.target.addDep(this) // 当前 watcher 订阅这个 dep,建立双向关系
    }
  }

  notify() {
    // 数据变化时,通知所有订阅者(watcher)更新
    this.subs.forEach((sub) => sub.update())
  }
}

Watcher(订阅者)

每个组件实例对应一个渲染 Watcher,computed 和 watch 也各自创建 Watcher。

js
// 简化版 Watcher
class Watcher {
  constructor(vm, expOrFn, cb) {
    this.vm = vm
    this.deps = []
    Dep.target = this  // 标记当前正在求值的 watcher
    this.getter = expOrFn
    this.value = this.get() // 执行 getter,触发属性的 dep.depend()
    Dep.target = null
  }

  get() {
    // 执行 getter(如渲染函数),触发属性的 getter,从而收集依赖
    return this.getter.call(this.vm)
  }

  addDep(dep) {
    this.deps.push(dep) // 记录依赖了哪些 dep
    dep.addSub(this)    // 双向记录:dep 也记录这个 watcher
  }

  update() {
    queueWatcher(this) // 异步更新,放入队列
  }
}

defineReactive(核心函数)

js
// 简化版 defineReactive
function defineReactive(obj, key, val) {
  const dep = new Dep()

  // 递归处理嵌套对象
  if (typeof val === 'object' && val !== null) {
    observe(val)
  }

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      // 收集依赖
      dep.depend()
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      // 新值也是对象,递归观察
      if (typeof newVal === 'object' && newVal !== null) {
        observe(newVal)
      }
      // 触发更新
      dep.notify()
    },
  })
}

Object.defineProperty 的局限

1. 无法检测新增属性

js
// ❌ 新增属性不是响应式的
this.user.email = '[email protected]' // 数据改了,视图不更新

// ✅ 方案一:$set
this.$set(this.user, 'email', '[email protected]')

// ✅ 方案二:提前在 data 中声明
data() {
  return {
    user: { name: '', email: '' } // 预先声明
  }
}

2. 无法检测删除属性

js
// ❌ 删除属性不触发更新
delete this.user.email

// ✅ $delete
this.$delete(this.user, 'email')

// ✅ 或用 lodash
import { omit } from 'lodash-es'
this.user = omit(this.user, ['email'])

3. 无法检测数组索引修改

js
// ❌ 直接设置索引不是响应式的
this.list[0] = 'new value' // 数据改了,视图不更新

// ✅ 方案一:$set
this.$set(this.list, 0, 'new value')

// ✅ 方案二:splice
this.list.splice(0, 1, 'new value')

4. 无法检测数组长度修改

js
// ❌ 不触发更新
this.list.length = 0

// ✅ 用 splice
this.list.splice(0)

数组的响应式处理

Vue 2 重写了以下数组方法,在调用时会触发视图更新:

push()    pop()    shift()    unshift()
splice()  sort()   reverse()

为什么 Vue 2 要重写这些方法?

Object.defineProperty 无法监听数组索引的变化(arr[0] = 'new' 不触发更新)。Vue 2 的解决方案是:重写上述 7 个会修改原数组的方法,在方法内部手动触发依赖更新。这些方法被称为"变异方法"(mutation methods),因为它们直接修改原数组而非返回新数组。

js
// 这些操作都会触发更新
this.list.push({ id: 3, name: '王五' })
this.list.splice(1, 1)
this.list.sort((a, b) => a.id - b.id)
this.list.reverse()

为什么 filter/map/concat 需要重新赋值?

js
// ❌ filter 返回新数组,原数组没变
this.list.filter(item => item.active) // 原 list 不变,不触发更新

// ✅ 重新赋值
this.list = this.list.filter(item => item.active)

💡 pushsplice 等方法是修改原数组,Vue 能拦截;filtermap返回新数组,需要替换引用。


异步更新队列

Vue 2 的 DOM 更新是异步的。数据变化后,DOM 不会立即更新,而是将更新放入一个队列,在同一个事件循环中的所有数据变化完成后,统一批量更新。

js
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    updateCount() {
      this.count = 1
      // ❌ DOM 还是旧的
      console.log(this.$el.textContent) // 0

      this.count = 2
      this.count = 3
      // 三次赋值只触发一次 DOM 更新(批量优化)

      // ✅ 等待 DOM 更新完成
      this.$nextTick(() => {
        console.log(this.$el.textContent) // 3
      })
    },
  },
}

$nextTick 原理

js
// 内部优先使用微任务(Promise.then > MutationObserver),降级为宏任务(setImmediate > setTimeout)
// 注意:注释中 Promise.then 优先级最高是因为微任务执行时机早于宏任务,
// 且在 Vue 2.5+ 中明确优先使用 Promise.then 而非 MutationObserver
const callbacks = []
let pending = false

function nextTick(cb) {
  callbacks.push(cb)
  if (!pending) {
    pending = true
    Promise.resolve().then(() => {
      const copies = callbacks.slice()
      callbacks.length = 0
      copies.forEach((fn) => fn())
      pending = false
    })
  }
}

深度监听的性能问题

js
data() {
  return {
    deepObj: {
      a: { b: { c: { d: 1 } } },
      list: new Array(1000).fill({ name: '' }),
    },
  }
},

watch: {
  // ❌ deep: true 会递归遍历所有属性,性能开销大
  deepObj: {
    handler(newVal) { console.log('变化了') },
    deep: true,
  },

  // ✅ 精确监听某个深层属性
  'deepObj.a.b.c.d'(newVal) { console.log('变化了') },

  // ✅ 只关心引用变化,不关心内部变化
  deepObj(newVal) { console.log('引用变了') },
}

手写简易响应式系统

js
// 完整的简化版响应式实现
const targetMap = new WeakMap()
let activeEffect = null

// 依赖收集
function track(target, key) {
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    depsMap = new Map()
    targetMap.set(target, depsMap)
  }
  let deps = depsMap.get(key)
  if (!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }
  deps.add(activeEffect)
}

// 触发更新
function trigger(target, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const deps = depsMap.get(key)
  if (deps) {
    deps.forEach((effect) => effect())
  }
}

// 响应式代理
function defineReactive(obj, key) {
  let val = obj[key]
  const dep = new Set()

  Object.defineProperty(obj, key, {
    get() {
      if (activeEffect) dep.add(activeEffect)
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      dep.forEach((fn) => fn())
    },
  })
}

// 副作用函数
function effect(fn) {
  activeEffect = fn
  fn()
  activeEffect = null
}

// 使用示例
const state = { count: 0 }
defineReactive(state, 'count')

effect(() => {
  console.log('count is:', state.count)
})

state.count = 1 // 输出: count is: 1
state.count = 2 // 输出: count is: 2

常见坑点速查表

问题原因解决方案
新增属性页面不更新defineProperty 无法检测this.$set(obj, key, value)
删除属性页面不更新defineProperty 无法检测this.$delete(obj, key)
数组某项改了页面没变索引赋值不触发splice()$set
对象深层属性不更新只劫持第一层deep: true$set
data 中未声明的属性初始化时未被劫持提前在 data 中声明所有属性
页面不更新但数据变了DOM 异步更新this.$nextTick()

调试技巧

js
// 监听响应式依赖变化
this.$watch(
  'someData',
  (newVal, oldVal) => {
    console.log('变化:', oldVal, '→', newVal)
  },
  { deep: true }
)

// 使用 Vue Devtools 查看组件数据
// 在浏览器中打开 Vue Devtools → 选择组件 → 查看 data/computed/watch

// 检查属性是否响应式
console.log(Object.getOwnPropertyDescriptor(this.$data, 'count'))
// 如果有 getter/setter,说明是响应式的

参考

个人学习笔记,部分内容借助 AI 辅助整理,仅供查阅参考,请以官方文档为准