系列文章目录



一、vue 响应式原理

1. Object.defineProperty 的用法(Vue 2 模式)

它的特点是:必须预先知道要监听哪个属性。

const data = { name: 'Alice' }; // 原始数据
const observedData = {};        // 代理后的数据

// 必须循环遍历所有属性(递归执行)
Object.defineProperty(observedData, 'name', {
    get() {
        console.log('检测到读取 name');
        return data.name;
    },
    set(newVal) {
        console.log('检测到修改 name 为:', newVal);
        data.name = newVal;
        // 这里会触发视图更新逻辑(如通知 Watcher)
    }
});

// 测试
console.log(observedData.name); // 输出: 检测到读取 name -> Alice
observedData.name = 'Bob';       // 输出: 检测到修改 name 为: Bob

// ❌ 陷阱演示:动态新增属性
observedData.age = 25; 
// 结果:没有任何日志输出,响应式失效!因为初始化时没有劫持 'age'

2. Proxy 的用法(Vue 3 模式)

它的特点是:拦截整个对象,管家守在大门口,不管谁进出(新增或删除)都能抓到。

const data = { name: 'Alice' };

const proxyData = new Proxy(data, {
    // target 是原对象,key 是属性名
    get(target, key) {
        console.log(`检测到读取属性: ${key}`);
        return target[key];
    },
    set(target, key, newVal) {
        console.log(`检测到修改属性: ${key},新值为: ${newVal}`);
        target[key] = newVal;
        return true; // 表示赋值成功
    },
    deleteProperty(target, key) {
        console.log(`检测到删除属性: ${key}`);
        delete target[key];
        return true;
    }
});

// 测试 1:读取与修改
console.log(proxyData.name); // 输出: 检测到读取属性: name
proxyData.name = 'Bob';       // 输出: 检测到修改属性: name

// ✅ 优势演示 1:动态新增属性
proxyData.age = 25;           // 输出: 检测到修改属性: age(原生支持,无需 $set)

// ✅ 优势演示 2:删除属性
delete proxyData.name;        // 输出: 检测到删除属性: name

// ✅ 优势演示 3:数组操作
const arrProxy = new Proxy([1, 2], {
    set(target, key, val) {
        console.log(`索引 ${key} 变更为 ${val}`);
        target[key] = val;
        return true;
    }
});
arrProxy.push(3); // 输出: 索引 2 变更为 3 (以及更新 length 的日志)

二、Vue响应式陷阱

在 Vue 的开发过程中,所谓的“响应式陷阱”通常是指:你认为数据变了视图就会跳,但实际上 Vue 并没有追踪到这个变化。

由于 Vue 2 和 Vue 3 的底层实现(Object.defineProperty vs Proxy)不同,它们的陷阱也各不相同。

1. Vue 2 的经典陷阱:无法感知“新成员”

Vue 2 使用 Object.defineProperty 在组件初始化时对属性进行劫持。这导致了两个核心限制:

A. 对象新增或删除属性

如果你在 data 初始定义之外给对象添加新属性,Vue 是看不见的。

data() {
  return {
    user: { name: 'Alice' }
  }
},
mounted() {
  this.user.age = 25; // ❌ 视图不会更新,因为 age 没有被初始化劫持
  this.$set(this.user, 'age', 25); // ✅ 正确做法
}

B. 数组的索引和长度修改

直接通过索引修改数组项,或者修改数组长度,Vue 2 也追踪不到。

this.items[0] = 'new'; // ❌ 无效
this.items.length = 0; // ❌ 无效
this.items.splice(0, 1, 'new'); // ✅ 正确做法,Vue 劫持了 push/splice 等方法

2. Vue 3 的新陷阱:响应式的“丢失”

Vue 3 改用 Proxy,完美解决了上述新增属性和数组的问题,但因为解构和赋值的特性,带来了新的陷阱:

A. 响应式解构陷阱 (Destructuring)

这是最常见的错误。如果你对 reactive 对象进行解构,得到的变量会失去响应性。

const state = reactive({ count: 0 });
let { count } = state; // ❌ count 变成了一个普通的数字 0,不再是响应式的
count++; // 视图不会跳

// ✅ 正确做法:使用 toRefs
const { count } = toRefs(state);

B. 整个对象直接赋值

如果你给一个 reactive 定义的对象重新赋值一个新对象,原本的响应式连接会断开。

let state = reactive({ list: [] });
state = { list: [1, 2, 3] }; // ❌ 之前的 Proxy 引用被覆盖了,响应式丢失

// ✅ 正确做法:修改属性或者使用 ref
const state = ref({ list: [] });
state.value = { list: [1, 2, 3] };

C. 原始引用 (Original Reference)

当你把一个原始对象赋值给响应式变量后,操作原始对象是不会触发更新的。

const raw = { a: 1 };
const state = reactive(raw);
raw.a = 2; // ❌ state.a 会变,但视图不一定会跳,因为没经过 Proxy 拦截

三、Vue2 依赖追踪机制 (Reactivity System) 深度解析

Vue 2 的核心逻辑是:数据劫持 + 发布订阅模式。

  • 数据劫持:简单来说,数据劫持就是在访问或修改对象的属性时,拦截这一行为,并在其中触发额外的逻辑(如更新 DOM、收集依赖等)。

  • 发布订阅模式(Publish-Subscribe Pattern,简称 Pub/Sub) :是一种异步的服务间通信模式。它就像一个“中间商”,让消息的发送者(发布者)和消息的接收者(订阅者)互不认识,也能完成信息的传递。

    • Observer (发布者):通过 Setter 触发消息。

    • Watcher(订阅者):负责具体的更新动作。

    • Dep(调度中心):每个属性都有一个 Dep 实例。它像一个中介,负责存储哪些 Watcher 订阅了自己,并在数据变动时统一分发通知。

Vue 2 的响应式系统是其核心灵魂,主要基于 Object.defineProperty 实现。我们可以将其工作机制拆解为“三个核心角色”和“三个关键阶段”。

1. 三个核心角色

  • Observer (发布者):利用 Object.defineProperty 将对象的属性转化为 getter 和 setter,实现 数据拦截

  • Dep (依赖收集器):每个属性都有一个专属的 Dep 实例,负责记录“谁在用我”以及“通知他们更新”。

  • Watcher (订阅者):代表具体的任务(如渲染 Watcher、computed Watcher、user Watcher)。当数据变化时,它是最终执行代码的对象。

简易代码示例 (Vue 2)

// 1. Dep: 管理订阅者
class Dep {
  constructor() {
    this.subs = []; // 存储所有 Watcher
  }
  // 收集依赖
  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target);
    }
  }
  // 通知更新
  notify() {
    this.subs.forEach(sub => sub.update());
  }
}
Dep.target = null; // 全局静态属性,用于指向当前正在计算的 Watcher

// 2. Observer: 拦截数据
function observe(data) {
  if (!data || typeof data !== 'object') return;
  Object.keys(data).forEach(key => {
    let value = data[key];
    const dep = new Dep(); // 每个 key 闭包持有一个 Dep

    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: true,
      get() {
        dep.depend(); // 【重要】getter 阶段:收集当前正在运行的 Watcher
        return value;
      },
      set(newValue) {
        if (newValue === value) return;
        value = newValue;
        dep.notify(); // 【重要】setter 阶段:数据变了,通知 Dep 下的所有 Watcher 更新
      }
    });
  });
}

// 3. Watcher: 执行任务
class Watcher {
  constructor(exp, cb) {
    this.cb = cb;
    Dep.target = this; // 将自己设为当前目标
    // 触发 getter,建立联系
    exp(); 
    Dep.target = null; // 释放
  }
  update() {
    this.cb(); // 执行真正的更新逻辑
  }
}

// 测试
const state = { count: 0 };
observe(state);
new Watcher(() => state.count, () => console.log('视图更新了!'));
state.count = 1; // 输出: 视图更新了!

1.1 Observer(观察者/发布者)

  • 本质:一个构造函数。

  • 职责:它是“装修师傅”。当你把一个普通的 JavaScript 对象传给 Vue 实例的 data 时,Observer 会遍历这个对象的所有属性,用 Object.defineProperty 把它们统统改装成带有 getter 和 setter 的形式。

1.2 Dep(Dependency - 依赖收集器/报社)

  • 本质:一个简单的管理类。

  • 职责:它是“中介”。每一个被 Observer 改装过的属性,内部都会持有一个 Dep 实例。

    • 谁读了这个数据(触发 getter),Dep 就把谁记在小本本上(subs 数组)。

    • 谁改了这个数据(触发 setter),Dep 就翻开小本本,挨个打电话通知。

1.3 Watcher(Watcher - 订阅者/读者)

  • 本质:一个执行监听任务的类。

  • 职责:它是“打工人”。当 Dep 发出通知时,Watcher 负责执行具体的动作(比如更新 DOM、运行你写的 watch 回调)。

2. 三个关键阶段

2.1 初始化阶段:数据劫持 (Data Hijacking)

当 Vue 实例化 时,它会遍历 data 中的所有属性,通过 Observer 进行递归包装。

  • Getter:当访问属性时(例如渲染页面),触发 Getter,此时 Dep 会把当前的 Watcher 收集起来(依赖收集)。

  • Setter:当修改属性时,触发 Setter,此时 Dep 会立即通知所有收集到的 Watcher(派发更新)。

2. 依赖收集阶段 (Dependency Collection)

这发生在 组件挂载(Mount) 期间:

  • Vue 创建一个 渲染 Watcher。

  • Watcher 触发组件的 render 函数。

  • 在执行 render 时,代码会读取 data 中的属性,从而触发 Getter。

  • Getter 内部执行 dep.depend(),将当前的渲染 Watcher 存入该属性的 Dep 数组中。

3. 派发更新阶段 (Dispatching Updates)

当你执行 this.msg = ‘new value’ 时:

  • 触发该属性的 Setter。

  • Setter 调用 dep.notify()。

  • Dep 遍历内部所有的 Watcher,并调用它们的 update() 方法。

  • 异步队列优化:Vue 不会立即更新 DOM,而是将 Watcher 推入一个队列,在下一个 Tick(微任务)中统一执行,避免重复渲染。

四、Vue 3 依赖追踪机制 (Reactivity System) 深度解析

1. 核心设计思想:追踪 (Track) 与 触发 (Trigger)

Vue 3 的响应式是基于 Proxy 实现的。它的基本逻辑是:

  • Track (追踪):当读取(Getter)数据时,记录当前是谁在用这个数据。
  • Trigger (触发):当修改(Setter)数据时,通知所有记录过的人去更新。

2. 全局依赖管理中心:targetMap

Vue 3 使用了一个全局的 WeakMap 来存储所有的依赖关系,结构如下:

  • targetMap (WeakMap): 键是 原始对象,值是 depsMap
  • depsMap (Map): 键是对象的 属性名(key),值是 dep
  • dep (Set): 存储所有依赖该属性的 Effect (副作用函数)

为什么用 WeakMap? > 为了性能和内存优化。当原始对象不再被引用时,WeakMap 会自动让垃圾回收机制清理掉相关的依赖,防止内存泄漏。

// 核心数据结构示意
const targetMap = new WeakMap(); 

/* 结构展开如下:
targetMap (WeakMap): {
  [rawObject]: Map {                // 键是原始对象
    "prop1": Set [effect1, effect2], // 键是属性名,值是副作用函数集合
    "prop2": Set [effect3]
  }
}
*/

  • 第一层 WeakMap: 键是对象引用。使用 WeakMap 是为了防止内存泄漏,一旦对象被销毁,其相关的依赖映射也会自动被回收。

  • 第二层 Map: 键是属性名 (Key)。一个对象有多个属性,每个属性都有自己的依赖池。

  • 第三层 Set: 存储的是 ReactiveEffect 实例(副作用函数)。使用 Set 是为了去重,确保同一个 effect 不会被重复收集。

3. 运行流程图解

  1. 初始化:通过 reactive()ref() 将对象包装成 Proxy。
  2. 收集依赖:执行一个副作用函数(如渲染组件),触发 Proxy 的 get,调用 track() 将该函数存入对应的 dep 集合。
  3. 派发更新:修改属性触发 Proxy 的 set,调用 trigger() 找到对应的 dep 集合,循环执行里面的副作用函数。

4. Vue 2 vs Vue 3 依赖追踪对比

维度 Vue 2 (defineProperty) Vue 3 (Proxy)
拦截方式 拦截属性 (Getter/Setter) 拦截对象 (整个 Object)
存储位置 闭包存储在每个属性中 全局统一管理 (targetMap)
性能 初始化需要递归遍历所有属性 惰性监听:只有访问到深层时才代理
动态性 无法监听新增/删除属性 完美支持动态增删属性

5. 核心伪代码实现 (简易版)

// 存储所有依赖的“大地图”
// 结构为: WeakMap<target, Map<key, Set<effect>>>
const targetMap = new WeakMap();
let activeEffect = null; // 当前正在运行的副作用函数

/**
 * 收集依赖:建立 属性 -> 副作用 的关联
 */
function track(target, key) {
  if (!activeEffect) return; // 如果当前没有副作用运行,直接返回

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map())); // 为每个对象创建一个 Map
  }

  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set())); // 为每个属性创建一个存放 effect 的 Set
  }

  dep.add(activeEffect); // 将当前副作用添加到依赖集合中
}

/**
 * 触发依赖:执行属性关联的所有副作用
 */
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect()); // 运行所有收集到的副作用
  }
}

/**
 * 创建响应式对象
 */
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      track(target, key); // 【核心】读取时收集依赖
      return res;
    },
    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;
    }
  });
}

/**
 * 模拟 Vue 的 watchEffect / mountComponent
 */
function effect(fn) {
  activeEffect = fn;
  fn(); // 立即执行一次,触发 getter 从而完成依赖收集
  activeEffect = null; // 执行完后清空
}

// --- 测试代码 ---
const state = reactive({ count: 0 });

effect(() => {
  console.log('当前计数是:', state.count);
});

state.count = 1; // 输出: 当前计数是: 1

五、vue2中,data中的每个属性都创建了一个dep实例,是如何将属性和dep实例对应上的?dep做的依赖收集是保存在哪里?

在 Vue 2 的响应式系统中,属性与 Dep(Dependency)实例的绑定主要通过 闭包(Closure) 实现,而依赖的存储则是由 Dep 实例内部的数组维护。

以下是深层机制的解析:

1. 属性如何与 Dep 实例对应?(闭包的力量)

在 Vue 2 初始化数据时,会调用 Object.defineProperty。这个过程发生在 defineReactive函数中。

核心逻辑:

  • 每一个属性在执行 defineReactive 时,都会在函数作用域内创建一个私有的 Dep 实例。
  • 由于 getter 和 setter 是在这个作用域内定义的函数,它们形成了闭包,可以一直访问到这个特定的 dep。

简易代码演示:

function defineReactive(obj, key, val) {
  // 1. 为每个属性创建一个专属的 Dep 实例
  // 这个 dep 变量被闭包捕获,与 key 形成一一对应关系
  const dep = new Dep();

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      if (Dep.target) {
        // 2. 依赖收集:将当前的 Watcher 存入该属性对应的 dep
        dep.depend();
      }
      return val;
    },
    set: function reactiveSetter(newVal) {
      if (newVal === val) return;
      val = newVal;
      // 3. 派发更新:通知该属性闭包里的 dep 去执行所有订阅者的 update
      dep.notify();
    }
  });
}

对应关系: 并不是通过一个全局哈希表来对应的,而是通过函数作用域。当你访问 data.name,执行的是 name 闭包里的 getter,操作的就是 name 闭包里的那个 dep。

2. 依赖收集保存在哪里?

依赖(即 Watcher 实例)保存在 Dep 实例内部的 subs 数组中。

数据结构关系图

  • Dep 实例: 每一个 data 属性(或嵌套对象的属性)都有一个 Dep。

  • subs 数组: 它是 Dep 类的一个属性(this.subs = [])。

  • Watcher 实例: 当组件渲染、执行 computed 或 watch 时,会创建一个 Watcher。这个 Watcher 会被推入它所读取到的属性的 subs 数组里。

更多推荐