聊一聊常见设计模式的 JavaScript 实现

开门见山

我们都知道 javascript 是一种基于原型的弱类型语言,拥有动态数据类型,灵活多变。因此,相比于传统的 javac++ 来说, javascript 面向对象的设计模式会有点牵强。

但这也并不妨碍我们学习使用 javascript 来了解设计模式思想及其设计理念。因为在我看来,这属于程序员的 “内功” ,只有 “内功” 修炼得当,才能更好的学习 “上乘武功”。

更何况,现在很多框架的源码都引入了非常多的设计模式思想,例如 发布订阅模式,单例模式,工程模式等比比皆是。

因此,想要学习框架源码,编写高质量,易维护的代码,设计模式的学习是必不可少的。今天我们就聊一聊 JavaScript 中一些常见的设计模式。

单例模式 (Singleton Pattern)

单例模式,顾名思义就是只有一个实例,并且它自己负责创建自己的对象,这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

代码示例

// 单例模式
const Singleton = (function () {
    let __instance = null;

    const Singleton = function () {
        if (__instance) return __instance;
        __instance = this;
        this.init();
        return __instance;
    }

    Singleton.prototype.init = function () {
        console.log('Singleton init completed!');
    }

    Singleton.getInstance = function () {
        if (__instance) return __instance;
        __instance = new Singleton()
        return __instance;
    }

    return Singleton;
})();


const s1 = Singleton.getInstance();
const s2 = new Singleton();

console.log(s1 === s2); // true

利用 IIFE 的方式构造 __instance 实例,并提供了获取实例的方法,并保存起来,每次访问时,如果已经初始化则不需要重新创建,直接返回 __instance 即可,保证只有一个实例。

优缺点

单例模式的优点在于在创建后在内存中只存在一个实例,能保证访问一致性,并且反复创建销毁实例来说节约内存和 cpu 资源。

单例模式的缺点也很明显,因为只有一个实例,也不太需要实例化过程,因此拓展性不好。

适用场景

比较适合项目中需要一个公共的状态,并通过单例也确保访问一致性的时候。例如,许多 UI 框架 中的全局 Loading 组件。

工厂模式 (Factory Pattern)

工厂模式,听名字就应该可以大概猜到,是根据不同的输入来创建同一类对象的。主要是为了将对象的创建与对象的实现分离。

代码示例

// 工厂模式

/* 工厂类 */
function CreateElement(type) {
    switch (type) {
        case 'Input':
            return new Input()
        case 'DIV':
            return new Div()
        default:
            throw new Error('当前没有这个产品')
    }
}

/* 产品类 */
function Input() {
    return document.createElement('input')
}

function Div() {
    return document.createElement('div')
}

const input = new CreateElement('Input');
const div = new CreateElement('DIV');


console.log(input)      // input
console.log(div)        // div

上述通过一个简单的例子演示了工厂模式的创建过程,我们知道有一个大工厂类,负责生产产品,只要通过传递不同的类型,可以实例化出不同的对象。

优缺点

工程模式将对象的创建和实现进行了分离,代码结构清晰,使得代码高度解耦,具有很多良好的封装,我们无需知道创建对象的过程就可以得到想要的对象,拓展性也强。

工厂模式的缺点在于,工厂类集中了所有实例的创建逻辑,它所能创建的类只能是事先考虑到的,如果需要添加新的类,则就需要改变工厂类。随着产品类的不断增多,代码的维护性就越差。

适用场景

当对象的创建比较复杂,而访问者无需知道创建的具体流程,我们可以考虑使用工厂模式。典型应用,如 VueReact 中创建虚拟 DOMcreateElement 函数。Vue-Router 的设计,根据不同的 mode 创建不同的路由实例。

建造者模式(Builder Pattern)

建造者模式,将一个复杂对象的构建与表示分离,使得同样的构建过程可以创建不同的表示。在工程模式中,我们不关心创建过程,直接得到一个完成的对象。而建造者模式中,我们关心对象的创建过程,将复杂对象模块化,使得每个模块都可以复用。

代码示例

// 建造者模式

/* 建造者 */
function ComputerBuilder(brand) {
    this.brand = brand;
}


ComputerBuilder.prototype.buildCPU = function (type) {
    switch (type) {
        case 'inter':
            this.cpu = 'inter 处理器';
            break;
        case 'AMD':
            this.cpu = 'AMD 处理器';
            break;
    }
    return this;
}

ComputerBuilder.prototype.buildMemory = function (mSize) {
    this.mSize = '内存' + mSize + 'G';
    return this;
}

ComputerBuilder.prototype.buildDisk = function (dSize) {
    this.dSize = '硬盘' + dSize + 'G';
    return this;
}


/* 厂家,负责组装 */
function computerDirector(brand, type, mSize, dSize) {
    const _computer = new ComputerBuilder(brand);
    _computer.buildCPU(type)
        .buildMemory(mSize)
        .buildDisk(dSize);
    return _computer;
}

const com = computerDirector('联想', 'inter', 16, 500);

console.log(com); // ComputerBuilder {brand: "联想", cpu: "inter 处理器", mSize: "内存16G", dSize: "硬盘500G"}

上述我们通过生产电脑的例子,描述了建造者模式的构建过程,我们的部件都是由一个个类创建出来的,最后进行组装完成整个对象的。如果后期需要拓展组件,只需要在建造者上增加对应的方法,再适当修改链式调用即可。

优缺点

建造者模式适用于构建复杂的、需要分步骤构建的对象,可以将构建过程分离,分步骤进行。优点显而易见,具有很好的拓展性,很高的复用性。

如果对象之间差异过大,复用性不高的话不建议使用这种模式,否则创建过程中会导致代码比较乱,复杂度过高,显得有些强行建造了。

适用场景

建造者模式适用于可以通过不同的部件组装得到不同完整产品的场景,可以将代码最小程度的拆分,利于后期维护。例如,你封装一个公共弹窗,里面涉及有标题,内容,按钮,文字等,但也不都是必须的,你可以在需要的时候去构建他们。

代理模式(Proxy Pattern)

代理模式,是给某一个对象提供一个代理对象,并由代理对象控制对原对象的引用,防止访问者直接访问目标对象,从而对目标对象起到一种间接保护的作用,类似我们日常生活中的各种中介及微商。

ps: ES6 新增的 Proxy 可以非常方面快捷的帮助我们对一个对象进行代理,想要了解 Proxy 的同学可参考我之前的写的 初探 Vue3.0 中的一大亮点——Proxy !

代码示例

// 代理模式

// 目标
function sendMsg(msg) {
    console.log(msg);
}

// 代理
function ProxyMsg(msg) {
    if (!msg) {
        console.log('msg is empty.')
        return
    }

    msg = '我要发送的数据是:' + msg;

    sendMsg(msg);
}

ProxyMsg('您好!');    // 我要发送的数据是:您好!

上述用一个发送消息的例子来描述了代理模式的工作原理,我们不直接通过 sendMsg 方法而是通过 ProxyMsg 方法来发送消息。这样做的好处是可以在发送消息之前对一些不合法的消息进行过滤,对合法的内容进行二次包装。

优缺点

代理模式的优点在于,代理对象作为访问者与目标对象之间桥梁,对目标对象起到保护的作用。可以很方面的拓展代理对象,并不直接干涉目标对象,一定程度上降低了系统的耦合度。

另一方面,额外新增的代理对象无异于增加了整个系统的复杂度,造成请求处理速度变慢,增加了系统的维护成本,在使用前需要酌情考虑。

适用场景

随着前端的不断发展,代理模式在前端领域的使用场景还是很多的。

典型的应用就是拦截器,项目中 axios 数据请求中的 interceptor 拦截器,以及一些权限校验的中间件等。另外像目前流行的 vue 框架,其中的数据响应式就是利用了这一思想,不同的是 vue2 采用的是 Object.defineProperty , 而 vue3 采用的是 Proxy

享元模式(Flyweight Pattern)

享元模式,字面解释,享就是共享,元就是元素,公共部分。

因此,享元模式就是通过共享技术实现相同或相似对象的重用,主要用于减少创建对象的数量,以减少内存占用和提高性能。

代码示例

// 享元对象
function Shape(shape) {
    this.shape = shape;
}

Shape.prototype.draw = function () {
    console.log(`画了一个 ${this.shape}`)
}

// 享员工厂
const ShapeFactory = (function () {
    const dataMap = {};
    return {
        getShapeContext(shape) {
            // 如果存在,则直接返回
            if (dataMap[shape]) return dataMap[shape];
            else {
                // 没有就创建,并保存当前shape的实例
                const instance = new Shape(shape);
                dataMap[shape] = instance
                return instance;
            }
        }
    }
})();

const rect = ShapeFactory.getShapeContext('rect');
const circle = ShapeFactory.getShapeContext('circle');

rect.draw();     // 画了一个 rect
circle.draw();   // 画了一个 circle

上述代码,我们用来一个绘画的例子,通过享元工厂去创建不同类型的 "画笔" 对象,并保存在我们的工厂函数中,下次使用的时候则不需要重新创建,直接从 map 中读取即可。这种方式,相比于传统的用的时候去 new 创建在数据量大的时候会节约很多内存。

优缺点

享元模式最大的优点就在于它可以极大的减少了系统中对象的创建,降低内存的使用,加快了运行速度,提高了运行效率。

提高效率的同时也暴露出其缺点,共享对象的创建,销毁等都需要增加额外的逻辑,会使整个系统的逻辑变得复杂,代码不容易阅读,维护的成本增加。

适用场景

享元模式比较适合项目中大量使用了相同或相似对象,可以共享资源时可以考虑。

其实在前端开发设计中还是比较常见的,例如我们所熟知和使用的 事件委托 经行事件绑定,就是利用了享元模式的原理,我们并不是给每个元素绑定事件,而是为其父元素绑定一个事件,根据事件参数 event 来判断。

另外 nodejs 中所使用的数据库连接池,一些缓存服务器的设计等是利用这个原理。

适配器模式(Adapter Pattern)

适配器模式,作为两个不兼容的接口之间的桥梁,目的就是通过适配器的转换解决类(对象)之间接口不兼容的问题,从而使得原本不兼容的接口可以兼容现有的需求。

与早些年传统的万能充电器的作用类似。

代码示例

// 适配器模式

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


// 高德地图 api
const AMap = {
    render: function () {
        console.log('开始渲染高德地图')
    }
}


// 适配器
const baiduAdapter = {
    render: function () {
        return baiduMap.show()
    }
}


function renderMap(map) {
    if (typeof map.render === 'function') {
        map.render()
    }
}

renderMap(AMap);            // 开始渲染高德地图
renderMap(baiduAdapter);    // 开始渲染百度地图

上述代码中演示了适配器模式的原理,我们之前用的是高德地图 ,如今我们也要接入百度地图,二者的 api 的渲染方式不同,为了解决不兼容的问题,我们构造了一个 baiduAdapter 适配器,这样我们就可以适用同样的接口完成不同地图的渲染。

优缺点

适配器模式相对来说是一种简单的设计模式,目的就是为了兼容旧的代码。因此,它的优点也很明显,就是不用大面积更改以前的旧的代码逻辑,使得原有的逻辑可以复用,拓展性强,灵活性强,可以随时随地更改或删除不同的适配器,而不会造成重大的影响。

适配器模式的缺点自然而然就是,多的适配器会增加系统的复杂度,会使得系统的代码变得十分松散,凌乱,代码的的可阅读性大大折扣。如大规模使用适配器导致代码变得凌乱松散时,可以考虑重构。

适用场景

适配器模式有点 亡羊补牢 的意思,如果现有的接口已经能够正常工作,那就永远不会用上适配器模式。但随着公司业务的发展,也许现在好好工作的接口,未来的某天却不再适用于新系统,这时候就得考虑适用适配器模式,为其重新赋能。

装饰器模式 (Decorator Pattern)

装饰器模式,在不改变原对象的基础上,对其添加属性或方法来进行拓展,使原有对象可以具有更多功能,而不影响原有的对象结构。与继承相比装饰者是一种更轻便灵活的做法。

psES7 关于装饰器 @Decorator 已经在草案中,我们项目中所使用的装饰器需借助第三方工具转义,等到时候标准定下来后就可放心使用了。

代码示例

  const btn = document.querySelector('#btn');

  // 原绑定事件
  btn.onclick = function () {
      console.log('按钮被点击了')
  }


  // 新增统计
  function ajaxToServer() {
      console.log('数据统计')
  }

  // 装饰器函数
  function decorator(target, eventName, cb) {
      const originFn = target['on' + eventName];
      originFn && originFn()
      cb && cb();
  }

  decorator(btn, 'click', ajaxToServer)

以一点按钮的点击事件为例,原点击事件只是打印 按钮被点击了 ,现在需要再点击的时候调用 ajaxToServer 做数据统计,我们通过一个 decorator 装饰器函数先将原始绑定的事件缓存起来,再添加我们 ajaxToServer 回调即可,一来没有影响到原始代码的改动,二来后期如果需要新增,按部就班即可。

优缺点

装饰器的有点在于我们不需要关心原对象的实现,装饰者和被装饰者之间不会相互耦合,就可以拓展原对象的方法,可维护性好,并且装饰器还可以复用,使得对象的拓展更加灵活。

由于装饰器的灵活性,因此随着装饰器的增多,会导致系统复杂度增加,尤其是多级装饰器,会导致代码错误定位困难繁琐,对于不熟悉这个模式的开发人员难以理解。

适用场景

装饰器模式适用于需要动态拓展对象或类的方法,或者需要对一些功能进行排列组合,完成复杂工功能的时候可以考虑装饰器模式。

我们在使用vue ,或者scss的时候,有时候会用到 mixinsmixins的原理就类似于装饰器。

外观模式 (Facade Pattern)

外观模式的本质是封装交互,简化调用,它的做法是隐藏了系统的复杂性,将子系统的一组接口封装起来,给使用者提供了一个统一的高层接口,减少了客户端与子系统之间的耦合性。

JavaScript中外观模式常常用于解决浏览器兼容性问题以及源码中的一些函数重载,很多主流的库,如 jQuerylodash 等都有涉及。

代码示例

// 事件绑定
function addEvent(element, type, fn) {
    if (element.addEventListener) {      // 支持 DOM2 级事件处理方法的浏览器
        element.addEventListener(type, fn, false)
    } else if (element.attachEvent) {    // 不支持 DOM2 级但支持 attachEvent
        element.attachEvent('on' + type, fn)
    } else {
        element['on' + type] = fn        // 都不支持的浏览器
    }
}

// 阻止事件冒泡
function cancelBubble(event) {
    if (event.stopPropagation) {
        event.stopPropagation()
    } else {                    // IE 下
        event.cancelBubble = true
    }
}

// axios 中 getDefaultAdapter
function getDefaultAdapter() {
  var adapter;
  // Only Node.JS has a process variable that is of [[Class]] process
  if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    // For node use HTTP adapter
    adapter = require('./adapters/http');
  } else if (typeof XMLHttpRequest !== 'undefined') {
    // For browsers use XHR adapter
    adapter = require('./adapters/xhr');
  }
  return adapter;
}

上述的代码片段都是演示了外观模式的特点,对于用户端来说都是统一的调用,但是接口内部却根据传参不同,或者运行环境不同等做了对应的处理,简化了客户端的使用。

优缺点

外观模式的优点在于使用者不必关系子系统的具体实现,通过统一的接口调用就能达到效果,降低了使用者和系统模块之间的耦合性,增加了可维护性和可扩展性。

由于外观模式是将一组子系统的接口进行整合,所以它的缺点就很明显,在系统内部扩展子系统时 , 容易产生风险。

适用场景

外观模式在很多开源作品中屡见不鲜,例如上述提到的 jQuerylodash 等,实际上我们开发也会经常用到。它比较适合将复杂的系统进行分层,让外观模块成为每层的入口,简化层与层之间调用。或者说当我们需要通过一个单独的函数或方法来访问一系列的函数或方法调用时,为了使代码更容易跟踪管理或者更好的维护时,可以考虑适用外观模式。

组合模式 (Composite Pattern)

组合模式是将一系列对象组合成树形结构,以表示 “部分-整体” 的层次结构,使用者只需统一地使用组合结构中的所有对象,而不需要关心它究竟是组合对象还是单个对象。它主要体现了整体与部分的关系,其典型的应用就是树形结构。

代码示例

// 创建部门
function createApartment(name) {
    return {
        name,
        _children: [],
        add(target) {
            this._children.push(target);
            return this;
        },
        show(cb) {
            this._children.forEach(function (child) {
                child.show(cb)
            })
        }
    }
}

// 创建员工
function createEmp(num, name) {
    return {
        num,
        name,
        show(cb) {
            cb(this)
        }
    }
}

// 创建部门
const techApartment = createApartment('技术部');

// 创建子部门
const proApartment = createApartment('产品组'),
    devApartment = createApartment('开发组');

techApartment.add(proApartment).add(devApartment);

proApartment.add(createEmp(100, '张三'))
    .add(createEmp(101, '李四'))

techApartment.add(createEmp(201, '小刘'))
    .add(createEmp(202, '小王'))
    .add(createEmp(203, '小陈'))
    .add(createEmp(204, '小亮'))

// 遍历
techApartment.show(function (item) {
    console.log(`工号:${item.num},姓名:${item.name}`)
})

/***
    工号:100,姓名:张三
    工号:101,姓名:李四
    工号:201,姓名:小刘
    工号:202,姓名:小王
    工号:203,姓名:小陈
    工号:204,姓名:小亮
***/

上述我们同通过一个部门的组织架构图来展示了什么是组合模式,可以发现组合对象和单个子对象具有相同的接口和数据结构,一次来保证操作一致,我们在遍历整个 techApartment 对象时,如果当前对象是没有子对象,则自身会做处理,否则会传递到下一个子对象中处理,以此完成整个递归遍历。

优缺点

组合模式的组合对象和单个子对象具有同样的接口,所以无论调用的是组合对象还是叶子对象调用方式上没有差别,外部调用非常方便。拓展性良好,新增节点会很方便,也不会影响到其他的对象。

随着节点的增加,组合模式也暴露出其不足,过多的节点会导致整个树状结构非常复制,层级嵌套深,内存占用较高,导致系统整体性能下降。

适用场景

如果对象组织呈树形结构,操作树中对象的方法比较类似时可以考虑适用组合模式。常见的比如组织架构图,文件目录,以及熟悉的 vue 中的 createElement 方法等都采用的组合模式这种设计理念。

桥接模式(Bridge Pattern)

桥接模式是为了将抽象部分与实现部分分离,使抽象部分和实现部分都可以独立的变化而不会互相影响,实现二者的解耦,从而降低了代码的耦合性,提高了代码的扩展性。

代码示例

// 桥接方法
function addEvent(ele, eventName, fn) {
    document.querySelector(ele).addEventListener(eventName, fn, false);
}

// 具体业务
addEvent('#btn', 'click', function () {
    console.log('hello world');     // hello world
})

上述通过一个简单的事件监听器的例子来展示了桥接模式的工作原理,桥接方法 addEvent 它内部不实现具体的业务逻辑,只是抽象出一个方法,它就充当了了 DOM 元素与其具体事件绑定的一个桥梁,要实现具体的业务逻辑只要给桥接函数传递参数即可。

优缺点

桥接模式分离了抽象和实现部分,将实现层(DOM 元素事件具体逻辑)和抽象层(绑定方法)解耦,使用者不需要关心细节的实现,只需要方便快捷的使用即可,提高了代码的拓展性。

桥接模式的弊端在于需要很好地抽象出桥接方法与业务逻辑,具有一定的局限性,另外桥接模式会引入额外的代码,增加系统的复杂度。

适用场景

如果开发中遇到部分系统的复用性大,且各个部件有独立的变化维度,就可以考虑引入桥接模式,实现代码的分层。常见的如同上述的事件监听器,动态更新 dom 的样式,以及 ajax 请求封装等。

发布-订阅模式 (Publish-Subscribe Pattern)

发布-订阅模式,它定义了一种一对多的关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知,使得它们能够自动更新。

代码示例

// 事件监听器
const Emitter = (function () {
    const _events = {};
    return {
        // 事件绑定
        on(type, cb) {
            if (!_events[type]) {
                _events[type] = [];
            }
            if (typeof cb === "function") {
                _events[type].push(cb);
            } else {
                throw  new Error('参数类型必须为函数')
            }
        },
        // 事件解绑
        off(type, cb) {
            if (!_events[type] || !_events[type].includes(cb)) return;
            // 移除事件监听
            _events[type].map((fn, index) => {
                 if (fn === cb) {
                    _events[type].splice(index, 1)
                }
            })
        },
        emit(type, ...args) {
            if (!_events[type]) return;
            _events[type].forEach(cb => cb(...args))
        }
    }
})();

// 事件订阅
Emitter.on('change', data => console.log(`我是第一条信息:${data}`))
Emitter.on('change', data => console.log(`我是第二条信息:${data}`))

// 事件发布
Emitter.emit('change', '参数')

上述我们通过发布订阅模式实现了一个简单的事件监听器,可以通过 on 方法监听某一事件,之后通过 emit 方法去分发,所有监听该事件的函数都会被依次执行,这就是发布订阅模式基本工作原理。

优缺点

发布订阅模式最大的特点就是发布者和订阅者之间完全解耦:发布者不需要订阅者是谁,只需要更新的时候遍历所以订阅该消息的订阅者即可。订阅者也不需要时时关注发布者的动态,当有消息更新时会自动接受。因此,可以将事件处理中心封装起来,统一管理,独立运行。

发布订阅模式的缺点在于,订阅者会增加内存消耗,及时后续没有触发,也会常驻内存中。随着订阅者的增多,系统复杂度会增加,代码运行效率、资源消耗会变大。另外,发布者与订阅者完全解耦,会导致代码追踪起来比较困难。

适用场景

发布订阅模式特别适用于要实现一对多关联的场景。日常生活中我们订阅的公众号,关注的明星微博,今日头条的新闻等,他们都会在有新消息的时候第一时间推送给你。而实际开发中,vue 的数据响应式,浏览器的 DOM 事件绑定等也都是这个原理。

策略模式 (Strategy Pattern)

策略模式就是将一系列算法封装起来,并使它们相互之间可以替换。被封装起来的算法具有独立性,外部不可改变其特性。它的目的就是将算法的使用与算法的实现分离开来,有效避免代码中很多if-else的条件语句。

代码示例

// 校验规则
const strategyMap = {
    // 校验手机号
    isMobile(mobile) {
        return /^1\d{10}$/.test(mobile);
    },
    // 校验是否必填
    isRequired(str) {
        return str.replace(/(^\s*)|(\s*$)/g, "") !== "";
    }
};

// 校验方法
function validate(formData) {
    let valid;

    for (let key in formData) {
        const val = formData[key].value;
        const rules = formData[key].rules;

        for (let i = 0; i < rules.length; i++) {
            const result = strategyMap[rules[i]['rule']].call(null, val);
            if (!result) {
                valid = {
                    errField: key,
                    errValue: val,
                    errMsg: rules[i]['message']
                }
                break;
            }
        }

        if (valid) return valid;
    }

    return valid;
}


// form 表单校验
const formData = {
    mobile: {
        value: '1380000000',
        rules: [
            {rule: 'isRequired', message: '手机号码不能为空'},
            {rule: 'isMobile', message: '手机号码格式不正确'},
        ]
    }
}

// 获取校验结果
const valid = validate(formData)
if (!valid) {
    console.log('校验通过')
} else {
    console.log(valid)   
    // {errField: "mobile", errValue: "1380000000", errMsg: "手机号码格式不正确"}
}

上述用了一个非常经典的表单校验来展示了策略模式的应用,我们可以事先将一些校验规则即一些策略算法放到一个 map 中,通过 validate 方法来完成对复杂表单的校验,相比于传统的 if-else 判断看起来简单明了的多,也可以大大提高开发效率。

优缺点

通过上述例子,可以发现策略模式可以将一个个算法封装起来,提高代码复用率,减少代码冗余;它可看作为 if/else 判断的另一种表现形式,在达到相同目的的同时,极大的减少了代码量以及代码维护成本。另外策略模式中各个策略之间相互独立,互不影响,使得它具有良好的可扩展性。

策略模式的缺点在于各个策略相互独立,因此一些复杂的算法逻辑无法共享,造成资源的浪费。另一方面,我们必须实现定义好种种策略,且使用者必须事先了解这些策略方能灵活运用,在一定程度上来讲,对于使用者来说不是很方便。

适用场景

策略模式比较适合实现某一个功能有多种方案可以选择,自由切换的场景,或者是有时需要多重条件判断,可以使用策略模式来规避多重条件判断的情况。前端典型应用就是许多开源框架中 form 表单的动态校验,以及电商系统中不同优惠券的对应不同的逻辑等。

状态模式 (State Pattern)

状态模式定义是一个对象在其内部状态改变时对应的改变它的行为,对象看起来似乎修改了它的类。其意思就是说 对象行为是基于状态来改变的,内部的状态转化,导致了行为表现形式不同。

其主要是用来解决系统中复杂对象的状态转换以及不同状态下行为的封装问题。

代码示例

// 正常状态
function NormalState() {
    this.handleChange = function (context) {
        console.log('正常状态')
        context.state = new ColorfulState()
    }
}

// 彩灯状态
function ColorfulState() {
    this.handleChange = function (context) {
        console.log('彩灯状态')
        context.state = new CloseState()
    }
}

// 关闭状态
function CloseState() {
    this.handleChange = function (context) {
        console.log('关闭状态')
        context.state = new NormalState()
    }
}

// 灯
function Light(state) {
    this.state = state;
    this.switch = function () {
        this.state.handleChange(this)
    }
}

// 设置灯光初始为关闭
const light = new Light(new CloseState());

setInterval(() => {
    light.switch()      
}, 1000)

// 关闭状态-->正常状态-->彩灯状态-->关闭状态...

我们通过生活中一个客厅的灯光状态来掩饰状态模式的是如何工作的,我们把每个状态定义成一个类,并且把每个状态所对应的功能处理封装起来,这样选择不同状态的时候,其实就是在选择不同的状态处理类,由于状态是在运行期被改变的,因此行为也会在运行期根据状态的改变而改变,看起来,同一个对象,在不同的运行时刻,行为是不一样的,就像是类被修改了一样。

优缺点

很明显,状态模式之间的状态都是一个个不同的类,相比于 switch-caseif-else 语句的使用,状态模式结构很清晰,拓展性很好,需要添加状态时,只需要在新增一个状态类即可,并且状态切换提供了统一的接口,外部的调用无需知道类内部如何实现状态和行为的变换,具有很好的封装性。

状态模式的缺点在于,每个状态都有对应的类,因此系统会引入了很多的类,这样导致系统中类的个数增加,维护成本变高。

适用场景

如果系统的代码中有多分支的条件语句,且这些分支依赖于某个对象的状态时,可以考虑使用状态模式来将分支的处理分散到单独的状态类中,来实现状态和行为的分离。

前端的Promise就是一个典型的状态模式。前端处理 ajax 请求返回不同的 status 时对应的处理逻辑,就可以考虑使用状态模式了。

命令模式 (Command Pattern)

命令模式,就是将一系列操作的指令封装起来,根据客户端不同的请求参数执行的对应的方法,本质上是对方法调用的封装,但它可以使请求发送者和接收者消除彼此之间的耦合关系。

代码示例

const Manager = (function () {
    // 命令
    const commander = {
        open: function () {
            console.log('打开电视')
        },
        close: function () {
            console.log('关闭电视')
        },
        change: function (channel) {
            console.log('更换频道 ' + channel)
        }
    }

    return {
        // 执行命令
        exec: function (cmd) {
            const args = [].splice.call(arguments, 1)
            commander[cmd] && commander[cmd](args)
        }
    }
})();

Manager.exec('open')        // 打开电视
Manager.exec('change', 10)  // 更换频道 10
Manager.exec('close')       // 关闭电视

上述代码以一种简单的方式展示了命令模式的基本用法,我们是先定义好一些命令,并暴露出一个执行命令的 exec 方法,使用者就可以通过 Manager.exec 传递不同的命令参数来达到执行不同命令的效果。

优缺点

上面的代码很明显就可以看出,命令模式中命令的请求和命令的执行两者完全解耦,因此系统的可扩展性良好,加入新的命令不会影响原有逻辑,而且复用性很强,可以被任何请求者使用,不关心请求者是谁。

命令模式的缺点在于,一是使用者要事先了解有哪些命令方能正常使用,二是随着命令的不断增加系统会变得很膨胀,复杂性会随之增加。

适用场景

命令模式比较适合于需要发布一些命令,但不清楚接受者和请求的操作,即只用知道发布了一个指令就行,具体做什么谁来做不用关心。常见的 GUI 编程中基本都采用这种模式,前端比较典型应用如,富文本编辑器中的各种按钮,canvas 动画中各种指令操作等。

总结

我们上面用了很大的篇幅总结了常见的一些的设计模式在 JavsScript 中的实现,虽然在 js 中有些设计模式看起来有些不尽人意,但这却不是我们所要关注的核心,我们真正需要关心的是这些设计模式的理念、它所要解决的问题。

设计模式对于我们学习框架源码,做一些前端架构是非常有帮助的,只有真正了解了它的思想,明白它所能解决的问题,才能让我们在开发中少走弯路,写出高质量的代码。

也希望阅读到这的你,继续加油,时刻保持一颗学习的心态,继续在程序员这条道路上摸爬滚打!

你可能感兴趣的:(聊一聊常见设计模式的 JavaScript 实现)