JavaScript设计模式

158 阅读22分钟

e43b6cef217b02f735ac7224757dbe89.jpeg

我对于设计模式的直接理解:设计模式就是一种解决问题的,优雅的编程思维,让我们写出的代码自己看着不觉得恶心。

简介

设计模式的定义是:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。通俗一点说,设计模式是在某种场合下对某个问题的一种解决方案。如果再通俗一点说,设计模式就是给面向对象软件开发中的一些好的设计取个名字。实际上即时我们之前没有接触过设计模式,但是在我们开发过程中或多或少也绝对写过和某种设计模式相像的代码。

设计模式的作用是:让人们写出可复用和可维护性高的程序。设计模式的实现都遵循一条原则,即“找出程序中变化的地方,并将变化封装起来”。一个程序的设计总是可以分为可变的部分和不变的部分。当我们找出可变的部分,并且把这些部分封装起来,那么剩下的就是不变和稳定的部分。这些不变和稳定的部分是非常容易复用的。

分辨模式的关键是:在具体的环境下,模式解决问题的意图。模式应该用在正确的地方,而不能生搬硬套,比如学会了策略模式之后,恨不得所有问题都用策略模式来解决,手里拿个锤子看啥都是钉子。

策略模式

理解:可以帮助我们少写很多if…else….同时使代码逻辑更清晰,降低不同区块之间的耦合性,使代码更易于维护。

定义:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

举个栗子:结算页的支付方式

341634742771_.pic_hd.jpg

如上图所示,我们经常在结算页遇到很多中支付方式:各种银行卡支付、信用卡支付、微信支付、云闪付等;无论各种支付方式,它们都是有一个共性和不同的

共性:支付前提都需要获取订单Id,支付金额等通用参数; 不同:支付操作的具体方式不同

基于以上分析,在不使用策略模式的情况下我们可能会这样完成上述功能;

function wxPay(orderId, money){
    console.log('微信支付')
}

function jdPay(orderId, money){
    console.log('京东支付')
}

function bankCardPay(orderId, money){
    console.log('银行卡支付')
}

function pay(type, orderId, money) {
    if(type === 'wxPay') {
         wxpay(orderId, money)
    }
    esle if(type === 'jdPay') {
         jdpay(orderId, money)
    }
    esle if(type === 'bankCardPay') {
         bankCardPay(orderId, money)
    }
    // 此处省略不知道多少个else if
}

上述代码有着显而易见的缺点:

1:pay函数过于庞大,包含了很多if... else if ...分支以便于涵盖所有的支付逻辑
2:如果后续再增加支付方式,或者想改动某个支付方式、去掉某个支付方式,我们必须深入到pay函数内部去实现,
系统的变化严重缺乏弹性。

基于上述的缺点,我们来使用策略模式来重构代码:

// 利用js的多态来实现

var payObj = {
    'wxPay': (orderId, money) => {
                console.log('微信支付')
             },
    'jdPay': (orderId, money) => {
                console.log('京东支付')
             },
    'bankCardPay': (orderId, money) => {
                console.log('银行卡支付')
             },
}

function pay(type, orderId, money) {
    payObj[type](orderId, money)
}

// 模拟面向对象的方式

function WxPay() {
}
WxPay.prototype.pay = function(orderId, money){
    console.log('微信支付')
}

function JdPay() {
}
JdPay.prototype.pay = function(orderId, money){
    console.log('京东支付')
}

function BankCardPay() {
}
BankCardPay.prototype.pay = function(orderId, money){
    console.log('银行卡支付')
}

function Pay() {
    this.payType = null;
}

Pay.prototype.setPayType = function(obj) {
    this.payType = obj
}

Pay.prototype.payOrder = function(obj) {
    if(!this.payType) {
        throw new Error('尚未设置支付方式!')
    }
    this.payType.pay()
}

let pay = new Pay();
pay.setPayType(new WxPay());
pay.payOrder(); // 微信支付

由于javaScript名称和语言本身起源的关系,在这里额外提一下传统面向对象语言的策略模式的实现,在实际的开发过程中我们可能更经常的采用第一种方式。

综上:策略模式的优缺点:

优点:结合组合函数、多态等方法,有效的减少了条件分支,各个算法被封装在独立的对象中,提高了代码的延展性;
缺点:可能会导致程序中,增加了许多策略对象,并且用户需要深刻的了解每个对象的细节。但个人感觉这缺点的影响并不严重

单例模式

理解:你所需要的对象,在相应的环境中只存在一个,以应对各种情况。

面向对象的单例定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

JavaScript中的单例定义:一个提供给全局可访问的、且唯一的对象(可以是一个全局常量,节点,对象等)。

举个栗子:界面上的loading或toast弹框。

411635183496_.pic.jpg

如上图所示,我们经常在页面上遇到的各种loading或toast弹框,这个弹框完全可以运用单例模式的逻辑进行设计。保证无论显示多少次loading或toast,这个弹框节点都只会被创建一次。不同的弹框,它们都是有一个共性和不同的:

共性:样式上大同小异,可同属于一个节点 不同:图片或文字内容不同,大点的样式不同,也可考虑结合策略模式来优化。

基于以上分析,在不使用单例模式的情况下我们可能会这样完成上述功能;


function UseToast() {
    this.show = (icon, mesage)=>{
        let div = document.createElement('div')
        div.setAttribute("id","toast")
        div.setAttribute("class","toast")

        let img = document.createElement('img')
        img.src = icon
        
        let msg = document.createElement('div')
        msg.innerText = mesage
        
        div.append(img)
        div.append(msg)
        
        document.body.append(div)
    }
    this.hide = ()=>{
        var thisNode=document.getElementById("toast")
        thisNode.parentNode.removeChild(thisNode)
    }
}

export default new UseToast()
    

上面的代码只是简单的写了一个大概,它的设计逻辑和我们在框架中常用的逻辑是类似的,先说一下它的缺点:

1:每次调用show方法都会创建一个新节点插入dom,隐藏节点时,从dom结构中删除目标元素,这整个过程其实带来了一些资源上的
浪费和性能上的消耗。
2:不同地方先后调用show方法,很可能在界面上会同时展示好几个toast弹框,并且弹框相互重叠,给用户带来不太好的体验。

基于上述的缺点,我们来使用单例模式来重构代码:

function UseToast() {
    this.show = (icon, mesage)=>{
        const toast = document.getElementById("toast")
        if(toast) {
            toast.style = "display: flex"
            
            const toastIcon = document.getElementById("toastIcon")
            toastIcon.src = icon
            
            const toastMsg = document.getElementById("toastMsg")
            toastMsg.innerText = mesage
            
            return
            
        }
        let div = document.createElement('div')
        div.setAttribute("id","toast")
        div.setAttribute("class","toast")

        let img = document.createElement('img')
        img.setAttribute("id","toastIcon")
        img.src = icon
        
        let msg = document.createElement('div')
        msg.setAttribute("id","toastMsg")
        msg.innerText = mesage
        
        div.append(img)
        div.append(msg)
        
        document.body.append(div)
    }
    this.hide = ()=>{
        const toast = document.getElementById("toast")
        if(!toast) throw new Error('没有toast节点')
        toast.style = "display:none"
    }
}

let toast = new UseToast()
toast.show('https://res.myscrm.cn/yued/icon_loading_w@2x.png', '加载中...')

let toast1 = new UseToast()
toast1.show('https://hangzhou.aliyuncs.com/icon_success.png', '加载中...')

上面的代码是借用单例的思想来实现的,已经基本满足了我们的需求,但是它仍然有一个小缺点:创建对象和管理单例逻辑的被写到一起去了,后续如果我们需要再写一个单例模式的对话框的话,就只能复制粘贴、修改代码。

// 通用单例模式
    let getSingle = function (fn) {
      let result;
      return function () { 
        return result || (result = fn.apply(this, arguments));
      };
    };

    function createLoading(icon, mesage) {
      let div = document.createElement("div");
      div.setAttribute("id", "toast");
      div.setAttribute("class", "toast");

      let img = document.createElement("img");
      img.setAttribute("id", "toastIcon");
      img.src = icon;

      let msg = document.createElement("div");
      msg.setAttribute("id", "toastMsg");
      msg.innerText = mesage;

      div.append(img);
      div.append(msg);

      div.style = "display:none";

      document.body.append(div);

      return div;
    }

    var createSingleLoading = getSingle(createLoading);
    
    var loading = createSingleLoading(
        "https://yunke-oss.oss-cn-hangzhou.aliyuncs.com/yued/qiwei/h5/icon_success.png",
        "加载中..."
      );
      loading.style = "display:flex";

上面的代码,提供了一种通用的单例模式解决方案,同时将loading节点的创建与管理单例逻辑严格分开,更易于代码的维护与复用。

综上:单例模式是一种简单且常用的模式,在实际运用中也会包括闭包、高阶函数等相关概念。从资源的重复利用的角度来说,它也有点性能能优化的意味。

装饰者模式

理解:为对象或方法添加新的功能,同时不影响其原有的功能。装饰者模式在es6中已经逐渐得到了原生的支持(目前还是预案),后续应用应该会非常的广泛。

定义:装饰者模式能够在不改变对象自身的基础上,在程序运行期间给对象动态地添加职责。基于面向对象语言考虑的话, 装饰者模式是对继承的导致类爆炸(为了完成一些功能的复用,而创建出大量的子类)问题的一种优化,提供了比继承更有弹性的替代方案。

举个栗子:数据埋点与上报

    // 先看一下简单版的装饰者模式实现的数据上报
    var save  = function () {
        console.log('保存')
    }

    var buriedPointDecorator = function() {
        console.log('数据埋点')
    }

    var reportDecorator = function() {
        console.log('数据上报')
    }

    var save1 = save

    save = function() {
        save1()
        buriedPointDecorator()
    }

    var save2 = save

    save = function() {
        save2()
        reportDecorator()
    }
    save() // 依次输出: 保存、数据埋点、数据上报

的确没有改变save函数原有的功能,并同时给save函数新增了数据埋点和数据上报的功能,但这总感觉有点儿low

    // 模拟面向对象语言的装饰者模式实现的数据上报
    function Save() {}
    
    Save.prototype.action = function() {
        console.log('保存')
    }

    function BuriedPointDecorator (decoratorObj) {
        this.decoratorObj = decoratorObj
    }

    BuriedPointDecorator.prototype.action = function() {
        this.decoratorObj.action()
        console.log('数据埋点')
    }

    function ReportDecorator (decoratorObj) {
        this.decoratorObj = decoratorObj
    }

    ReportDecorator.prototype.action = function() {
        this.decoratorObj.action()
        console.log('数据上报')
    }

    let save = new Save()
    save = new BuriedPointDecorator(save);
    save = new ReportDecorator(save);
    save.action() // 依次输出: 保存、数据埋点、数据上报

似乎高级了一点,但这不过是单板的模拟面向对象的写法,并且在实际编程开发过程中也并不常用。

上面的两种写法都有一个共同的问题: 必须维护一个中间变量,虽然看起来并不起眼,但如果函数的装饰链较长,或者需要装饰的函数变多,代码就会变得很冗长,一点也不丝滑!

// 使用es6提供的装饰器预案来实现
  function buriedPointDecorator(target, name, descriptor) {
      console.log('数据埋点')
  }
  function reportDecorator(target, name, descriptor) {
    console.log('数据上报')
  }
  
  class Save {
      @reportDecorator
      @buriedPointDecorator
      action() {
          console.log('保存')
      }
  }

  const save = new Save()
  save.action() // 依次输出:数据埋点、数据上报、保存

已经丝滑了很多,但是这里还有一些问题:

1:es6的装饰器只能装饰类和类的方法,由于函数存在变量名提升的元素,所以不能装饰函数;

2:目前es6的装饰器只是预案,也就是还没完全确定,后续写法上可能会出现大的改动;

3class是面向对象的写法,但在实际开放过程中我们更多的是面向函数;

基于类的装饰器问题,使用高阶函数来装饰普通的函数:

function decorator(fn) {
  return function() {
    console.log('数据埋点');
    const result = fn.apply(this, arguments);
    console.log('数据上报');
    return result;
  }
}

var save = function () {
    console.log('保存')
}

save = decorator(save)
save() // 依次输出:数据埋点、保存、数据上报

很明显这种方式还是不够丝滑的,首先使用起来不是很方便,甚至还有点儿鸡肋的感觉。

最后一种,基于原生Js的Function.prototype属性来实现装饰者模式:


   Function.prototype.before = function (beforeFn) {
      var _this = this;
      return function () {
        beforeFn.apply(this, arguments);
        return _this.apply(this, arguments);
      };
    };
    
   Function.prototype.after = function (afterFn) {
      var _this = this;
      return function () {
        var result = _this.apply(this, arguments);
        afterFn.apply(this, arguments);
        return result;
      };
    };

   var save = function () {
      console.log("保存");
    };
   save = save
      .before(() => {
        console.log("数据埋点");
      })
      .after(() => {
        console.log("数据上报");
      });
      
   save(); // 依次输出:数据埋点、保存、数据上报

看起来和上面的相比的确是丝滑很多,后续我们可以用这种方式来装饰任何function。然而这里也有一个致命的缺陷:我们污染了Function的原型,暂不说什么原型链污染攻击,首先这种操作在eslint和tslint中是不被允许的,然而在我们实际开发的项目中eslint和ts又是必备的。

综上:装饰者模式的确是一种很有用的设计模式,可惜在当前js整个生态中的适配并不完善。虽然有各种各样的实现方式,但每一种或多或少都有一些缺陷,希望在后续js的发展中能尽快的完善这一点不足吧!

发布订阅模式

理解: 观察者模式的升级版,只是多了一个中间商。网上有一个很好的订牛奶的例子:客户每天把订单直接交给奶农,奶农收到订单后第二天把生产好的牛奶送到客户家中——观察者模式;客户每天把订单交给鲜奶配送站,奶农每天把生产好的牛奶送到鲜奶配送站,然后由配送站统一配送给客户——发布订阅模式。

定义:对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。

先看一个简单的发布订阅demo

   var event = {
        // 出于订阅者可能只想接收自己订阅的事件的考虑,这里采用{}
        eventList: {},
        // 接收订阅事件
        listen:function(key,fn) {
            if(!this.eventList[key]) {
                this.eventList[key] = []
            }
            this.eventList[key].push(fn)
           
        },
        // 触发订阅事件
        emit: function() {
            var key  = [].shift.call(arguments)
            var fns = this.eventList[key]
            if(!fns || fns.listen === 0) throw new Error('没有查找到相关订阅事件')

            for(var i = 0,fn; fn = fns[i++];) {
                fn.apply(this, arguments)
            }
        },
        // 取消某个具体事件的订阅,或取消一类事件的订阅
        remove:function(key, fn) {
            if(fn && this.eventList[key] && this.eventList[key].length){
                try {
                this.eventList[key].forEach((i, j) => {
                    if (i === fn) {
                        this.eventList[key].splice(j, 1);
                        throw new Error('已找到目标函数,结束循环')
                    }
                });
                }catch(e){}
                return
            }
            if(this.eventList[key]) delete this.eventList[key]
        }
    }

    // 注入订阅事件1
    event.listen('event1', function(){console.log('事件event1的第一次订阅')})
    event.listen('event1', function(){console.log('事件event1的第二次订阅')})
    event.listen('event1', function(){console.log('事件event1的第三次订阅')})

    // 注入订阅事件2
    event.listen('event2', function(){console.log('事件event2的第一次订阅')})
    event.listen('event2', function(){console.log('事件event2的第二次订阅')})
    event.listen('event2', function(){console.log('事件event2的第三次订阅')})

    // 触发订阅事件1 依次输出:事件event1的第一次订阅, 事件event1的第二次订阅,事件event1的第三次订阅
    event.emit('event1') 
    event.remove('event2') // 取消订阅事件2
    event.emit('event2') // 报错:没有查找到相关订阅事件

上面的代码只是一个发布订阅的简单模型,在实际开发过程中我们可能遇到的需求逻辑比这个复杂的多,但是万变不离其宗。我们来看一个购物车的栗子:

421635268730_.pic.jpg

431635268741_.pic.jpg

一些常见的商城应用中都会有这个需求,在程序内的某个页面加购商品,程序内所有购物车组件的数据状态都得相应的更新。在不考虑使用数据状态管理库(比如mobx、 redux、vuex,它们的设计思想也是发布订阅模式)的情况下,我们该考虑怎样实现?

<body>
    <div id="shopCar1">购物车1商品数量:</div>
    <div id="shopCar2">购物车2商品数量:</div>
    <div id="addCarBtn">加入购物车</div>

</body>
<script>
    var event = {
        eventList: {},
        listen:function(key,fn) {
            if(!this.eventList[key]) {
                this.eventList[key] = []
            }
            this.eventList[key].push(fn)
           
        },
        emit: function() {
            var key  = [].shift.call(arguments)
            var fns = this.eventList[key]
            if(!fns || fns.listen === 0) throw new Error('没有查找到相关订阅事件')

            for(var i = 0,fn; fn = fns[i++];) {
                fn.apply(this, arguments)
            }
        },
        remove:function(key, fn) {
            if(fn && this.eventList[key] && this.eventList[key].length){
                try {
                this.eventList[key].forEach((i, j) => {
                    if (i === fn) {
                        this.eventList[key].splice(j, 1);
                        throw new Error('已找到目标函数,结束循环')
                    }
                });
                }catch(e){}
                return
            }
            if(this.eventList[key]) delete this.eventList[key]
        }
    }

    var shopNum = 0

    var carOneGetShopNum = function() {
        const shopCar1 = document.getElementById('shopCar1')
        shopCar1.innerText = '购物车1商品数量:' + shopNum
    }

    var carTwoGetShopNum = function() {
        const shopCar2 = document.getElementById('shopCar2')
        shopCar2.innerText = '购物车2商品数量:' + shopNum
    }

    event.listen('shopCar1', carOneGetShopNum);
    event.listen('shopCar2', carTwoGetShopNum);

    document.getElementById('addCarBtn').onclick = function() {
        shopNum++;
        event.emit('shopCar1')
        event.emit('shopCar2')
    }
</script>

发布订阅模式可以说是我们日常开发中接触的最多的设计模式,因为我们日常接触的react、vue、redux、vuex中都有发布订阅思想的运用。并且发布订阅模式的威力,远不止于解决一个购物车同步商品数量的问题。

比如说大型应用程序中不同模块之间的相互通信,最典型的就是登录态,登录之后其他依赖登录的模块如果优雅的获取登录后的相关信息;还比如CF或LOL一场对局中各个玩家之间的消息通信,当一个玩家被淘汰时,其他玩家要怎样优雅的收到被淘汰玩家的通知。这也间接说明了一个问题:我们在什么时候考虑使用发布订阅模式?请看下图

image.png

和什么时候使用Vuex一样,在不同模块对同一个状态都有共同的依赖性的时候,就要考虑使用发布订阅了。可是我依然觉得这只是发布订阅模式使用的场景之一,如果发布订阅仅仅用来解决不同模块之间的通信问题,那么依然不能体验它的威力。

再看下一个栗子:利用发布订阅模式实现数据埋点上报,并优化界面加载。

image.png 简单描述一下需求背景:比如一个首页模块,一下加载了很多数据在界面内,所以有些数据需要用户往下滚动才能看到。这个时候需要我们统计位于界面下层的数据,最终有多少被用户看到了,来推算用户在首页往下滚动的兴趣值。


<body>
    <div id="box">
     <div class="ck">1</div>
      <img
        src="https://cdn4.buysellads.net/uu/1/3386/1525189943-38523.png"
        onload="loadImage()"
        alt=""
      />
    </div>
  </body>
  <script>
   // ... 此处省略上述的event对象
    
    // 此处建议,在实际框架开发中数据上报监听onScrollStart事件,图片优化监听onScrollEnd事件,同时要做节流
    document.getElementById("box").onscroll = function () {
      // 触发滚动事件
      event.emit("scroll");
    };
    // 为商品图片的onload绑定事件,监听相应的事件
    // 图片优化的考虑,列表渲染时我们可以先设置img的src为一个特别轻量的loading图片
    function loadImage() {
      event.listen("scroll", function () {
        console.log("检测我是否满足显示条件,上报数据并设置src为真实的商品图片");
      });
    }
  </script>

上面的代码只提供一个简单的思路,实际的实现过程肯定远比这个复杂。我们来考虑一个问题,以体现发布订阅模式的价值,如果不使用这种方式对于上述需求你有什么好的解决办法?不要考虑去监听浏览器的滚动,然后去遍历列表中的所有数据,挨个检查是否符合条件...另外由于篇幅的原因,在这里不在叙述,只留下一个问题:如何利用发布订阅模式实现全局的错误收集?

综上:发布订阅模式的优势是非常明显的,要不然也不会取得那么多大佬的青睐。它解决了开发过程中时间和对象的解耦问题,但它也并非毫无问题,尽管我个人感觉它的问题微不足道。在这里也简单说下,两个关系不是很紧密的模块产生了订阅和发布的关系,一旦出现bug找起来可能需要费点心力的;创建订阅者本身也要占用内存,然而可能你订阅的那个消息从未到来。

代理模式

理解:明星的代言人,国防部发言人,企业法人代表,这些都是现实生活中代理模式的体现。在我们实际开发中,接口访问通过bff层转发到真正的后端接口,以及电脑配置fiddler解决跨域问题调试接口也都是代理模式的体现。

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

image.png

和前端直接相关的代理,就是es6的proxy,具体用法在这里就不再赘述了。先说一下我觉得可实现的、以及比较有用的前端适用代理的一个场景:接口请求缓存代理。

let proxyRequest = (function() {
    let caches = {}
    return async function() {
        let fn = [].shift.call(arguments)
        let parmas = [].join.call(arguments, ',')
        if(parmas in caches) return caches[parmas]
        return caches[parmas] = await fn(arguments)
    }
})();

上面的代码就解决了一个网络请求的优化问题,当本次请求的参数和之前某次的请求参数是一致的时候,就不会发出请求,直接从缓存取数据。然而这也会有一个问题:随着请求次数的增加、caches对象也会越来越大,并且它还是在闭包之中始终占着内存的。另外在实际开发过程中,我们经常会使用react的useMemo、useCallback,这已经是对网络请求做出了优化。所以是否使用此方式,以及怎样使用此方式,就需要做出相应的考量了。

另外再说一个也是我们常用的点就是长列表元素事件代理(事件委托),使用ul代理子元素li的事件,通过e.target获取到用户点击具体的目标:

<style>
    ul {
        width: 200px;
        border-radius: 4px;
        border: solid 1px aquamarine;
        padding: 15px;
    }
    li {
        width: 100%;
        height: 20px;
        border-radius: 4px;
        margin-bottom: 10px;
        padding: 5px;
        border: solid 1px antiquewhite;
    }
</style>
<body>
    <ul id="ul">
        <li data-idx="1">1</li>
        <li data-idx="2">2</li>
        <li data-idx="3">3</li>
        <li data-idx="4">4</li>
        <li data-idx="5">5</li>
        
    </ul>
</body>
<script>
    document.getElementById('ul').onclick = function(e) {
        console.log(e.target.getAttribute('data-idx'))
    }
</script>

在react.17以前我们可以使用这种方式做到一定的性能优化,但是17以后此方式已经起不到什么性能优化的作用了,因为react的事件机制本身就是合成事件,通过事件代理来完成的。

wecom-temp-d7fb6e76de4b1355ffa16263c9f11d88.png

wecom-temp-124b3b01361bbdecd6a4a8a5ce5c733c.png

在前端日常开发过程中比较常见的使用图片的预加载:


import React, { useState, useEffect, CSSProperties } from 'react'

interface Props {
  src:string
  alt?:string
  className?:string
  style?: CSSProperties
}

const Img: React.FC<Props> = (props) => {
    const { src, alt, className, style } = props
    const [imgUrl, setImgUrl] = useState<string>('')
    const MyImg = (function() {
        return {
            setSrc: function(url:string) {
                setImgUrl(url)
            }
        }
    })()
    const proxyImg = (function() {
        const img:any = new Image()
        img.onload = function() {
            MyImg.setSrc(img.src)
        }
        return {
            setSrc: function(url:string) {
                MyImg.setSrc('https://res.myscrm.cn/yued/qiwei/h5/1631634301091_icon_loading_w@2x.png')
                img.src = url
            }
        }
    })()

    useEffect(() => {
        proxyImg.setSrc(src)
    }, [])
  return (
    <img className={className} src={imgUrl} alt={alt} style={style} />
  )
}

export default Img

图片预加载这个代理模式老实说,在当前整个C端市场偏向于移动端,手机网速普遍较快、CDN、图片压缩等各种技术加持的情况下应用的场景也不多。早些年很多PC端的网站比如千图网、淘宝、京东那些Pc端的网站,一屏下来几百张图片的情况下它确实很有用。

除了上述几种相对常见的前端应用代理模式之外,还有一些请求收集代理(虚拟代理收集请求,并延迟发送,降低服务器压力,实现方式可参考接口缓存),保护代理,防火墙代理等,这些代理模式的应用在前端范围内使用频率感觉真的不高,所以在次不再叙述。

综上:代理模式的确是一种非常有用的模式,只是在前端范畴内何时使用代理模式、怎样使用代理,需要做出仔细的考量。

状态模式

理解:通过分割状态,根据状态的转换来切换事物的配套行为,比如说玩游戏、每一级算一个状态:一级的时候玩家的武器是根木棒,攻击招式也就是木棒攻击;二级的时候玩家的武器是把刀,攻击招式也就是刀子攻击;三级的时候玩家的武器是把枪,攻击招式也就是射击....

关键:区分事物内部的状态,事物内部状态的改变往往会带来事物的行为改变。

定义:允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。

举个小栗子,我们在开发中可能会遇到这样的需求,需要用户按照第一步、第二步、第三步...的流程来实现某个功能。此时我们就可以把每一步看作一个状态,并把每一步用户需要做的事情(点击保存按钮),看作相对应的行为。

image.png

// 模拟面向对象的形式来实现
<body>
    <div class="box">
        <div class="head">
            <div class="box_item">第一步</div>>>>>>>>>>>
            <div class="box_item">第二步</div>>>>>>>>>>>
            <div class="box_item">第三步</div>
        </div>
        <div id="footBut" class="foot_but">保存</div>
    </div>
</body>
<script>
    function Status1(leader){
        // 接收当前status的管理者
        this.leader = leader
    }
    Status1.prototype.save = function() {
        console.log('第一步的保存')
        // 执行完本状态相关的操作后,调用leader的切换状态方法,把状态切换为相应的下一步的状态,下一步的状态从leader那里获取
        this.leader.changeStatus(this.leader.status2)
    }

    function Status2(leader){
         // 接收当前status的管理者
        this.leader = leader
    }

    Status2.prototype.save = function() {
        console.log('第二步的保存')
       // 执行完本状态相关的操作后,调用leader的切换状态方法,把状态切换为相应的下一步的状态,下一步的状态从leader那里获取
        this.leader.changeStatus(this.leader.status3)
    }

    function Status3(leader){
         // 接收当前status的管理者
        this.leader = leader
    }
    Status3.prototype.save = function() {
        console.log('第三步的保存,流程执行完(所有状态切换完毕)')
    }
    // AllStatus对象负责管理所有的status,也就是所有status的leader
    function AllStatus () {
        // 搜集所要管理的状态
        this.status1 = new Status1(this)
        this.status2 = new Status2(this)
        this.status3 = new Status3(this)

        // 预先定义一个状态事件托付对象
        this.button  = null;
    }
    AllStatus.prototype.init = function() {
        // 获取保存按钮
        this.button = document.getElementById('footBut')
        // 配置初始状态
        this.currStatus = this.status1
        // 给按钮绑定对应的事件
        this.button.onclick = ()=> {
            this.currStatus.save()
        }
    }
    // 定义切换状态的方法,供下属status使用
    AllStatus.prototype.changeStatus = function(newStatus) {
        this.currStatus = newStatus
    }

    let status = new AllStatus()
    status.init()
</script>

上述代码,依次点击保存按钮的执行结果如下:

image.png

有没有一种很熟悉的感觉?貌似这中操作,完全可以用策略模式来实现,并且用策略模式来实现似乎更为简单!但实际上很多时候策略就是策略、状态就是状态,看问题的出发点不同,也就会有不同的实现方式了。让我们看下比较官方的说法:

image.png

简而言之:不同的状态之间必然是有关系的,但是不同的策略之间是可以没有关系的!这个在我看来,应该就是状态模式是策略模式的根本不同。

基于上述的讨论,以及JavaScript没有必要完全面向对象的考虑,来看一下另外一种实现方式:

   var FSM = {
       status1: {
           save:function () {
               console.log('第一步的保存')
               this.currStatus = FSM.status2
           }
       },
       status2: {
           save:function () {
               console.log('第二步的保存')
               this.currStatus = FSM.status3
           }
       },
       status3: {
           save:function () {
            console.log('第三步的保存,流程执行完(所有状态切换完毕)')
           }
       }
   }

   function AllStatus () {
        // 配置初始状态
        this.currStatus = FSM.status1
        // 预先定义一个状态事件托付对象
        this.button  = null;
    }
    AllStatus.prototype.init = function() {
        // 获取保存按钮
        this.button = document.getElementById('footBut')
        // 给按钮绑定对应的事件
        this.button.onclick = ()=> {
            this.currStatus.save.call(this)
        }
    }

    let status = new AllStatus()
    status.init()

综上,当需求因有不同的状态而复杂度比较高时,使用状态模式可以帮我们理清需求中错综复杂的关系,有利于代码的理解与维护。

享元模式(对象池技术)

理解:发挥资源的最大用处,比如一个人可以干两份活的话,就没别要两份活请两个人来做了。

核心:运用共享技术来有效支持大量细粒度的对象,以达到性能优化的目的。

先看一个小栗子: 简单描述一下需求背景,假定我们已经利用发布订阅模式,收集了大概10条数据,记录用户是怎样一步步操作直至下单的。所以这些数据只有在用户点击下单按钮后才有效,在用户点击下单按钮时集中上报。我们利用img标签的src属性上报数据:

// 不使用享元模式(对象池技术)
for(var i = 0; i < 10; i++) {
    var img = new Image
    img.src = `https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png?i=${i}`
}

上面的代码肯定可以满足我们的需求,但同样也创建了10个img对象,同理如果上传100条数据就是100个对象...

// 使用对象池技术来优化
 var objectPoolFactory = function (createObj) {
      var objectPool = [];
      return {
        create: function () {
          var obj;
          console.log(objectPool[0]?.name);
          if (objectPool.length === 0) {
            console.log("新建");
            obj = createObj.apply(this, arguments);
          } else {
            console.log("取缓存");
            obj = objectPool.shift();
          }
          return obj;
        },
        recover: function (obj) {
          objectPool.push(obj);
        },
      };
    };

    var imgFactory = objectPoolFactory(function () {
      let img = new Image();
      img.name = "唯一的img";
      img.onerror= function() {
          setTimeout(()=>{
              new Image().src = img.src;
          },100)
      }
      return img;
    });

    var upData = (function() {
        var i = 0;
        return function() {
            var img = imgFactory.create();
            img.src = `https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png?i=${i}`
            imgFactory.recover(img)
            i++
            if(i<10) setTimeout(()=>{ upData()},70)
        }
    })()

    upData()

使用享元思想的对象池技术优化的代码,也如愿满足了我们的需求,并且只用了一个img对象

image.png

上面的代码是利用和享元模式思想类似的对象池技术实现的,下面看一个小栗子来简单了解一下真正的享元模式:

    function UpDataImg(name) { // (1)
      this.img = new Image;
      this.img.name = name;
      let _img = this.img;
      this.img.onerror= function() {
          setTimeout(()=>{
              new Image().src = _img.src;
          },100)
      }
      return this.img;
    }

    var createUpImg = (function () {
      var imgs = {};
      return {
        create: function (alt) {// (2)
          var obj;
          if (!imgs[alt]) {
            console.log("新建");
            obj =  new UpDataImg('first');
          } else {
            console.log("取缓存");
            obj = imgs[alt]
          }
          if(obj)obj.alt = alt
          return obj;
        },
        recover: function (obj) {
          imgs[obj.alt] = obj
        },
      };
    })();

    var upData = (function () {
      var i = 0;
      return function () {
        var upImg = createUpImg.create("firstAlt");
        upImg.src = `https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png?i=${i}`;
        createUpImg.recover(upImg);
        i++;
        if (i < 10) setTimeout(()=>{ upData() },70)
      };
    })();

    upData();

享元思想的两种方式看起来也是有点儿类似的,区别在于享元模式的关键是区分对象的内部状态和外部状态,所以为了刻意追求这一点:

(1)我在UpDataImg构造函数那里多加了一个没什么用的name参数当做内部状态传入;
(2)在create方法那里加了一个alt属性当做外部状态传入;

在我看来费尽心思的去区分一个对象的内部状态和外部状态是一件比较烧脑的事儿,所以既然享元要求共享,那么我还是比较喜欢使用与享元模式相似但又不用区分内外部状态的对象池技术。

综上:享元模式是为解决性能问题而生的模式,这跟大部分模式的诞生原因都不一样。在一个存在大量相似对象的系统中,享元模式可以很好地解决大量对象带来的性能问题。

结语

上述的设计模式主要是我个人认为优点比较明显,或者相对来说也比较常用的模式。在学习它们的过程中我产生过一些疑惑:JavaScript是面向对象比较好,还是面向函数比较好?因为每个模式之中或多或少,都有面向对象的影子,但同时某些事情采用面向函数的思维处理又比较容易。

可能是由于在日常开发中我们更多的是面向函数编程,所以涉及到面向对象比较多、比较深入的东西,通常不如面向函数那样易于理解。

主要参考文献:

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