浅谈Vue 响应式原理

为啥要使用Vue呢?还不是它有一个 响应式 吗,可以让数据变化自动驱动视图更新,能让我们在开发过程中无需手动操作 DOM,只需专注于数据逻辑。

接下来我们将进一步理解一下响应式原理,不仅能解决开发中的 "数据不更新" 等常见问题,更能深入掌握 Vue 的设计思想。

来吧,开始吧!

一、响应式的核心:解决两个关键问题

在理解具体实现前,需先明确响应式系统的核心目标 ------ 解决两个问题:

  1. "谁依赖了数据" :识别哪些视图 / 逻辑(如组件模板、computedwatch)依赖了某个响应式数据(依赖收集);
  2. "数据变了通知谁" :当响应式数据修改时,自动通知所有依赖它的视图 / 逻辑执行更新(触发更新)。

这两个问题的解决流程,构成了响应式系统的核心闭环:
初始化响应式数据 → 渲染时收集依赖 → 数据修改时触发更新 → 依赖执行更新(如视图重渲染)

二、Vue 2 响应式实现:基于 Object.defineProperty

Vue 2 的响应式系统核心是 ES5 提供的 Object.defineProperty 方法,它能 "劫持" 对象属性的读取(get修改(set 操作,从而实现数据追踪与更新通知。

1. 三步实现核心逻辑

Vue 2 对 data 中的数据执行以下三步处理,使其具备响应式能力:

步骤 1:数据劫持(初始化响应式)

当组件初始化时,Vue 会遍历 data 中的所有属性,通过 Object.defineProperty 为每个属性添加 getter(读取属性时触发)和 setter(修改属性时触发)。

同时,为了支持嵌套对象(如 data.user.name),会递归处理所有子属性。

Vue 复制代码
// Vue 2 核心逻辑简化(仅示意)
function defineReactive(obj, key, value) {
  // 递归处理嵌套对象
  observe(value);

  // 为属性添加 getter/setter
  Object.defineProperty(obj, key, {
    enumerable: true, // 允许遍历
    configurable: true, // 允许修改属性描述符
    get() {
      console.log(`读取属性 ${key}:${value}`);
      // 步骤 2:依赖收集(后续展开)
      collectDependency(); 
      return value;
    },
    set(newValue) {
      if (newValue === value) return; // 值未变,跳过更新
      console.log(`修改属性 ${key}:${newValue}`);
      // 递归处理新值(若新值是对象,需继续劫持)
      observe(newValue);
      value = newValue;
      // 步骤 3:触发更新(后续展开)
      triggerUpdate(); 
    }
  });
}

// 遍历对象,为所有属性添加响应式
function observe(obj) {
  // 仅对对象/数组生效(基础类型无需劫持)
  if (typeof obj !== 'object' || obj === null) return;
  // 遍历对象属性,执行 defineReactive
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key]);
  });
}

// 示例:将 data 转为响应式
const data = { count: 0, user: { name: '张三' } };
observe(data);

此时,读取 / 修改 data 的属性会触发 getter/setter

  • data.count → 触发 getter,打印 "读取属性 count:0";
  • data.count = 1 → 触发 setter,打印 "修改属性 count:1",并执行后续更新逻辑。

步骤 2:依赖收集(记录 "谁用了数据")

光有数据劫持还不够 ------Vue 需要知道 "哪些视图依赖了这个数据",才能在数据变化时精准通知。这一步的核心是 Dep(依赖管理器)Watcher(观察者)

  • Dep :每个响应式属性对应一个 Dep,用于存储所有依赖该属性的 Watcher
  • Watcher :代表一个 "依赖"(如组件模板、computedwatch),当数据变化时,Watcher 会执行更新逻辑(如重新渲染组件)。

依赖收集流程

  1. 组件渲染时,Vue 会为组件创建一个 Watcher,并标记为 "当前活跃的 Watcher";
  2. 渲染过程中读取响应式属性,触发 getter
  3. getter 中,Dep 会将 "当前活跃的 Watcher" 加入依赖列表;
  4. 渲染完成后,清除 "当前活跃的 Watcher" 标记。
vue 复制代码
// 依赖管理器:存储并通知 Watcher
class Dep {
  constructor() {
    this.watchers = []; // 存储依赖当前属性的 Watcher
  }

  // 添加 Watcher 到依赖列表
  addWatcher(watcher) {
    this.watchers.push(watcher);
  }

  // 通知所有 Watcher 执行更新
  notify() {
    this.watchers.forEach(watcher => watcher.update());
  }
}

// 观察者:代表一个依赖(如组件渲染)
class Watcher {
  constructor(updateFn) {
    this.updateFn = updateFn; // 数据变化时的更新逻辑
  }

  // 执行更新
  update() {
    this.updateFn();
  }
}

// 改造 defineReactive,加入依赖收集
function defineReactive(obj, key, value) {
  observe(value);
  const dep = new Dep(); // 每个属性对应一个 Dep

  Object.defineProperty(obj, key, {
    get() {
      // 若存在当前活跃的 Watcher,加入 Dep
      if (Dep.target) { 
        dep.addWatcher(Dep.target);
      }
      return value;
    },
    set(newValue) {
      if (newValue === value) return;
      observe(newValue);
      value = newValue;
      dep.notify(); // 通知所有 Watcher 更新
    }
  });
}

// 标记当前活跃的 Watcher(Dep 静态属性)
Dep.target = null;
function mountComponent(updateFn) {
  const watcher = new Watcher(updateFn);
  Dep.target = watcher; // 标记当前 Watcher
  updateFn(); // 执行渲染(触发 get 收集依赖)
  Dep.target = null; // 清除标记
}

// 示例:模拟组件渲染
mountComponent(() => {
  console.log('组件渲染:count =', data.count);
});

// 修改数据:触发 set → Dep 通知 Watcher → 组件重新渲染
data.count = 1; // 输出 "组件渲染:count = 1"

步骤 3:触发更新(视图同步)

当响应式属性被修改时(触发 setter),Dep 会调用 notify() 方法,遍历所有依赖的 Watcher 并执行 update()

对于组件模板对应的 Watcherupdate() 会触发组件重新渲染:Vue 会重新编译模板、生成虚拟 DOM、对比新旧虚拟 DOM(Diff 算法),最终只更新需要变化的 DOM 节点,避免全量重绘,提升性能。

2. Vue 2 响应式的局限性

由于 Object.defineProperty 本身的设计限制,Vue 2 响应式系统存在三个明显缺陷,需通过额外 API 规避:

  1. 无法监听对象新增 / 删除属性Object.defineProperty 只能劫持已存在的属性,若给对象新增属性(如 data.user.age = 18)或删除属性(如 delete data.user.name),无法触发 setter/getter,需用 Vue.set(obj, key, value)Vue.delete(obj, key) 手动触发响应式;
  2. 无法监听数组的部分修改pushpopsplice 等数组方法修改数组时,不会触发 setter,Vue 2 只能通过 "重写数组原型方法" 的方式解决(如 Array.prototype.push = function() { ... }),但直接修改数组索引(如 data.arr[0] = 1)仍无法触发响应式;
  3. 初始化时需遍历所有属性 :对于大型对象,遍历所有属性并添加 getter/setter 会消耗一定性能,且嵌套对象需递归处理,增加初始化成本。

三、Vue 3 响应式实现:基于 Proxy

为解决 Vue 2 的局限性,Vue 3 采用 ES6 提供的 Proxy 重构了响应式系统。Proxy 可以直接 "代理" 整个对象(而非单个属性),支持拦截更多操作(如新增 / 删除属性、数组修改),功能更强大且代码更简洁。

1. Proxy 是什么?

Proxy 是 ES6 原生对象,它能创建一个 "代理对象",拦截对目标对象的所有操作 (如读取、修改、新增、删除属性,甚至函数调用),并自定义这些操作的行为。相比 Object.definePropertyProxy 的核心优势是 "代理整个对象",无需遍历属性。

vue 复制代码
// Proxy 基础用法示例
const target = { count: 0, arr: [1, 2] };

// 创建代理对象,定义拦截器
const proxy = new Proxy(target, {
  // 拦截属性读取(对应 get)
  get(target, key) {
    console.log(`读取属性 ${key}:${target[key]}`);
    return target[key];
  },
  // 拦截属性修改/新增(对应 set)
  set(target, key, value) {
    console.log(`修改/新增属性 ${key}:${value}`);
    target[key] = value;
    return true; // 必须返回 true,标识操作成功
  },
  // 拦截属性删除(Vue 2 无法做到)
  deleteProperty(target, key) {
    console.log(`删除属性 ${key}`);
    delete target[key];
    return true;
  }
});

// 操作代理对象,触发拦截器
proxy.count; // 读取:"读取属性 count:0"
proxy.count = 1; // 修改:"修改/新增属性 count:1"
proxy.age = 18; // 新增:"修改/新增属性 age:18"(自动拦截)
delete proxy.age; // 删除:"删除属性 age"(自动拦截)
proxy.arr.push(3); // 数组修改:"读取属性 arr:[1,2]" → "修改/新增属性 length:3"(自动拦截)

2. Vue 3 响应式核心逻辑

Vue 3 响应式的核心流程与 Vue 2 一致(依赖收集→触发更新),但用 Proxy 替代 Object.defineProperty,并通过 effect 函数替代 Watcher,简化了代码且解决了局限性。

步骤 1:创建响应式对象(reactive 函数)

Vue 3 提供 reactive 函数,用于将普通对象转为响应式对象 ------ 本质是通过 Proxy 为对象创建代理,并在拦截器中加入依赖收集和更新通知逻辑。

vue 复制代码
// Vue 3 核心逻辑简化(仅示意)
const targetMap = new WeakMap(); // 存储目标对象的依赖映射:target → key → Dep

// 1. 依赖管理器(与 Vue 2 类似,用 Set 避免重复依赖)
class Dep {
  constructor() {
    this.subscribers = new Set();
  }

  // 收集依赖
  depend() {
    if (currentEffect) {
      this.subscribers.add(currentEffect);
    }
  }

  // 触发更新
  notify() {
    this.subscribers.forEach(effect => effect());
  }
}

// 2. 获取属性对应的 Dep(不存在则创建)
function getDep(target, key) {
  // 第一层:target → depsMap(key 到 Dep 的映射)
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  // 第二层:key → Dep
  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Dep();
    depsMap.set(key, dep);
  }

  return dep;
}

// 3. 创建响应式对象(核心:Proxy 代理)
function reactive(target) {
  return new Proxy(target, {
    get(target, key) {
      const dep = getDep(target, key);
      dep.depend(); // 收集依赖
      const value = target[key];
      // 递归处理嵌套对象(返回嵌套对象的代理)
      if (typeof value === 'object' && value !== null) {
        return reactive(value);
      }
      return value;
    },
    set(target, key, value) {
      const dep = getDep(target, key);
      target[key] = value;
      dep.notify(); // 触发更新
      return true;
    },
    deleteProperty(target, key) {
      const dep = getDep(target, key);
      delete target[key];
      dep.notify(); // 触发更新
      return true;
    }
  });
}

步骤 2:依赖收集(effect 函数)

Vue 3 用 effect 函数替代 Vue 2 的 Watcher,代表一个 "副作用函数"(如组件渲染、watch 回调)。当执行 effect 时,会自动收集依赖:

vue 复制代码
let currentEffect = null; // 当前活跃的副作用函数

// 注册副作用函数
function effect(effectFn) {
  currentEffect = effectFn;
  effectFn(); // 执行副作用(触发 get 收集依赖)
  currentEffect = null;
}

// 示例:创建响应式对象 + 注册副作用(模拟组件渲染)
const proxyData = reactive({ count: 0, arr: [1, 2] });

// 注册组件渲染副作用
effect(() => {
  console.log('组件渲染:count =', proxyData.count);
  console.log('组件渲染:arr =', proxyData.arr);
});

// 修改数据:自动触发更新
proxyData.count = 1; // 输出 "组件渲染:count = 1" + "组件渲染:arr = [1,2]"
proxyData.arr.push(3); // 输出 "组件渲染:count = 1" + "组件渲染:arr = [1,2,3]"
proxyData.newKey = 'newValue'; // 新增属性,触发更新:输出渲染日志
delete proxyData.newKey; // 删除属性,触发更新:输出渲染日志

3. Vue 3 响应式的优势

相比 Vue 2,Vue 3 基于 Proxy 的响应式系统解决了所有局限性,且性能更优:

  1. 天然支持对象新增 / 删除属性Proxyset 拦截器能监听新增属性,deleteProperty 拦截器能监听删除属性,无需手动调用 Vue.set
  2. 完美支持数组所有修改方式 :无论是 push/splice 等方法,还是直接修改索引(如 proxy.arr[0] = 1),都能触发 set 拦截器,无需重写数组原型;
  3. 延迟初始化,性能更优Proxy 代理整个对象时,无需遍历所有属性,只有当属性被读取时才会递归处理嵌套对象,减少初始化成本,尤其适合大型对象;
  4. 支持更多数据类型 :除了对象 / 数组,Proxy 还能代理 MapSet 等复杂数据类型,Vue 3 也针对性提供了 reactive 适配。

四、Vue 3 响应式的补充:refreactive 的区别

在 Vue 3 开发中,我们常同时使用 refreactive 创建响应式数据,二者的核心区别在于处理的数据类型不同

  • reactive :用于处理对象 / 数组 类型的数据,返回的是代理对象,访问时无需 .value
  • ref :用于处理基础类型 (如 stringnumberboolean),返回的是 "包装对象",访问 / 修改时需通过 .value 属性(模板中使用时可省略 .value)。

本质ref 内部其实是通过 reactive 实现的 ------ref(0) 等价于 reactive({ value: 0 }),只是为了简化基础类型的响应式操作,封装了 .value 访问逻辑。

vue 复制代码
<template>
  <!-- 模板中使用 ref 无需 .value -->
  <div>{{ countRef }} - {{ userReactive.name }}</div>
  <button @click="handleUpdate">修改数据</button>
</template>

<script setup>
import { ref, reactive } from 'vue';

// 基础类型用 ref,需 .value 访问
const countRef = ref(0);
// 对象类型用 reactive,直接访问属性
const userReactive = reactive({ name: '张三' });

const handleUpdate = () => {
  countRef.value = 1; // 修改 ref 数据需加 .value
  userReactive.name = '李四'; // 修改 reactive 数据直接操作属性
};
</script>

使用场景建议

  • 若明确处理基础类型 (如计数器、开关状态),优先用 ref,语法更简洁;
  • 若处理复杂对象 / 数组 (如用户信息、列表数据),优先用 reactive,避免多层 .value 嵌套(如 ref({ user: { name: '张三' } })refData.value.user.name 访问,不如 reactive 直接)。

五、响应式系统的常见问题与解决方案

理解原理后,还需能解决开发中 "数据不更新" 的常见问题,这些问题本质都是 "依赖未正确收集" 或 "更新未触发"。

1. 问题 1:Vue 2 中对象新增属性不响应

现象 :给 data 中的对象新增属性时,视图不更新:

vue 复制代码
// Vue 2 代码
data() {
  return {
    user: { name: '张三' } // 初始无 age 属性
  };
},
methods: {
  addAge() {
    this.user.age = 18; // 新增属性,视图不更新
  }
}

原因 :Vue 2 初始化时仅对 data 中已存在的属性(如 user.name)添加 getter/setter,新增的 user.age 未被劫持,无法触发更新。
解决方案

  • Vue.set 手动触发响应式:this.$set(this.user, 'age', 18)
  • 初始化时提前声明属性:user: { name: '张三', age: undefined }

2. 问题 2:Vue 2 中数组修改不响应

现象:直接修改数组索引或长度时,视图不更新:

vue 复制代码
// Vue 2 代码
data() {
  return {
    list: [1, 2, 3]
  };
},
methods: {
  updateList() {
    this.list[0] = 10; // 直接修改索引,视图不更新
    this.list.length = 2; // 修改长度,视图不更新
  }
}

原因 :Vue 2 未拦截数组的索引修改和长度修改操作,仅重写了 push/pop/splice 等 7 个原型方法。
解决方案

  • 使用 Vue 2 重写的数组方法:this.list.splice(0, 1, 10)(替换索引 0 的值);
  • Vue.set 修改索引:this.$set(this.list, 0, 10)
  • (推荐)直接替换数组:this.list = [10, 2, 3](新数组会被重新劫持)。

3. 问题 3:Vue 3 中 ref 数据在模板外未加 .value

现象 :在 script 中修改 ref 数据时未加 .value,视图不更新:

vue 复制代码
// Vue 3 代码(错误)
<script setup>
import { ref } from 'vue';
const count = ref(0);

const updateCount = () => {
  count = 1; // 未加 .value,修改的是普通变量,不是响应式数据
};
</script>

原因ref 返回的是 "包装对象",响应式数据存储在 value 属性中,直接修改 count 变量会丢失响应式关联。
解决方案 :修改时必须加 .valuecount.value = 1

4. 问题 4:响应式数据被 "解构" 后丢失响应式

现象 :解构 reactiveref 数据后,修改解构变量不触发更新:

vue 复制代码
// Vue 3 代码(错误)
<script setup>
import { reactive, ref } from 'vue';

// 1. 解构 reactive 数据
const user = reactive({ name: '张三' });
const { name } = user; // 解构后 name 是普通字符串,无响应式
name = '李四'; // 视图不更新

// 2. 解构 ref 数据(未加 .value)
const count = ref(0);
const { value: countVal } = count; // 解构出普通数值
countVal = 1; // 视图不更新
</script>

原因

  • 解构 reactive 数据时,会将属性值 "解包" 为普通变量,失去与原响应式对象的关联;

  • 解构 ref 数据时,value 是响应式的,但直接赋值给普通变量后,修改变量无法触发原 ref 的更新。
    解决方案

  • 避免解构 reactive 数据,直接通过原对象访问:user.name = '李四'

  • 若需解构 ref 数据,可使用 toRefs(将 reactive 对象转为 ref 集合):

    vue 复制代码
    import { reactive, toRefs } from 'vue';
    const user = reactive({ name: '张三' });
    const { name } = toRefs(user); // name 是 ref 类型,需 .value 修改
    name.value = '李四'; // 视图更新

六、响应式原理的核心总结

无论是 Vue 2 还是 Vue 3,响应式系统的核心逻辑始终围绕 "依赖收集 " 和 "触发更新",差异仅在于 "数据劫持方式":

维度 Vue 2(Object.defineProperty) Vue 3(Proxy)
劫持粒度 单个属性(需遍历对象) 整个对象(无需遍历)
支持数据类型 对象、数组(需特殊处理) 对象、数组、Map、Set 等
对象新增 / 删除属性 不支持(需 Vue.set/Vue.delete) 天然支持(拦截 set/deleteProperty)
数组索引 / 长度修改 不支持(需 splice 或替换数组) 天然支持(拦截 set)
初始化性能 较差(需递归遍历所有属性) 较好(延迟递归,读取时处理)

开发建议

  • 若使用 Vue 2,需牢记 "对象新增属性用 $set、数组修改用重写方法" 的规则;
  • 若使用 Vue 3,优先用 ref 处理基础类型、reactive 处理对象类型,避免解构导致的响应式丢失;
  • 无论哪个版本,都应避免 "在响应式数据中存储非响应式内容"(如函数、DOM 元素),以免影响性能或导致异常。
相关推荐
货拉拉技术5 小时前
微前端中的错误堆栈问题探究
前端·javascript·vue.js
前端老鹰5 小时前
HTML `<datalist>`:原生下拉搜索框,无需 JS 也能实现联想功能
前端·css·html
南北是北北5 小时前
Android TexureView和SurfaceView
前端·面试
code_YuJun5 小时前
脚手架架构设计
前端
pepedd8645 小时前
WebAssembly简单入门
前端·webassembly·trae
ze_juejin6 小时前
JavaScript 的基本数据类型
前端
喜葵6 小时前
前端安全防护深度实践:从XSS到CSRF的完整安全解决方案
前端·安全·xss
满分观察网友z6 小时前
JavaScript 算法探秘:如何优雅地打印一个“回文”数字金字塔(从入门到高阶)
前端
恋猫de小郭6 小时前
Flutter 真 3D 游戏引擎来了,flame_3d 了解一下
android·前端·flutter