5 结构型-代理模式

代理模式,式如其名——在某些情况下,出于种种考虑/限制,一个对象不能直接访问 另一个对象,需要一个第三者 (代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式。

代理模式非常好理解,因为你可能天天都在用,只是没有刻意挖掘过它背后的玄机——比如大家耳熟能详的科学上网 ,就是代理模式的典型案例。

5.1 ES6中的Proxy

在 ES6 中,提供了专门以代理角色出现的代理器 —— Proxy。它的基本用法如下:

    const proxy = new Proxy(obj, handler)

第一个参数是我们的目标对象,也就是上文中的“未知妹子”。handler 也是一个对象,用来定义代理的行为 ,相当于上文中的“婚介所”。当我们通过 proxy 去访问目标对象的时候,handler会对我们的行为作一层拦截,我们的每次访问都需要经过 handler 这个第三方。

5.2 “婚介所”的实现

未知妹子的个人信息,刚问了下我们已经注册了 VIP 的同事哥,大致如下:

    // 未知妹子
    const girl = {
      // 姓名
      name: '小美',
      // 自我介绍
      aboutMe: '...'(大家自行脑补吧)
      // 年龄
      age: 24,
      // 职业
      career: 'teacher',
      // 假头像
      fakeAvatar: 'xxxx'(新垣结衣的图片地址)
      // 真实头像
      avatar: 'xxxx'(自己的照片地址),
      // 手机号
      phone: 123456,
    }

婚介所收到了小美的信息,开始营业。大家想,这个姓名、自我介绍、假头像,这些信息大差不差,曝光一下没问题。但是人家妹子的年龄、职业、真实头像、手机号码,是不是属于非常私密的信息了?要想 get 这些信息,平台要考验一下你的诚意了 —— 首先,你是不是已经通过了实名审核?如果通过实名审核,那么你可以查看一些相对私密的信息(年龄、职业)。然后,你是不是 VIP ?只有 VIP 可以查看真实照片和联系方式。满足了这两个判定条件,你才可以顺利访问到别人的全部私人信息,不然,就劝退你提醒你去完成认证和VIP购买再来。

    // 普通私密信息
    const baseInfo = ['age', 'career']
    // 最私密信息
    const privateInfo = ['avatar', 'phone']
    
    // 用户(同事A)对象实例
    const user = {
      ...(一些必要的个人信息)
      isValidated: true,
      isVIP: false,
    }
    
    // 婚介所登场了
    const lovers = new Proxy(girl, {
      get: function(girl, 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: 60,
    }
    
    // 为用户增开presents字段存储礼物
    const girl = {
      // 姓名
      name: '小美',
      // 自我介绍
      aboutMe: '...'(大家自行脑补吧)
      // 年龄
      age: 24,
      // 职业
      career: 'teacher',
      // 假头像
      fakeAvatar: 'xxxx'(新垣结衣的图片地址)
      // 真实头像
      avatar: 'xxxx'(自己的照片地址),
      // 手机号
      phone: 123456,
      // 礼物数组
      presents: [],
      // 拒收50块以下的礼物
      bottomValue: 50,
      // 记录最近一次收到的礼物
      lastPresent: present,
    }
    
    // 婚介所推出了小礼物功能
    const lovers = new Proxy(girl, {
      get: function(girl, key) {
        if(baseInfo.indexOf(key)!==-1 && !user.isValidated) {
            alert('您还没有完成验证哦')
            return
        }
        
        //...(此处省略其它有的没的各种校验逻辑)
      
        // 此处我们认为只有验证过的用户才可以购买VIP
        if(user.isValidated && privateInfo.indexOf(key) && !user.isVIP) {
            alert('只有VIP才可以查看该信息哦')
            return
        }
      }
      
      set: function(girl, key, val) {
        // 最近一次送来的礼物会尝试赋值给lastPresent字段
        if(key === 'lastPresent') {
          if(val.value < girl.bottomValue) {
              alert('sorry,您的礼物被拒收了')
              return
          }
        
          // 如果没有拒收,则赋值成功,同时并入presents数组
          girl['lastPresent'] = val
          girl['presents'] = [...girl.presents, val]
        }
      }
     
    })

5.3 事件代理

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

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

    <!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}`)
        }
    } )

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

5.4 虚拟代理

简单地给大家描述一下懒加载是个什么东西:它是针对图片加载时机的优化:在一些图片量比较大的网站,比如电商网站首页,或者团购网站、小游戏首页等。如果我们尝试在用户打开页面的时候,就把所有的图片资源加载完毕,那么很可能会造成白屏、卡顿等现象。

此时我们会采取“先占位、后加载”的方式来展示图片 —— 在元素露出之前,我们给它一个 div 作占位,当它滚动到可视区域内时,再即时地去加载真实的图片资源,这样做既减轻了性能压力、又保住了用户体验。

除了图片懒加载,还有一种操作叫图片预加载 。预加载主要是为了避免网络不好、或者图片太大时,页面长时间给用户留白的尴尬。常见的操作是先让这个 img 标签展示一个占位图,然后创建一个 Image 实例,让这个 Image 实例的 src 指向真实的目标图片地址、观察该 Image 实例的加载情况 —— 当其对应的真实图片加载完毕后,即已经有了该图片的缓存内容,再将 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 = targetUrl
        }
    }

这个 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 发起了图片加载请求、完成了图片加载工作,却从未在渲染层面抛头露面。因此这种模式被称为“虚拟代理”模式。

5.5 缓存代理

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

一个比较典型的例子,是对传入的参数进行求和:

    // 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 针对重复的入参只会计算一次,这将大大节省计算过程中的时间开销。现在我们有 6 个入参,可能还看不出来,当我们针对大量入参、做反复计算时,缓存代理的优势将得到更充分的凸显。

Last Updated:
Contributors: leeguooooo