第7章 7.3 Proxy 与 Reflect

🎯 上一章我们干了啥

上一章我们学了闭包高阶函数,学会了把函数当成变量传来传去,还记得那个「计数器工厂」吗?makeCounter() 每次调用都能生成一个独立的计数器。这就是闭包的威力——函数能「记住」自己出生时的环境。

但你有没有想过这个问题:我想在每次读取对象属性时自动做点事情?比如记录谁在访问、验证数据是否合法、自动更新界面……用原生对象,你能做到吗?

做不到。 普通对象就像一个密封的保险箱,你放进去、拿出来,里面发生了什么你一无所知。

这一章我们要学的 ProxyReflect,就是给你的保险箱装一个透明的监控摄像头。你依然可以正常存取,但所有操作都会被拦截、记录、甚至被修改。

学完这一章,你就能理解 Vue 3 的响应式原理是怎么回事了——为什么 msg = 'hello' 能自动更新页面?答案就在这里。


🎯 为什么要学这个?

真实场景:你在做一个在线表格

假设你在做一个「班级\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n成绩管理系统」,有个需求:任何同学修改了自己的成绩,系统就要自动发一条通知给班主任

用普通对象,你会怎么写?

const student = { name: '张小明', score: 85 };
student.score = 90;  // 怎么自动通知?
console.log(student.score);

你发现没?改完就改完了,没有任何钩子可以让你介入。你只能在业务代码里手动调用 notify(),散落在各处,后期维护灾难。

痛点总结

  1. 无法拦截对象操作——改属性、删属性时想做点别的事,做不到
  2. 无法验证数据——用户随便传个 -100 分,你拦不住
  3. 无法实现自动响应——改一个值,界面自动更新,Vue 3 怎么做到的?

学完 Proxy,这些问题一行代码就能解决。


🧱 基础 25 分钟:核心概念

概念一:Proxy 是什么

类比时间:Proxy 就像房产中介

你想租房子,正常流程是:你自己去找房东、谈价格、签合同。但有了中介之后,你把所有需求告诉中介,中介帮你处理一切,房东不知道你,你也不知道房东具体是谁。

在代码里:
- 房东 = 原始对象(target)
- 中介 = Proxy 对象
- = 使用代码

所有对房子的操作(查看、修改、删除)都经过中介,中介可以:
- 让你看之前先整理一下(get 拦截)
- 让你改之前先审核一下(set 拦截)
- 直接拒绝你的请求(throw 错误)

最简单的 Proxy 长这样

const person = { name: '张小明', age: 18 };

const personProxy = new Proxy(person, {
get(target, property) {
console.log(`有人读取了 ${property}`);
return target[property];
},
set(target, property, value) {
console.log(`有人修改了 ${property} 为 ${value}`);
target[property] = value;
return true;
}
});

console.log(personProxy.name);   // 输出: 有人读取了 name  再输出: 张小明
personProxy.age = 20;            // 输出: 有人修改了 age 为 20
console.log(personProxy.age);    // 输出: 有人读取了 age  再输出: 20

代码解读:
- new Proxy(原始对象, 拦截器配置) 创建代理
- get(target, property) 拦截读取操作
- set(target, property, value) 拦截写入操作

为什么要用 Reflect

问题来了:上面代码里,getset 里面我们直接 return target[property],这不也挺好吗?为什么要引入 Reflect?

因为 Proxy 只是拦截,Reflect 才是「默认行为」的标准实现。

想象一下:中介拦截了你的请求,但中介也可以说「我不管,你自己处理」。Reflect 就是这个「你自己处理」的语法糖。

const person = { name: '张小明', age: 18 };

const personProxy = new Proxy(person, {
get(target, property, receiver) {
console.log(`读取 ${property},我先查一下...`);
// 以前手动写:
// return target[property];

// 现在用 Reflect(更标准、更强大):
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
console.log(`写入 ${property}=${value},我来帮你交办...`);
return Reflect.set(target, property, value, receiver);
}
});

console.log(personProxy.name);
personProxy.age = 20;

Reflect 的好处:
1. 语义更清晰——Reflect.get 就是「获取属性」的标准做法
2. 支持第三个参数 receiver——处理继承链时必备
3. 返回一个布尔值——告诉你操作是否成功

核心拦截器一览

Proxy 支持 13 种拦截操作,最常用的 5 种:

拦截器 触发时机 典型用法
get 读取属性 obj.prop 数据验证、默认值
set 写入属性 obj.prop = val 响应式通知、数据验证
has in 运算符 'prop' in obj 控制哪些属性可被发现
deleteProperty delete obj.prop 阻止删除、保护数据
apply 函数调用 obj() 日志、访问控制

实战:数据验证的 Proxy

想象你有个用户对象,年龄不能是负数,邮箱必须包含 @

function createValidatedUser(data) {
return new Proxy(data, {
get(target, prop, receiver) {
  return Reflect.get(target, prop, receiver);
},
set(target, prop, value) {
  // 年龄不能是负数
  if (prop === 'age' && value < 0) {
    throw new TypeError('年龄不能是负数!');
  }
  // 邮箱必须包含 @
  if (prop === 'email' && !value.includes('@')) {
    throw new TypeError('邮箱格式不对!');
  }
  return Reflect.set(target, prop, value);
}
});
}

const user = createValidatedUser({ name: '李小花', age: 20, email: 'lixiaohua@163.com' });
console.log(user.name);           // 正常输出: 李小花

user.age = -5;                    // 报错: TypeError: 年龄不能是负数!
user.email = 'invalid-email';     // 报错: TypeError: 邮箱格式不对!

user.age = 25;                    // 正常
console.log(user.age);            // 输出: 25

这就是数据验证的威力,所有验证逻辑集中在 Proxy 内部,业务代码无需关心。

概念二:Vue 3 的响应式原理(选读)

如果你用过 Vue 3,可能会好奇:为什么 message = 'hello' 能自动更新界面?

答案就是 Proxy:

// Vue 3 响应式的简化版原理
function reactive(obj) {
return new Proxy(obj, {
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;
}
});
}

// 使用
const state = reactive({ message: 'hello' });
state.message = 'world';  // 自动触发界面更新

现在你明白了——Vue 3 就是用 Proxy 拦截了所有赋值操作,然后在 set 里通知界面更新。


🔥 实战 35 分钟:3 个递进项目

项目 1(5 分钟):「属性访问日志器」

目标:创建一个对象,所有读写操作都打印日志。

function createLoggedObject(target) {
return new Proxy(target, {
get(target, prop, receiver) {
  console.log(`📖 读取属性: ${prop},值是: ${target[prop]}`);
  return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
  console.log(`📝 写入属性: ${prop},旧值: ${target[prop]} → 新值: ${value}`);
  return Reflect.set(target, prop, value, receiver);
}
});
}

// 用起来
const config = createLoggedObject({ host: 'localhost', port: 3000 });
config.host;        // 输出: 📖 读取属性: host,值是: localhost
config.port = 8080; // 输出: 📝 写入属性: port,旧值: 3000 → 新值: 8080

预期输出

📖 读取属性: host,值是: localhost
📝 写入属性: port,旧值: 3000 → 新值: 8080

一句话解释:Proxy 把所有操作都「录像」了,随时可以回放。


项目 2(15 分钟):「待办事项管理器」

目标:做一个待办清单,增删改查都打印日志,且完成的任务自动标记时间戳。

class TodoManager {
constructor() {
// 用 Proxy 包装内部的 _todos 数组
this._todos = [];
this._proxy = new Proxy(this._todos, {
  get(target, prop, receiver) {
    const value = Reflect.get(target, prop, receiver);
    // 如果是方法,绑定到原始数组
    if (typeof value === 'function') {
      return value.bind(target);
    }
    return value;
  },
  set(target, prop, value, receiver) {
    if (prop === 'length') {
      return Reflect.set(target, prop, value);
    }
    // 完成任务时自动添加完成时间
    if (prop === 'completed' && value === true) {
      const todo = target[target.findIndex(t => t.id === value)];
      // 简化版,实际应该用 receiver
      console.log(`✅ 任务已完成!时间: ${new Date().toLocaleString()}`);
    }
    return Reflect.set(target, prop, value, receiver);
  }
});
}

get todos() {
return this._proxy;
}

add(text) {
const todo = {
  id: Date.now(),
  text: text,
  completed: false,
  createdAt: new Date().toLocaleString()
};
this._todos.push(todo);
console.log(`➕ 添加任务: "${text}"`);
return todo;
}

complete(id) {
const todo = this._todos.find(t => t.id === id);
if (todo) {
  todo.completed = true;
  console.log(`✅ 完成任务: "${todo.text}"`);
}
}

list() {
console.log('\n📋 当前待办列表:');
this._todos.forEach(t => {
  const status = t.completed ? '✅' : '⬜';
  console.log(`${status} [${t.id}] ${t.text}`);
});
}
}

// 用起来
const manager = new TodoManager();
manager.add('买菜');          // ➕ 添加任务: "买菜"
manager.add('做饭');          // ➕ 添加任务: "做饭"
manager.add('写作业');        // ➕ 添加任务: "写作业"
manager.list();
/*
📋 当前待办列表:
⬜ [1719123456789] 买菜
⬜ [1719123456790] 做饭
⬜ [1719123456791] 写作业
*/

manager.complete(manager.todos[0].id);  // ✅ 任务已完成!时间: 2024/6/26 下午4:30:00
manager.list();
/*
📋 当前待办列表:
✅ [1719123456789] 买菜
⬜ [1719123456790] 做饭
⬜ [1719123456791] 写作业
*/

预期输出

➕ 添加任务: "买菜"
➕ 添加任务: "做饭"
➕ 添加任务: "写作业"

📋 当前待办列表:
⬜ [1719123456789] 买菜
⬜ [1719123456790] 做饭
⬜ [1719123456791] 写作业

✅ 任务已完成!时间: 2024/6/26 下午4:30:00
📋 当前待办列表:
✅ [1719123456789] 买菜
⬜ [1719123456790] 做饭
⬜ [1719123456791] 写作业

一句话解释:Proxy 让 TodoManager 自动「知道」什么时候有任务完成,然后自动记录时间。


项目 3(15 分钟):「配置文件校验器」

目标:读取一个 JSON 配置文件,用 Proxy 做校验,不合法就报错,合法就打印成功。

// 模拟一个 JSON 配置文件(实际项目可能从文件或 API 读取)
const configData = {
server: {
host: '192.168.1.100',
port: 8080,
debug: false
},
database: {
url: 'mysql://localhost:3306',
poolSize: 10,
timeout: 5000
}
};

// 配置校验规则
function createConfigValidator(data, schema) {
return new Proxy(data, {
get(target, prop, receiver) {
  const value = Reflect.get(target, prop, receiver);
  // 如果还有子 schema,继续代理
  if (schema[prop] && typeof schema[prop] === 'object' && !Array.isArray(schema[prop])) {
    return createConfigValidator(value, schema[prop]);
  }
  return value;
},
set(target, prop, value, receiver) {
  const rules = schema[prop];
  // 端口必须是数字
  if (prop === 'port' && typeof value !== 'number') {
    throw new TypeError(`配置错误: server.port 必须是数字,当前是 ${typeof value}`);
  }
  // 端口范围校验
  if (prop === 'port' && (value < 1 || value > 65535)) {
    throw new RangeError(`配置错误: server.port 必须在 1-65535 之间,当前是 ${value}`);
  }
  // poolSize 必须是正数
  if (prop === 'poolSize' && value <= 0) {
    throw new RangeError(`配置错误: database.poolSize 必须是正数,当前是 ${value}`);
  }
  // debug 必须是布尔值
  if (prop === 'debug' && typeof value !== 'boolean') {
    throw new TypeError(`配置错误: server.debug 必须是布尔值,当前是 ${typeof value}`);
  }
  console.log(`✅ 配置更新: ${prop} = ${value}`);
  return Reflect.set(target, prop, value, receiver);
}
});
}

// 校验规则 schema
const schema = {
server: {
host: { type: 'string' },
port: { type: 'number', min: 1, max: 65535 },
debug: { type: 'boolean' }
},
database: {
url: { type: 'string' },
poolSize: { type: 'number', min: 1 },
timeout: { type: 'number', min: 0 }
}
};

// 创建带校验的配置对象
const config = createConfigValidator(configData, schema);

console.log('--- 读取配置 ---');
console.log('服务器地址:', config.server.host);
console.log('服务器端口:', config.server.port);

console.log('\n--- 测试更新(合法) ---');
config.server.port = 3000;      // ✅ 配置更新: port = 3000

console.log('\n--- 测试更新(非法) ---');
try {
config.server.port = 'abc';   // 报错: TypeError: 配置错误: server.port 必须是数字
} catch (e) {
console.log('❌ 捕获到错误:', e.message);
}

try {
config.server.port = 99999;   // 报错: RangeError: 配置错误: server.port 必须在 1-65535 之间
} catch (e) {
console.log('❌ 捕获到错误:', e.message);
}

try {
config.database.poolSize = -5; // 报错: RangeError: 配置错误: database.poolSize 必须是正数
} catch (e) {
console.log('❌ 捕获到错误:', e.message);
}

console.log('\n--- 最终配置 ---');
console.log(config);

预期输出

--- 读取配置 ---
服务器地址: 192.168.1.100
服务器端口: 8080

--- 测试更新(合法) ---
✅ 配置更新: port = 3000

--- 测试更新(非法) ---
❌ 捕获到错误: 配置错误: server.port 必须是数字,当前是 string
❌ 捕获到错误: 配置错误: server.port 必须在 1-65535 之间,当前是 99999
❌ 捕获到错误: 配置错误: database.poolSize 必须是正数,当前是 -5

--- 最终配置 ---
{ server: { host: '192.168.1.100', port: 3000, debug: false },
database: { url: 'mysql://localhost:3306', poolSize: 10, timeout: 5000 } }

一句话解释:Proxy 像一个「配置保镖」,任何非法修改都会被拦截并报警。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:Proxy 不能拦截原生类型

// ❌ 错误示例
const num = 123;
const proxyNum = new Proxy(num, {});  // TypeError: Proxy 不支持原始值

// ✅ 正确做法:先包装成对象
const numObj = new Number(123);
const proxyNum = new Proxy(numObj, {
get(target, prop) {
console.log(`读取了 ${prop}`);
return Reflect.get(target, prop);
}
});

原因:Proxy 的第一个参数必须是对象,不能是原始值(string、number、boolean)。


坑 2:set 必须返回布尔值

// ❌ 错误示例
const obj = {};
const proxy = new Proxy(obj, {
set(target, prop, value) {
target[prop] = value;
// 没有 return!默认返回 undefined,视为失败
}
});
proxy.name = 'test';  // 静默失败,name 没有被设置!
console.log(obj.name); // undefined

// ✅ 正确做法
const proxy2 = new Proxy(obj, {
set(target, prop, value) {
target[prop] = value;
return true;  // 必须返回 true 表示成功
}
});
proxy2.name = 'test';
console.log(obj.name); // 'test'

原因:Proxy 的 set 拦截器必须返回 true 表示成功,falseundefined 会被视为失败(在严格模式下甚至报错)。


坑 3:this 指向问题

const obj = {
greet() { console.log('你好,我是' + this.name); },
name: '原始对象'
};

const proxy = new Proxy(obj, {
get(target, prop, receiver) {
// ❌ 错误:直接调用 target 的方法,this 指向原始对象
if (typeof target[prop] === 'function') {
  return target[prop]();  // this 指向 target,不是 proxy
}
return Reflect.get(target, prop);
}
});

proxy.greet(); // 输出: 你好,我是原始对象 ❌ 你以为会是代理对象

// ✅ 正确:用 receiver 调用,保持 this 指向代理对象
const proxy2 = new Proxy(obj, {
get(target, prop, receiver) {
if (typeof target[prop] === 'function') {
  // 使用 receiver,方法里的 this 会指向代理对象
  return target[prop].bind(receiver);
}
return Reflect.get(target, prop, receiver);
}
});

proxy2.name = '代理对象';
proxy2.greet(); // 输出: 你好,我是代理对象 ✅

原因:当你在 get 里调用方法时,如果不用 receiverthis 会指向原始对象而不是代理对象。


坑 4:数组越界不报错

const arr = [1, 2, 3];
const proxyArr = new Proxy(arr, {
get(target, prop) {
console.log(`读取索引 ${prop}`);
return Reflect.get(target, prop);
}
});

console.log(proxyArr[100]);  // 输出: 读取索引 100,然后输出 undefined(不报错!)
console.log(proxyArr[-1]);   // 输出: 读取索引 -1,JavaScript 会帮你转成 arr[arr.length - 1] = 3

原因:Proxy 的 get 拦截对所有属性访问都生效,包括数组越界访问。如果想拦截,需要在 get 里手动检查索引范围。


坑 5:循环引用导致栈溢出

// ❌ 危险示例
const obj = {};
const proxy = new Proxy(obj, {
get(target, prop) {
return target[prop];  // 如果 prop 是 'self',会无限递归!
}
});

obj.self = obj;  // 原始对象引用自己
// proxy.self  // 栈溢出!

// ✅ 正确做法:用 Reflect.get,它会正确处理
const proxy2 = new Proxy(obj, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
}
});
// proxy2.self 还是会栈溢出,但至少不会死循环在拦截器里

性能小贴士

Proxy 每次访问都有拦截开销,如果你在性能敏感的循环里用 Proxy,注意:

// ❌ 性能差的写法:循环里反复访问代理对象
for (let i = 0; i < 1000000; i++) {
proxy.value;  // 每次都走拦截器
}

// ✅ 性能好的写法:把值取出来用
const value = proxy.value;  // 只走一次拦截器
for (let i = 0; i < 1000000; i++) {
value;  // 直接用局部变量
}

调试技巧:打印所有操作

function debugProxy(target) {
return new Proxy(target, {
get(target, prop, receiver) {
  const value = Reflect.get(target, prop, receiver);
  console.log(`[GET] ${String(prop)} => ${JSON.stringify(value)}`);
  return value;
},
set(target, prop, value, receiver) {
  console.log(`[SET] ${String(prop)} <= ${JSON.stringify(value)}`);
  return Reflect.set(target, prop, value, receiver);
},
deleteProperty(target, prop) {
  console.log(`[DELETE] ${String(prop)}`);
  return Reflect.deleteProperty(target, prop);
}
});
}

// 用这个来调试你的代理对象
const debuggedObj = debugProxy({ count: 0 });
debuggedObj.count = 10;
debuggedObj.name = '测试';
delete debuggedObj.count;

✏️ 练习题

练习 1(1 分钟):基础抄改

const person = { name: '张三', age: 25 };
const proxy = new Proxy(person, {
get(target, prop) {
return Reflect.get(target, prop);
}
});
// 改一行代码,让读取属性时打印 "读取了: xxx"
  • 预期输出:读取了: name 然后 张三
  • 提示:在 get 函数里加一句 console.log

练习 2(2 分钟):加个判断

const score = new Proxy({ value: 0 }, {
set(target, prop, value) {
// 在这里加判断:如果 value < 0,抛出错误 "分数不能为负"
return Reflect.set(target, prop, value);
}
});
score.value = -10;  // 应该报错
  • 预期输出:Error: 分数不能为负
  • 提示:用 if 判断 value 是否小于 0

练习 3(3 分钟):处理新数据

用项目 1 的 createLoggedObject,处理一个 product 对象,包含 namepricestock 三个属性。

  • 输入:{ name: 'iPhone', price: 6999, stock: 100 }
  • 预期输出:读取和写入时都有日志打印
  • 提示:直接把项目 1 的代码拿过来,改一下对象名和属性

练习 4(4 分钟):串起来

把练习 2 的「分数校验」和练习 3 的「日志功能」合并,创建一个既有日志又有校验的 student 对象。

  • 输入:{ name: '王小二', score: 85 }
  • 预期输出:修改 score 为负数时报错,同时每次读取/写入都有日志
  • 提示:Proxy 的 get 和 set 都可以加 console.log

练习 5(5 分钟):分析报错

以下代码运行后会怎样?为什么?

const handler = {
get(target, prop) {
return target[prop];
}
};
const proxy = new Proxy({}, handler);
console.log(proxy.foo);
console.log(proxy.bar);
  • 预期输出:两次 undefined,无报错
  • 提示:Proxy 不会检查属性是否存在,只负责拦截

作业:做一个「智能表单验证器」

需求描述:做一个表单验证工具,用户输入各种字段时自动校验格式。

功能点
1. 用 Proxy 包装表单数据
2. 支持的校验规则:姓名(2-10字)、年龄(18-100)、邮箱(必须含 @)、手机号(11位纯数字)
3. 校验失败时 throw 错误,不更新数据
4. 每次成功的修改打印日志

加分项
1. 支持自定义错误提示信息
2. 表单提交前一次性验证所有字段,返回「是否通过 + 哪些字段有问题」

验收标准
- 能跑起来 + 非法输入被拦截 + 合法输入被记录
- 代码有注释


📚 总结

这一章学了 3 个核心点:
1. Proxy 是对象的「中介」,拦截所有读写操作
2. Reflect 是「默认行为」的标准实现,配合 Proxy 使用
3. Proxy + set/get 拦截 = 响应式数据的基础(Vue 3 就是这么干的)

下章预告:学会了拦截对象操作,下一章我们要学习另一种控制代码执行的方式——不是拦截每次操作,而是一步步「懒洋洋」地生成数据,用多少取多少,不浪费一分力气。这就是迭代器与生成器。想象一下,一个巨大的文件不用一次性读入内存,而是读一行处理一行——怎么做到的?下章见!


推荐资源
- MDN Proxy 文档:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
- 《你不知道的 JavaScript》上卷 第 2 部分「ES6」
- 视频:B 站「原生 JS 带你实现 Vue 3 响应式原理」(约 20 分钟)

互动钩子:你在项目里遇到过「数据被偷偷改了」的坑吗?是用什么方法发现的?评论区聊聊,老粉优先回复!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。