代理模式
代理模式(Proxy Pattern
)是一种结构型设计模式,也是前端开发中广泛的几个设计模式之一,在很多开源软件库里面都能见到代理模式的使用。
1、基本概念
代理模式是为其他对象提供一种代理以控制对这个对象的访问。
就拿前端经典的场景举例,比如有些操作并不想频繁的触发它,需要有人限制它的触发频率;就比如有些时候我们在操作数据的时候想做一些额外的事儿,比如Vue
的双向数据绑定。
什么时候适合使用代理模式呢?------想在访问一个对象时做一些控制。
代理模式的UML
图如下:
2、代码示例
ts
interface Subject {
profit(number: number): void;
}
class RealSubject implements Subject {
profit(number: number): void {
console.log("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
console.log(`you can earn money ${number} every day`);
console.log("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
}
}
class ProxySubject implements Subject {
private readSubject = new RealSubject();
profit(number: number): void {
if (number <= 0) {
console.warn("salary must bigger than zero");
return;
}
this.readSubject.profit(number);
}
}
(function bootstrap() {
const sub = new ProxySubject();
for (let i = 0; i < 10; i++) {
const rnd = Math.random();
sub.profit(rnd > 0.5 ? Number.parseInt((rnd * 1000).toFixed(0)) : 0);
}
})();
3、前端开发中的实践
以上的代码是通用的实现方式,但是前端提供了某些便捷的API可以使得我们能够更简单的实现代理模式。
在ES5
时,我们可以利用Object.defineProperty通过给对象定义getter
和setter
的时候进行一些控制,从而达到代理的效果,也可以利用闭包+高阶函数实现。
ES6
新增了Proxy
,我们可以直接使用语法糖实现代理模式,不过在使用Proxy
的时候,在任何时候尽量都是用Reflect
的API与之配合,因为它们俩的组合能够保证this的指向符合您的预期 。(比如Vue3
的双向绑定就使用的是Proxy
)。
3.1 实现数组负数的索引取值
可以用Proxy
来实现具有负数
索引的数组.
js
function SafetyArray(arr) {
return new Proxy(arr, {
get(target, propKey, receiver) {
let index = Number(propKey);
// 如果 propKey 是负数索引,则将其转换为正数索引
if (index < 0) {
index = target.length + index;
}
// 使用Reflect.get能够保证在任何情况下this指向都是预期的,比如不会指向到代理对象这种非预期的场景。
return Reflect.get(target, index, receiver);
},
set(target, propKey, value, receiver) {
let index = Number(propKey);
// 不允许给数组设置除了数字以外的键
if (Number.isNaN(index) && propKey !== "length") {
return false;
}
// 如果 propKey 是负数索引,则将其转换为正数索引
if (index < 0) {
index = target.length + index;
}
// 使用Reflect.get能够保证在任何情况下this指向都是预期的,比如不会指向到代理对象这种非预期的场景。
return Reflect.set(target, index, value, receiver);
},
});
}
3.2 安全的取值器
有了上面的思路,我们还可以利用代理模式来实现一个安全的取值器(解释:什么是安全的取值器?因为JS是动态语言,在编写代码时我们预期某个值是对象,但是在运行时可能因为某些错误导致它读取不到,从而值为undefined,然后我们尝试在undefined上面取值就会报错,而使用安全的取值器方法,当函数发现目标对象是undefined时就提前返回了,防止报错 ),比如lodash
提供的get
这样的函数。
以下是我的一个简单的实现:
js
/**
* 安全的获取对象o上键为p的值(不考虑原型链)
* @param {Object} o
* @param {String} p p支持a.b.c或者b.a[o][d].e这样的形式,对于[]这种形式的取值,如果不按预期传递,解析的结果可能就非预期
*/
function safetyGetProperty(o, p) {
// 非引用类型直接报错
if (!isRef(o)) {
throw new Error("o must be a reference type");
}
p = String(p);
// 如果当前对象上不存在这个key,说明用户传递的内容是复杂key,才继续后续的流程,否则可以直接取值
if (o && o.hasOwnProperty(p)) {
return o[p];
}
// 解析keys
const props = parseProps(p);
let prop = props.shift();
let target = o[prop];
// 如果target不是一个真值,那么继续循环将会报错,如果realKeys的length还存在,说明key值还没有取完,需要继续向下迭代
while (target && props.length) {
prop = props.shift();
target = target[prop];
}
// 如果keys的值用尽,说明是正常终止,否则就是非正常终止的,则返回null。
return props.length === 0 ? target : null;
}
/**
* 安全的设置对象o上键为p的值v(不考虑原型链)
* @param {Object} o
* @param {String} p
* @param {any} v
*/
function safetySetProperty(
o,
p,
v,
propDesc = {
enumerable: true,
writable: true,
configurable: true,
}
) {
// 非引用类型直接报错
if (!isRef(o)) {
throw new Error("o must be a reference type");
}
p = String(p);
// 解析props
const realKeys = parseProps(p);
let target = o;
let prop = realKeys.shift();
while (realKeys.length) {
// 是否是纯数字的键
let isPureNumProp = /\d+/.test(prop);
// 如果对象不存在
if (!target[prop]) {
// 如果是纯数字的key,初始化为数组,否则初试化为对象
target[prop] = isPureNumProp ? [] : {};
}
// 向后迭代
target = target[prop];
prop = realKeys.shift();
}
Object.defineProperty(target, prop, {
...propDesc,
value: v,
});
}
/**
* 判断是否是引用类型
* @param {Array | Object} o
* @returns
*/
function isRef(o) {
return ["Object", "Array"].some((key) => {
return Object.prototype.toString.call(o, key) === `[object ${key}]`;
});
}
function parseProps(prop) {
// 先以.形式分割,如果最后一个字符为.则视为最后想要取的键位'',如果第一个是.,则视其为第一个键值的一部分
const primaryKeys = prop.split(".");
if (/^\./.test(prop)) {
// 弹出空值
primaryKeys.shift();
// 取出真值,并且将.视为第一个键的一部分
const tmp = primaryKeys.shift();
primaryKeys.unshift("." + tmp);
}
const parsedProps = [];
for (let i = 0; i < primaryKeys.length; i++) {
const key = primaryKeys[i];
if (/\[[\w]+\]/.test(key)) {
const keyGroup = parseSquareBrackets(key);
parsedProps.push(...keyGroup);
} else {
parsedProps.push(key);
}
}
return parsedProps;
}
/**
* 解析方括号中的key值
* @param {String} prop
*/
function parseSquareBrackets(prop) {
let pos = 0;
let str = "";
let parsedKeys = [];
// 定义一个解析中的标记
let parsing = false;
while (pos < prop.length) {
const char = prop[pos++];
// 解析到第一个`[`之前的key,当前的[不计入key中
if (char === "[") {
if (str != "") {
parsedKeys.push(str);
str = "";
}
parsing = true;
continue;
}
// 遇到`]`则视为已经解析到了一个key
else if (char === "]" && parsing) {
parsing = false;
parsedKeys.push(str);
str = "";
} else {
// 极端的case 单的`]`,还没有开始就已经遇到]
str += char;
}
}
// 极端case 单的`[`
if (parsing) {
const tmp = parsedKeys.pop();
parsedKeys.push(tmp + "[" + str);
str = "";
}
// 极端的case 单的`]`
if (str != "") {
parsedKeys.push(str);
str = "";
}
return parsedKeys;
}
function createSafetyObject(ref = {}) {
return new Proxy(ref, {
get(target, prop) {
return safetyGetProperty(target, prop)
},
set(target, prop, newValue) {
return safetySetProperty(target, prop, newValue)
}
})
}
// 可以尝试定义到Object上
// Object.defineProperty(Object, 'createSafety', {
// value: createSafetyObject
// })
const obj = createSafetyObject();
obj["c[0].d"] = 2
obj.bbb = 10;
console.log(obj.c[0].d) // 2
console.log(obj.bbb) // 10
3.3 图片懒加载
不使用Proxy
API实现图片加载:
ts
interface IImage {
display(): void;
}
class RealImage implements IImage {
private url: string;
constructor(url: string) {
this.url = url;
}
loadImage(): void {
console.log(`从 ${this.url} 加载图片`);
let img = document.createElement('img');
img.src = this.url;
document.body.appendChild(img); // 将图像添加到 DOM 中
}
display(): void {
this.loadImage();
console.log('显示图片');
}
}
class ImageProxy implements IImage {
private realImage: RealImage | null = null;
private url: string;
constructor(url: string) {
this.url = url;
}
display(): void {
if (!this.realImage) {
console.log('第一次访问图片,现在开始加载...');
this.realImage = new RealImage(this.url);
this.realImage.display();
}
}
}
// 检查元素是否在视口中
function isInViewport(element: HTMLElement) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
// 处理滚动事件
function onScroll() {
document.querySelectorAll('img[data-src]').forEach(imgElement => {
if (isInViewport(imgElement as HTMLElement) && !imgElement.src) {
const imgUrl = imgElement.getAttribute('data-src');
if (imgUrl) {
const lazyImage = new ImageProxy(imgUrl);
lazyImage.display();
}
}
});
}
// 为滚动事件添加监听器
window.addEventListener('scroll', onScroll);
// 初始加载时也执行一次检查
onScroll();
使用Proxy
实现图片加载:
ts
class RealImage {
imgElement: HTMLImageElement;
constructor(imgElement: HTMLImageElement) {
this.imgElement = imgElement;
}
display(): void {
const imgUrl = this.imgElement.getAttribute('data-src');
if (imgUrl) {
console.log(`从 ${imgUrl} 加载图片`);
this.imgElement.src = imgUrl;
this.imgElement.onload = () => console.log('图片加载完成');
this.imgElement.onerror = () => console.error('图片加载失败');
}
}
}
// 创建一个 Proxy Handler
const ImageProxyHandler: ProxyHandler<RealImage> = {
get: function(target, prop, receiver) {
if (prop === "display") {
return function() {
if (!target.imgElement.src) { // 如果图片未加载,则调用 display 方法加载
target.display();
}
};
}
return Reflect.get(target, prop, receiver);
}
};
// 检查元素是否在视口中
function isInViewport(element: HTMLElement) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
// 滚动事件处理函数
function onScroll() {
document.querySelectorAll('img[data-src]').forEach(imgElement => {
if (isInViewport(imgElement as HTMLElement)) {
const realImage = new RealImage(imgElement as HTMLImageElement);
const lazyImage = new Proxy(realImage, ImageProxyHandler);
lazyImage.display();
}
});
}
// 添加滚动事件监听器
window.addEventListener('scroll', onScroll);
// 初始加载时也检查图片
onScroll();
3.4 统一的错误捕获
在NestJS的源码中,很多设计都利用了代理模式来进行统一的异常处理,这样的设计可以使得代码更加健壮与稳定。
以下就是其中一处例子,我节选了关键代码向大家展示:
ts
class NestFactoryStatic {
public async create() {
// 节选了部分代码
const instance = new NestApplication();
const target = this.createNestInstance(instance);
return this.createAdapterProxy<T>(target, httpServer);
}
private createNestInstance<T>(instance: T): T {
return this.createProxy(instance);
}
private createProxy(target: any) {
const proxy = this.createExceptionProxy();
return new Proxy(target, {
get: proxy,
set: proxy,
});
}
private createExceptionProxy() {
return (receiver: Record<string, any>, prop: string) => {
if (!(prop in receiver)) {
return;
}
if (isFunction(receiver[prop])) {
// 进行可能的错误捕获
return this.createExceptionZone(receiver, prop);
}
// 对于属性的访问直接放行
return receiver[prop];
};
}
}
3.5 缓存函数的结果
在实际的开发中,有些操作可能比较耗费系统资源,所以我们可以利用将其缓存起来,从而提高软件整体的运行效率,以下就是使用代理模式来实现对函数的结果的缓存的一个示例,lodash
也提供了一个这样的API->memoize
。
ts
function createMemoizedFunction(func) {
const cache = new Map();
return new Proxy(func, {
apply(target, thisArg, args) {
// 创建一个唯一的缓存键,基于函数的参数
const cacheKey = args.toString();
if (cache.has(cacheKey)) {
console.log('从缓存中获取结果');
return cache.get(cacheKey);
}
console.log('计算结果并缓存');
const result = target.apply(thisArg, args);
cache.set(cacheKey, result);
return result;
}
});
}
// 示例函数:计算两个数的和
function add(a, b) {
return a + b;
}
// 创建一个记忆化版本的 add 函数
const memoizedAdd = createMemoizedFunction(add);
// 使用记忆化函数
console.log(memoizedAdd(2, 3)); // 计算结果并缓存
console.log(memoizedAdd(2, 3)); // 从缓存中获取结果
console.log(memoizedAdd(4, 5)); // 计算结果并缓存
总结
代理模式和上文我们提到的装饰模式有一定的相似点和不同点,它们共同的特点都是使用新的类包装现有的对象,新类都实现了与原始对象相同的接口,并且通过这个接口将调用委托给原始对象。但是,代理模式强调的是对访问的控制,装饰模式强调的是增加能力(不过从这一点来说也无关紧要,毕竟实际写代码我们只要满足业务并且能够把代码组织好,运行起来更加健壮就可以,而不是为了应付考试😂。)
在前端实际的开发中,可以广泛利用Proxy
更简单的实现代理模式。