JavaScript 中的设计模式

设计模式

设计模式是对软件设计开发过程中反复出现的某类问题的通用解决方案。 设计模式更多的是指导思想和方法论,而不是现成的代码,当然每种设计模式都有每种语言中的具体实现方式。学习设计模式更多的是理解各种模式的内在思想和解决的问题,毕竟这是前人无数经验总结成的最佳实践,而代码实现则是对加深理解的辅助。

单一职责原则和开放封闭原则

  • 单一职责原则:一个类只负责一个功能领域中的相应职责,或者可以定义为:就一个类而言,应该只有一个引起它变化的原因。
  • 开放封闭原则:核心的思想是软件实体(类、模块、函数等)是可扩展的、但不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。

设计模式的类型

设计模型可以分为三大类:

  1. 结构型模式(Structural Patterns)是指类与对象的复合关系
  2. 创建型模式(Creational Patterns)是指关于如何创建范例的方式
  3. 行为型模式(Behavioral Patterns)是指对象间如何联系和通讯的

这三大类设计模式又可以分为更多小类,下面介绍 JavaScript 中常见的九种设计模式

单例模式 (Singleton)

介绍

单例模式的思想在于保证一个特定类仅有一个实例,即不管使用这个类创建多少个新对象,都会得到与第一次创建的对象完全相同。

单体模式有以下优点:

  • 用来划分命名空间,减少全局变量数量
  • 使代码组织的更一致,提高代码阅读性和维护性
  • 只能被实例化一次

使用场景

单例模式只允许实例化一次,能提高对象访问速度并且节约内存,通常被用于下面场景:

  • 需要频繁创建再销毁的对象,或频繁使用的对象:如:弹窗,文件
  • 常用的工具类对象
  • 常用的资源消耗大的对象

例子

// 单例构造器
const FooServiceSingleton = (function() {
    // 隐藏的 Class 的构造函数
    function FooService() {}

    // 未初始化的单例对象
    let fooService;

    return {
        // 创建/获取单例对象的函数
        getInstance: function() {
            if (!fooService) {
                fooService = new FooService();
            }
            return fooService;
        }
    }
})();

const fooService1 = FooServiceSingleton.getInstance();
const fooService2 = FooServiceSingleton.getInstance();

console.log(fooService1 === fooService2); // true
  1. 使用 IIFE 创建局部作用域并即时执行;
  2. getInstance() 为一个闭包 ,使用闭包保存局部作用域中的单例对象并返回。

工厂模式 (Factory)

介绍

工厂模式的目的在于创建对象,实现下列目标:

  • 可重复执行,来创建相似对象
  • 当编译时位置具体类型(类)时,为调用者提供一种创建对象的接口

通过工厂方法(或类)创建的对象,都继承父对象,可以解决创建多个类似对象的问题。

优点

  • 一个调用者想创建一个对象,只要知道其名称就可以了,不再需要重复引入一个个构造函数
  • 扩展性高,如果想增加一个产品,只要扩展一个工厂类就可以
  • 屏蔽产品的具体实现,调用者只关心产品的接口

缺点

每次增加一个产品时,都需要增加一个具体类和对象实现工厂,使得系统中类的个数成倍增加,在一定程度上增加了系统的复杂度,同时也增加了系统具体类的依赖

使用场景

什么场景适合应用工厂模式而不是直接 new 一个对象呢?当构造函数过多不方便管理,且需要创建的对象之间存在某些关联(有同一个父类、实现同一个接口等)时,不妨使用工厂模式。工厂模式提供一种集中化、统一化的方式,避免了分散创建对象导致的代码重复、灵活性差的问题。

例子

// 汽车构造函数
function SuzukiCar(color) {
    this.color = color;
    this.brand = 'Suzuki';
}

// 汽车构造函数
function HondaCar(color) {
    this.color = color;
    this.brand = 'Honda';
}

// 汽车构造函数
function BMWCar(color) {
    this.color = color;
    this.brand = 'BMW';
}

// 汽车品牌枚举
const BRANDS = {
    suzuki: 1,
    honda: 2,
    bmw: 3
}

/**
 * 汽车工厂
 */
function CarFactory() {
    this.create = function(brand, color) {
        switch (brand) {
            case BRANDS.suzuki:
                return new SuzukiCar(color);
            case BRANDS.honda:
                return new HondaCar(color);
            case BRANDS.bmw:
                return new BMWCar(color);
            default:
                break;
        }
    }
}

测试:

const carFactory = new CarFactory();
const cars = [];

cars.push(carFactory.create(BRANDS.suzuki, 'brown'));
cars.push(carFactory.create(BRANDS.honda, 'grey'));
cars.push(carFactory.create(BRANDS.bmw, 'red'));

function say() {
    console.log(`Hi, I am a ${this.color} ${this.brand} car`);
}

for (const car of cars) {
    say.call(car);
}

// Hi, I am a brown Suzuki car
// Hi, I am a grey Honda car
// Hi, I am a red BMW car

迭代器模式 (Iterator)

介绍

迭代器模式是提供一种方法,顺序访问一个聚合对象中每个元素,并且不暴露该对象内部。

这种模式属于行为型模式,有以下几个特点:

  • 访问一个聚合对象的内容,而无需暴露它的内部表示
  • 提供统一接口来遍历不同结构的数据集合
  • 遍历的同事更改迭代器所在的集合结构可能会导致问题

在迭代器模式中,通常包含有一个包含某种数据集合的对象,需要提供一种简单的方法来访问每个元素。
这里对象需要提供一个 next() 方法,每次调用都必须返回下一个连续的元素。
一个迭代器通常需要实现以下接口:

  • hasNext():判断迭代是否结束,返回 Boolean
  • next():查找并返回下一个元素

优点

  • 它简化了聚合类,并支持以不同的方式遍历一个聚合对象
  • 在同一个聚合上可以有多个遍历
  • 在迭代器模式中,增加新的聚合类和迭代器类都很方便,无须修改原有代码

缺点

由于迭代器模式将存储数据和遍历数据的职责分离,增加新的聚合类需要对应增加新的迭代器类,类的个数成对增加,这在一定程度上增加了系统的复杂性。

应用场景

  • 提供一致的遍历各种数据结构的方式,而不用了解数据的内部结构
  • 提供遍历容器(集合)的能力而无需改变容器的接口

例子

const item = [1, 'red', false, 3.14];

function Iterator(items) {
    this.items = items;
    this.index = 0;
}

Iterator.prototype = {
    hasNext: function() {
        return this.index < this.items.length;
    },
    next: function() {
        return this.items[this.index++];
    }
}

测试:

const iterator = new Iterator(item);

while (iterator.hasNext()) {
    console.log(iterator.next());
}

// 1, red, false, 3.14

访问者模式 (Visitor)

介绍

访问者模式是一种将算法与对象结构分离的设计模式,通俗点讲就是:访问者模式让我们能够在不改变一个对象结构的前提下能够给该对象增加新的逻辑,新增的逻辑保存在一个独立的访问者对象中。访问者模式常用于拓展一些第三方的库和工具。

访问者模式的实现有以下几个要素

  1. Visitor Object:访问者对象,拥有一个 visit() 方法
  2. Receiving Object:接收对象,拥有一个 accept() 方法
  3. visit(receivingObj):用于 Visitor 接收一个 Receiving Object
  4. accept(visitor):用于 Receving Object 接收一个 Visitor,并通过调用 Visitor 的 visit() 为其提供获取 Receiving Object 数据的能力

应用场景

  • 扩展一个类的功能 web
  • 动态增加功能,动态撤销

例子

Receiving Object:

function Employee(name, salary) {
    this.name = name;
    this.salary = salary;
}

Employee.prototype = {
    getSalary: function() {
        return this.salary;
    },
    setSalary: function(salary) {
        this.salary = salary;
    },
    accept: function(visitor) {
        visitor.visit(this);
    }
}

Visitor Object:

function Visitor() {}

Visitor.prototype = {
    visit: function(employee) {
        employee.setSalary(employee.getSalary() * 2);
    }
}

测试:

const employee = new Employee('bruce', 1000);
const visitor = new Visitor();
employee.accept(visitor);

console.log(employee.getSalary());

// 2000

策略模式 (Strategy)

介绍

策略模式封装一系列算法,支持我们在运行时,使用相同接口,选择不同算法。它的目的是为了将算法的使用与算法的实现分离开来。
策略模式通常会有两部分组成,一部分是策略类,它负责实现通用的算法,另一部分是环境类,它用户接收客户端请求并委托给策略类。

优点

  • 有效地避免多重条件选择语句
  • 支持开闭原则,将算法独立封装,使得更加便于切换、理解和扩展
  • 更加便于代码复用

缺点

  • 策略类会增多
  • 所有策略类都需要对外暴露

例子

我们需要使用策略模式,实现一个处理表单验证的方法,无论表单的具体类型是什么都会调用验证方法。我们需要让验证器能选择最佳的策略来处理任务,并将具体的验证数据委托给适当算法。

// 我们假设需要验证下面的表单数据的有效性:
let data = {
    name: 'name',
    age: 'unknown',
    nickname: 'leo',
}

// 这里需要先配置验证器, 对表单数据中不同的数据使用不同的算法:

validator.config = {
    name: 'isNonEmpty',
    age: 'isNumber',
    nickname: 'isAlphaNum',
}

// 并且我们需要将验证的错误信息打印到控制台:

validator.validate(data);
if (validator.hasErrors()) {
    console.log(validator.msg.join('\n'));
}

接下来我们才要实现 validator 中具体的验证算法, 他们都有一个相同接口 validator.types , 提供 validate() 方法和 instructions 帮助信息:

// 非空值检查
validator.types.isNonEmpty = {
    validate: function(value) {
        return value !== '';
    }
    instructions: '该值不能为空'
}

// 数值类型检查
validator.types.isNumber = {
    validate: function(value) {
        return !isNaN(value);
    }
    instructions: '该值只能是数字'
}

// 检查是否只包含数字和字母
validator.types.isAlphaNum = {
    validate: function(value) {
        return !/[^a-z0-9]/i.test(value);
    }
    instructions: '该值只能包含数字和字母,且不包含特殊字符'
}

最后就是要实现最核心的 validator 对象:

let validator = {
    types: {}, // 所有可用的检查
    msg: [], // 当前验证的错误信息
    config: {}, // 验证配置
    validate: function(data) { // 接口方法
        let type, checker, result;
        this.msg = []; // 清空错误信息
        for (let k in data) {
            if (data.hasOwnProperty(k)) {
                type = this.config[k];
                checker = this.types[type];
                if (!type) continue; // 不存在类型 则 不需要验证
                if (!checker) {
                    throw {
                        name: '验证失败',
                        msg: `不能验证类型:${type}`
                    }
                }
                result = checker.validate(data[k]);
                if (!result) {
                    this.msg.push(`无效的值:${k}${checker.instructions}`);
                }
            }
        }
        return this.hasErrors();
    }
    hasErrors: function() {
        return this.msg.length != 0;
    }
}

总结这个案例, 我们可以看出 validator 对象是通用的, 需要增强 validator 对象的方法只需添加更多的类型检查, 后续针对每个新的用例, 只需配置验证器和运行 validator() 方法就可以。

外观模式 (Facade)

介绍

外观模式是最常见的设计模式之一,它为子系统中的一组接口提供一个统一的高层接口,使子系统更容易使用。简而言之外观设计模式就是把多个子系统中复杂逻辑进行抽象,从而提供一个更统一、更简洁、更易用的 API。很多我们常用的框架和库基本都遵循了外观设计模式,比如 JQuery 就把复杂的原生 DOM 操作进行了抽象和封装,并消除了浏览器之间的兼容问题,从而提供了一个更高级更易用的版本。它为一些复杂的子系统接口提供一个更高级的统一接口,方便对这些子系统的接口访问。

它不仅简化类中的接口,还对接口和调用者进行解耦,外观模式也常被认为是开发者必备,它可以将一些复杂操作封装起来,并创建一个简单接口用于调用。

优点

  • 轻量级,减少系统相互依赖。
  • 提高灵活性。
  • 提高了安全性。

缺点

不符合开闭原则

例子

将经常同时使用浏览器事件 preventDefault() , stopPropagation() 封装成一个 stop() 方法:

let myEvent = {
    // ...
    stop: e => {
        // 其他 
        if (typeof e.preventDefault === 'function') {
            e.preventDefault();
        }
        if (typeof e.stopPropagation === 'function') {
            e.stopPropagation();
        }
        // IE
        if (typeof e.returnValue === 'boolean') {
            e.returnValue = false;
        }
        if (typeof e.cancelBubble === 'boolean') {
            e.cancelBubble = true;
        }
    }
};

代理模式 (Proxy)

介绍

代理模式为其他对象提供一种代理,来控制这个对象的访问,代理是在客户端和真实对象之间的介质。当访问一个对象本身的代价太高(比如太占内存、初始化时间太长等)或者需要增加额外的逻辑又不修改对象本身时便可以使用代理。

优点

  • 职责单一且清晰
  • 保护真实对象
  • 开闭原则,高拓展性

缺点

  • 由于在客户端和真实对象间添加代理对象,导致请求处理速度变慢
  • 实现代理模式需要额外工作,有些代理模式实现起来非常复杂

使用场景

  • 需要隐藏或保护某个类
  • 需要给不同访问者提供不同权限
  • 需要为某个类添加功能,如添加日志缓存等

例子

function StockPriceAPI() {
    // Subject Interface 实现
    this.getValue = function(stock, callback) {
        console.log('Calling external API ... ');
        setTimeout(() => {
            switch (stock) {
                case 'GOOGL':
                    callback('$1265.23');
                    break;
                case 'AAPL':
                    callback('$287.05');
                    break;
                case 'MSFT':
                    callback('$173.70');
                    break;
                default:
                    callback('');
            }
        }, 2000);
    }
}

function StockPriceAPIProxy() {
    // 缓存对象
    this.cache = {};
    // 真实 API 对象
    this.realAPI = new StockPriceAPI();
    // Subject Interface 实现
    this.getValue = function(stock, callback) {
        const cachedPrice = this.cache[stock];
        if (cachedPrice) {
            console.log('Got price from cache');
            callback(cachedPrice);
        } else {
            this.realAPI.getValue(stock, (price) => {
                this.cache[stock] = price;
                callback(price);
            });
        }
    }
}

const api = new StockPriceAPIProxy();
api.getValue('GOOGL', (price) => {
    console.log(price)
});
api.getValue('AAPL', (price) => {
    console.log(price)
});
api.getValue('MSFT', (price) => {
    console.log(price)
});

setTimeout(() => {
    api.getValue('GOOGL', (price) => {
        console.log(price)
    });
    api.getValue('AAPL', (price) => {
        console.log(price)
    });
    api.getValue('MSFT', (price) => {
        console.log(price)
    });
}, 3000)

输出:

Calling external API...
    Calling external API...
    $1265.23
$287.05
$173.70
Got price from cache
$1265.23
Got price from cache
$287.05
Got price from cache
$173.70

中介者模式 (Mediator)

介绍

在中介者模式中,中介者(Mediator)包装了一系列对象相互作用的方式,使得这些对象不必直接相互作用,而是由中介者协调它们之间的交互,从而使它们可以松散偶合。当某些对象之间的作用发生改变时,不会立即影响其他的一些对象之间的作用,保证这些作用可以彼此独立的变化。
中介者模式和观察者模式有一定的相似性,都是一对多的关系,也都是集中式通信,不同的是中介者模式是处理同级对象之间的交互,而观察者模式是处理 Observer 和 Subject 之间的交互。

优点

  • 降低类的复杂度,从一对多转成一对一
  • 为各个类之间解耦
  • 提高代码可维护性

缺点

  • 中介者会越来越庞大,变得难以维护

使用场景

  • 系统中对象之间存在比较复杂的引用关系,而且难以复用该对象
  • 需要生成最少的子类,实现一个中间类封装多个类中的行为的时候

例子

// 聊天室成员类:
function Member(name) {
    this.name = name;
    this.chatroom = null;
}

Member.prototype = {
    // 发送消息
    send: function(message, toMember) {
        this.chatroom.send(message, this, toMember);
    },
    // 接收消息
    receive: function(message, fromMember) {
        console.log(`${fromMember.name} to ${this.name}: ${message}`);
    }
}

// 聊天室类:
function Chatroom() {
    this.members = {};
}

Chatroom.prototype = {
    // 增加成员
    addMember: function(member) {
        this.members[member.name] = member;
        member.chatroom = this;
    },
    // 发送消息
    send: function(message, fromMember, toMember) {
        toMember.receive(message, fromMember);
    }
}

测试:

const chatroom = new Chatroom();
const bruce = new Member('bruce');
const frank = new Member('frank');

chatroom.addMember(bruce);
chatroom.addMember(frank);

bruce.send('Hey frank', frank);

//bruce to frank: hello frank

观察者模式 (Observer)

介绍

观察者模式又称发布订阅模式(Publish/Subscribe Pattern),这种模式下,一个对象订阅定一个对象的特定活动,并在状态改变后获得通知。
这里的订阅者称为观察者,而被观察者称为发布者,当一个事件发生,发布者会发布通知所有订阅者,并常常以事件对象形式传递消息。
所有浏览器事件(鼠标悬停,按键等事件)都是该模式的例子。

观察者模式与发布订阅模式的区别

观察者模式:
一种一对多的依赖关系,多个观察者对象同时监听一个主题对象。这个主题对象在状态上发生变化时,会通知所有观察者对象,使它们能够自动更新自己。

发布订阅模式:
发布订阅模式理念和观察者模式相同,但是处理方式上不同。
在发布订阅模式中,发布者和订阅者不知道对方的存在,他们通过调度中心串联起来。
订阅者把自己想订阅的事件注册到调度中心,当该事件触发时候,发布者发布该事件到调度中心(并携带上下文),由调度中心统一调度订阅者注册到调度中心的处理代码。

异同点:

  • 观察者模式中,观察者知道发布者是谁,发布者保持对观察者进行记录。而发布订阅模式中,发布者和订阅者不知道对方的存在。它们只是通过调度中心进行通信。
  • 发布订阅模式中,组件是松散耦合的,正好和观察者模式相反。
  • 观察者模式大多是同步,如当事件触发,发布者就会去调用观察者的方法。而发布订阅模式大多是异步的(使用消息队列)。
  • 观察者模式需要在单个应用程序地址空间中实现,而发布-订阅更像交叉应用模式。

优点

  • 都可以一对多
  • 程序便于扩展
  • 观察者模式:单向解耦,发布者不需要清楚订阅者何时何地订阅,只需要维护订阅队列,发送消息即可
  • 发布订阅模式:双向解耦,发布者和订阅者都不用清楚对方,全部由订阅中心做处理

缺点

  • 如果一个被观察者和多个观察者的话,会增加维护的难度,并且会消耗很多时间。
  • 如果观察者和发布者之间有循环依赖,可能会导致循环调用引起系统奔溃。
  • 观察者无法得知观察的目标对象是如何发生变化,只能知道目标对象发生了变化。
  • 发布订阅模式,中心任务过重,一旦崩溃,所有订阅者都会受到影响。

使用场景

浏览器事件(鼠标悬停,按键等事件)

例子

class EventChannel {
    constructor() {
        this.subjects = {} // 主题
    }

    hasSubject(subject) {
        return this.subjects[subject] ? true : false
    }

    /**
     * 订阅的主题
     * @param {String} subject 主题
     * @param {Function} callback 订阅者
     */
    on(subject, callback) {
        if (!this.hasSubject(subject)) {
            this.subjects[subject] = []
        }
        this.subjects[subject].push(callback)
    }

    /**
     * 取消订阅
     */
    off(subject, callback) {
        if (!this.hasSubject(subject)) {
            return
        }
        const callbackList = this.subjects[subject]
        const index = callbackList.indexOf(callback)
        if (index > -1) {
            callbackList.splice(index, 1)
        }
    }

    /**
     * 发布主题
     * @param {String} subject 主题
     * @param {Argument} data 参数
     */
    emit(subject, ...data) {
        if (!this.hasSubject(subject)) {
            return
        }
        this.subjects[subject].forEach(callback => {
            callback(...data)
        })
    }
}

const channel = new EventChannel()

channel.on('update', function(data) {
    console.log(`update value: ${data}`)
})
channel.emit('update', 123)

转载规则

《JavaScript 中的设计模式》Konata 采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。
  目录