第六章 代理模式

代理模式是为对象提供一个代用品或占用符,以便控制对它的访问。

代理模式的关键是,当客户不方便直接访问一个对象或者不满足需要的时候,提供一个替身对象来控制对这个对象的访问,客户实际上访问的是替身对象。替身对象对请求做出一些处理后,再把请求转交给本体对象。

image-20211103104718266

不用代理模式

image-20211103104857197

使用代理模式

6.2 保护代理和虚拟代理

保护代理:代理 B 可以帮助 A 过滤掉一些请求。保护代理用于控制不同权限的对象对目标对象的访问。但在 JavaScript 中不容易实现保护代理,因为我们无法判断谁访问了对象。

虚拟代理:JavaScript 中虚拟代理是常用的代理模式。虚拟代理可以把一些开销很大的操作,延迟到真正需要它的时候采取创建。

6.3 虚拟代理实现图片预加载

图片预加载是一种常用的技术:如果直接给某个 img 标签节点设置 src 属性,由于图片过大或者网络不佳,图片的位置往往有段时间是空白。常见的做法是先用一张 loading 图片站位,然后用异步的方式加载图片,等图片加载好了再填充到 img 节点内。这种场景很适合用虚拟代理。

第一步是创建一个本体对象,这个对象可以往页面中创建 img 标签,并且提供一个对外的 setSrc 接口,外界调用这个接口,就可以给 img 标签设置 src 属性

js
1var myImage = (function () {
2  var imgNode = document.createElement('img')
3  document.body.appendChild(imgNode)
4  return {
5    setSrc: function (src) {
6      imgNode.src = src
7    },
8  }
9})()
10myImage.setSrc('http://xxxx.jpg')

第二步是创建代理对象,通过这个代理对象,在图片被真正加载好之前,页面会出现一张 loading 的占位图,来提示用户正在加载中。

js
1var proxyImage = (function () {
2  const img = new Image()
3  img.onload = function () {
4    // 3. 代理的src加载完成,会触发onload事件
5    myImage.setSrc(this.src) // 4. 此时再重新给被代理的节点设置src属性
6  }
7  return {
8    setSrc(src) {
9      myImage.setSrc('loading.png') //1.先让node节点预先加载loading图
10      img.src = src //2.设置代理的src属性
11    },
12  }
13})()
14proxyImage.setSrc('http://xxxx') // proxyImage代理了myImage的访问,并且加入额外的预加载操作

6.4 代理的意义

上面的代码实际上不需要代理也可以完成,那么代理的意义在哪呢?

单一职责原则

单一职责原则指的是一个类(包括对象和函数),应该只有一个引起它变化的原因。如果一个对象承担了多项职责,这意味着它会变得巨大,引起它变化的原因有很多个。面向对象设计鼓励将行为分布到细粒度的对象之中,如果一个对象承担的职责过多,等于把这些职责耦合到一起,这种耦合会导致脆弱和低内聚的设计。当变化发生时,设计可能会遭到破坏。

职责定义为引起变化的原因。上述代码中,myImage 对象除了负责给展示的 img 节点设置 src 外,还需要预加载图片。我们在处理其中一个职责时,有可能因为其强耦合性而影响另外一个职责的实现。

在面向对象的设计中,大部分情况下,如果违反其他任何原则,同时将违反开放-封闭原则。如果我们知识从网上获取一些体积很小的图片,或者 5 年后网速快到不需要预加载,我们可能希望把预加载的代码从 myImage 对象中删掉。这样就不得不改动 myImage 对象了。

实际上,我们只是需要给 img 节点设置 src,预加载图片只是一个锦上添花的功能。如果可以把这个操作放在另一个对象里,自然是非常好的方法。这样代理的作用就体现出来了,负责预加载图片,预加载的操作完成之后,把请求重新交给本体 MyImage。

纵观整个程序,我们并没有修改或者增加 MyImage 的接口,但是通过代理对象,实际上给系统添加了新的行为。这是符合开放-封闭原则的。给 img 节点设置 src 和图片预加载这两个功能,被隔离在两个对象里,它们可以各自变化而不影响对象。何况就算有一天我们不再需要预加载,只需要改成请求本体而不是请求代理对象即可。

6.5 代理和本体接口的一致性

如果有一天我们不再需要预加载,那么就不再需要代理对象,可以选择直接请求本体。其中关键是代理对象和本体都对外提供了 setSrc 方法,在客户看来,代理对象和本体是一致的,代理接手请求的过程对于用户来说是透明的,用户并不清楚代理和本体的区别,这样做有两个好处。

  1. 用户可以放心地请求代理,他只关心是否能得到想要的结果。

  2. 在任何使用本体的地方都可以替换成使用代理。

6.6 虚拟代理合并 HTTP 请求

假设现在有一排 checkbox 节点,每点击一个就会往服务器同步文件。

这里是 html

html
1  <input type="checkbox" id="1"></input>1
2  <input type="checkbox" id="2"></input>2
3  <input type="checkbox" id="3"></input>3
4  <input type="checkbox" id="4"></input>4
5  <input type="checkbox" id="5"></input>5
6  <input type="checkbox" id="6"></input>6
7  <input type="checkbox" id="7"></input>7
8  <input type="checkbox" id="8"></input>8
9  <input type="checkbox" id="9"></input>9

下面给他们绑定事件,每次选中后都会往服务器发送同步哪个文件的请求。

js
1const checkBoxNodes = document.querySelectorAll('input')
2var syncFile = function (id) {
3  console.log('开始同步文件,id为' + id)
4}
5for (let checkBoxNode of checkBoxNodes) {
6  checkBoxNode.onclick = function () {
7    if (this.checked === true) {
8      syncFile(this.id)
9    }
10  }
11}

每次我们选中 checkbox,就会依次像服务器发送请求。如果用户在短时间内频繁点击(如一秒钟点四个 checkbox),那么网络请求的开销就会非常大。

解决方案是我们可以使用一个代理函数每次都收集要发送给服务器的请求,最后一次性发送给服务器。

js
1const checkBoxNodes = document.querySelectorAll('input')
2var syncFile = function (id) {
3  console.log('开始同步文件,id为' + id)
4}
5
6var proxySyncFile = (function () {
7  var cache = []
8  var timer
9  return function (id) {
10    cache.push(id)
11    clearTimeout(timer) //防抖
12    timer = setTimeout(function () {
13      syncFile(cache.join(',')) // 发送请求给服务器
14      cache.length = 0 //记得清空保存起来的cache
15      clearTimeout(timer)
16    }, 2000)
17  }
18})()
19
20for (let checkBoxNode of checkBoxNodes) {
21  checkBoxNode.onclick = function () {
22    if (this.checked === true) {
23      proxySyncFile(this.id)
24    }
25  }
26}

6.8 缓存代理

缓存代理可以为一些开销大的运算结果提供暂时的存储,在下次运算时,如果传递进来的参数跟之前一致,则可以直接返回前面存储的运算结果。

6.8.1 缓存代理的例子——计算乘积

下面是一个用来计算乘积的懒加载函数

js
1var mult = function(...rest) {
2  let a = 1 mult = function(...rest) {
3    for (let i of rest) {
4      a *= i
5    }
6    return a
7  }
8  return mult(...rest)
9}
10console.log(mult(1, 2, 3))

如果给它加上缓存,那么就可以减少计算

js
1var mult = function (...rest) {
2  let a = 1
3  let cache = {}
4  mult = function (...rest) {
5    const property = rest.join(',')
6    // 判断有没有传递过同样的参数
7    if (!(property in cache)) {
8      for (let i of rest) {
9        console.log('这里是复杂的计算')
10        a *= i
11      }
12      cache[property] = a // 计算后把计算参数和计算结果保存在缓存里
13    } // 如果有就直接返回缓存的结果,不需要重复计算了
14    return cache[property]
15  }
16  return mult(...rest)
17}
18console.log(mult(1, 2, 3)) // "这里是复杂的计算" * 3
19//6
20console.log(mult(1, 2, 3)) // 6

上面的懒加载函数 mult 需要完成两个职责:计算乘积,缓存

按照单一职责原则,我们应当用虚拟缓存代理模式来分离它的职责。

js
1var mult = function(...rest) {
2  let a = 1
3  mult = function(...rest) {
4    for (let i of rest) {
5      console.log("这里是复杂的计算") a *= i
6    }
7    return a
8  }
9  return mult(...rest)
10}
11var proxyMult = (function() {
12  let cache = {}
13  return function(...rest) {
14    let property = rest.join(',')
15    if (property in cache) {
16      return cache[property]
17    }
18    return cache[property] = mult(...rest)
19  }
20})()
21console.log(proxyMult(1, 2, 3))
22console.log(proxyMult(1, 2, 3))

通过增加缓存代理的方式,mult 函数可以继续专注于自身的职责,缓存的功能则是由代理对象实现的。

6.9 用高阶函数动态创建代理

这一章作者并没写什么内容,只是贴了大段代码,实际上这章就是在代理模式的基础上使用通用单例模式的思想,你会觉得这里的代码跟通用代理模式的代码很像

js
1var mult = function(...rest) {
2  let a = 1
3  mult = function(...rest) {
4    for (let i of rest) {
5      console.log("这里是复杂的计算") a *= i
6    }
7    return a
8  }
9  return mult(...rest)
10}
11
12/* 创建缓存代理的工厂 */
13var createProxyFactory = function(fn) {
14  let cache = {}
15  return function(...rest) {
16    let property = rest.join(',')
17    // 这里跟通用单例模式的代码非常类似,单例模式返回cache的引用,这里是返回cache里的属性
18    if (property in cache) {
19      return cache[property]
20    }
21    return cache[property] = fn(...rest)
22  }
23}
24const proxyMult = createProxyFactory(mult)
25console.log(proxyMult(1, 2, 3))
26console.log(proxyMult(1, 2, 3))

createProxyFactory 是高阶函数,现在我们把用来计算的函数当作参数传递给它,就可以给各种计算方法创建不同的缓存代理,这样一来整个程序会更加灵活。

6.11 小结

代理模式包含很多,在 JavaScript 中使用最多的是虚拟代理和缓存代理。

我们在编写业务代码的时候,往往不需要去预先猜测是否需要使用代理模式。当真正发现不方便直接访问某个对象的时候,再编写代理也不迟。