《JavaScript设计模式与开发实践》-- 代理模式

117 阅读12分钟

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

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

第一个例子 —— 小明追MM的故事

在四月一个晴朗的早晨,小明遇见了他的百分百女孩,我们暂且称小明的女神为A。两天之后,小明决定给A送一束花来表白。刚好小明打听到A和他有一个共同的朋友B,于是内向的小明决定让B来代替自己完成送花这件事情。

用代码来描述小明追女神的过程,先看不用代理模式的情况:

var Flower = function(){};

var xiaoming = {
    sendFlower: function(target) {
        var flower = new Flower();
        target.receiveFlower(flower);
    }
};

var A = {
    receiveFlower: function(flower) {
        console.log('收到花' + flower);
    }
};

xiaoming.sendFlower(A);

接下来我们引入代理B

var Flower = function(){};

var xiaoming = {
    sendFlower: function(target) {
        var flower = new Flower();
        target.receiveFlower(flower);
    }
};

var B = {
    receiveFlower: function(flower) {
        A.receiveFlower(flower);
    }
};

var A = {
    receiveFlower: function(flower) {
        console.log('收到花' + flower);
    }
};

xiaoming.sendFlower(B);

执行结果跟第一段代码一致,至此我们就完成了一个最简单的代理模式的编写。

有人可能会疑惑,小明自己去送花和代理B帮小明送花,二者看起来并没有本质的区别,引入一个代理对象看起来只是把事情搞复杂了而已。

的确,此处的代理模式毫无用处,它所做的只是把请求简单地转交给本体。

现在我们改变故事的背景设定,假设当A在心情好的时候收到花,小明表白成功的几率有60%,而当A在心情差的时候收到花,小明表白的成功率无限趋近于0

小明跟A刚刚认识两天,还无法辨别A什么时候心情好。如果不合时宜地把花送给A,花被直接扔掉的可能性很大,这束花可是小明吃了7天泡面换来的。

但是A的朋友B却很了解A,所以小明只管把花交给BB会监听A的心情变化,然后选择A心情好的时候把花转交给A

var Flower = function(){};

var xiaoming = {
    sendFlower: function(target) {
        var flower = new Flower();
        target.receiveFlower(flower);
    }
};

var B = {
    receiveFlower: function(flower) {
        A.listenGoodMood(function() {  // 监听 A 的好心情
            A.receiveFlower(flower);
        });
    }
};

var A = {
    receiveFlower: function(flower) {
        console.log('收到花' + flower);
    },
    listenGoodMood: function(fn) {
        setTimeout(function() {  // 假设 10 秒之后 A 的心情变好
            fn();
        }, 10000);
    }
};

xiaoming.sendFlower(B);

保护代理和虚拟代理

虽然这只是一个虚拟的例子,但我们可以从中找到两种代理模式的身影。代理B可以帮助A过滤掉一些请求,这种代理叫做保护代理

另外,假设现实中的花价格不菲,导致在程序世界里,new Flower也是一个代价昂贵的操作,那么我们可以把new Flower的操作交给代理B去执行,代理B会选择在A心情好时再执行new Flower,这是代理模式的另一种形式,叫做虚拟代理虚拟代理把一些开销很大的对象,延迟到真正需要它的时候再去创建

var B = {
    receiveFlower: function(flower) {
        A.listenGoodMood(function() {  // 监听 A 的好心情
            var flower = new Flower();  // 延迟创建 flower 对象
            A.receiveFlower(flower);
        });
    }
};

保护代理用于控制不同权限的对象对目标对象的访问,但在JavaScript中并不容易实现保护代理,因为我们无法判断谁访问了某个对象。而虚拟代理是最常用的一种代理模式。

虚拟代理实现图片预加载

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

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

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

myImage.setSrc('http://xxx.png');

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

现在开始引入代理对象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('http://loading.gif');
            img.src = src;
        }
    }
})();

proxyImage.setSrc('http://xxx.png');

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

代理的意义

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

var myImage = (function() {
    var imgNode = document.createElement('img');
    document.body.appendChild(imgNode);
    var img = new Image;
    img.onload = function() {
        imgNode.src = img.src;
    };
    
    return {
        setSrc: function(src) {
            imgNode.src = 'http://loading.gif';
            img.src = src;
        }
    }
})();

myImage.setSrc('http://xxx.png');

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

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

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

另外,在面向对象的程序设计中,大多数情况下,若违反其他任何原则,同时将违反开放-封闭原则。如果不需要预加载,就不得不改动myImage对象。实际上,我们需要的只是给img节点设置src,预加载图片只是一个锦上添花的功能。如果能把这个操作放在另一个对象里,自然是一个非常好的方法。于是代理的作用在这里就体现出来了,代理负责预加载图片,预加载的操作完成之后,把请求重新交给本体myImage

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

代理和本体接口的一致性

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

  • 用户可以放心地请求代理,他只关心是否能得到想要的结果。
  • 在任何使用本体的地方都可以替换成使用代理。

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('http://loading.gif');
        img.src = src;
    }
})();

proxyImage('http://xxx.png');

虚拟代理合并HTTP请求

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

image.png

我们先在页面上放置好这些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>

接下来,给这些checkbox绑定点击事件,并且在点击的同时往另一台服务器同步文件:

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

var checkbox = document.getElementsByTagName('input');

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

当我们选中3checkbox的时候,依次往服务器发送了3次同步文件的请求。可以预见,点击越多的checkbox,会带来越频繁的网络请求,同时也带来越大的开销。

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

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 集合
        }, 2000);
    }
})();

var checkbox = document.getElementsByTagName('input');

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

缓存代理

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

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

先创建一个用于求乘积的函数:

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

mult(2, 3);     // 6
mult(2, 3, 4);  // 24

现在加入缓存代理函数:

var proxyMult = (function() {
    var cache = {};
    return function() {
        var args = Array.prototype.join.call(arguments, ',');
        if (args in cache) {
            return cache[args];
        }
        return cache[args] = mult.apply(this, arguments);
    }
})();

proxyMult(1, 2, 3, 4);  // 24
proxyMult(1, 2, 3, 4);  // 24

当我们第二次调用proxyMult(1, 2, 3, 4)的时候,本体mult函数并没有被计算,proxyMult直接返回了之前缓存好的计算结果。

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

用高阶函数动态创建代理

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

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

// 计算加和
var plus = function() {
    var a = 0;
    for (var i = 0, 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),
    proxyPlus = createProxyFactory(plus);
    
alert(proxyMult(1, 2, 3, 4));  // 24
alert(proxyMult(1, 2, 3, 4));  // 24
alert(proxyPlus(1, 2, 3, 4));  // 10
alert(proxyPlus(1, 2, 3, 4));  // 10

其他代理模式

代理模式的变体种类非常多,限于篇幅及其在JavaScript中的适用性,只简单介绍一下这些代理:

  • 防火墙代理:控制网络资源的访问,保护主题不让“坏人”破坏。
  • 远程代理:为一个对象在不同的地址空间提供局部代表,在Java中,远程代理可以是另一个虚拟机中的对象。
  • 保护代理:用于对象应该有不同访问权限的情况。
  • 智能引用代理:取代了简单的指针,它在访问对象时执行一些附加操作,比如计算一个对象被引用的次数。
  • 写时复制代理:通常用于复制一个庞大对象的情况。写时复制代理延迟了复制的过程,当对象被真正修改时,才对它进行复制操作。写时复制代理是虚拟代理的一种变体,DLL(操作系统中的动态链接库)是其典型运用场景。