前言
本文通过多个例子介绍代理模式。代理模式有多种,我们主要介绍在 JavaScript 中常用的两种,虚拟代理和缓存代理。
虚拟代理
下文分别介绍通过虚拟代理实现以下两个例子:
- 图片预加载
- 合并高频 HTTP 请求。
图片预加载
在 Web 开发中,图片预加载是一种常用的技术,如果直接给某个<img>
标签标签节点设置src
属性,由于图片过大或者网络不佳,图片的位置往往会显示一段时间的空白。常见的做法是用一张 loading 图片占位,然后用异步的方式加载图片,等图片加载好了再把它填充到<img>
节点里,这种场景就很适合用代理模式(虚拟代理),但我们还是先使用常规逻辑来书写这个代码,再使用代理模式重构。
我们想要实现这样一个效果:
1 | let imgElement = document.getElementsById("preloadImage"); |
调用setSrc
的时候,会去请求给定 url 当中的图片,在请求到达之前,有一个 loading 的效果图片作为占位。
常规逻辑
上面的代码关键在于对 MyImage
类的实现。在调用setSrc
的时候,创建一个Image
对象,用它来做真正的请求。而作为参数的<img>
元素,则直接加载 loading 的 gif 图,当假的 Image
对象触发 onload
回调之后,再将真正的 src
赋值给 <img>
.
1 | class MyImage { |
这个类是可以正常工作的。可以点进这个 Codepen 去看看,这个 codepen 同时加载了六张 4k 大图,应该能看到 loading 的效果,如果你的网速太快,没看到 loading,在 network 那里将网速调低一点再刷新一下。
虽然它可以工作,但这个类违反了单一职责原则。
单一职责原则指的就是一个类(通常也包括对象和函数)而言,应该仅有一个引起它变化的原因。如果一个对象承担了多项职责,就意味着这个对象变的巨大。面向对象设计鼓励将行为分布到细粒度的对象之中,如果一个对象承担的职责过多,等于把这些职责耦合在了一起,这种耦合会导致脆弱和低内聚的设计。当变化发生时,设计可能会遭到意外的破坏。
就上面这个类而言,它就承担了两个职责,包括给img
节点设置src
,以及预加载图片。我们在处理其中一个职责时,有可能因为其强耦合性影响另外一个职责的实现。
另外,在面向对象的程序设计中,大多数情况下,若违反其他任何原则,同时将违反开放-封闭原则。如果我们只是从网络上获取一些体积很小的图片,或者五年后的网速快到根本不需要预加载,我们可能希望把预加载图片的这段代码从 MyImage
对象中删除。这时候就不得不改动 MyImage
类了。
实际上,我们需要的只是给img
节点设置src
,预加载图片只是一个锦上添花的功能。如果能把这个操作放在另一个对象里,自然是一个非常好的方法。
使用代理模式实现
我们要的效果是,让MyImage
对象单纯地直接设置对应的<img>
元素的src
属性,而预加载 loading 图的职责则分给一个ProxyImage
去做,这个ProxyImage
就是我们的中介者。
具体实现
MyImage
类这次很简单,直接设置src
:
1 | class MyImage { |
b. 预加载的功能在ProxyImage
中实现:
1 | class ProxyImage { |
OK, 现在我们通过ProxyImage
间接地访问 MyImage
,ProxyImage
控制了客户对MyImage
的访问,并且在此过程中加入一些额外的操作(此例的额外操作是在图片请求完成前为真正的图片添加一个 loading 图):
1 | let imgElement = document.getElementById("myImage"); |
完整代码
Codepen
代码的入口函数是main()
,其为 DOM 中的五个图片加载了 5 张 4k 大图。
合并高频 HTTP 请求
假设我们有这样一个需求, 页面上有多个文件,都可以勾选,勾选之后立即触发上传。当我们选中 3 个 checkbox 的时候,依次往服务器发送了 3 次上传文件的请求。假设我单身二十年手速惊人,一次点了十几个文件,可以想象,这种频繁的网络请求将会给服务器带来很大的压力。解决方案是,我们可以通过一个代理函数 proxyUploadFile 来收集一段时间内的请求,然后一次性发给服务器。比如等待两秒后,再将需要同步的文件一次性发给服务器。如果不是对实时性要求非常高的系统,2 秒的延迟不会带来太大的副作用,却能大大减轻服务器的压力。
1 | const uploadFile = id => { |
该例子较为简单,不赘述,读者可以去这个 Codepen 玩一玩。
缓存代理
缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前的一致,则可以直接返回前面存储的计算结果。
本节通过一个计算乘积的例子介绍缓存代理。
常规思路的实现
先实现一个计算乘积的函数:
1 | const mult = function() { |
接着我们要想办法缓存结果,很自然的想到了可以通过闭包:
1 | const cachedMult = (function() { |
调用的时候
1 | cachedMult(20, 20, 20); // 8000 |
使用代理的实现
Hold on hold on.
仔细观察一下常规思路的实现。你会发现实际上常规思路的实现就是所谓的缓存代理!我们将 cachedMult
这个函数的名字修改成 proxyMult
,是不是一切都很熟悉?我们调用proxyMult
,这个函数缓存了计算结果,实际调用的函数是mult
。
Yep, 它就是我们熟悉的代理模式。所以缓存代理,在 Javascript 中实际上就是用闭包做缓存。
一般套路是这样的:
1 | const proxyWhatever = (function(){ |
用一个立即执行函数返回一个函数,由于这个函数引用了其作用域以外的cache
数组,它成了一个闭包。
总结
代理模式包括许多小分类,我们在本文中介绍了 JavaScript 中最常用的虚拟代理和缓存代理。虽然代理模式非常有用,但我们在编写业务代码的时候,往往不需要去预先猜测是否需要使用代理模式。当真正发现不方便直接访问某个对象的时候,再编写代理也不迟。