事件代理

事件代理,可能是代理模式最常见的一种应用方式,也是一道实打实的高频面试题。它的场景是一个父元素下有多个子元素,像这样:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <meta http-equiv="X-UA-Compatible" content="ie=edge">
  7. <title>事件代理</title>
  8. </head>
  9. <body>
  10. <div id="father">
  11. <a href="#">链接1号</a>
  12. <a href="#">链接2号</a>
  13. <a href="#">链接3号</a>
  14. <a href="#">链接4号</a>
  15. <a href="#">链接5号</a>
  16. <a href="#">链接6号</a>
  17. </div>
  18. </body>
  19. </html>

比如点击第一个 a 标签,弹出“我是链接1号”这样的提示。这意味着我们至少要安装 6 个监听函数给 6 个不同的的元素(一般我们会用循环,代码如下所示),如果我们的 a 标签进一步增多,那么性能的开销会更大。

  1. // 假如不用代理模式,我们将循环安装监听函数
  2. const aNodes = document.getElementById('father').getElementsByTagName('a')
  3. const aLength = aNodes.length
  4. for(let i=0;i<aLength;i++) {
  5. aNodes[i].addEventListener('click', function(e) {
  6. e.preventDefault()
  7. alert(`我是${aNodes[i].innerText}`)
  8. })
  9. }

考虑到事件本身具有“冒泡”的特性,当我们点击 a 元素时,点击事件会“冒泡”到父元素 div 上,从而被监听到。如此一来,点击事件的监听函数只需要在 div 元素上被绑定一次即可,而不需要在子元素上被绑定 N 次——这种做法就是事件代理,它可以很大程度上提高我们代码的性能。

事件代理的实现

用代理模式实现多个子元素的事件监听,代码会简单很多:

  1. // 获取父元素
  2. const father = document.getElementById('father')
  3. // 给父元素安装一次监听函数
  4. father.addEventListener('click', function(e) {
  5. // 识别是否是目标子元素
  6. if(e.target.tagName === 'A') {
  7. // 以下是监听函数的函数体
  8. e.preventDefault()
  9. alert(`我是${e.target.innerText}`)
  10. }
  11. } )

在这种做法下,我们的点击操作并不会直接触及目标子元素,而是由父元素对事件进行处理和分发、间接地将其作用于子元素,因此这种操作从模式上划分属于代理模式。

虚拟代理

在《性能小册的Lazy-Load小节》,我们介绍了懒加载这种技术,此处强烈建议大家,尤其是近期有校招或跳槽需求的同学,转过头去复习一下这个小节,说不定下一次的面试题里就有原题,这点在该小节的评论区已经有同学佐证了。
我们此处简单地给大家描述一下懒加载是个什么东西:它是针对图片加载时机的优化:在一些图片量比较大的网站,比如电商网站首页,或者团购网站、小游戏首页等。如果我们尝试在用户打开页面的时候,就把所有的图片资源加载完毕,那么很可能会造成白屏、卡顿等现象。
此时我们会采取“先占位、后加载”的方式来展示图片 —— 在元素露出之前,我们给它一个 div 作占位,当它滚动到可视区域内时,再即时地去加载真实的图片资源,这样做既减轻了性能压力、又保住了用户体验。
除了图片懒加载,还有一种操作叫图片预加载。预加载主要是为了避免网络不好、或者图片太大时,页面长时间给用户留白的尴尬。常见的操作是先让这个 img 标签展示一个占位图,然后创建一个 Image 实例,让这个 Image 实例的 src 指向真实的目标图片地址、观察该 Image 实例的加载情况 —— 当其对应的真实图片加载完毕后,即已经有了该图片的缓存内容,再将 DOM 上的 img 元素的 src 指向真实的目标图片地址。此时我们直接去取了目标图片的缓存,所以展示速度会非常快,从占位图到目标图片的时间差会非常小、小到用户注意不到,这样体验就会非常好了。
上面的思路,我们可以不假思索地实现如下

  1. class PreLoadImage {
  2. // 占位图的url地址
  3. static LOADING_URL = 'xxxxxx'
  4. constructor(imgNode) {
  5. // 获取该实例对应的DOM节点
  6. this.imgNode = imgNode
  7. }
  8. // 该方法用于设置真实的图片地址
  9. setSrc(targetUrl) {
  10. // img节点初始化时展示的是一个占位图
  11. this.imgNode.src = PreLoadImage.LOADING_URL
  12. // 创建一个帮我们加载图片的Image实例
  13. const image = new Image()
  14. // 监听目标图片加载的情况,完成时再将DOM上的img节点的src属性设置为目标图片的url
  15. image.onload = () => {
  16. this.imgNode.src = targetUrl
  17. }
  18. // 设置src属性,Image实例开始加载图片
  19. image.src = targetUrl
  20. }
  21. }

这个 PreLoadImage 乍一看没问题,但其实违反了我们设计原则中的单一职责原则。PreLoadImage 不仅要负责图片的加载,还要负责 DOM 层面的操作(img 节点的初始化和后续的改变)。这样一来,就出现了两个可能导致这个类发生变化的原因
好的做法是将两个逻辑分离,让 PreLoadImage 专心去做 DOM 层面的事情(真实 DOM 节点的获取、img 节点的链接设置),再找一个对象来专门来帮我们搞加载——这两个对象之间缺个媒婆,这媒婆非代理器不可:

  1. class PreLoadImage {
  2. constructor(imgNode) {
  3. // 获取真实的DOM节点
  4. this.imgNode = imgNode
  5. }
  6. // 操作img节点的src属性
  7. setSrc(imgUrl) {
  8. this.imgNode.src = imgUrl
  9. }
  10. }
  11. class ProxyImage {
  12. // 占位图的url地址
  13. static LOADING_URL = 'xxxxxx'
  14. constructor(targetImage) {
  15. // 目标Image,即PreLoadImage实例
  16. this.targetImage = targetImage
  17. }
  18. // 该方法主要操作虚拟Image,完成加载
  19. setSrc(targetUrl) {
  20. // 真实img节点初始化时展示的是一个占位图
  21. this.targetImage.setSrc(ProxyImage.LOADING_URL)
  22. // 创建一个帮我们加载图片的虚拟Image实例
  23. const virtualImage = new Image()
  24. // 监听目标图片加载的情况,完成时再将DOM上的真实img节点的src属性设置为目标图片的url
  25. virtualImage.onload = () => {
  26. this.targetImage.setSrc(targetUrl)
  27. }
  28. // 设置src属性,虚拟Image实例开始加载图片
  29. virtualImage.src = targetUrl
  30. }
  31. }

ProxyImage 帮我们调度了预加载相关的工作,我们可以通过 ProxyImage 这个代理,实现对真实 img 节点的间接访问,并得到我们想要的效果。
在这个实例中,virtualImage 这个对象是一个“幕后英雄”,它始终存在于 JavaScript 世界中、代替真实 DOM 发起了图片加载请求、完成了图片加载工作,却从未在渲染层面抛头露面。因此这种模式被称为“虚拟代理”模式。

缓存代理

缓存代理比较好理解,它应用于一些计算量较大的场景里。在这种场景下,我们需要“用空间换时间”——当我们需要用到某个已经计算过的值的时候,不想再耗时进行二次计算,而是希望能从内存里去取出现成的计算结果。这种场景下,就需要一个代理来帮我们在进行计算的同时,进行计算结果的缓存了。
一个比较典型的例子,是对传入的参数进行求和:

  1. // addAll方法会对你传入的所有参数做求和操作
  2. const addAll = function() {
  3. console.log('进行了一次新计算')
  4. let result = 0
  5. const len = arguments.length
  6. for(let i = 0; i < len; i++) {
  7. result += arguments[i]
  8. }
  9. return result
  10. }
  11. // 为求和方法创建代理
  12. const proxyAddAll = (function(){
  13. // 求和结果的缓存池
  14. const resultCache = {}
  15. return function() {
  16. // 将入参转化为一个唯一的入参字符串
  17. const args = Array.prototype.join.call(arguments, ',')
  18. // 检查本次入参是否有对应的计算结果
  19. if(args in resultCache) {
  20. // 如果有,则返回缓存池里现成的结果
  21. return resultCache[args]
  22. }
  23. return resultCache[args] = addAll(...arguments)
  24. }
  25. })()

我们把这个方法丢进控制台,尝试同一套入参两次,结果喜人:
代理模式——应用实践范例解析 - 图1
我们发现 proxyAddAll 针对重复的入参只会计算一次,这将大大节省计算过程中的时间开销。现在我们有 6 个入参,可能还看不出来,当我们针对大量入参、做反复计算时,缓存代理的优势将得到更充分的凸显。

保护代理

保护代理,其实在我们上个小节大家就见识过了。此处我们仅作提点,不作重复演示。
开婚介所的时候,为了保护用户的私人信息,访问年龄的时候,去校验是否已经通过了我们的实名认证;获取联系方式时,会校验是否具有VIP 资格。所谓“保护代理”,就是在访问层面做文章,在 getter 和 setter 函数里去进行校验和拦截,确保一部分变量是安全的。
值得一提的是,上节中我们提到的 Proxy,它本身就是为拦截而生的,所以我们目前实现保护代理时,考虑的首要方案就是 ES6 中的 Proxy。