在JavaScript中,设计模式是一种常见的编程技术,用于解决各种编码问题。在本文中,我们将介绍8种JS设计模式,包括单例模式、工厂模式、观察者模式等,并总结了几点使用设计模式能给工程带来的好处,如代码可解耦、可扩展性、可靠性、条理性、可复用性

概览

单例模式

单例模式是一种只允许创建一个实例的设计模式。在JavaScript中,可以使用闭包来实现单例模式。

构造器模式

构造器模式是一种创建对象的设计模式,它使用构造函数来创建对象。

建造者模式

建造者模式是一种创建复杂对象的设计模式,它将对象的构建过程分解为多个简单的步骤。

代理模式

代理模式是一种设计模式,它允许在不改变原始对象的情况下控制对象的访问。在JavaScript中,可以使用代理模式来控制对象的访问权限。

外观模式

外观模式是一种设计模式,它允许为复杂的子系统提供一个简单的接口。在JavaScript中,可以使用外观模式来隐藏复杂的代码。

观察者模式

观察者模式是一种设计模式,它允许对象在状态发生变化时通知其他对象。在JavaScript中,可以使用事件处理程序来实现观察者模式。

策略模式

策略模式是一种设计模式,它允许在运行时选择算法。在JavaScript中,可以使用策略模式来选择不同的算法。

迭代器模式

迭代器模式是一种行为型设计模式,它提供了一种顺序访问集合对象中各个元素的方法,而不暴露其内部表示。

1.单例模式

1.1 概念解读

单例模式:保证一个类只有一个实例,一般先判断实例是否存在,如果存在直接返回,不存在则先创建再返回,这样就可以保证一个类只有一个实例对象。

1.2 作用

  • 模块间通信
  • 保证某个类的对象的唯一性
  • 防止变量污染

1.3 注意事项

  • 正确使用this
  • 闭包容易造成内存泄漏,所以要及时清除不需要的变量
  • 创建一个新对象的成本较高

1.4 实际案例

单例模式广泛应用于不同程序语言中,在实际软件应用中应用比较多的比如电脑的任务管理器、回收站、网站的计数器、多线程的线程池的设计等。

1.5 代码实现

(function(){
  // 养鱼游戏
  let fish = null
  function catchFish() {
    // 如果鱼存在,则直接返回
    if(fish) {
      return fish
    }else {
      // 如果鱼不存在,则获取鱼再返回
      fish = document.querySelector('#cat')
      return {
        fish,
        water: function() {
          let water = this.fish.getAttribute('weight')
          this.fish.setAttribute('weight', ++water)
        }
      }
    }
  }

  // 每隔3小时喂一次水
  setInterval(() => {
    catchFish().water()
  }, 3*60*60*1000)
})()

2. 构造器模式

2.1 概念解读

构造器模式:用于创建特定类型的对象,以便实现业务逻辑和功能的可复用。

2.2 作用

  • 创建特定类型的对象
  • 逻辑和业务的封装

2.3 注意事项

  • 注意划分好业务逻辑的边界
  • 配合单例实现初始化等工作
  • 构造函数命名规范,第一个字母大写
  • new对象的成本,把公用方法放到原型链上

2.4 实际案例

使用构造函数创建对象是一种常见的编程技术,它可以帮助我们更好地组织和管理代码。构造器模式广泛应用于JavaScript工具库和框架中,比如Lodash和AngularJS等。

2.5 代码展示

function Tools(){
  if(!(this instanceof Tools)){
    return new Tools()
  }
  this.name = 'js工具库'
  // 获取dom的方法
  this.getEl = function(elem) {
    return document.querySelector(elem)
  }
  // 判断是否是数组
  this.isArray = function(arr) {
    return Array.isArray(arr)
  }
  // 其他通用方法...
}

2.6 拓展

if(!(this instanceof Tools)){
    return new Tools()
}

2.5 代码展示中的这段代码的作用:

确保在使用构造器模式创建对象时不需要使用 new 关键字。

构造器模式用于创建对象,通常通过使用 new 关键字调用构造函数来实例化对象。但有时候,开发者可能会忘记使用 new 关键字,这会导致构造函数内部的 this 指向全局对象(如 window)。这样做可能会导致意想不到的行为或错误,并且在严格模式下还会抛出错误。

为了避免这种情况,代码中的 if (!(this instanceof Tools)) 判断语句用于检查当前的 this 对象是否是 Tools 的实例。如果不是 Tools 的实例,即意味着构造函数没有通过 new 关键字调用,于是它会自动将当前的上下文重新用 new 关键字调用构造函数,返回一个新的实例。

这种方式可以确保无论使用构造函数时是否忘记了 new 关键字,都能正确地实例化一个 Tools 对象。它是一种常用的构造器模式的防御性编程技巧,可以提高代码的健壮性。

需要注意的是,在使用这种技巧时,要确保构造函数内部没有副作用。因为在每次调用时都会重新创建一个新的实例,可能会导致不必要的性能开销。所以,只有在确保构造函数不会有副作用的情况下,才适合使用这种方式。

3. 建造者模式

3.1 概念解读

建造者模式:将一个复杂的逻辑或者功能通过有条理的分工来一步步实现。

3.2 作用

  • 分布创建一个复杂的对象或者实现一个复杂的功能
  • 解耦封装过程,无需关注具体创建的细节

3.3 注意事项

  • 需要有可靠算法和逻辑的支持
  • 按需暴露一定的接口

3.4 实际案例

建造者模式其实在很多领域也有应用,比如小型js插件,大部分都采用了建造者模式。其他案例如下:

  • jquery的ajax的封装
  • jquery插件封装
  • react/vue某一具体组件的设计

3.5 代码展示

举一个使用建造者模式实现的一个案例:Canvas入门实战之用javascript面向对象实现一个图形验证码。让我们使用建造者模式实现一个非常常见的验证码插件吧!

我修改了原作者的代码,重新封装了一下,并进行了使用的说明和示例:

// canvas绘制图形验证码
class Gcode {
    constructor(el, option) {
        this.state = {
            text: ''
        };
        this.el = typeof el === 'string' ? document.querySelector(el) : el;
        this.option = option;
        this.init();
    }
    init() {
        if (this.el.getContext) {
            let ctx = this.el.getContext('2d'),
                // 设置画布宽高
                cw = this.el.width = this.option.width || 200, ch = this.el.height = this.option.height || 40, textLen = this.option.textLen || 4, lineNum = this.option.lineNum || 4;
            this.state.text = this.randomText(textLen);
            this.onClick(ctx, textLen, lineNum, cw, ch);
            this.drawLine(ctx, lineNum, cw, ch);
            this.drawText(ctx, this.state.text, ch);
        }
    }
    onClick(ctx, textLen, lineNum, cw, ch) {
        let _ = this;
        this.el.addEventListener('click', function () {
            _.state.text = _.randomText(textLen);
            _.drawLine(ctx, lineNum, cw, ch);
            _.drawText(ctx, _.state.text, ch);
        }, false);
    }
    // 画干扰线
    drawLine(ctx, lineNum, maxW, maxH) {
        ctx.clearRect(0, 0, maxW, maxH);
        for (let i = 0; i < lineNum; i++) {
            let dx1 = Math.random() * maxW, dy1 = Math.random() * maxH, dx2 = Math.random() * maxW, dy2 = Math.random() * maxH;
            ctx.strokeStyle = 'rgb(' + 255 * Math.random() + ',' + 255 * Math.random() + ',' + 255 * Math.random() + ')';
            ctx.beginPath();
            ctx.moveTo(dx1, dy1);
            ctx.lineTo(dx2, dy2);
            ctx.stroke();
        }
    }
    // 画文字
    drawText(ctx, text, maxH) {
        let len = text.length;
        for (let i = 0; i < len; i++) {
            let dx = 30 * Math.random() + 30 * i, dy = Math.random() * 5 + maxH / 2;
            ctx.fillStyle = 'rgb(' + 255 * Math.random() + ',' + 255 * Math.random() + ',' + 255 * Math.random() + ')';
            ctx.font = '30px Helvetica';
            ctx.textBaseline = 'middle';
            ctx.fillText(text[i], dx, dy);
        }
    }
    // 生成指定个数的随机文字
    randomText(len) {
        let source = ['a', 'b', 'c', 'd', 'e',
            'f', 'g', 'h', 'i', 'j',
            'k', 'l', 'm', 'o', 'p',
            'q', 'r', 's', 't', 'u',
            'v', 'w', 'x', 'y', 'z'];
        let result = [];
        let sourceLen = source.length;
        for (let i = 0; i < len; i++) {
            let text = this.generateUniqueText(source, result, sourceLen);
            result.push(text);
        }
        return result.join('');
    }
    // 生成唯一文字
    generateUniqueText(source, hasList, limit) {
        let text = source[Math.floor(Math.random() * limit)];
        if (hasList.indexOf(text) > -1) {
            return this.generateUniqueText(source, hasList, limit);
        } else {
            return text;
        }
    }
    // 获取当前验证码内容
    getText(){
        return this.state.text;
    }
}

// 调用 每次点击都会刷新验证码
let gcode = new Gcode('#canvas_code', {
    lineNum: 6
});

// 可用XXX.getText()来获取验证码内容
setInterval(() => {
    console.log(gcode.getText());
}, 1000);

4.代理模式

4.1 概念解读

代理模式: 一个对象通过某种代理方式来控制对另一个对象的访问。

4.2 作用

  • 远程代理(一个对象对另一个对象的局部代理)
  • 虚拟代理(对于需要创建开销很大的对象如渲染网页大图时可以先用缩略图代替真图)
  • 安全代理(保护真实对象的访问权限)
  • 缓存代理(一些开销比较大的运算提供暂时的存储,下次运算时,如果传递进来的参数跟之前相同,则可以直接返回前面存储的运算结果)

4.3 注意事项

使用代理会增加代码的复杂度,所以应该有选择的使用代理。

实际案例

我们可以使用代理模式实现如下功能:

  • 通过缓存代理来优化计算性能
  • 图片占位符/骨架屏/预加载等
  • 合并请求/资源

4.4 代码展示

接下来我们通过实现一个计算缓存器来说说代理模式的应用

// 缓存代理
function sum(a, b){
  return a + b
}
let proxySum = (function(){
  let cache = {}
  return function(){
      let args = Array.prototype.join.call(arguments, ',');
      if(args in cache){
          return cache[args];
      }

      cache[args] = sum.apply(this, arguments)
      return cache[args]
  }
})()

5.外观模式

5.1 概念解读

外观模式(facade): 为子系统中的一组接口提供一个一致的表现,使得子系统更容易使用而不需要关注内部复杂而繁琐的细节。

5.2 作用

  • 对接口和调用者进行了一定的解耦
  • 创造经典的三层结构MVC
  • 在开发阶段减少不同子系统之间的依赖和耦合,方便各个子系统的迭代和扩展
  • 为大型复杂系统提供一个清晰的接口

5.3 注意事项

当外观模式被开发者连续调用时会造成一定的性能损耗,这是由于每次调用都会进行可用性检测

5.4 实际案例

我们可以使用外观模式来设计兼容不同浏览器的事件绑定的方法以及其他需要统一实现接口的方法或者抽象类。

5.5 代码展示

接下来我们通过实现一个兼容不同浏览器的事件监听函数来让大家理解外观模式如何使用。

function on(type, fn){
  // 对于支持dom2级事件处理程序
  if(document.addEventListener){
      dom.addEventListener(type,fn,false);
  }else if(dom.attachEvent){
  // 对于IE9以下的ie浏览器
      dom.attachEvent('on'+type,fn);
  }else {
      dom['on'+ type] = fn;
  }
}

6.观察者模式

6.1 概念解读

观察者模式(又叫订阅者模式):定义了一种一对多的关系, 所有观察对象同时监听某一主题对象,当主题对象状态发生变化时就会通知所有观察者对象,使得他们能够自动更新自己。

6.2 作用

  • 目标对象与观察者存在一种动态关联,增加了灵活性
  • 支持简单的广播通信, 自动通知所有已经订阅过的对象
  • 目标对象和观察者之间的抽象耦合关系能够单独扩展和重用

6.3 注意事项

观察者模式一般都要注意要先监听,再触发(特殊情况也可以先发布,后订阅。比如QQ的离线模式)

6.4 实际案例

观察者模式是非常经典的设计模式,主要应用如下:

  • 系统消息通知
  • 网站日志记录
  • 内容订阅功能
  • javascript事件机制
  • react/vue等的观察者

6.5 代码展示

接下来我们使用原生javascript实现一个观察者模式:

class Subject {
  constructor() {
    this.subs = {}
  }

    // key可以作为某个主题/内容/频道,fn是某个订阅者(的回调函数)
  addSub(key, fn) {
    const subArr = this.subs[key]
    if (!subArr) {
      this.subs[key] = []
    }
    this.subs[key].push(fn)
  }

  trigger(key, message) {
    const subArr = this.subs[key]
    if (!subArr || subArr.length === 0) {
      return false
    }
    for(let i = 0, len = subArr.length; i < len; i++) {
      const fn = subArr[i]
      fn(message)
    }
  }

  unSub(key, fn) {
    const subArr = this.subs[key]
    if (!subArr) {
      return false
    }
    if (!fn) {
      this.subs[key] = []
    } else {
      for (let i = 0, len = subArr.length; i < len; i++) {
        const _fn = subArr[i]
        if (_fn === fn) {
          subArr.splice(i, 1)
        }
      }
    }
  }
}

// 测试
// 订阅
let subA = new Subject()
let A = (message) => {
  console.log('订阅者收到信息: ' + message)
}
subA.addSub('panel_1', A)

// 发布
subA.trigger('panel_1', 'QiuYeDx')   // A收到信息: --> QiuYeDx

7.策略模式

7.1 概念解读

策略模式:策略模式将不同算法进行合理的分类和单独封装,让不同算法之间可以互相替换而不会影响到算法的使用者。

7.2 作用

  • 实现不同,作用一致
  • 调用方式相同,降低了使用成本以及不同算法之间的耦合
  • 单独定义算法模型,方便单元测试
  • 避免大量冗余的代码判断,比如if else等

7.3 实际案例

  • 实现更优雅的表单验证
  • 游戏里的角色计分器
  • 棋牌类游戏的输赢算法

7.4 代码展示

接下来我们实现一个根据不同类型实现求和算法的模式来带大家理解策略模式。

const obj = {
  A: (num) => num * 4,
  B: (num) => num * 6,
  C: (num) => num * 8
}

const getSum =function(type, num) {
  return obj[type](num)
}

8.迭代器模式

8.1 概念解读

迭代器模式:提供一种方法顺序访问一个聚合对象中的各个元素,使用者并不需要关心该方法的内部表示。

8.2 作用

  • 为遍历不同集合提供统一接口
  • 保护原集合但又提供外部访问内部元素的方式

8.3 实际案例

迭代器模式模式最常见的案例就是数组的遍历方法如forEach、map、reduce。

8.4 代码展示

接下来笔者使用自己封装的一个遍历函数来让大家更加理解迭代器模式的使用,该方法不仅可以遍历数组和字符串,还能遍历对象。 lodash里的 _.forEach(collection, [iteratee=_.identity])方法也是采用策略模式的典型应用。

function _each(el, fn = (v, k, el) => {}) {
  // 判断数据类型
  function checkType(target){
    return Object.prototype.toString.call(target).slice(8,-1)
  }

  // 数组或者字符串
  if(['Array', 'String'].indexOf(checkType(el)) > -1) {
    for(let i=0, len = el.length; i< len; i++) {
      fn(el[i], i, el)
    }
  }else if(checkType(el) === 'Object') {
    for(let key in el) {
      fn(el[key], key, el)
    }
  }
}

A Student on the way to full stack of Web3.