代理模式(结构型)
代理模式 —— 一家小型婚介所的发家致富之路
在某些情况下,出于种种考虑/限制,一个对象不能直接访问另一个对象,需要一个第三者(代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式。
一、科学上网背后的故事
科学上网即 VPN(虚拟专用网络)
一般访问一个 url 时,会发生以下过程:
境内网络屏蔽就是作用于 DNS 解析过程,使 ip 地址解析失败,而通过 VPN 解除屏蔽就是一个代理模式,使用 VPN 访问 url 的过程如下:
可以看到,比起常规的访问,VPN 的使用多出了一个代理服务器。这个第三方的 ip 地址,不在被禁用的那批 ip 地址之列,因此可以顺利访问到这台服务器。而这台服务器的 DNS 解析过程没有被禁用,所以可以顺利访问外网。代理服务器在请求到外网后,再将响应体转发 —— 像这种第三方代替访问目标对象的模式,就是代理模式。
二、用代理模式开一家婚介所
1、ES6 中的 Proxy
const proxy = new Proxy(target, handler);
- target(必须):用来代理的 “对象”,被代理之后它是不能直接被访问的。
- handler(必须):实现代理的过程。
2、“婚介所”的实现
未知男生的个人信息结构大致如下:
// 未知男生
const man = {
// 姓名
name: "Tim",
// 自我介绍
aboutMe: "...",
// 年龄
age: 23,
// 职业
career: "teacher",
// 假头像
fakeAvatar: "xxxx",
// 真实头像
avatar: "xxxx",
// 手机号
phone: 123456,
};
婚介所收到了 Tim 的信息并开始营业。
注意,年龄、职业、真实头像、手机号等属于 Tim 的私密信息,要想 get
这些信息,平台需要考验你的诚意 —— 根据是否通过了实名认证、是否为 VIP 等条件逐层开放可查看的信息:
// 普通私密信息
const baseInfo = ["age", "career"];
// 最私密信息
const privateInfo = ["avatar", "phone"];
// 用户 A 对象实例
const user = {
// ...
isValidated: true,
isVIP: false,
};
// SKT 婚介所登场了
const SKTLovers = new Proxy(man, {
get: function (man, key) {
if (baseInfo.indexOf(key) !== -1 && !user.isValidated) {
alert("请先完成实名认证");
return;
}
// 此处省略各种校验逻辑
// 只有实名认证的用户才可以购买 VIP
if (user.isValidated && privateInfo.indexOf(key) && !user.isVIP) {
alert("只有 VIP 才可以查看该信息");
return;
}
},
});
以上主要是 getter
层面的拦截。假设允许会员间互送礼物,每个会员可以告知婚介所自己所接受礼物的价格下限,这样可以作 setter
层面的拦截:
// 规定礼物的数据结构由 type 和 value 组成
const present = {
type: "火箭",
value: 2000,
};
// 为用户增开 presents 字段存储礼物
const man = {
// 姓名
name: "Tim",
// 自我介绍
aboutMe: "...",
// 年龄
age: 23,
// 职业
career: "teacher",
// 假头像
fakeAvatar: "xxxx",
// 真实头像
avatar: "xxxx",
// 手机号
phone: 123456,
// 礼物数组
presents: [],
// 拒收 100 块以下的礼物
bottomValue: 100,
// 记录最近一次收到的礼物
lastPresent: present,
};
// SKT 婚介所推出了小礼物功能
const SKTLovers = new Proxy(man, {
get: function (man, key) {
if (baseInfo.indexOf(key) !== -1 && !user.isValidated) {
alert("请先完成实名认证");
return;
}
// 此处省略各种校验逻辑
// 只有实名认证的用户才可以购买 VIP
if (user.isValidated && privateInfo.indexOf(key) && !user.isVIP) {
alert("只有 VIP 才可以查看该信息");
return;
}
},
set: function (man, key, val) {
// 最近一次送来的礼物会尝试赋值给 lastPresent 字段
if (key === "lastPresent") {
if (val.value < man.bottomValue) {
alert("不好意思,您的礼物被拒收了");
return;
}
// 如果没有拒收,则赋值成功,同时并入 presents 数组
man[lastPresent] = val;
man[presents] = [...presents, val];
}
},
});
代理模式除了开个婚介所,在前端领域一直是应用广泛的设计模式。
三、代理模式应用实践
1、事件代理
事件代理是代理模式最常见的应用。它的场景是一个父元素下有多个子元素,像这样:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>事件代理</title>
</head>
<body>
<div id="father">
<a href="#">链接1号</a>
<a href="#">链接2号</a>
<a href="#">链接3号</a>
<a href="#">链接4号</a>
<a href="#">链接5号</a>
<a href="#">链接6号</a>
</div>
</body>
</html>
现在想要鼠标点击每个 a
标签,都可以弹出“我是 xxx”这样的提示。比如点击第一个 a
标签,弹出“我是链接 1 号”。
这意味着至少要 6 个监听函数给 6 个不同的的元素,如果 a
标签进一步增多,性能的开销会很大:
// 不用代理模式的情况下将循环安装监听函数
const aNodes = document.getElementById("father").getElementsByTagName("a");
const aLength = aNodes.length;
for (let i = 0; i < aLength; i++) {
aNodes[i].addEventListener("click", function (e) {
e.preventDefault();
alert(`我是${aNodes[i].innerText}`);
});
}
考虑到事件本身具有冒泡特性,当点击 a
元素时,点击事件会冒泡到父元素 div
上,从而被监听到。如此一来,点击事件的监听函数只需要在 div
元素上被绑定一次即可,而不需要在子元素上被绑定 N 次 —— 这就是事件代理,它可以很大程度上提高代码的性能:
// 获取父元素
const father = document.getElementById("father");
// 给父元素安装一次监听函数
father.addEventListener("click", function (e) {
// 识别是否是目标子元素
if (e.target.tagName === "A") {
// 以下是监听函数的函数体
e.preventDefault();
alert(`我是${e.target.innerText}`);
}
});
在这种做法下,点击操作并不会直接触及目标子元素,而是由父元素对事件进行处理和分发、间接地将其作用于子元素,因此这种操作从模式上划分属于代理模式。
2、虚拟代理
图片懒加载:针对图片加载时机的优化:在图片量较大的网站,如果首次打开页面就把所有的图片资源加载完,很可能会造成白屏、卡顿等现象。而懒加载就是采取“先占位、后加载”的方式来展示图片 —— 在元素展示前,给它一个
div
作占位,当它滚动到可视区域内即时加载图片资源,既减轻了性能压力、又保住了用户体验。图片预加载:预加载主要是为了避免网络不好、或者图片太大时页面长时间留白的问题。常见的操作是先让
img
标签展示一个占位图,然后创建一个 Image 实例,让这个 Image 实例的src
指向目标图片地址,当其对应的图片加载完,再将 DOM 上img
元素的src
指向目标图片地址。此时直接去取目标图片的缓存,展示速度会非常快,从占位图到目标图片的时间差会小到用户注意不到,提升了用户体验。
图片预加载的实现如下:
class PreLoadImage {
// 占位图的 url 地址
static LOADING_URL = "xxxxxx";
constructor(imgNode) {
// 获取该实例对应的 DOM 节点
this.imgNode = imgNode;
}
// 该方法用于设置真实的图片地址
setSrc(targetUrl) {
// img 节点初始化时展示的是一个占位图
this.imgNode.src = PreLoadImage.LOADING_URL;
// 创建一个帮我们加载图片的 Image 实例
const image = new Image();
// 监听目标图片加载的情况
// 完成时再将 DOM 上 img 节点的 src 属性设为目标图片的 url
image.onload = () => {
this.imgNode.src = targetUrl;
};
// 设置 src 属性,Image 实例开始加载图片
image.src = srcUrl;
}
}
上面的 PreLoadImage 乍一看没问题,但其违反了单一职责原则。PreLoadImage 不仅要负责图片的加载,还要负责 DOM 层面的操作(img
节点的初始化和后续的改变)。这样就出现了两个可能导致这个类发生变化的原因。
更好的做法是将两个逻辑分离,让 PreLoadImage 专心去做 DOM 层面的事(真实 DOM 节点的获取、img
节点的链接设置),再找一个对象来专门搞加载 —— 这两个对象之间缺个媒婆,这媒婆非代理器不可:
class PreLoadImage {
constructor(imgNode) {
// 获取真实的 DOM 节点
this.imgNode = imgNode;
}
// 操作 img 节点的 src 属性
setSrc(imgUrl) {
this.imgNode.src = imgUrl;
}
}
class ProxyImage {
// 占位图的 url 地址
static LOADING_URL = "xxxxxx";
constructor(targetImage) {
// 目标 Image,即 PreLoadImage 实例
this.targetImage = targetImage;
}
// 该方法主要操作虚拟 Image,完成加载
setSrc(targetUrl) {
// 真实 img 节点初始化时展示的是一个占位图
this.targetImage.setSrc(ProxyImage.LOADING_URL);
// 创建一个帮我们加载图片的虚拟 Image 实例
const virtualImage = new Image();
// 监听目标图片加载的情况
// 完成时再将 DOM 上真实 img 节点的 src 属性设置为目标图片的 url
virtualImage.onload = () => {
this.targetImage.setSrc(targetUrl);
};
// 设置 src 属性,虚拟 Image 实例开始加载图片
virtualImage.src = targetUrl;
}
}
上面的 ProxyImage 调度了预加载相关的工作,可以通过 ProxyImage 这个代理,实现对真实 img
节点的间接访问,并得到我们想要的效果。
在这个实例中,virtualImage 这个对象是一个“幕后英雄”,它始终存在于 JavaScript 世界中、代替真实 DOM 发起了图片加载请求、完成了图片加载工作,却从未在渲染层面抛头露面。因此这种模式被称为“虚拟代理”模式。
3、缓存代理
缓存代理应用于计算量较大的场景中,这种场景需要“用空间换时间”——当用到某个已计算过的值时,不再耗时进行二次计算,而是从内存中直接取现成的计算结果,这就需要一个代理在我们进行计算的同时,进行计算结果的缓存。
一个比较典型的例子,是对传入的参数进行求和:
// addAll 方法会对你传入的所有参数做求和操作
const addAll = function () {
console.log("进行了一次新计算");
let result = 0;
const len = arguments.length;
for (let i = 0; i < len; i++) {
result += arguments[i];
}
return result;
};
// 为求和方法创建代理
const proxyAddAll = (function () {
// 求和结果的缓存池
const resultCache = {};
return function () {
// 将入参转化为一个唯一的入参字符串
const args = Array.prototype.join.call(arguments, ",");
// 检查本次入参是否有对应的计算结果
if (args in resultCache) {
// 如果有,则返回缓存池里现成的结果
return resultCache[args];
}
return (resultCache[args] = addAll(...arguments));
};
})();
现在在控制台执行该方法,尝试同一方法入参两次,结果如下:
可以看到 proxyAddAll
针对重复的入参只会计算一次,这将大大节省计算过程中的时间开销。当大量入参、做反复计算时,缓存代理的优势将得到更充分的凸显。
4、保护代理
上面开 SKT 婚介所时,为了保护 Tim 的隐私,用户在访问 Tim 的年龄时,会去校验用户是否通过了实名认证;为了确保婚介所用户的诚意,当 TA 想获取 Tim 的联系方式时,还会校验 TA 是否为 VIP。所谓“保护代理”,就是在访问层面做文章,在 getter
和 setter
函数中去进行校验和拦截,确保一部分变量是安全的。
上面提到的 Proxy 本身就是为拦截而生的,所以目前实现保护代理考虑的首要方案就是 ES6 中的 Proxy。