JavaScript中的设计模式之装饰者模式、代理模式、适配器模式(内含大量实例)

714 阅读11分钟

前言

学习设计模式是为了让我们在合适的场景能够很快的找到某种模式作为解决方案,从代码层面来看设计模式的作用是让我们写出可复用和可维护性高的程序。笔者选取了装饰者模式代理模式适配器模式这三种相似且容易混淆的模式进行详解,区别他们的关键在于模式的意图

另外所有设计模式均遵循一条原则,这很重要:

找出程序中变化的部分,并将变化封装起来

本文大部分内容参考了曾探老师的《JavaScript设计模式与开发实践》,如果有对JavaScript设计模式与实践感兴趣的小伙伴强烈建议阅读原书。

装饰者模式

在传统的面向对象语言中,给对象添加功能常常使用继承的方式,但是继承的方式并不灵活,还会带来许多问题😕;一方面会导致超类和子类之间存在强耦合性,当超类改变时,子类也随之改变;另一方面,继承这种功能复用的方式通常被称为“白箱复用”,“白箱”是相对可见性而言的,超类的内部细节是对子类可见的,继承常常被认为破坏了封装性。另外继承还有可能创建出大量的子类,消耗内存。装饰者模式能够在不改变对象自身的基础上,在程序运行期间给对象动态的添加职责。跟继承相比,装饰者是一种更轻便灵活的做法,这是一种“即用即付”的方式,比如天冷了就多穿一件外套,需要飞行就在头上插一支竹蜻蜓。

装饰函数

在JavaScript中,可以很方便的给某个对象拓展属性和方法,但却很难在不改动某个函数源代码的情况下,给该函数添加一些额外的功能。

let a = function(){
    alert(1)
}

//改成:
let a = function(){
    alert(1)
    alert(2)
}

很多时候我们不应该去碰原函数,可能原函数的实现非常杂乱且不是自己写的😤。现在我们需要一个方法,在不改变函数源代码的情况下,给函数增加功能。

let a = function(){
    alert(1)
}

let _a = a

a = function(){
    _a()
    alert(2)
}
a()

先用_a保存原函数的引用,再在新函数中增加新功能并调用原函数最后赋值给变量a;这样的确实现了没有改变函数源代码的情况下给函数增加功能,也符合开放——封闭原则,不过这种方式存在两个问题

  • 必须要维护_a这个变量,如果装饰链过长也就是包裹原函数的函数过多会产生多个变量需要维护。
  • 其实还遇到了this被劫持的问题,看下面这个例子
var _getElementById = document.getElementById

document.getElementById = function(id){
    alert(1)
    return _getElementById(id)
}

var button = document.getElementById('btn')

执行这段代码后控制台会抛出异常: Uncaught TypeError: Illegal invocation,因为此时_getElementById是个全局函数,this是指向window的,而document.getElementById内部实现需要使用this,this在这个方法内预期是指向document,而不是window,这就是错误发生的原因。这里只要把document作为上下文this传入就可以了

document.getElementById = function(id){
    alert(1)
    return _getElementById.apply(document,id)
}

这么做显然不方便,下面引入一种完美的方法给函数动态添加功能——AOP😆

用AOP装饰函数

AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日记统计、安全控制、异常处理等。把这些功能抽离出来,再通过“动态织入”的方式掺入业务逻辑模块中。这样做的好处是首先是可以保存业务逻辑模块的纯净和高内聚性,其次是可以很方便地复用日志统计等功能模块。

来看下面JavaScript的实现:

    Function.prototype.before(fn){
        let self = this
        return function(){
            fn.apply(self,arguments)
        }
    }

    Function.prototype.after = function(afterfn){
        let self = this
        return function(){
            let res = self.apply(this,arguments)
            afterfn.apply(this,arguments)
            return res
        }
    }
    window.onload = function () {
        console.log('window.onload');
    }

    window.onload = (window.onload || function () { })
        .before(() => { console.log('before'); })
        .after(() => { console.log('after'); })

image.png

插件式的表单验证

我们都写过表单验证的代码,在表单提交给后台之前常常需要做一些校验,比如登录的时候需要校验用户名和密码是否为空

<html>

<body>
    用户名:<input id="username" type="text">
    密码:<input id="password" type="password">
    <input id="submitBtn" value="提交" type="button">
</body>
<script>
    let button = document.getElementById('submitBtn')
    let usernameInput = document.getElementById('username')
    let passwordInput = document.getElementById('password')
    function submit() {
        if (!usernameInput.value) return alert('用户名不能为空')
        if (!passwordInput.value) return alert('密码不能为空')
        console.log(usernameInput.value, passwordInput.value);
        let parms = {
            username:usernameInput.value,
            password:passwordInput.value
        }
        ajax('http://xxx.com/login',parms)
    }
    button.onclick = function () {
        submit()
    }
</script>

</html>

submit函数在此处承担了两个职责,除了提交ajax请求之外,还要验证用户输入的合法性。这种代码一来回造成函数臃肿,职责混乱,二来谈不上任何复用性。

    function validata() {
        if (usernameInput.value === '') {
            return false
        }
        if (passwordInput.value === '') {
            return false
        }
    }
    function submit() {
        if (validata() === false) {
            return
        }
        console.log(usernameInput.value, passwordInput.value);
        let parms = {
            username: usernameInput.value,
            password: passwordInput.value
        }
        ajax('http://xxx.com/login', parms)
    }
    button.onclick = function () {
        submit()
    }

这里我们把校验的逻辑抽离了出来,但在submit函数中还是要计算validata的返回值,我们用装饰者模式来实现validata和submit的完全分离

<html>

<body>
    用户名:<input id="username" type="text">
    密码:<input id="password" type="password">
    <input id="submitBtn" value="提交" type="button">
</body>
<script>
    let button = document.getElementById('submitBtn')
    let usernameInput = document.getElementById('username')
    let passwordInput = document.getElementById('password')

    Function.prototype.before = function (beforefn) {
        let self = this
        return function () {
            if (beforefn.apply(this, arguments) === false) {
                return
            }
            return self.apply(this, arguments)
        }
    }
    function validata() {
        if (usernameInput.value === '') {
            alert('用户名不能为空')
            return false
        }
        if (passwordInput.value === '') {
            alert('密码不能为空')
            return false
        }
    }
    function submit() {
        console.log(usernameInput.value, passwordInput.value);
        let parms = {
            username: usernameInput.value,
            password: passwordInput.value
        }
        ajax('http://xxx.com/login', parms)
    }
    submit = submit.before(validata)
    button.onclick = function () {
        submit()
    }

</script>

</html>

这段代码中,校验输入和提交表单的代码完全分离出来,它们不再有任何耦合关系,submit = submit.before(validata)这句代码如同把校验规则动态接入submit函数之前,validata成为一个即插即用的函数,它甚至可以被写成配置文件的形式,这有利于我们分开维护这两个函数。再利用策略模式稍加改造,我们就可以把这些校验规则写成插件的形式😃。

代理模式

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

注意和装饰者模式的区分,代理模式是代理对象控制对原对象的访问;而装饰者模式是为对象动态加入行为。也就是说代理模式在一开始就已经确定了代理对象和原对象的关系,而装饰者模式用于一开始不能确定对象的全部功能。区别两种模式的是它们的意图和设计目的。

未命名文件 (1).png

书中用一个有趣的例子引出了代理模式:

在四月的一个晴朗的早晨,小明遇见了他的百分百女孩,我们暂且称呼小明的女神为A。两天之后,小明决定给A送一束花来表白。刚好小明打听到A和他有一个共同的朋友B,B可以知道A的心情,于是小明决定让B在A心情好的时候再送花,这样可以大大提高成功率。

下面用代码来描述小明追女神的过程

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,即小明通过B来给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.receiveFlower(flower)
         })
    }
}

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

xiaoming.sendFlower(B)

虚拟代理实现图片预加载

虚拟代理指的是把一些开销很大的对象,延迟到真正需要它的时候才去创建,拿上面的小明的例子来说,如果new Flower是一个昂贵的操作,我们可以让B去监听A的心情,当A的心情变好后再执行new Flower的操作,这就是虚拟代理。
另外常见的还有一种代理叫做保护代理,保护代理用来控制不同权限的对象对目标对象的访问。我们依旧拿上面小明的例子来举例,B可以帮助A过滤一些请求,如果追求者年龄太大或者身高太矮,这种请求B就可以直接帮A拒绝掉,A和B一个充当白脸一个充当黑脸。白脸A继续维持自己的女神形象,不希望直接拒绝任何人,于是找来了黑脸B来控制对A的访问。但是在JavaScript中我们无法知道是谁访问了某个对象,所以并不好实现保护代理,下面我们详细介绍虚拟代理。

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

let proxyImg = (function(){
    let img = new Image()
    img.onload = function(){
        myImage.setSrc(this.src)
    }
    return function(src){
        myImage.setSrc('xxxxx')   //添加占位图
        img.src = src
    }
})();

proxyImg(xxx)     //需要加载的图

这段代码首先加载占位图,同时加载需要展示的图片,当需要展示的图片加载好后触发img.onload,注意这里this是指向的img,然后加载好后的图片会替换占位图。下面来看没有使用虚拟代理的代码该怎么实现图片预加载。

let myImage = (function(){
    let imgNode = document.createElement('img')
    document.body.appendChild(imgNode)
    let img = new Image
    img.onload = function(){
        imgNode.src = this.src
    }
    return {
        setSrc:function(src){
            imgNode.src = 'xxxxx' //占位图
            img.src = src
        }
    }
})()
myImage.setSrc(xxxx)

在不用设计模式的情况下,我们用了更简短的代码实现了图片预加载的功能,那代理模式是不是很鸡肋呢?下面来看看代理模式的意义

代理的意义

面向对象有一个设计原则——单一职责原则,就一个类(通常也包括对象和函数)而言,应该仅有一个引起他变化的原因。如果一个对象承担了多项职责,就意味了这个对象将变得巨大,引起他变化的原因会有多个。面向对象设计鼓励将行为分布在细粒度的对象之中,如果一个对象承担的职责过多,等于把这些职责耦合到了一起,这种耦合会导致脆弱和低内聚的设计。当变化改变时,设计可能会遭到意外的破坏。
上述例子中myImage承担了生成img节点和预加载两个职责。我们在处理其中一个职责时,有可能会因为其强耦合性影响另一个职责的实现。
另外,在面向对象的程序设计中,大多数情况下,若违反其他任何原则,同时将违反开放-封闭原则。该原则的定义如下:

软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。

预加载图片的例子中,如果图片很小,我们不需要预加载这个功能了,在不用设计模式的情况下,我们需要直接修改myImage函数,违背开放—封闭原则。反之,我们可以直接使用myImage函数,需要时,使用proxyImg

缓存代理

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

    let mult = function () {
        console.log('计算');
        let result = 1;
        for (let i = 0; i < arguments.length; i++) {
            result = arguments[i] * result;
        }
        return result
    }


    let proxyMult = (function() {
        let cache = {}
        return function () {
            var args = Array.prototype.join.call(arguments, ',')
            if (args in cache) {
                return cache[args]
            }
            return cache[args] = mult.call(this, arguments[0])
        }
    })()
    console.log("直接计算",proxyMult(3,4,5));
    console.log("使用缓存",proxyMult(3,4,5));

mult是一个累乘的函数,我们把使用过的乘数当作变量存入cache中作为一个属性,通过判断之后是否有相同的乘数参与计算而选择是否中cache中缓存的值还是重新调用mult函数重新计算一次。

缓存代理用于ajax异步请求数据

我们常常在项目中遇到分页的请求,同一页的数据理论上应该只需要去后台拉去一次,这些已经拉去到的数据在某个地方缓存之后,下一次再请求同一页的时候,便可以直接使用之前的数据。

我们来实现下:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>利用缓存代理实现翻页效果</title>
</head>
<style>
    body {
        display: flex;
        justify-content: center;
        align-items: center;
        flex-direction: column;
        height: 100vh;
    }
</style>

<body>
    <div id="container"></div>
    <!-- 翻页器 -->
    <div id="footContainer">
        <button id="pre"></button>
        <button id="next"></button>
    </div>
</body>
<script>
    let container = document.getElementById('container')
    let preBtn = document.getElementById('pre')
    let nextBtn = document.getElementById('next')
    let page = 1, cache = { '1': `${ajax(1).content}` }
    container.innerHTML = `${ajax(1).content}`
    preBtn.onclick = proxyPrePage
    nextBtn.onclick = proxyNextPage
    function ajax(page) {
        return {
            page,
            content: `<span>我是第${page}页的内容</span>`
        }
    }
    function prePage() {
        if (page <= 1) {
            alert("不能往前翻啦!")
            return
        }
        container.innerHTML = `${ajax(--page).content}`
    }
    function nextPage() {
        container.innerHTML = `${ajax(++page).content}`
    }
    function proxyPrePage() {
        if (page - 1 in cache) {
            console.log("proxyPrePage");
            container.innerHTML = cache[--page]
            return
        }
        if (page > 1) {
            cache[String(page - 1)] = ajax(page - 1).content
        }
        prePage()
    }
    function proxyNextPage() {
        if (page + 1 in cache) {
            console.log("proxyNextPage");
            container.innerHTML = cache[String(++page)]
            return
        }
        cache[String(page + 1)] = ajax(page + 1).content
        nextPage()
    }

</script>

</html>

为了简化代码,用了ajax函数代替异步请求,这里代码量虽然增加了,但是更易于维护了,要是不需要缓存的情况下直接修改为preBtn.onclick = prePage,nextBtn.onclick = nextPage即可,这样的设计会降低代码的耦合性,不用担心要是去修改原函数会带来怎样的副作用。

适配器模式

适配器的别名是包装器,试想下面这个场景:当我们试图调用某个模块的接口时或者某个对象的接口时,发现这个接口的格式并不符合预期。这时候有两种方法,第一种方法是直接修改原接口,但这显然不是我们想要的,尤其是当这个接口不是我们编码的....第二种方法是创建一个适配器,将原接口转换为我们想要的接口。生活中有很多常见的例子:USB转接头,电源适配器等。

下面这个实例能让我们更加充分的理解适配器模式:

let googleMap = {
    show:function(){
        console.log('开始渲染谷歌地图')
    }
}

let baiduMap = {
    show:function(){
        console.log('开始渲染百度地图')
    }
}

let renderMap = function(map){
    if(map.show instanceof function){
        map.show()
    }
}

在这个例子中googleMap.show和baiduMap.show都是第三方提供的接口,这段代码能够正常运行是因为他们都提供了名为show的接口,如果换一个名字呢?这时候我们可以用一个包装器包装一下

let baiduMap = {
    display:function(){
        console.log('开始渲染百度地图')
    }
}

let baiduMapAdapter = {
    show:function(){
        baiduMap.display()
    }
}

总结

最后,始终要注意的是区分设计模式的关键在于模式的意图:

  • 装饰者模式用来为被装饰者提供更多的功能
  • 代理模式用来控制不同的请求对本体的访问
  • 适配器模式解决两个已有接口之间不匹配的问题

参考

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