牛刀小试:Vue 3的响应式系统和Proxy?

牛刀小试:Vue 3的响应式系统和Proxy?

引言

众所周知,Vue3 的响应式系统主要依赖于 ES6 的 Proxy 对象来实现。相比于 Vue2 使用的 Object.definePropertyProxy提供了更强大的功能和更好的性能。但我们在日常使用框架时却很少注意到背后的底层原理,本文就旨在通过重温Vue3的响应式系统,学习和回顾ES6的相关特性。

Proxy概述

ES6 Proxy 是一种新的 JavaScript 功能,它允许你创建一个对象的代理,从而可以拦截和自定义基本操作,例如属性查找、赋值、枚举和函数调用等。Proxy 可以被视为在目标对象之前设置的一层拦截,所有对该对象的访问都必须首先通过这层拦截,这提供了一种机制,可以对外界的访问进行过滤和改写。

语法

const p = new Proxy(target, handler)

target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。

handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

例子

拦截器

这里我们创建了一个get handler,当程序试图访问对象时,如果属性存在于对象,则返回其对象的值,否则返回37。

以下是传递给 get 方法的参数,this 上下文绑定在handler 对象上。

target目标对象。property被获取的属性名。receiver是Proxy 或者继承 Proxy 的对象

jsx 复制代码
const handler = {
  get: function (obj, prop) {
    return prop in obj ? obj[prop] : 37;
  },
};

const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;

console.log(p.a, p.b); // 1, undefined
console.log("c" in p, p.c); // false, 37

无操作转发

在以下例子中,我们使用了一个原生 JavaScript 对象,代理会将所有应用到它的操作转发到这个对象上。

jsx 复制代码
let target = {};
let p = new Proxy(target, {});

p.a = 37; // 操作转发到目标

console.log(target.a); // 37 操作已经被正确地转发

验证功能

通过代理,你可以轻松地验证向一个对象的传值。向Proxy的handler设置相关的setter方法,可以拦截对对象的赋值操作,并确保预期之内的结果。

以下是传递给 set() 方法的参数。this 绑定在 handler 对象上。

target目标对象。

property将被设置的属性名或 Symbol

value新属性值。

receiver最初接收赋值的对象。通常是 proxy 本身,但 handler 的 set 方法也有可能在原型链上,或以其他方式被间接地调用(因此不一定是 proxy 本身)。

jsx 复制代码
// Proxy & setter拦截器

let validator = {
    set: function (obj, prop, value) {
    if (prop === "age") {
      if (!Number.isInteger(value)) {
        throw new TypeError("The age is not an integer");
      }
      if (value > 200) {
        throw new RangeError("The age seems invalid");
      }
    }

    // 保存属性值的默认行为!
    obj[prop] = value;

    // 表示成功
    return true;
  },
}

let person = new Proxy({},validator)

person.age = 50
console.log(person.age)

// TypeError: The age is not an integer
person.age = 11.4

// RangeError: The age seems invalid
person.age = 3000

Reflect概述

ES6 Reflect 是一个内置的对象,它提供了一系列静态方法,用于执行可拦截的 JavaScript 操作。Reflect 并不是一个函数对象,因此它是不可构造的。

Reflect 的设计目的之一是与 Proxy 配合使用。Proxy handler 中的方法(如 get, set)与 Reflect 对象上的方法具有相同的名称和参数。这使得在 Proxy handler 中调用 Reflect 对应的方法来执行默认行为变得非常方便和规范。

主要方法

Reflect 上的所有方法都是静态的,与 Proxy 的 handler 方法一一对应。

  • Reflect.get(target, propertyKey[, receiver]): 获取对象属性的值,类似于 target[propertyKey]
  • Reflect.set(target, propertyKey, value[, receiver]): 设置对象属性的值,类似于 target[propertyKey] = value。它会返回一个布尔值表示是否设置成功。
  • Reflect.has(target, propertyKey): 判断一个对象是否存在某个属性,类似于 propertyKey in target
  • Reflect.deleteProperty(target, propertyKey): 删除对象的属性,类似于 delete target[propertyKey]

为什么与Proxy是最佳搭档?

Proxy 的拦截器中,我们通常需要执行原始操作。直接使用 obj[prop] = value 这样的语法虽然可行,但存在一些问题,尤其是在处理继承和 getter/setter 时。

Reflect 方法提供了执行这些默认操作的标准方式,并能正确处理 this 指向(通过 receiver 参数)。

例子

在这个例子中,我们使用 Reflect.set 来完成属性的赋值。这确保了即使对象有 setterthis 也会正确地指向代理对象 p

jsx 复制代码
const target = {
    _name: 'Guest',
    get name() {
        return this._name;
    },
    set name(val) {
        console.log('Setter called!');
        this._name = val;
    }
};

const handler = {
    set(obj, prop, value, receiver) {
        console.log(`Setting ${prop} to ${value}`);
        // 使用 Reflect.set 来调用原始的 setter,并确保 this 指向 receiver (代理对象 p)
        return Reflect.set(obj, prop, value, receiver);
    }
};

const p = new Proxy(target, handler);

p.name = 'Admin';
// 输出:
// Setting name to Admin
// Setter called!

console.log(p.name); // Admin

使用 Reflect 不仅代码更简洁,而且更健壮,能够正确处理 JavaScript 复杂的内部机制。

手动挡响应式

了解完基本的原理后,我们将"手动挡"开始一步步实现Vue的响应式。

实现单个值的响应式

下面代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 Set 类型的 dep (Dependency 依赖)变量,用来存放需要执行的副作用( effect 函数),即修改 total 值的方法;

② 创建 track() 函数,用来将需要执行的副作用(Effect)保存到 dep 变量中(也称收集副作用);

③ 创建 trigger() 函数,用来执行 dep 变量中的所有副作用;

在每次修改 pricequantity 后,调用 trigger() 函数(扳机,触发器)执行所有副作用后, total 值将自动更新为最新值。

jsx 复制代码
let price = 10, quantity = 2, total = 0;
const dep = new Set(); // ① 
const effect = () => { total = price * quantity };
const track = () => { dep.add(effect) };  // ②
const trigger = () => { dep.forEach( effect => effect() )};  // ③

track();
console.log(`total: ${total}`); // total: 0
trigger();
console.log(`total: ${total}`); // total: 20
price = 20;
trigger();
console.log(`total: ${total}`); // total: 40

实现单个对象的响应式

我们的对象具有多个属性,并且每个属性都需要自己的 dep(Dependency)。

我们将所有副作用保存在一个 Set 集合中,而该集合不会有重复项,这里我们引入一个 Map 类型集合(即 depsMap ),其 key 为对象的属性(如: price 属性), value 为前面保存副作用的 Set 集合(如: dep 对象)

下面的代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 Map 类型的 depsMap 变量,用来保存每个需要响应式变化的对象属性(key 为对象的属性, value 为前面 Set 集合);

② 创建 track() 函数,用来将需要执行的副作用保存到 depsMap 变量中对应的对象属性下(也称收集副作用);

③ 创建 trigger() 函数,用来执行 dep 变量中指定对象属性的所有副作用;

这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。

jsx 复制代码
let product = { price: 10, quantity: 2 }, total = 0;
const depsMap = new Map(); // 1 创建依赖映射表
const effect = () => { total = product.price * product.quantity };
const track = key => {     // 2 查找该属性已有的依赖,若没有则自行创建一个
  let dep = depsMap.get(key);
  if(!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  dep.add(effect);
}

const trigger = key => {  // 3 找到该属性对应的所有依赖,并执行相关的副作用函数
  let dep = depsMap.get(key);
  if(dep) {
    dep.forEach( effect => effect() );
  }
};

track('price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger('price');
console.log(`total: ${total}`); // total: 40

为什么是Map充当依赖映射?

Map与Set的区别:

  • Set只储存唯一的值,只有值没有键,重复值不会被添加。
  • Map存储键值对,存在键值对的映射关系。
jsx 复制代码
// Set 存储唯一值的集合
const effects = new Set(); // 只存储值,不存储键
effects.add(() => console.log('effect1'));
effects.add(() => console.log('effect2'));
effects.add(() => console.log('effect1')); // 重复值不会被添加

// Set 的内部结构
[effect1函数, effect2函数]
// 只有值,没有键

// Map 存储键值对
const depsMap = new Map(); // key -> value 的映射关系
depsMap.set('price', new Set([effect1, effect2]));
depsMap.set('quantity', new Set([effect3]));

// Map 的内部结构
{
  'price' -> Set([effect1, effect2]),
  'quantity' -> Set([effect3])
}
// 有明确的键值对应关系

Set用于储存副作用函数运算,Map用于保存依赖的键值对。

实现多个对象的响应式

下面代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 WeakMap 类型的 targetMap 变量,用来要观察每个响应式对象;

② 创建 track() 函数,用来将需要执行的副作用保存到指定对象( target )的依赖中(也称收集副作用);

③ 创建 trigger() 函数,用来执行指定对象( target )中指定属性( key )的所有副作用;

这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。

jsx 复制代码
let product = { price: 10, quantity: 2 }, total = 0;
const targetMap = new WeakMap();     // 1 初始化 targetMap,保存观察对象
const effect = () => { total = product.price * product.quantity };
const track = (target, key) => {     // 2 收集依赖
  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(effect);
}

const trigger = (target, key) => {  // 3 执行指定对象的指定属性的所有副作用
  const depsMap = targetMap.get(target);
  if(!depsMap) return;
    let dep = depsMap.get(key);
  if(dep) {
    dep.forEach( effect => effect() );
  }
};

track(product, 'price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger(product, 'price');
console.log(`total: ${total}`); // total: 40

"自动挡"的响应式系统:Proxy与Reflect的结合

到目前为止,我们已经建立了一个依赖追踪系统,但它还是"手动挡"的:

  1. 我们需要在代码中手动调用 track() 来收集依赖。
  2. 我们需要在数据更新后手动调用 trigger() 来触发更新。

这显然不够智能。现在,让我们利用 ProxyReflect 将它升级为"自动挡"。我们的目标是:当访问一个对象的属性时,自动执行 track();当修改一个对象的属性时,自动执行 trigger()

第一步:创建 reactive 函数

我们先创建一个 reactive 函数,它接收一个普通对象,并返回一个该对象的代理。所有的操作都将发生在这个代理的 handler 中。

jsx 复制代码
const targetMap = new WeakMap(); // 依赖存储保持不变

// 副作用函数也保持不变
let activeEffect = null; // 我们需要一个变量来存储当前正在运行的副作用函数

function reactive(target) {
  const handler = {
    // get 拦截器:当读取属性时触发
    get(target, key, receiver) {
      console.log(`GET: 访问属性 ${key}`);
      // ... 在这里自动收集依赖
    },
    // set 拦截器:当设置属性时触发
    set(target, key, value, receiver) {
      console.log(`SET: 设置属性 ${key} 为 ${value}`);
      // ... 在这里自动触发更新
    }
  };
  return new Proxy(target, handler);
}

第二步:在 get 拦截器中自动收集依赖

当代码访问代理对象的属性时(例如 product.price),get 拦截器会被触发。这正是我们调用 track() 的时机。

同时,为了让 track 函数知道要收集哪个副作用,我们引入一个全局变量 activeEffect,用于存储当前正在执行的副作用函数。

jsx 复制代码
// track 函数现在需要知道当前激活的副作用是哪个
function track(target, key) {
  if (activeEffect) { // 只有在 activeEffect 存在时才进行追踪
    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); // 收集当前激活的副作用
  }
}

// reactive 函数的 get handler
const handler = {
  get(target, key, receiver) {
    track(target, key); // 自动收集依赖
    // 使用 Reflect.get 返回属性的原始值,确保 this 指向正确
    return Reflect.get(target, key, receiver);
  },
  // ... set handler
};

第三步:在 set 拦截器中自动触发更新

当代码修改代理对象的属性时(例如 product.price = 20),set 拦截器会被触发。这时则调用 trigger()

jsx 复制代码
// trigger 函数保持不变
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}

// reactive 函数的 set handler
const handler = {
  // ... get handler
  set(target, key, value, receiver) {
    // 使用 Reflect.set 设置新值
    const result = Reflect.set(target, key, value, receiver);
    trigger(target, key); // 自动触发更新
    return result; // 返回设置操作是否成功
  }
};

第四步:整合与测试

现在,我们把所有部分整合起来,并创建一个 watchEffect 函数来管理 activeEffect

jsx 复制代码
const targetMap = new WeakMap();
let activeEffect = null;

function track(target, key) {
  if (activeEffect) {
    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;
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      track(target, key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      trigger(target, key);
      return result;
    }
  };
  return new Proxy(target, handler);
}

// watchEffect 用于注册副作用函数
function watchEffect(effect) {
  activeEffect = effect;
  effect(); // 立即执行一次,以触发 get 从而收集依赖
  activeEffect = null;
}

// --- 测试 ---
let product = reactive({ price: 10, quantity: 2 });
let total = 0;

watchEffect(() => {
  // 这个函数现在是我们的副作用
  total = product.price * product.quantity;
});

console.log(`total: ${total}`); // total: 20

// 当我们修改 price 时,不再需要手动调用 trigger
product.price = 20;
console.log(`total: ${total}`); // total: 40 (自动更新!)

// 当我们修改 quantity 时,也同样会自动更新
product.quantity = 3;
console.log(`total: ${total}`); // total: 60 (自动更新!)

现在,我们拥有了一个真正的"自动挡"响应式系统。我们只需要用 reactive() 包裹我们的数据,并用 watchEffect() 注册依赖于这些数据的操作,剩下的依赖收集和触发更新都由 Proxy 自动完成了。

总结

在本文中,我们通过一个循序渐进的过程,亲手实现了一个迷你版的Vue 3响应式系统。让我们回顾一下这个旅程:

  1. 从基础开始 :我们首先理解了响应式的核心概念------当数据变化时,依赖该数据的代码应该自动重新执行。我们用一个简单的 Set 来存储单个依赖(副作用函数),并手动调用 tracktrigger
  2. 支持对象属性 :为了处理对象,我们引入了 Map,建立了从"属性名"到"依赖集合"的映射关系,使得每个属性都能独立追踪自己的依赖。
  3. 支持多个对象 :为了管理多个响应式对象,我们引入了 WeakMap,构建了 targetMap -> depsMap -> dep 的三层依赖存储结构。至此,我们的"手动挡"响应式系统已经成型。
  4. 迈向自动化 :我们认识到手动调用 tracktrigger 的繁琐和不可靠。于是,我们引入了 ES6 的 ProxyReflect
    • Proxy 允许我们拦截对象的 getset 操作。我们在 get 拦截器中自动调用 track,在 set 拦截器中自动调用 trigger
    • Reflect 则作为 Proxy 的最佳搭档,提供了执行对象默认操作的标准方法,确保了操作的健壮性和 this 指向的正确性。

通过这个过程,我们不仅深入理解了Vue 3响应式系统的核心原理,还掌握了 ProxyReflect 这两个强大的JavaScript特性。

参考

相关推荐
走到天涯海角33 分钟前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!35 分钟前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰1 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi2 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen3 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马4 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒4 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端
乘风gg4 小时前
DeepSeek V4.1 Flash 来了,明天中午 Flash 降价 60%
前端·ai编程·claude
默_笙5 小时前
❗ 给金鱼装记忆(上):从白板到笔记本,AI 的短期记忆生存指南
前端·javascript
酷酷的逗逗乐5 小时前
前端转 Agent 开发 · 第五节:Memory 会话记忆
前端·程序员