Skip to content

好的,我们来使用代码片段来辅助理解,让这些核心概念更加具体。

Vue 2 响应式原理深度解析

一、 官方简版响应式原理

Vue 响应式系统的核心是:当数据变化后,会自动执行一些行为(如更新视图)

面试时,若能清晰阐述官方这张图的流程,已经能拿到不错的分数(80-90分)。

核心流程解析

  1. 初始化 - 响应式数据:

    • Vue 内部会遍历 data 对象的所有属性。
    • 通过 Object.defineProperty() 将每个属性转换为带有 gettersetter 的访问器属性。
    • 此时,数据就变成了“响应式”的。
  2. 依赖收集 (Dependency Collection):

    • 首次渲染: 组件的 render 函数被执行,生成虚拟 DOM。
    • 触发 Getter: 在 render 函数执行期间,会读取 data 中的数据(如 this.message),这会触发对应属性的 getter
    • 收集 Watcher: getter 会将当前正在执行的 render 函数所对应的 Watcher 对象记录下来,作为该属性的“依赖”。这个过程称为 依赖收集。简单来说,就是“哪个 Watcher(代表着某个函数)用到了我这个数据,我就把它记下来”。
  3. 派发更新 (Notify):

    • 触发 Setter: 当你修改 data 中的数据时(如 this.message = 'new value'),会触发对应属性的 setter
    • 通知 Watcher: setter 会通知所有之前收集到的 Watcher 对象,“我变了!”。
    • 触发更新: Watcher 接收到通知后,会重新执行它所代表的函数,在这里就是重新执行 render 函数。
  4. 重新渲染:

    • render 函数重新执行,生成新的虚拟 DOM 树。
    • Vue 对比新旧虚拟 DOM 树,计算出最小的变更,并更新到真实 DOM 上。

二、 深入响应式原理 (Observer, Dep, Watcher, Scheduler)

为了在面试中展现更深层次的理解,我们需要了解其内部的四大核心部件。

响应式设计的最终目标

当一个对象本身(如向数组中添加元素)或对象属性发生变化时,能够自动触发一些预设函数(最常见的就是 render 函数,但也可以是 watch 回调等)的执行。

核心部件 1: Observer (观察者)

  • 解决问题: 如何将一个普通的 JavaScript 对象转换成响应式的?
  • 核心职责: 遍历一个对象(包括其嵌套对象)的所有属性,并使用 Object.defineProperty() 将它们转换为带有 gettersetter 的访问器属性。
  • 执行时机: 在组件生命周期的 beforeCreatecreated 钩子函数之间完成。

代码辅助理解:Object.defineProperty 的作用

javascript
// 简化的 Observer 核心逻辑伪代码
function defineReactive(obj, key, val) {
  // 每个属性都有一个自己的依赖管理器
  const dep = new Dep();

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 当读取属性时,进行依赖收集
      // Dep.target 指向的是当前正在执行的 Watcher
      if (Dep.target) {
        dep.depend(); // 让 dep 记住这个 Watcher
      }
      return val;
    },
    set: function reactiveSetter(newVal) {
      if (newVal === val) return;
      val = newVal;
      // 当设置属性时,通知所有依赖此属性的 Watcher 进行更新
      dep.notify();
    }
  });
}

Observer 的实现细节与局限性

  1. 对象属性的动态增删:

    • 问题: Observer 在初始化时遍历属性。如果你在初始化之后向对象添加一个新属性,或者删除一个已有属性,Vue 是 无法监测 到的,因为新属性没有被 Object.defineProperty() 处理。
    • 解决方案:
      • 新增属性: this.$set(object, propertyName, value)Vue.set(...)
      • 删除属性: this.$delete(object, propertyName)Vue.delete(...)
    • 最佳实践: 在 data 中预先声明所有需要用到的属性,即使初始值是 nullundefined
    javascript
    // 错误示范:无法触发视图更新
    this.someObject.newProperty = 'value';
    delete this.someObject.oldProperty;
    
    // 正确示范:可以触发视图更新
    this.$set(this.someObject, 'newProperty', 'value');
    this.$delete(this.someObject, 'oldProperty');
  2. 数组的处理:

    • 问题: Object.defineProperty() 无法直接监听数组的索引赋值(如 arr[0] = 100)或长度变化。
    • 解决方案 (原型拦截): Vue 修改了数组的原型。当你调用如 push, pop, shift, unshift, splice, sort, reverse 等会改变原数组的方法时,你实际上调用的是 Vue 重写后 的方法。这些重写的方法在执行原始逻辑的同时,还会通知 Vue 进行更新。
      • 原型链: 你的数组实例 -> Vue重写的数组原型 -> 原生Array.prototype
    • 局限性:
      • 通过索引直接修改数组项无法被监测: this.arr[0] = 'newValue'
      • 直接修改数组长度无法被监测: this.arr.length = 0
    • 解决方案:
      • 修改数组项: this.$set(this.arr, index, newValue)
      • 注意: 如果数组项本身是对象,修改该对象的属性是响应式的,因为对象本身被 Observer 处理过。this.arr[0].name = 'newName' 可以 触发更新。

核心部件 2: Dep (Dependency - 依赖容器)

  • 解决问题: gettersetter 被触发时,具体应该做什么?谁依赖了我?我变化了该通知谁?
  • 核心职责: 充当一个依赖收集的容器和派发更新的中心。
    • 记录依赖 (depend): 在 getter 被触发时,Dep 对象会收集当前正在执行的 Watcher
    • 派发更新 (notify): 在 setter 被触发时,Dep 对象会通知所有它收集到的 Watcher 去执行更新。
  • 实例关系: 每个 响应式属性(以及对象/数组本身)都有一个自己专属的 Dep 实例。

代码辅助理解:Dep 类的伪代码

javascript
// Dep 是一个可观察对象,可以有多个指令订阅它
class Dep {
  constructor() {
    this.subs = []; // subs 是 subscribers 的缩写,存储所有订阅者(Watcher)
  }

  // 添加订阅者
  addSub(sub) {
    this.subs.push(sub);
  }

  // 依赖收集:当 Dep.target 不为空时,将 target(一个 Watcher)添加到订阅者列表中
  depend() {
    if (Dep.target) {
      Dep.target.addDep(this); // this 是 dep 实例,让 Watcher 记住自己
    }
  }

  // 通知所有订阅者
  notify() {
    // 遍历所有订阅者,并调用它们的 update 方法
    const subs = this.subs.slice(); // 稳定副本
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update();
    }
  }
}

// Dep.target 是一个全局静态属性,用于存放当前正在计算的 Watcher
Dep.target = null;
const targetStack = [];

function pushTarget(target) {
  targetStack.push(Dep.target);
  Dep.target = target;
}

function popTarget() {
  Dep.target = targetStack.pop();
}

核心部件 3: Watcher (观察者)

  • 解决问题: Dep 如何知道是哪个函数在依赖数据?
  • 核心职责: 充当 Dep 和需要更新的函数(如 render 函数)之间的桥梁。
  • 工作机制:
    1. 当一个函数(如组件的 render 函数)要执行前,会先创建一个对应的 Watcher 实例。
    2. Watcher 会将自身设置到一个全局的位置(例如 Dep.target = this)。
    3. 然后 Watcher 再去执行那个函数(render)。
    4. 函数执行过程中,如果用到了响应式数据,就会触发数据的 getter
    5. getter 中的 Dep 对象检查全局的 Dep.target,发现当前有一个 Watcher,于是就将这个 Watcher 收集起来。
    6. 函数执行完毕后,Watcher 会将全局的 Dep.target 清空。
  • 结论: Dep 中收集的依赖,实际上是一个个的 Watcher 实例。数据变化时,Dep 通知 WatcherWatcher 再去执行它所包装的函数。

代码辅助理解:Watcher 类的伪代码

javascript
class Watcher {
  constructor(getter) { // getter 是一个函数,比如 render 函数或 watch 回调
    this.getter = getter;
    this.value = this.get(); // 创建时立即执行一次,进行依赖收集
  }

  // get 方法是核心,它负责依赖收集
  get() {
    pushTarget(this); // 将当前 Watcher 实例设置为全局的 Dep.target
    let value;
    try {
      value = this.getter.call(); // 执行 getter 函数,例如 render()
                                    // 这会触发响应式数据的 getter,从而进行依赖收集
    } finally {
      popTarget(); // 执行完毕,将 Dep.target 恢复到上一个状态
    }
    return value;
  }

  // 当依赖的数据变化时,由 Dep 调用
  update() {
    // this.get(); // 这是简化的调用,实际会通过 Scheduler 调度
    queueWatcher(this); // 将 watcher 推入调度器队列
  }
}

核心部件 4: Scheduler (调度器)

  • 解决问题: 如果在同一个事件循环(tick)中多次修改同一个数据,或者修改多个数据,会不会导致 render 函数被频繁执行多次,造成性能浪费?
  • 核心职责: 批处理和调度 Watcher 的更新。
  • 工作机制:
    1. Watcher 接收到 Dep 的更新通知时,它 不会立即 执行更新。
    2. 它会把自己添加到一个异步更新队列中。这个队列会确保同一个 Watcher 只会被添加一次。
    3. Vue 使用 nextTick 方法,将一个“刷新队列”的任务推入到 微任务队列 (microtask queue) 中。
    4. 在当前同步代码执行完毕后,事件循环会开始处理微任务队列,此时“刷新队列”的任务被执行,队列中所有的 Watcher 才会被依次执行更新。
  • nextTick: 这是 Vue 提供给开发者一个在下次 DOM 更新循环结束之后执行延迟回调的方法。其内部会优先尝试使用 Promise.then 等微任务,如果环境不支持,则降级为 setTimeout 等宏任务。

代码辅助理解:Scheduler 的作用

javascript
// 假设有以下操作
vm.name = 'new name'; // 触发 watcher.update() -> queueWatcher(watcher)
vm.age = 25;          // 触发 watcher.update() -> queueWatcher(watcher)
vm.address = 'new address'; // 触发 watcher.update() -> queueWatcher(watcher)

// 如果没有 Scheduler,render() 会被调用三次,造成浪费
// render();
// render();
// render();

// 有了 Scheduler
// queueWatcher 会将同一个 watcher 只放入队列一次
// 并通过 nextTick 在下一个微任务 tick 中执行队列
nextTick(() => {
  // 在这里,队列中的所有 watcher 被执行,render() 只被调用一次
  flushSchedulerQueue(); // -> 执行 render()
});

三、 响应式系统完整流程图

  1. 初始化:

    • Observer 递归遍历 data,为每个属性及其子属性创建 Dep 实例,并重写 getter/setter
  2. 首次渲染 (依赖收集):

    • 为组件创建一个 render Watcher
    • WatcherDep.target 指向自己,然后执行 render 函数。
    • render 函数中读取数据,触发 getter
    • getter 中的 Dep 实例通过 Dep.target 收集到当前的 render Watcher
    • render 执行完毕,Dep.target 被清空。
  3. 数据变更 (派发更新 & 调度):

    • 代码修改了某个数据,触发其 setter
    • setter 调用其对应 Dep 实例的 notify() 方法。
    • Dep 遍历自己收集的所有 Watcher,并调用 watcher.update()
    • Watcher 接收到更新通知,将自己添加到 Scheduler 的异步更新队列中。
    • Scheduler 通过 nextTick 将一个“刷新队列”的任务放入微任务队列。
  4. 异步执行更新:

    • 当前同步任务执行栈清空。
    • 事件循环从微任务队列中取出“刷新队列”任务并执行。
    • Scheduler 遍历并执行队列中所有 Watcher 的更新(即重新执行 render 函数等)。
    • 重新执行 render 会生成新的 VNode,并触发后续的 Diff 和 Patch 过程,最终更新真实 DOM。这个过程又会进行新一轮的依赖收集。

四、 面试实战测试题

测试题 1

html
<div id="app">
  <h1>{{ message }}</h1>
  <h2>count is: {{ count }}</h2>
  <button @click="changeObj">Change Obj</button>
</div>
javascript
new Vue({
  el: '#app',
  data: {
    message: 'Hello',
    count: 0,
    obj: {
        // a: 'a'  // 假设obj中没有a属性
    }
  },
  methods: {
    changeObj() {
      // 请问下面这行代码会导致视图重新渲染吗?
      this.$set(this.obj, 'a', 'new value');
    }
  }
})
  • 问题: 点击按钮后,视图会重新渲染吗?为什么?
  • 答案:
  • 解析:
    1. 虽然模板中没有直接使用 obj 或者 obj.a,但是 obj 本身是一个响应式对象。Observer 在处理 obj 时,为 obj 对象自身创建了一个 Dep 实例。
    2. render 函数执行期间,虽然没有读取 obj 的属性,但 Vue 的依赖收集机制可能会将 render Watcher 作为 obj 自身的依赖。
    3. this.$set 在为 obj 添加新属性后,会手动触发 obj 自身 Depnotify 方法,通知其所有依赖进行更新。
    4. 因此,render Watcher 会被通知更新,导致整个组件重新渲染。

测试题 2

html
<div id="app">
  <h1 id="title">{{ a }} - {{ b }} - {{ c }} - {{ d }}</h1>
  <button @click="changeAll">Change All</button>
</div>
javascript
new Vue({
  el: '#app',
  data: {
    a: 1, b: 2, c: 3, d: 4
  },
  methods: {
    changeAll() {
      // 1. nextTick 回调先注册
      this.$nextTick(() => {
        const titleEl = document.getElementById('title');
        console.log(titleEl.innerText);
      });

      // 2. 然后修改数据
      this.a = 5;
      this.b = 6;
      this.c = 7;
      this.d = 8;
    }
  }
})
  • 问题: 点击按钮后,控制台会输出什么?

  • 答案: 1 - 2 - 3 - 4

  • 解析:

    1. changeAll 方法被调用,首先执行 this.$nextTick(),将一个回调函数(我们称之为 fn1)添加到 微任务队列 的末尾。
    2. 接着,代码同步执行 this.a = 5。这会导致 render Watcher 被添加到 Scheduler 的更新队列中。Scheduler 再通过 nextTick 将一个“刷新队列”的函数(我们称之为 fn2)也添加到 微任务队列 中。此时微任务队列是 [fn1, fn2]
    3. 代码继续同步执行 this.b = 6, this.c = 7, this.d = 8。由于 render Watcher 已经在更新队列中,所以不会被重复添加。
    4. 同步代码执行完毕。事件循环开始处理微任务队列。
    5. 首先执行 fn1,此时 fn2 还在队列中等待,DOM 还没有被更新,所以获取到的 innerText 仍然是旧值 1 - 2 - 3 - 4
    6. 然后执行 fn2,这会触发组件的重新渲染,DOM 才被更新为 5 - 6 - 7 - 8
  • 结论: nextTick 允许我们在数据变化之后,但在 DOM 更新 之前 执行代码。如果想在 DOM 更新之后执行代码,需要将 nextTick 放在数据修改的 后面