JavaScript设计模式与开发实践-设计模式(代理模式)

154 阅读9分钟

代理模式定义

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


代理场景

    代理模式是一种非常有意义的模式,在生活中可以找到很多代理模式的场景。
    比如,明星都有经纪人作为代理。如果想请明星来办一场商业演出,只能联系他的经纪人。
    经纪人会把商业演出的细节和报酬谈好之后,再把合同交给明星。
    ---
    代理模式的关键是,
    当客户不方便直接访问一个对象或不满足需要的时候,提供一个替身对象来控制这个对象的访问,
    客户实际上访问的是替身对象。替身对象对请求做出一些处理之后,再把请求转交给本体对象。
    graph LR
    emperor((客户)).->emperor1((本体))
    graph LR
    emperor1((客户)).->emperor2((代理)).->emperor3((本体))

虚拟代理实现图片预加载

在Web开发中,图片预加载是一种常用的技术,
如果直接给某个img标签节点设置src属性,由于图片过大或者网络不佳,图片的位置往往有段时间会是一片空白。
常见的作法是先用一张loading图片占位符,然后用异步的方式加载图片,等图片加载好了再把它填充到img节点里,
这种场景就很适合用虚拟代理。
  • 默认情况下加载图片:

首先创建一个普通的本体对象,这个对象负责往页面中创建一个img标签,并提供一个对外的setSrc接口,外界调用这个接口,便可以给该img标签设置src属性:

            var myImage = (function () {
                var img = document.createElement('img')
                document.body.appendChild(img)

                return {
                    setSrc: function (src) {
                        img.src = src
                    }
                }
            })()
            myImage.setSrc(
            'https://img0.baidu.com/it/u=8159143,4223671867&fm=253&fmt=auto&app=138&f=JPEG?w=467&h=472'
            )

我们把网速调整5KB/s,然后通过myImage.setSrc给该img节点设置src,可以看到,在图片被加载好之前,页面中有一段长长的空白时间。

用浏览器模拟加载速度:

    1. F12打开控制台 --> Network image.png
    1. 配置自定义Network -- custom(add) image.png
  • 3.选择自定义项 image.png 浏览器中查看加载情况: 5Kb.gif

  • 使用代理对象优化图片加载:

引入代理对象proxyImage,通过这个代理对象,在图片未被真正加载好之前,页面中将出现一站占位的菊花图loading.gif,来提升用户图片正在加载:

            var myImage = (function () {
                var imgNode = document.createElement('img')
                document.body.appendChild(imgNode)

                return {
                    setSrc: function (src) {
                        imgNode.src = src
                    }
                }
            })()

            var proxyImage = (function () {
                var img = new Image()
                img.onload = function () {
                    myImage.setSrc(this.src) // 加载实际图片
                }
                return {
                    setSrc: function(src) {
                        myImage.setSrc('https://img1.baidu.com/it/u=3347744165,1458093853&fm=253&fmt=auto&app=138&f=GIF?w=130&h=140')
                        img.src = src // 触发onload,
                    }
                }
            })()

            proxyImage.setSrc('https://img0.baidu.com/it/u=8159143,4223671867&fm=253&fmt=auto&app=138&f=JPEG?w=467&h=472')

浏览器查看加载情况: proxyImage.gif

现在我们通过proxyImage间接地访问MyImage。proxyImage控制了客户对MyImage的访问,并且在此过程中加入一些额外的操作,比如在真正的图片加载好之前,先把 img 节点的 src 设置未 一张loading图片。

代理的意义

疑问?? 不过是实现一个小小的图片预加载功能,即使不需要引入任何模式也能办到,那么引入代理模式的好处究竟在哪里?下面我们先抛开代理,编写一个更常见的图片预加载函数。
不用代理的预加载图片函数实现如下:

            var MyImage = (function() {
                // 创建img标签节点并插入body
                var imgNode = document.createElement('img')
                document.body.appendChild(imgNode)

                // 建立图片对象
                let img = new Image()
                img.onload = function () {
                    imgNode.src = img.src
                }
                return {
                    setStr: function (src) {
                        imgNode.src = 'https://img1.baidu.com/it/u=3347744165,1458093853&fm=253&fmt=auto&app=138&f=GIF?w=130&h=140'
                        img.src = src // 赋值后触发 onload 更新imgNode
                    }
                }
           })()

           MyImage.setStr('https://img0.baidu.com/it/u=8159143,4223671867&fm=253&fmt=auto&app=138&f=JPEG?w=467&h=472')

为了说明代理的意义,下面我们引入一个面向对象设计的原则 ———— 单一职责原则

单一职责原则指的是,就一个类(通常也包括对象和函数等)而言,应该仅有一个引起它变化的原因。

如果一个对象承担了多项职责,就意味着这个对象将变得巨大,引起它变化得原因可能会有多个。面向对象设计鼓励将行为分布到细粒度得对象之中,如果一个对象承担的职责过多,等于把这些职责耦合到了一起,这种耦合会导致脆弱的低内聚的设计。当变化发生时,设计可能会遭到意外的破坏。  职责被定义为"引起变化的原因"。上段代码中的 MyImage对象 除了负责给 img节点 设置src外,还要负责预加载图片。我们在处理其中一个职责时,有可能因为其强耦合性影响另外一个职责的实现。
 另外,在面向对象的程序设计中,大多数情况,若违反其他任何原则,同时将违反 开放—封闭原则。如果我们只是从网络上获取一些体积很小的图片,或者5年后的网速快到根本不再需要预加载,我们可能希望把预加载图片的这段代码从 MyImage对象 里删掉。这时候就不得不改动 MyImage对象了。
 实际上,我们需要的只是给img节点设置src,预加载图片只是一个锦上添花的功能。如果能把这个操作放在另一个对象里面,自然是一个非常好的方法。于是代理的作用再这里就体现出来了。代理负责预加载图片,预加载的操作完成之后,把请求重新交给本体MyImage。
 纵观整个程序,我们并没有改变或者增加MyImage的接口,但是通过代理对象,实际上给系统加了新的行为。这是符合开放—封闭原则的。给img节点设置src和图片预加载这两个功能,被隔离再两个对象里,它们可以各自变化而不影响对象。何况就算有一天我们不再需要预加载,那么只需要改成请求本体而不是请求代理即可。

代理和本体接口的一致性

    如果有一天我们不再需要预加载,那么就不再需要代理对象,可以直接选择请求本体。
    其中关键是代理对象和本体都对外提供了 setSrc 方法,
    在客户看来,代理对象和本体是一致的,代理接手请求的过程对于用户来说是透明的,
    用户并不清楚代理和本体的区别,这样做有两个好处:
    1. 用户可以放心地请求代理,他只关心是否能得到想要的结果。
    2. 在任何使用本体的地方都可以替换成使用代理。
    在java等语言中,代理和本体都需要显示地实现同一个接口,
    一方面接口保证他们拥有同样的方法,
    另一方面,面向接口编程迎合依赖倒置原则,通过接口进行向上转型,从而避开编译器的类型检查,
    代理和本体将来可以被替换使用。
    在 JavaScript 这种动态类型语言中,我们有时通过鸭子类型来检测代理和本体是否都实现了 setSrc 方法,
    另外大多数时候甚至干脆不做检测,全部依赖程序员的自觉性,这对程序的健壮性是有影响的。
    不过对于一门快速开发的脚本语言,这些影响还是在可以接受的范围内,而且我们也习惯了没有接口的世界。

另外值得一提的是,如果代理对象和本体对象都为一个函数(函数也是对象),函数必然都能被执行,则可以认为它们也具有一致的 "接口",代码如下:

        var myImage = (function () {
            var imgNode = document.createElement('img')
            document.body.appendChild(imgNode)
            return function (src) {
                imgNode.src = src
            }
        })()

        var proxyImage = (function () {
            var img = new Image()
            img.onload = function () {
                myImage(this.src)
            }

            return function (src) {
                myImage('https://img1.baidu.com/it/u=3347744165,1458093853&fm=253&fmt=auto&app=138&f=GIF?w=130&h=140')
                img.src = src
            }
        })()
        proxyImage('https://img0.baidu.com/it/u=8159143,4223671867&fm=253&fmt=auto&app=138&f=JPEG?w=467&h=472')

虚拟代理合并HTTP请求

在Web开发中,也许最大的开销就算网络请求。 假设我们在做一个文件同步的功能,当我们选中一个checkbox的时候,它对应的文件就会被同步到另外一台备用服务器上面。

<body>
    <input type="checkbox" id="1"></input>1
    <input type="checkbox" id="2"></input>2
    <input type="checkbox" id="3"></input>3
    <input type="checkbox" id="4"></input>4
    <input type="checkbox" id="5"></input>5
    <input type="checkbox" id="6"></input>6
    <input type="checkbox" id="7"></input>7
    <input type="checkbox" id="8"></input>8
    <input type="checkbox" id="9"></input>9
</body>
            var checkbox = document.querySelectorAll('input')

            var synchronousFile = function (id) {
                console.log("开始同步文件, id为:" + id)
            }

            for (var i =0,c; c= checkbox[i++];) {
                c.onclick = function () {
                    if (this.checked) {
                        synchronousFile(this.id)
                    }
                }
            }

当我们选中3个checkbox的时候,依次往服务器发送了3次同步文件的请求。而点击一个checkbox并不是很复杂的操作。如此频繁的网络请求将会带来相当大的开销。

解决方案:我们可以通过一个代理函数 proxySynchronousFile来收集一段时间之内的请求,最后一次性发送给服务器。比如我们等待 2秒 之后才把这 2秒之内需要同步的文件ID打包发给服务器,如果不是对实时性要求非常高的系统,2秒的延迟不会带来太大的副作用,却能大大减轻服务器的压力。 等待 2秒 之后才把这 2秒之内需要同步的文件ID打包发给服务器代码如下:

<body>
    <input type="checkbox" id="1"></input>1
    <input type="checkbox" id="2"></input>2
    <input type="checkbox" id="3"></input>3
    <input type="checkbox" id="4"></input>4
    <input type="checkbox" id="5"></input>5
    <input type="checkbox" id="6"></input>6
    <input type="checkbox" id="7"></input>7
    <input type="checkbox" id="8"></input>8
    <input type="checkbox" id="9"></input>9
</body>
            var checkbox = document.querySelectorAll('input')

            var synchronousFile = function (id) {
                console.log("开始同步文件, id为:" + id)
            }

            var proxySynchronousFile = (function() {
                var cache = [], // 保存一段实际内需要同步的ID
                    timer; // 定时器
                return function (id) {
                    cache.push(id)
                    if (timer) { // 保证不会覆盖已经启动的定时器
                        return
                    }

                    timer = setTimeout(function() {
                        synchronousFile(cache.join(',')) // 2秒后向本体发送需要同步的ID集合
                        clearTimeout(timer) // 清空定时器
                        timer = null
                        cache.length = 0 // 清空ID集合
                    }, 200)
                }    
            })()

            for (var i =0,c; c= checkbox[i++];) {
                c.onclick = function () {
                    if (this.checked) {
                        // synchronousFile(this.id)
                        proxySynchronousFile(this.id)
                    }
                }
            }

缓存代理

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

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

使用 arguments 写法:

// 先创建一个用于乘积的函数
            var mult = function () {
                console.log("开始计算乘积")

                var a = 1;
                for (var i = 0, l = arguments.length; i < l; i++) {
                    a = a * arguments[i]
                }
                return a
            }
            // console.log(mult(2,3)) // 6
            // console.log(mult(2,3,4)) // 24

            // 现在加入缓存代理函数:
            var proxyMult = (function() {
                var cache = {} // 缓存
                // 格式例如:{"1,2,3,4": 24}
                return function () {
                    var args = Array.prototype.join.call(arguments, ',') // cache的key, 如: "1,2,3,4"
                    if (args in cache) {
                        console.log("第二次调用直接返回之前缓存的计算结果")
                        return cache[args]
                    }
                    return cache[args] = mult.apply(this, arguments)
                }
            })()

            console.log(proxyMult(1,2,3,4)) // 24
            console.log( proxyMult(1,2,3,4)) // 24

使用es6的剩余参数写法:

        let mult = function (...params) {
            let array = params.flat(Infinity)
            let a = 1;
            for (let i = 0; i < array.length; i++) {
                a = a * array[i]
            }
            return a
        }

        console.log(mult(2,3,4)) // 24
        console.log(mult([2,3,4])) // 24

        let proxyMult = (function () {
            let cache = {}
            return function (...params) {
                let cacheKey = Symbol.for(Array.prototype.join.call(params, ','))
                if (Object.hasOwn(cache, cacheKey)) {
                    return cache[cacheKey]
                }
                return cache[cacheKey] = mult.apply(this, params)
            }
        })()

        console.log(proxyMult(2,3,4)) // 24
        console.log(proxyMult([2,3,4])) // 24
        console.log(proxyMult(2,4,8)) // 64
        console.log(proxyMult([2,4,8])) // 64

当我们第二次调用proxyMult(1,2,3,4)的时候,本体mult函数并没有被计算,proxyMult直接返回了之前缓存好的计算结果。通过增加缓存代理的方式,mult函数可以继续专注于自身的职责——计算乘积,缓存的功能是由代理对象实现的。

用高阶函数动态创建代理

通过传入高阶函数这种更加灵活的方式,可以为各种计算方法创建缓存代理。现在这些计算方法被当作参数传入一个专门用于创建缓存代理的工厂种,这样一来,我们就可以为乘法、加法、减法等创建缓存代理,代码如下:

            /*************** 计算乘积 ***************/
            var mult = function () {
                var a = 1;
                for (var i = 0, l; l = arguments.length, i <l;i++){
                    a = a * arguments[i]
                }
                return a;
            }

            /*************** 计算加和 ***************/
            var plus = function () {
                var a = 1;
                for (var i = 0, l; l = arguments.length, i <l;i++){
                    a = a + arguments[i]
                }
                return a;
            }
            
         // 创建缓存代理工厂
         var createProxyFactory = function (fn) {
            var cache = {}
            return function () {
                var args = Array.prototype.join.call(arguments, ',')
                if (args in cache) {
                    return cache[args]
                }
                return cache[args] = fn.apply(this, arguments)
            }
         }

         var proxyMult = createProxyFactory(mult)
         console.log(proxyMult(2,4,8)) // 64

         var proxyPlus = createProxyFactory(plus)
         console.log(proxyPlus(2,4,8)) // 15