JavaScript 与设计模式

JavaScript与设计模式

作为一名有追求的 JavaScript 开发者,你努力编写整洁、健康且可维护的代码。当你解决一些有趣并且特定的挑战时,也许会发现经常对完全不同的问题编写非常类似的代码。也许你还没有意识到,但其实你已经在使用设计模式了。设计模式是软件设计领域对常见问题可复用解决方案

对于任何编程语言,很多可复用的解决方案通常都是被该语言相关社区的开发者们发明并经过大量测试的。由于设计模式是集成了大量开发者经验的解决方案,通常都非常实用并且能帮助我们以更优的方式编码并解决问题。

使用设计模式主要有如下益处

  • 经得起推敲:我们可以肯定设计模式是有用的,因为已经被业界大量的开发者使用了。而且,你几乎可以确信设计模式已经被修订及优化过无数次了。
  • 可复用设计模式与具体的问题没有关系,它是对某类特定问题的成熟的解决方案。
  • 富有表现力设计模式可以优雅的表述解决方案。
  • 便于交流:当开发者们对设计模式都熟悉时,他们可以很容易地对具体问题的潜在解决方案进行交流。
  • 避免后续重构代码:如果应用程序被特意使用设计模式编写了,通常你都不需要后续再对代码进行重构,因为对具体问题采用的合理的设计模式往往都是最优的解决方案。
  • 减少代码量设计模式往往优雅且是最优方案,通常比其他方案要求更少的代码量。

JavaScript 闪亮登场

截止到今天,JavaScript 在 Web 开发领域仍是最流行的编程语言。该语言起初是作为“胶水语言”,在早期的浏览器中配合 HTML 元素使用,也被叫做客户端脚本语言。在那个浏览器仅能展示静态 HTML 元素的年代,JavaScript 的登场无疑是划时代的。和你想的一样,客户端脚本语言的诞生加剧了当时包括微软、网景等大玩家在浏览器开发领域的“浏览器大战”。

当时,每个大玩家都想推动自家实现的脚本语言,比如网景(更具体些,Brendan Eich)创造了 JavaScript,微软创造了 JScript,诸如此类。和你想的一样,这些不同的脚本语言的差别很大,开发者们不得不针对每种浏览器单独进行开发。很快大家都意识到这样下去不可持续,需要制定一套标准及跨浏览器的解决方案以统一及简化 Web 开发过程。这套标准也就是 ECMAScript

ECMAScript 是所有现代浏览器都尽力支持的标准化的脚本语言规范说明,并且有对 ECMAScript 的不同实现(JavaScript 是其中一种比较流行的实现)。自从诞生之日起,ECMAScript 标准化了很多重要的特性,并且随着 ECMAScript 的不断迭代,有了许多不同的版本。浏览器对于 ES6(包括更高版本)的支持尚不完整,所以通常需要转化为 ES5。

JavaScript 有哪些特性?

如果有人问你“什么是 JavaScript?”,你也许会类似这样回答:

JavaScript 是一个高级的、轻量的、解释型的、基于原型式面向对象的多范式编程语言,函数作为其一等公民,其具有动态类型,支持事件驱动、函数式和指令式的编程风格,因作为网站的脚本语言而闻名。

上述定义主要说明 JavaScript 代码具有低内存占用,容易学习与使用,具有类似 C++ 或 Java 的语法风格。作为脚本语言,JavaScript 是解释型的而非编译型的,并且支持包括过程式(属于指令式编程风格的一种)、面向对象与函数式的编程风格。对于开发者来说是一门很灵活的编程语言。

以上,我们了解了所有听起来类似其他语言的特性,下面让我们具体看看一些值得特别关注地区分于其他语言的 JavaScript 特有的特性。

JavaScript 的函数是一等公民

对于很多来自 C/C++ 背景的开发者,刚开始使用 JavaScript 时,函数作为一等公民这个特性可能会有些难以掌握。JavaScript 将函数视为一等公民,意味着你可以像普通变量一样将函数A作为参数传入函数B。

// 将函数作为变量 cb,performOperation 函数执行时会执行传入的函数 cb
const performOperation = (a, b, cb) => {
    const c = a + b;
    cb(c);
}

performOperation(2, 3, result => {
    // 输出 5
    console.log(`The result of the operation is ${result}`);
})

JavaScript 的面向对象是基于原型的

与其他面向对象(OOP)语言一样,JavaScript 支持对象,对于对象我们通常最先想到的术语是类和继承。但不同于其他基于(class-based)的面向对象语言,JavaScript 本身没有类的概念(尽管 ES6 可以使用 class 关键字声明类,但本质是个语法糖),其通过基于原型(prototype-based)的方式达到面向对象的目的。

基于原型的编程是一种面向对象的编程风格,其行为重用(也称为继承)是通过重用已存在对象的原型来达到目的的。后面我们讨论设计模式时会继续深入探讨,此特性在许多 JavaScript 设计模式中都有用到。

JavaScript 的事件循环

你如果有使用过 JavaScript 的经历的话,一定熟悉回调函数这个术语。回调函数其实就是函数A可以作为另一个函数B的参数(记住,JavaScript 将函数视为一等公民),并且函数A会在某个事件发生后执行。回调函数往往被用作订阅类似鼠标点击或者键盘输入等事件。

JS EventLoop

每次事件(会被有关监听者绑定)触发时,相关消息会被发送到一个以 FIFO 的方式同步处理的消息队列中。这就是 JavaScript 的事件循环(Event Loop)机制。更多有关 JavaScript 事件循环机制的细节可参考:《JavaScript中的TaskQueue,Macrotask 与 Microtask》

设计模式分类

如本文开头所描述,设计模式是软件设计领域对常见问题的可复用的解决方案。设计模式有多种归类方式,最流行的分类如下:

  • 创建型设计模式(Creational)
  • 结构型设计模式(Structural)
  • 行为型设计模式(Behavioral)
  • 并发型设计模式(Concurrency)
  • 架构型设计模式(Architectural)

需要注意的是在很多主流分类中,架构型设计模式并不属于设计模式讨论的范畴,而是另外的架构模式。

创建型设计模式

此类设计模式主要处理对象的创建机制,对于特定问题,相较于基础的创建对象的方式能优化对象的创建,基础创建对象的方式可能导致设计问题或增加系统设计的复杂度。创建型设计模式通过某种形式控制对象的创建以解决特定问题,这类设计模式主要有:

  • 工厂方法模式(Factory method)
  • 抽象工厂模式(Abstract factor)
  • 建造者模式(Builder)
  • 原型模式(Prototype)
  • 单例模式(Singleton)

结构型设计模式

此类设计模式主要处理对象间的关系,能保证系统的某部分发生变更后不会影响到系统整体。常见的有:

  • 适配器模式(Adapter)
  • 桥接模式(Bridge)
  • 组合模式(Composite)
  • 装饰器模式(Decorator)
  • 外观模式(Facade)
  • 享元模式(Flyweight)
  • 代理模式(Proxy)

行为型设计模式

此类设计模式关注于系统中不同对象间的高效通信与职责委派。主要有:

  • 责任链模式(Chain Of Responsibility)
  • 命令模式(Command)
  • 迭代器模式(Iterator)
  • 中介者模式(Mediator)
  • 备忘录模式(Memento)
  • 观察者模式(Observer)
  • 状态模式(State)
  • 策略模式(Strategy)
  • 访问者模式(Visitor)
  • 模板方法模式(Template Method)

并发型设计模式

此类设计模式主要涉及处理多线程编程范式。常见的有:

  • 主动对象模式(Active Object)
  • 试探模式(Balking)
  • 屏障模式(Barrier)
  • 双重检查锁定模式(Double-checked Locking)
  • 守护挂起模式(Guarded Suspension)
  • 领导者/追随者模式(Leaders/Followers)
  • 监视器对象模式(Monitor Object)
  • 反应器模式(Reactor)
  • 读写锁模式(Read-Write Lock)
  • 调度者模式(Scheduler)
  • 线程池模式(Thread Pool)
  • 线程本地存储模式(Thread-Local Storage)

架构型设计模式

此类设计模式主要用于代码架构意图。较著名的有:

  • MVC(Model-View-Controller)
  • MVP(Model-View-Presenter)
  • MVVM(Model-View-ViewModel)

JavaScript 常见设计模式

每种设计模式都代表特定类型问题的特定解决方案,并没有所谓全宇宙通用的设计模式来很好地适应各种问题。因此需要我们学习在什么情况下某种设计模式能派上用场并发挥其实际价值,当我们熟悉了设计模式及它们所适用的场景后,就能更容易地确定某种设计模式是否能应用于所要解决的问题。

记住,对问题使用错误的设计模式很可能会导致不期望的效果,比如不必要的代码复杂度、不必要的性能开销等。

以上是我们在应用某个设计模式时需要考虑的问题。接下来我们来探讨一些比较常用并且大多数 JavaScript 开发者需要熟悉的设计模式。

构造器模式(Constructor)

对于典型的面向对象语言,构造器是类中特殊的函数,主要用于初始化对象(对属性赋予默认或者传入的值)。

在 JavaScript 中,创建一个对象通常有以下方式:

// 我们可以使用如下方式创建对象
const instance = {};
// 或
const instance = Object.create(Object.prototype);
// 或
const instance = new Object();

创建对象后,可以使用下列方式对创建的对象添加属性:

// 从 ES3 开始支持
// 点符号
instance.key = "A key's value";

// 中括号
instance["key"] = "A key's value";

// 从 ES5 开始支持
// 使用 Object.defineProperty 设置单个属性
Object.defineProperty(instance, "key", {
    value: "A key's value",
    writable: true,
    enumerable: true,
    configurable: true
});

// 使用 Object.defineProperties 同时设置多个属性
Object.defineProperties(instance, {
    "firstKey": {
        value: "First key's value",
        writable: true
    },
    "secondKey": {
        value: "Second key's value",
        writable: false
    }
});

前面我们提到过 JavaScript 没有从语言层面原生性的支持类(ES6 class 只是语法糖),但通过对函数使用“new”关键字的形式,JavaScript 支持构造器。通过这种方式,我们可以将某个函数作为构造函数来初始化对象的属性,就像使用类中的构造器一样。

// 声明 Person 对象的构造器(函数)
const Person = (name, age, isDeveloper) => {
    this.name = name;
    this.age = age;
    this.isDeveloper = isDeveloper || false;

    this.writesCode = function() {
      console.log(this.isDeveloper? "This person does write code" : "This person does not write code");
    }
}

// 创建对象 Bob
const person1 = new Person("Bob", 38, true);
// 创建对象 Alice
const person2 = new Person("Alice", 32);

// 输出: This person does write code
person1.writesCode();
// 输出: this person does not write code
person2.writesCode();

然而,上述代码还有改进的空间。如果你还记得的话,我们前面有提到过 JavaScript 是基于原型的继承。上述代码的问题出在在 Person 构造器中每次创建对象实例时都会重复定义 writesCode 方法。我们可以通过将 wirtesCode 方法设置为 Person 的原型方法来避免这个问题。

// 声明 Person 对象的构造器
const Person = (name, age, isDeveloper) => {
    this.name = name;
    this.age = age;
    this.isDeveloper = isDeveloper || false;
}

// 扩展函数的原型
Person.prototype.writesCode = function() {
    console.log(this.isDeveloper? "This person does write code" : "This person does not write code");
}

现在,每个 Person 实例都能共享同一个 writesCode 方法。

模块模式(Module)

就独特性而言,JavaScript 永远不会停止让你感到惊讶。JavaScript 另一个独特之处(对于面向对象语言来说)就是其不支持访问修饰符(access modifiers)。而其他典型的面向对象语言,用户是可以在声明一个类时确定其成员的访问权限。为此,JavaScript 开发人员鼓捣出来模仿这些行为的方式。

在我们正式进入 JavaScript 的模块模式前,先讨论下其闭包(closure)的概念。闭包是指一个能访问其父级作用域的函数(即使在其父级函数结束运行后),可以帮助我们通过作用域来模仿访问修饰符的行为。举个例子:

// 我们使用一个立即执行函数来声明 counter 这个私有变量
const counterIncrementer = (
  () => {
    let counter = 0;

    return () => {
        return ++counter;
    };
    }
)();

// 输出 1
console.log(counterIncrementer());
// 输出 2
console.log(counterIncrementer());
// 输出 3
console.log(counterIncrementer());

如上面的例子,通过使用立即执行函数(IIFE),我们将 counter 变量绑定到一个已经执行完闭的函数中,而且仍然可以通过其返回的子函数将 counter 变量递增。以上我们已通过操作作用域的方式将 counter 私有化了,无法通过外部直接访问到 counter 变量。

使用闭包,我们在创建对象时就可以为其定义私有及公有的部分。这其实就是模块,当我们需要隐藏对象的某些细节并且仅展示特定对外接口时,这会非常实用。如下面的例子:

// 通过使用闭包我们暴露了一个对象作为对外接口
// 可以管理私有成员 objects 数组
const collection = (() => {
    // 私有成员
    const objects = [];

    // 公有成员
    return {
        addObject: (object) => {
            objects.push(object);
        },
        removeObject: (object) => {
            const index = objects.indexOf(object);
            if (index >= 0) {
                objects.splice(index, 1);
            }
        },
        getObjects: () => {
            return JSON.parse(JSON.stringify(objects));
        }
    };
})();

collection.addObject("Bob");
collection.addObject("Alice");
collection.addObject("Franck");
// 输出 ["Bob", "Alice", "Franck"]
console.log(collection.getObjects());
collection.removeObject("Alice");
// 输出 ["Bob", "Franck"]
console.log(collection.getObjects());

模块模式很好地将对象私有和公有的部分清晰地分离开来。然而,当你希望改变成员的可见性时,由于访问公有部分及私有部分的性质不同,你需要修改所有用到这些成员的代码。另外,在对象创建添加的方法是无法访问到对象的私有成员的。

揭示模块模式(Revealing Module)

此模式是上述模块模式的增强版本,主要的不同是我们将所有对象的逻辑放到了模块的私有部分,然后通过返回匿名对象的方式暴露出公有的部分。我们可以在将私有成员映射到公有成员时改变他们的命名。

// 我们将所有对象的逻辑都放到私有成员中,然后暴露出一个映射了私有成员的匿名对象
const namesCollection = (() => {
    // 私有成员
    const objects = [];

    const addObject = (object) => {
        objects.push(object);
    }

    const removeObject = (object) => {
        var index = objects.indexOf(object);
        if (index >= 0) {
            objects.splice(index, 1);
        }
    }

    const getObjects = () => {
        return JSON.parse(JSON.stringify(objects));
    }

    // 公有成员
    return {
        addName: addObject,
        removeName: removeObject,
        getNames: getObjects
    };
})();

namesCollection.addName("Bob");
namesCollection.addName("Alice");
namesCollection.addName("Franck");
// 输出 ["Bob", "Alice", "Franck"]
console.log(namesCollection.getNames());
namesCollection.removeName("Alice");
// 输出 ["Bob", "Franck"]
console.log(namesCollection.getNames());

揭示模块模式与其他模块模式不同点主要在于公有成员是如何引用的,其更容易使用与修改。然后揭示模块模式在某些特殊场景也会比较脆弱,比如使用该模式创建的对象作为继承链上的原型时,会有如下问题:

  • 如果私有函数被公有函数引用了,我们不能重写这个公有函数,这是因为私有函数会继续指向之前的函数实现,并可能导致系统出 bug。
  • 如果公有成员指向了私有变量,当我们尝试从模块外重写这个公有成员时,其他函数仍会引用该私有变量值,这可能会导致系统出 bug。

单例模式(Singleton)

该模式的应用场景是我们仅需要类的一个实例时,比如我们需要一个包含某类配置信息的对象。在这类场景中,我们需要在代码中不同部分用到这些配置时都单独创建一个对象。

const singleton = (() => {
    // 仅被初始化一次的私有值
    let config;

    const initializeConfiguration = (values) => {
        this.randomNumber = Math.random();
        values = values || {};
        this.number = values.number || 5;
        this.size = values.size || 10;
    }

    // 暴露用于获取单例值的统一处理函数
    return {
        getConfig: (values) => {
            // 确保仅被初始化一次
            if (config === undefined) {
                config = new initializeConfiguration(values);
            }

            return config;
        }
    };
})();

const configObject = singleton.getConfig({ "size": 8 });
// 输出 number: 5, size: 8, randomNumber: 某个随机值
console.log(configObject);
const configObject1 = singleton.getConfig({ "number": 8 });
// 输出 number: 5, size: 8, randomNumber: 和上面相同
console.log(configObject1);

需要注意的是,在每次获取实例时都需要确保其有且仅有同一个实例。

观察者模式(Observer)

当我们需要优化系统中不同部分的通信时,观察者模式将是个非常有用的工具,能很好地降低对象间的耦合性。

观察者模式有很多不同版本,其最基础的形式是拥有两个主要部分,分别为 SubjectObserver

Subject 处理有关 Observers 订阅的某个主题的所有操作。这些操包括:为 Observer 订阅特定主题,为 Observer 取消订阅特定主题,通知所有 Observers 关于某个特定主题的事件发布。

观察者模式演化出发布者/订阅者模式(publisher/subscriber),后面有代码示例。相对于典型的观察者模式发布者/订阅者模式主要的不同在于其更进一步地降低耦合性。在观察者模式中,Subject 持有所有 Observers 的引用并且直接通过 Subject 对象本身直接调用 Observer 的方法进行通知。而在发布者/订阅者模式中,发布者订阅者之间是通过频道(或者说经纪人,从数据结构角度看通常是个消息队列)建立通信渠道的,也就是发布者和订阅者间互不认识(完全解耦)。发布者在成功发布了某个事件到频道中就可以进行后续的业务逻辑,而无需关心订阅者

const publisherSubscriber = {};

// container对象处理订阅及发布
((container) => {
    // id代表某个主题的唯一订阅ID
    const id = 0;

    // 订阅特定主题并传入主题触发后的处理函数
    container.subscribe = (topic, f) => {
        if (!(topic in container)) {
          container[topic] = [];
        }

        container[topic].push({
            "id": ++id,
            "callback": f
        });

        return id;
    }

    // 针对主题每个订阅都有其独一无二的ID,我们可以用于取消订阅
    container.unsubscribe = (topic, id) => {
        const subscribers = [];
        for (const subscriber of container[topic]) {
            if (subscriber.id !== id) {
                subscribers.push(subscriber);
            }
        }
        container[topic] = subscribers;
    }

    container.publish = (topic, data) => {
        for (const subscriber of container[topic]) {
            // 执行订阅时传入的处理函数时
            subscriber.callback(data);
        }
    }

})(publisherSubscriber);

const subscriptionID1 = publisherSubscriber.subscribe("mouseClicked", (data) => {
    console.log("I am Bob's callback function for a mouse clicked event and this is my event data: " + JSON.stringify(data));
});

const subscriptionID2 = publisherSubscriber.subscribe("mouseHovered", (data) => {
    console.log("I am Bob's callback function for a hovered mouse event and this is my event data: " + JSON.stringify(data));
});

const subscriptionID3 = publisherSubscriber.subscribe("mouseClicked", (data) => {
    console.log("I am Alice's callback function for a mouse clicked event and this is my event data: " + JSON.stringify(data));
});

// 执行3次 console.log
publisherSubscriber.publish("mouseClicked", {"data": "data1"});
publisherSubscriber.publish("mouseHovered", {"data": "data2"});

// 取消订阅
publisherSubscriber.unsubscribe("mouseClicked", subscriptionID3);

// 执行2次 console.log
publisherSubscriber.publish("mouseClicked", {"data": "data1"});
publisherSubscriber.publish("mouseHovered", {"data": "data2"});

使用这个模式的缺点是其加大了测试系统不同部分的难度,比如没有优雅的方式可以测试订阅者部分是否符合预期。

中介者模式(Mediator)

当谈到将系统解耦时,中介者模式或许也是个非常有用的工具。

中介者是一个对象,它作为中介者被用作系统不同部分间的通信并处理它们间的工作流(workflow)。这里特别强调了处理工作流,为什么呢?

因为中介者模式与上面谈到的发布者/订阅者模式有很多的相同点,你也许会问自己:“嗯......这两种设计模式都用于实现对象间更好的通信.......那他们有何不同呢?🤔”

主要不同在于中介者模式同时处理了工作流,而发布者/订阅者模式使用“即发即弃(fire and forget)”的方式处理通信。发布者/订阅者模式可以认为是个简单的事件(消息)聚合器,它只关心为发布者发送特定事件并确保对应的订阅者知道该事件被触发了,并不关心事件触发后的处理逻辑。

中介者模式的一个不错的例子是作为向导类型的接口。假设你开发的系统有较复杂的注册流程,并且需要用户填写大量的信息时,通常将这些流程拆解为一系列的步骤是个不错的实践。通过这种方式,代码会更简洁(更易维护),并且对于用户来说更友好(不需要仅为了完成注册而填写大量的信息)。此时中介者作为处理注册步骤的对象,对于不同用户潜在的不同的注册过程,需要考虑各种可能的工作流(注册流)。

这种模式的缺点是我们在系统中引入了一个潜在的错误点,假如中介者挂了,整个系统可能会停止工作。

原型模式(Prototype)

和本文前面谈到的一样,JavaScript 底子里不支持类,其通过基于原型的编程方式进行对象继承的。这使我们能够为对象创建其原型对象,如下面的例子:

const personPrototype = {
    sayHi: function (){
        console.log("Hello, my name is " + this.name + ", and I am " + this.age);
    },
    sayBye: function () {
        console.log("Bye Bye!");
    }
};

const Person = (name, age) => {
    name = name || "John Doe";
    age = age || 26;

    function constructorFunction(name, age) {
        this.name = name;
        this.age = age;
    };

    constructorFunction.prototype = personPrototype;

    const instance = new constructorFunction(name, age);
    return instance;
}

const person1 = Person();
const person2 = Person("Bob", 38);

// 输出: Hello, my name is John Doe, and I am 26
person1.sayHi();
// 输出: Hello, my name is Bob, and I am 38
person2.sayHi();

需要注意到,基于原型的继承是可以提升性能的。因为创建的对象会保留对原型对象的引用,而无需对每个对象都进行重复声明。

命令模式(Command)

当我们需要通过从某个集中的对象触发命令的方式来降低对象间的耦合时,命令模式会很有帮助。比如,应用中使用了大量的 API 服务的调用,当我们的 API 服务发生变更时,可能要根据已变更的 API 改动很多地方。这种情况下我们需要实现一个抽象层,将 API 服务的调用从直接使用的对象中抽离出来。这样我们可以避免在 API 服务发生变更时需要同时修改多个地方,因为实际请求 API 服务只存在于一个地方。

和其他任何模式一样,我们需要确切的知道在什么情况下才真的需要使用这个模式。我们需要实际问题做出取舍,因为增加了抽象层可能会降低性能,但却可能提高代码的清晰度及更易维护。

// 实际执行命令的对象
const invoker = {
    add: (x, y) => x + y,
    subtract: (x, y) => x - y,
}

// 执行命令的抽象层,对于调用者来说是 invoker 对象的接口
const manager = {
    execute: (name, ...args) => {
        if (name in invoker) {
            return invoker[name](...args);
        }
        return false;
    }
}

// 输出 8
console.log(manager.execute("add", 3, 5));
// 输出 2
console.log(manager.execute("subtract", 5, 3));

外观模式(Facade)

外观模式用于隐藏系统的复杂性,在内部实现及客户端间建立抽象层,并向客户端提供了一个可以访问系统的接口。

典型的例子是前端曾经一度流行的 JQuery 库中的 selector 选择器,其对外隐藏了对 DOM 的相关操作。

jQuery(".parent .child div.span")

其大大简化了选择 DOM 元素的过程,尽管表面上看起来很简单,但其内部是做了大量的复杂逻辑。

总结

任何中高级 JavaScript 开发者都需要意识到设计模式是非常有用的工具,熟悉有关设计模式的细节会非常有用,能让我们在项目的生命周期中节省大量宝贵时间,尤其是在维护阶段。

由于篇幅所限,本文只列举了部分常见的 JavaScript 设计模式。我们日常开发过程中也要下意识的去应用设计模式并不断积累这类经验,多学、多思、多运用,相信你一定能成为更好的开发者。