在 JavaScript 的 Proxy 体系中,set 是最常用、最核心的拦截方法之一。它专门用于拦截对象属性的赋值操作。无论是直接赋值 obj.prop = value,还是通过 Object.assign()、解构赋值等方式,只要涉及属性写入,set 都能介入控制。理解这个,对于实现数据验证、响应式系统、权限控制等功能非常重要。
什么是 set ?
简单来说,set 让我们能够在属性被赋值时执行自定义逻辑。当代码尝试给代理对象的某个属性赋值时,我们定义的 set 方执行,而不是直接修改目标对象。
的语法结构:
const handler = {
set: function(target, property, value, receiver) {
// target: 被代理的原始对象
// property: 要赋值的属性名(字符串或 Symbol)
// value: 要赋予的新值
// receiver: 通常是代理对象本身,或者是实际接收赋值的对象
// 返回值: 必须返回一个布尔值,表示赋值是否成功
}
};
参数解析:
-
target:这是被代理的原始对象。我们可以将修改应用到它上面。 -
property:需要赋值的属性名,可以是字符串或 Symbol 类型。 -
value:要赋予的新值。 -
receiver:这个参数比较特殊,它通常是代理对象本身。但在涉及原型链继承的场景中,它可能是实际接收赋值的对象。
返回值:
这个必须返回一个布尔值。返回 true 表示赋值操作成功,返回 false 表示操作失败(在严格模式下会抛出 TypeError)。
从基础示例开始
我们先从一个简单的例子入手,看看 set 的基本工作方式。
示例 1:基础拦截与值转换
在这个例子中,我们在赋值时对值进行了转换处理。
// 代码号:learning-proxy-set-01
const targetData = { name: "初始值", count: 0 };
const handler = {
set: function(target, property, value) {
console.log(`[set] 正在设置属性 "${String(property)}" = ${value}`);
// 对特定属性进行转换处理
if (property === 'name') {
// 将名字转换为大写
target[property] = value.toUpperCase();
} else if (property === 'count') {
// 确保 count 是数字
target[property] = Number(value);
} else {
target[property] = value;
}
// 必须返回 true 表示操作成功
return true;
}
};
const proxyInstance = new Proxy(targetData, handler);
proxyInstance.name = "hello world";
proxyInstance.count = "42";
proxyInstance.other = "其他值";
console.log(proxyInstance.name); // 输出: HELLO WORLD
console.log(proxyInstance.count); // 输出: 42(数字类型)
console.log(proxyInstance.other); // 输出: 其他值
console.log(targetData.name); // 输出: HELLO WORLD(原始对象也被修改)
个人经验: 在开发中,set 是我用得最多的 Proxy ,没有之一。从数据验证到响应式更新,从日志记录到值转换,几乎每个复杂项目都会用到它。一个常见的场景是在调试阶段给所有属性赋值添加日志,快速定位问题。
数据验证与类型检查
set 最实用的场景之一,就是实现数据验证和类型检查,确保只有符合要求的值才能被写入。
示例 2:严格的数据验证
// 代码号:learning-proxy-set-02
const userSchema = {
name: { type: 'string', required: true, minLength: 2 },
age: { type: 'number', required: true, min: 0, max: 150 },
email: { type: 'string', required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
};
const validateAndSet = (target, prop, value) => {
const rule = userSchema[prop];
if (!rule) {
// 允许未定义的属性,也可以选择拒绝
target[prop] = value;
return true;
}
// 类型检查
if (rule.type === 'string' && typeof value !== 'string') {
throw new TypeError(`属性 ${prop} 必须是字符串类型`);
}
if (rule.type === 'number' && typeof value !== 'number') {
throw new TypeError(`属性 ${prop} 必须是数字类型`);
}
// 字符串长度检查
if (rule.minLength && value.length < rule.minLength) {
throw new Error(`属性 ${prop} 长度不能小于 ${rule.minLength}`);
}
// 数字范围检查
if (rule.min !== undefined && value < rule.min) {
throw new Error(`属性 ${prop} 不能小于 ${rule.min}`);
}
if (rule.max !== undefined && value > rule.max) {
throw new Error(`属性 ${prop} 不能大于 ${rule.max}`);
}
// 正则表达式检查
if (rule.pattern && !rule.pattern.test(value)) {
throw new Error(`属性 ${prop} 格式不正确`);
}
console.log(`[验证通过] ${prop} = ${value}`);
target[prop] = value;
return true;
};
const userData = {};
const userProxy = new Proxy(userData, { set: validateAndSet });
// 正常赋值
userProxy.name = "张三";
userProxy.age = 25;
userProxy.email = "zhangsan@example.com";
console.log(userProxy.name); // 输出: 张三
// 验证失败的情况
try {
userProxy.name = "李"; // 长度不足
} catch (e) {
console.log(e.message); // 输出: 属性 name 长度不能小于 2
}
try {
userProxy.age = 200; // 超出范围
} catch (e) {
console.log(e.message); // 输出: 属性 age 不能大于 150
}
try {
userProxy.email = "invalid-email"; // 格式错误
} catch (e) {
console.log(e.message); // 输出: 属性 email 格式不正确
}
为什么不用 Object.defineProperty 而要使用 Proxy?
Object.defineProperty 需要逐个属性定义 getter/setter,对于动态添加的属性无能为力。而 Proxy 的 set 可以拦截所有属性的赋值,无论是预先定义的还是动态新增的,这种方式更加灵活和强大。
实现响应式系统
set 是实现响应式数据系统(如 Vue、MobX 的核心机制)的基础。
示例 3:简单的响应式数据
// 代码号:learning-proxy-set-03
class ReactiveData {
constructor(data) {
this._subscribers = new Map(); // 存储每个属性的订阅函数
this._data = data;
return new Proxy(this._data, {
set: (target, prop, value) => {
const oldValue = target[prop];
// 值未变则跳过
if (oldValue === value) {
return true;
}
target[prop] = value;
console.log(`[响应式] 属性 ${String(prop)} 从 ${oldValue} 变为 ${value}`);
// 通知订阅者
const subs = this._subscribers.get(prop);
if (subs) {
subs.forEach(callback => callback(value, oldValue));
}
return true;
}
});
}
// 订阅属性变化
watch(prop, callback) {
if (!this._subscribers.has(prop)) {
this._subscribers.set(prop, []);
}
this._subscribers.get(prop).push(callback);
// 返回取消订阅的函数
return () => {
const subs = this._subscribers.get(prop);
const index = subs.indexOf(callback);
if (index !== -1) subs.splice(index, 1);
};
}
getData() {
return this._data;
}
}
const reactive = new ReactiveData({ count: 0, name: "初始" });
const proxy = reactive.getData();
// 订阅 count 变化
reactive.watch('count', (newVal, oldVal) => {
console.log(`[订阅通知] count 从 ${oldVal} 变为 ${newVal}`);
});
// 触发变化
proxy.count = 1; // 输出: [响应式] 属性 count 从 0 变为 1
// [订阅通知] count 从 0 变为 1
proxy.count = 2; // 输出: [响应式] 属性 count 从 1 变为 2
// [订阅通知] count 从 1 变为 2
proxy.name = "新名字"; // 输出: [响应式] 属性 name 从 初始 变为 新名字
与 receiver 参数的关系
receiver 参数在涉及原型链继承时非常重要。它代表实际接收赋值的对象。
示例 4:receiver 参数的作用
// 代码号:learning-proxy-set-04
const parent = { inherited: "父级值" };
const child = { own: "子级值" };
const handler = {
set: function(target, prop, value, receiver) {
console.log(`[set] target: ${target === parent ? 'parent' : 'child'}`);
console.log(`[set] receiver: ${receiver === child ? 'child' : 'parent'}`);
console.log(`[set] 属性: ${String(prop)} = ${value}`);
target[prop] = value;
return true;
}
};
const parentProxy = new Proxy(parent, handler);
Object.setPrototypeOf(child, parentProxy);
// 赋值给 child 上不存在的属性,会沿着原型链触发 parentProxy 的 set
child.newProp = "新值";
// 输出: [set] target: parent
// [set] receiver: child
// [set] 属性: newProp = 新值
// 注意:值实际被设置在了 parent 上,因为 set 修改的是 target
console.log(parent.newProp); // 输出: 新值
console.log(child.newProp); // 输出: undefined(child 上没有该属性)
个人建议: 在实现 set 时,如果希望赋值行为符合直觉(即赋值给实际接收者),需要使用 Reflect.set(target, prop, value, receiver)。这可以正确处理原型链场景。
// 更规范的实现
const betterHandler = {
set: function(target, prop, value, receiver) {
console.log(`设置属性: ${String(prop)}`);
// 使用 Reflect.set 正确处理原型链
return Reflect.set(target, prop, value, receiver);
}
};
返回值的严格要求
set 的返回值非常重要。在严格模式下,返回 false 会导致抛出 TypeError。
示例 5:返回值的影响
// 代码号:learning-proxy-set-05
"use strict";
const target = {};
// 错误示例:返回 false 导致异常
const illegalProxy = new Proxy(target, {
set: function(target, prop, value) {
console.log(`尝试设置 ${prop},但被拒绝`);
return false; // 返回 false 表示失败
}
});
try {
illegalProxy.name = "测试";
} catch (e) {
console.log(e.message);
// 输出: 'set' on proxy: trap returned falsish for property 'name'
}
// 正确示例:返回 true 表示成功
const legalProxy = new Proxy(target, {
set: function(target, prop, value) {
target[prop] = value;
return true; // 必须返回 true
}
});
legalProxy.name = "测试"; // 正常工作
console.log(legalProxy.name); // 输出: 测试
本节课程知识要点:
-
必须返回布尔值:
set必须返回true或false,返回false在严格模式下会抛出TypeError。 -
值验证时机:在修改目标对象之前进行验证,避免部分修改。
-
receiver 的重要性:在原型链场景中,使用
Reflect.set可以正确处理receiver参数。 -
与 get 配合:通常
set和get会成对出现,实现完整的属性访问控制。 -
不可变属性:对于不可配置、不可写的属性,
set的返回值会受到约束。
实际应用:数据脱敏与权限控制
下面是一个更贴近实际应用的例子,展示如何根据用户权限控制属性的写入。
示例 6:基于角色的权限控制
// 代码号:learning-proxy-set-06
class SecureStore {
constructor(initialData, userRole) {
this._data = { ...initialData };
this._userRole = userRole;
// 权限定义
this._permissions = {
admin: { write: true, read: true, fields: '*' },
editor: { write: true, read: true, fields: ['name', 'description', 'status'] },
viewer: { write: false, read: true, fields: '*' }
};
return new Proxy(this._data, {
set: (target, prop, value) => {
const perms = this._permissions[this._userRole];
// 检查是否有写入权限
if (!perms.write) {
console.warn(`[权限拒绝] ${this._userRole} 角色没有写入权限`);
return false;
}
// 检查字段权限
if (perms.fields !== '*' && !perms.fields.includes(prop)) {
console.warn(`[权限拒绝] ${this._userRole} 角色不能修改字段 ${String(prop)}`);
return false;
}
// 特殊字段的额外验证
if (prop === 'salary' && this._userRole !== 'admin') {
console.warn(`[权限拒绝] 只有管理员可以修改薪资`);
return false;
}
console.log(`[权限通过] ${this._userRole} 修改 ${String(prop)} = ${value}`);
target[prop] = value;
return true;
},
get: (target, prop) => {
const perms = this._permissions[this._userRole];
// 检查读取权限
if (!perms.read) {
console.warn(`[权限拒绝] ${this._userRole} 角色没有读取权限`);
return undefined;
}
// 检查字段可见性
if (perms.fields !== '*' && !perms.fields.includes(prop) && prop !== 'salary') {
console.warn(`[权限拒绝] ${this._userRole} 角色不能读取字段 ${String(prop)}`);
return undefined;
}
// 薪资字段特殊处理
if (prop === 'salary' && this._userRole !== 'admin') {
console.warn(`[权限拒绝] 只有管理员可以查看薪资`);
return undefined;
}
return target[prop];
}
});
}
}
const employeeData = {
name: "李四",
email: "lisi@company.com",
salary: 100000,
status: "active",
department: "技术部"
};
// 不同角色的访问
const adminStore = new SecureStore(employeeData, 'admin');
const editorStore = new SecureStore(employeeData, 'editor');
const viewerStore = new SecureStore(employeeData, 'viewer');
// 管理员:完整权限
adminStore.name = "李四(管理员修改)";
console.log(adminStore.salary); // 输出: 100000
// 编辑者:只能修改特定字段
editorStore.name = "李四(编辑修改)"; // 成功
editorStore.salary = 120000; // 输出: [权限拒绝] 只有管理员可以修改薪资
console.log(editorStore.salary); // 输出: [权限拒绝] 只有管理员可以查看薪资 \n undefined
// 查看者:不能写入
viewerStore.name = "尝试修改"; // 输出: [权限拒绝] viewer 角色没有写入权限
console.log(viewerStore.name); // 输出: 李四(编辑修改)
结合 Reflect API 的实践
在实现 set 时,使用 Reflect.set 可以正确处理所有边缘情况。
// 代码号:learning-proxy-set-07
const baseObject = { a: 1 };
const handler = {
set: function(target, prop, value, receiver) {
console.log(`[拦截] 设置 ${String(prop)} = ${value}`);
// 使用 Reflect.set 正确处理所有情况
// 包括原型链、不可写属性等边界情况
const success = Reflect.set(target, prop, value, receiver);
if (success) {
console.log(`[成功] 属性 ${String(prop)} 已设置`);
} else {
console.log(`[失败] 属性 ${String(prop)} 设置被拒绝`);
}
return success;
}
};
const proxy = new Proxy(baseObject, handler);
proxy.a = 2; // 输出: [拦截] 设置 a = 2 \n [成功] 属性 a 已设置
proxy.b = 3; // 输出: [拦截] 设置 b = 3 \n [成功] 属性 b 已设置
// 尝试设置不可扩展对象的属性
Object.preventExtensions(baseObject);
proxy.c = 4; // 输出: [拦截] 设置 c = 4 \n [失败] 属性 c 设置被拒绝
个人建议: 在实现 set 时,优先使用 Reflect.set(target, prop, value, receiver) 来执行实际赋值。这样做不仅能正确处理所有边界情况,还能让代码更简洁、更符合规范。只有在需要自定义赋值行为(如值转换、验证失败时阻止赋值)时,才手动操作 target[prop]。
set 是 JavaScript Proxy 中最常用、最核心的拦截方法之一。它让我们能够精确控制属性的赋值行为,实现数据验证、类型转换、响应式更新、权限控制、审计日志等丰富功能。理解它的参数含义(尤其是 receiver)、返回值要求以及与 Reflect.set 的配合使用,是正确运用这个的关键。在开发中,set 是构建健壮、灵活、安全的 JavaScript 应用不可或缺的工具。