JavaScript 的 Proxy
前言
从本质上讲,Proxy 提供了一种为对象的基本操作定制行为的方法。将其视为中间人,位于代码和对象之间,拦截并可能改变对象的交互方式。允许开发人员为读取属性、分配值甚至确定属性是否存在等操作定义自定义行为。除此之外,代理的真正吸引力在于它们的潜在应用,从数据验证和属性监视到对象虚拟化等更高级的模式,下面我来一一探索
什么是 Proxy
在 JavaScript 中 Proxy 对象类似于一个保护罩或一个包裹另一个对象的仲裁者,我们将其称为“目标”。这种包装允许代理拦截并控制在目标对象上执行的各种基本操作。这就像有一个监护人监督我们如何与数据交互,让我们有能力重新定义或定制这些交互
基本语法
创建代理很简单,但了解其结构对于有效利用至关重要。构造函数 Proxy 需要两个主要参数
- target:原始对象
- handler:定义目标操作的自定义行为
const target = {};
let proxy = new Proxy(target, {
get: function (target, property, receiver) {
return property in target ? target[property] : "Not Found";
},
});
proxy.name = "Riki";
console.log(proxy.name); // Outputs: "Riki"
console.log(proxy.age); // Outputs: "Not Found"
可撤销代理
假设我们有一个资源,并且希望随时关闭对其的访问,我们就可以把它包装成一个可撤销的代理
- 基本使用
const object = {
data: "Valuable data",
};
const { proxy, revoke } = Proxy.revocable(object, {});
//将代理的对象传递到某个地方
console.log(proxy.data);
// 通过调用revoke关闭代理
revoke();
// 代理对象不再可以反问
console.log(proxy.data); // Error
调用 revoke()是将代理中对目标对象的所有内部引用删除,因此它们不再连接,revoke 与是分开的 proxy 是分开的,通常我们需要在离开当前作用域时同时进行传递,可以将其绑定到代理 proxy.revoke = revoke,但这样不太高级的样子
- 高级使用我们知道 WeakMap 不会阻止垃圾收集,如果使用 WeakMap,当代理对象变得“无法访问”时我们就不再需要为其做善后工作
const revokes = new WeakMap();
let object = {
data: "Valuable data",
};
let { proxy, revoke } = Proxy.revocable(object, {});
revokes.set(proxy, revoke);
// 当你需要禁用代理的时候
const revokeProxy = revokes.get(proxy);
revokeProxy();
console.log(proxy.data); // Error (revoked)
应用
对于对象上的大多数操作,JavaScript 规范中有一个“内部方法”。例如 [[Get]]读取对象属性时调用、[[Set]]写入属性值时候调用。我们不能直接通过方法名来调用它们,下面是一些常见的映射关系
内部方法 | 处理程序方法 | 触发时机 |
---|---|---|
[[Get]] | get | 读取属性 |
[[Set]] | set | 写入属性 |
[[HasProperty]] | has | in 操作员 |
[[Delete]] | deleteProperty delete | 操作员 |
[[Call]] | apply | 函数调用 |
[[Construct]] | construct | new 操作员 |
[[GetPrototypeOf]] | getPrototypeOf | 对象 getPrototypeof |
[[SetPrototypeOf]] | setPrototypeOf | 对象.setPrototypeOf |
[[IsExtensible]] | isExtensible | 对象可扩展 |
[[PreventExtensions]] | preventExtensions | 对象.preventExtensions |
[[DefineOwnProperty]] | defineProperty | 对象.defineProperty ,对象.defineProperties |
[[GetOwnProperty]] | getOwnPropertyDescriptor | 对象.getOwnPropertyDescriptor , for..in,Object.keys/values/entries |
[[OwnPropertyKeys]] | ownKeys | 对象.getOwnPropertyNames ,对象.getOwnPropertySymbols , for..in,Object.keys/values/entries |
注意
内部方法和陷阱必须满足的条件
- [[Set]] [[Delete]] 如果值写入成功则必须返回 true,否则 false
- [[GetPrototypeOf]],读取代理的原型必须始终返回目标对象的原型
接下来介绍内部方法的代理
get 代理
拦截属性读取,handler 应该有一个方法 get(target, property, receiver)。
它在读取属性时触发,带有以下参数
- target 是目标对象,作为第一个参数传递给 new Proxy,
- property 属性名称,
- receiver 如果目标属性是 getter,那么 receiver 就是将 this 在其调用中使用的对象。通常这是 proxy 对象本身
let numbers = [0, 1, 2];
numbers = new Proxy(numbers, {
get(target, prop) {
// 如果数组中存在即返回 不存在返回 0
if (prop in target) {
return target[prop];
} else {
return 0; // default value
}
},
});
console.log(numbers[1]); // 1
console.log(numbers[123]); // 0 (没有索引为123的项 返回 0)
// 正如所见,我们可以用来Proxy实现“默认”值的任何逻辑
set 代理
当写入属性时会触发 set(target, property, value, receiver)
- target 是目标对象,作为第一个参数传递给 new Proxy
- property 属性名称
- value 适当的价值
- receiver 与 gettrap 类似,仅对 setter 属性重要
let numbers = [];
numbers = new Proxy(numbers, {
set(target, prop, val) {
// 仅当值为number类型复制成功
if (typeof val == "number") {
target[prop] = val;
return true;
} else {
return false;
}
},
});
numbers.push(1); // 添加成功
numbers.push(2); // 添加成功
console.log("Length is: " + numbers.length); // 2
numbers.push("test"); // TypeError ('set' on proxy returned false)
// 对于set,它必须返回true才能成功写入。如果我们忘记执行此操作或返回任何错误值,则会触发该操作TypeError
我们不必重写诸如 push 和 unshift 之类的增值数组方法来在其中添加检查,因为它们在内部使用[[Set]]被代理拦截的操作
使用“ownKeys”和“getOwnPropertyDescriptor”代理对象迭代
Object.keys、for..in 和大多数其他迭代对象属性的方法使用[[OwnPropertyKeys]]内部方法来获取属性列表。
这些方法在细节上有所不同:
- Object.getOwnPropertyNames(obj)返回非符号键
- Object.getOwnPropertySymbols(obj)返回符号键
- Object.keys/values()返回带有标志的非符号键/值 enumerable(属性标志在文章属性标志和描述符中进行了解释)
- for..in 使用标志循环非符号键 enumerable 以及原型键
下面的我们代理 ownKeys,并且在使用 Object.keys、Object.values 时来跳过以“下划线”开头的属性
let user = {
name: "riki",
age: 30,
_password: "***",
};
user = new Proxy(user, {
ownKeys(target) {
return Object.keys(target).filter((key) => !key.startsWith("_"));
},
});
// 使用 for 循环
for (let key in user) console.log(key); // name, age
console.log(Object.keys(user)); // [name, age]
console.log(Object.values(user)); // [riki, 30]
到目前为止没有任何问题,但如果我们返回对象中不存在的键呢?猜猜会发生什么?
let user = {};
user = new Proxy(user, {
ownKeys(target) {
return ["a", "b", "c"];
},
});
console.log(Object.keys(user)); // <empty>
// 原因是:Object.keys仅返回带有 enumerable 标志的属性。为了检查它,它调用每个属性的内部方法[[GetOwnProperty]]来获取其描述符。在这里,由于没有属性,它的描述符是空的,没有enumerable标志,所以它被跳过。
let user = {};
user = new Proxy(user, {
ownKeys(target) {
return ["a", "b", "c"];
},
getOwnPropertyDescriptor(target, prop) {
return {
enumerable: true, // 看这里
configurable: true,
};
},
});
console.log(Object.keys(user)); // a, b, c
使用 deleteProperty 保护属性
有一个普遍的约定,即以下划线前缀的属性和方法是内部的。不应从对象外部访问它们,让我们来实现他
let user = {
name: "riki",
_password: "***",
};
user = new Proxy(user, {
// 读取
get(target, prop) {
if (prop.startsWith("_")) {
throw new Error("Access denied");
}
let value = target[prop];
// 注意 当值是函数时,我们绑定它,这样它就可以使用 this
return typeof value === "function" ? value.bind(target) : value;
},
// 写入
set(target, prop, val) {
if (prop.startsWith("_")) {
throw new Error("Access denied");
} else {
target[prop] = val;
return true;
}
},
// 删除
deleteProperty(target, prop) {
if (prop.startsWith("_")) {
throw new Error("Access denied");
} else {
delete target[prop];
return true;
}
},
// 遍历
ownKeys(target) {
return Object.keys(target).filter((key) => !key.startsWith("_"));
},
});
try {
console.log(user._password); // Error: Access denied
} catch (e) {
console.log(e.message);
}
try {
user._password = "test"; // Error: Access denied
} catch (e) {
console.log(e.message);
}
try {
delete user._password; // Error: Access denied
} catch (e) {
console.log(e.message);
}
for (let key in user) console.log(key); // name
in 操作符拦截
我们想使用 in 运算符来检查数字是否在某个范围
let range = {
start: 1,
end: 10,
};
range = new Proxy(range, {
has(target, prop) {
return prop >= target.start && prop <= target.end;
},
});
console.log(5 in range); // true
console.log(50 in range); // false
apply 代理
假设我们现在需要一个延迟函数
- 通常的做法
function delay(f, ms) {
return function () {
setTimeout(() => f.apply(this, arguments), ms);
};
}
function sayHi(user) {
console.log(`Hello, ${user}!`);
}
console.log(sayHi.length); // 1
sayHi = delay(sayHi, 3000);
sayHi("riki"); // Hello, riki! (after 3 seconds)
// 这样功能当然能实现,但是delay函数不会转发属性读/写操作,比如函数的name、length等等
console.log(sayHi.length); // 0
- 更高级的使用代理实现
function delay(f, ms) {
return new Proxy(f, {
apply(target, thisArg, args) {
setTimeout(() => target.apply(thisArg, args), ms);
},
});
}
function sayHi(user) {
console.log(`Hello, ${user}!`);
}
sayHi = delay(sayHi, 3000);
console.log(sayHi.length); // 1
sayHi("riki"); // Hello, riki! (after 3 seconds)
Reflect 内置对象
前面说过,内部方法,例如[[Get]]、[[Set]]等都是不能直接调用。Reflect 让其成为可能
let user = {};
Reflect.set(user, "name", "Riki");
console.log(user.name); // Riki
对于每个可由 Proxy 捕获的内部方法,在 Reflect 中都有一个相应的方法,其名称和参数与陷阱相同
let user = {
name: "Riki",
};
user = new Proxy(user, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
},
set(target, prop, val, receiver) {
return Reflect.set(target, prop, val, receiver);
},
});
console.log(user.name); // Riki
user.name = "Pete";
console.log(user.name); // Pete
但似乎不使用 Reflect 我们也能实现上面的例子。的确是这样,那么为什么为我们提供了 Reflect 呢?
让我们看一个例子来说明为什么 Reflect.get 更好!
// 这里有一个 user 对象
let user = {
_name: "Guest",
get name() {
return this._name;
},
};
// userProxy 是一个代理,它拦截 user 对象的所有读取操作
let userProxy = new Proxy(user, {
get(target, prop, receiver) {
return target[prop];
},
});
// admin继承自user
let admin = {
__proto__: userProxy,
_name: "Admin",
};
console.log(admin.name); // Guest; (why??)
预期中 admin.name 应该返回"Admin";发生了什么?
- 当我们读取 时 admin.name,由于 admin 对象没有自己的属性,因此搜索将转到其原型
- 原型 proto 是 userProxy 然后非预期的信息出现了
Reflect.get 如果我们使用它,一切都会正常工作
let user = {
_name: "Guest",
get name() {
return this._name;
},
};
let userProxy = new Proxy(user, {
get(target, prop, receiver) {
// receiver = admin
return Reflect.get(target, prop, receiver);
},
});
let admin = {
__proto__: userProxy,
_name: "Admin",
};
console.log(admin.name); // Admin; (
限制
许多内置对象,例如 Map、Set、Date、Promise 使用所谓的“内部槽”,这些类似与属性,但保留用于内部为了规范用途
let map = new Map();
// 当Mapp被代理的时候
let proxy = new Proxy(map, {});
proxy.set("test", 1); // Error (set方法失效了)
幸运的是,有一种方法可以解决这个问题
let map = new Map();
let proxy = new Proxy(map, {
get(target, prop, receiver) {
let value = Reflect.get(...arguments);
return typeof value == "function" ? value.bind(target) : value;
},
});
proxy.set("test", 1);
console.log(proxy.get("test")); // 1 (这是可以正常工作的)
// 类似的还有对象的私有属性,也需要通过上面的方法转发
class User {
#name = "Guest";
getName() {
return this.#name;
}
}
几个小应用
- 读取对象不存在的属性报错
let user = {
name: "Riki",
};
function wrap(target) {
return new Proxy(target, {
get(target, prop, receiver) {
if (prop in target) {
return Reflect.get(target, prop, receiver);
} else {
throw new ReferenceError(`Property doesn't exist: "${prop}"`);
}
},
});
}
user = wrap(user);
console.log(user.name); // Riki
console.log(user.age); // ReferenceError: Property doesn't exist: "age"
- 访问负数组[-1]
let array = [1, 2, 3];
array = new Proxy(array, {
get(target, prop, receiver) {
if (prop < 0) {
prop = Number(prop) + target.length;
}
return Reflect.get(target, prop, receiver);
},
});
console.log(array[-1]); // 3
console.log(array[-2]); // 2
- Observable
let handlers = Symbol("handlers");
function makeObservable(target) {
// 添加一个处理函数列表
target[handlers] = [];
// 添加 一个 observe 方法
target.observe = function (handler) {
this[handlers].push(handler);
};
// 然后代理他
return new Proxy(target, {
set(target, property, value, receiver) {
// 这里我们又使用了Reflect 防止非预期的行为
let success = Reflect.set(...arguments);
// 当赋值成功后一起调研观察者函数
if (success) {
target[handlers].forEach((handler) => handler(property, value));
}
return success;
},
});
}
let user = {};
user = makeObservable(user);
user.observe((key, value) => {
console.log(`SET ${key}=${value}`);
});
user.name = "Riki"; // SET name=Riki
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。