Skip to content

你并不了解 JavaScript:作用域与闭包 - 第二版

第八章:模块化模式

在本章中,我们将通过探索所有编程中最重要的代码组织模式之一:模块,来结束本书的正文。正如我们将看到的那样,模块本质上是由我们已经讲过的内容构建而成:这是你学习词法作用域和闭包所付出努力的回报。

我们研究了词法作用域的各个角度,从全局作用域的广度到嵌套的块作用域,再到错综复杂的变量生命周期。然后,我们利用词法作用域来了解闭包的全部功能。

花点时间回顾一下,你在这段旅程中已经走了多远;在更深入地了解 JS 方面,你已经迈出了一大步!

本书的核心主题是,理解并掌握作用域和闭包是正确构建和组织代码的关键,尤其是决定在变量中存储信息的位置。

在最后一章中,我们的目标是了解模块如何体现这些主题的重要性,如何将它们从抽象概念提升为构建程序中具体而实用的改进。

封装和最小暴露 (POLE)

封装经常被作为面向对象 (OO) 编程的一项原则,但它的基础性和广泛适用性远不止于此。封装的目的是将信息(数据)和行为(功能)捆绑在一起或置于同一位置,共同达到一个目的。

与任何语法或代码机制无关,封装的精神可以通过一些简单的事情来实现,比如使用单独的文件来保存整个程序中具有共同目的的部分。如果我们将为搜索结果列表提供功能的所有内容都打包到一个名为 "search-list.js" 的文件中,我们就封装了程序的这一部分。

现代前端编程最近的趋势是围绕组件架构来组织应用程序,这进一步推动了封装。对于许多人来说,将构成搜索结果列表的所有内容(甚至包括代码以外的内容,包括呈现标记和样式)整合到一个单一的程序逻辑单元中,即我们可以与之交互的有形内容中,感觉很自然。然后,我们给这个集合贴上 "SearchList" 组件的标签。

另一个关键目标是控制封装数据和功能某些方面的可见性。请回顾第 6 章中的最小暴露原则 (POLE),该原则旨在防御各种作用域过度暴露的危险;这些危险同时影响变量和函数。在 JS 中,我们通常通过词法作用域的机制来实现可见性控制。

这样做的目的是将程序中相似的片段组合在一起,有选择性地限制程序对我们认为私有细节部分的访问。不被视为私有的部分则被标记为公共,整个程序都可以访问。

这种努力的自然会得到更好的代码组织。当我们知道事物的位置、清晰明确的边界和连接点时,构建和维护软件就会变得更加容易。如果我们能避免过度暴露数据和功能的隐患,也就更容易保证质量。

这些就是将 JS 程序组织成模块的一些主要好处。

什么是模块?

模块是相关数据和函数(在此通常称为方法)的集合,其特点是分为隐藏的私有细节和公共可访问的细节,通常称为「公共 API」。

模块也是有状态的:它随着时间的推移维护一些信息,以及访问和更新这些信息的功能。

注意:
模块模式的一个更广泛的关注点是,通过解耦和其他程序架构技术,全面拥抱系统级模块化。这是一个复杂的话题,远远超出了我们的讨论范围,但值得在本书之外进一步研究。

为了更好地理解什么是模块,让我们将模块的一些特征与非模块的有用代码模式进行比较。

命名空间(无状态分组)

如果你将一组相关的函数组合在一起,但不包含数据,那么你就没有真正意义上的模块封装。命名空间 (namespace) 是这种无状态函数分组的更贴切的术语:

js
// 命名空间,而不是模块
var Utils = {
    cancelEvt(evt) {
        evt.preventDefault();
        evt.stopPropagation();
        evt.stopImmediatePropagation();
    },
    wait(ms) {
        return new Promise(function c(res) {
            setTimeout(res, ms);
        });
    },
    isValidEmail(email) {
        return /[^@]+@[^@.]+\.[^@.]+/.test(email);
    },
};
// 命名空间,而不是模块
var Utils = {
    cancelEvt(evt) {
        evt.preventDefault();
        evt.stopPropagation();
        evt.stopImmediatePropagation();
    },
    wait(ms) {
        return new Promise(function c(res) {
            setTimeout(res, ms);
        });
    },
    isValidEmail(email) {
        return /[^@]+@[^@.]+\.[^@.]+/.test(email);
    },
};

这里的 Utils 是实用工具的集合,但它们都是与状态无关的函数。将功能集合在一起通常是一种好的做法,但这并不能使其成为一个模块。相反,我们定义了一个 Utils 命名空间,并在其下组织了这些函数。

数据结构(有状态分组)

即使你把数据和有状态函数捆绑在一起,如果你没有限制其中任何一个函数的可见性,那么你就没有达到封装的 POLE 层面;给它贴上模块的标签也没什么用。

想想看:

js
// 数据结构,而不是模块
var Student = {
    records: [
        { id: 14, name: "Kyle", grade: 86 },
        { id: 73, name: "Suzy", grade: 87 },
        { id: 112, name: "Frank", grade: 75 },
        { id: 6, name: "Sarah", grade: 91 },
    ],
    getName(studentID) {
        var student = this.records.find((student) => student.id == studentID);
        return student.name;
    },
};

Student.getName(73);
// Suzy
// 数据结构,而不是模块
var Student = {
    records: [
        { id: 14, name: "Kyle", grade: 86 },
        { id: 73, name: "Suzy", grade: 87 },
        { id: 112, name: "Frank", grade: 75 },
        { id: 6, name: "Sarah", grade: 91 },
    ],
    getName(studentID) {
        var student = this.records.find((student) => student.id == studentID);
        return student.name;
    },
};

Student.getName(73);
// Suzy

由于 records 是可公开访问的数据,而不是隐藏在公共 API 后面的数据,因此这里的 Student 并不是一个真正的模块。

Student 确实具有封装的数据和功能方面,但不具有可见性控制方面。最好给它贴上数据结构实例的标签。

模块(状态访问控制)

要充分体现模块模化的精神,我们不仅需要分组和状态,还需要通过可见性(私有与公用)进行访问控制。

让我们把上一节中的 Student 变成一个模块。我们将从一种我称之为「传统模块」的形式开始,这种模块在 2000 年代初首次出现时被称为「揭示模块」。思考一下:

js
var Student = (function defineStudent() {
    var records = [
        { id: 14, name: "Kyle", grade: 86 },
        { id: 73, name: "Suzy", grade: 87 },
        { id: 112, name: "Frank", grade: 75 },
        { id: 6, name: "Sarah", grade: 91 },
    ];

    var publicAPI = {
        getName,
    };

    return publicAPI;

    // ************************

    function getName(studentID) {
        var student = records.find((student) => student.id == studentID);
        return student.name;
    }
})();

Student.getName(73); // Suzy
var Student = (function defineStudent() {
    var records = [
        { id: 14, name: "Kyle", grade: 86 },
        { id: 73, name: "Suzy", grade: 87 },
        { id: 112, name: "Frank", grade: 75 },
        { id: 6, name: "Sarah", grade: 91 },
    ];

    var publicAPI = {
        getName,
    };

    return publicAPI;

    // ************************

    function getName(studentID) {
        var student = records.find((student) => student.id == studentID);
        return student.name;
    }
})();

Student.getName(73); // Suzy

Student 现在是一个模块实例。它有一个公共 API,只有一个方法:getName(..)。该方法可以访问隐藏的私有 records 数据。

警告:
我需要指出的是,将明确的学生数据硬编码到模块定义中只是为了说明问题。程序中的典型模块将从外部来源接收数据,通常是从数据库、JSON 数据文件、Ajax 调用等加载的数据。然后,这些数据通常会通过模块公共 API 上的方法注入模块实例。

传统模块模式是如何运作的?

请注意,模块实例是通过执行 defineStudent() IIFE 创建的。该 IIFE 返回一个对象(名为 publicAPI),该对象上有一个引用内部 getName(..) 函数的属性。

将对象命名为 publicAPI 是我的风格偏好。对象可以随心所欲地命名(JS 并不关心),也可以直接返回对象,而不将其赋值给任何内部命名变量。附录 A 将详细介绍这种选择。

从外部看,Student.getName(..) 会调用这个公开的内部函数,该函数通过闭包保持对内部 records 变量的访问。

你并不一定要返回一个以函数作为属性之一的对象。你可以直接返回一个函数来代替对象。这样仍然可以满足传统模块的所有核心要求。

根据词法作用域的工作原理,在外层模块定义函数中定义变量和函数,默认情况下会将所有内容都设为私有。只有添加到函数返回的公共 API 对象中的属性才会被导出供外部公共使用。

使用 IIFE 意味着我们的程序只需要模块的一个中心实例,也就是通常所说的「单例」。事实上,这个例子非常简单,没有明显的理由让我们只需要一个 Student 模块实例。

模块工厂(多实例)

但如果我们确实想在程序中定义一个支持多个实例的模块,我们可以对代码稍作调整:

js
// 工厂函数,而不是单例 IIFE
function defineStudent() {
    var records = [
        { id: 14, name: "Kyle", grade: 86 },
        { id: 73, name: "Suzy", grade: 87 },
        { id: 112, name: "Frank", grade: 75 },
        { id: 6, name: "Sarah", grade: 91 },
    ];

    var publicAPI = {
        getName,
    };

    return publicAPI;

    // ************************

    function getName(studentID) {
        var student = records.find((student) => student.id == studentID);
        return student.name;
    }
}

var fullTime = defineStudent();
fullTime.getName(73); // Suzy
// 工厂函数,而不是单例 IIFE
function defineStudent() {
    var records = [
        { id: 14, name: "Kyle", grade: 86 },
        { id: 73, name: "Suzy", grade: 87 },
        { id: 112, name: "Frank", grade: 75 },
        { id: 6, name: "Sarah", grade: 91 },
    ];

    var publicAPI = {
        getName,
    };

    return publicAPI;

    // ************************

    function getName(studentID) {
        var student = records.find((student) => student.id == studentID);
        return student.name;
    }
}

var fullTime = defineStudent();
fullTime.getName(73); // Suzy

我们并没有将 defineStudent() 指定为 IIFE,而是将其定义为一个普通的独立函数,在这里通常称为「模块工厂」函数。

然后,我们调用模块工厂,生成一个标为 fullTime 的模块实例。这个模块实例意味着一个新的内部作用域实例,因此,getName(..)records 拥有一个新的闭包。现在,fullTime.getName(..) 将调用该特定实例上的方法。

传统模块的定义

因此,为了澄清什么是传统模块:

  • 必须有一个外部作用域,通常是至少运行一次的模块工厂函数。
  • 模块的内部作用域必须至少有一条代表模块状态的隐藏信息。
  • 模块必须在其公共 API 中返回至少一个函数的引用,该函数对隐藏的模块状态具有闭包(以便实际保留该状态)。

我们将在附录 A 中详细介绍这种传统模块方法的其他变体。

Node CommonJS 模块

在第 4 章中,我们介绍了 Node 使用的 CommonJS 模块格式。与前面介绍的传统模块格式不同,CommonJS 模块是基于文件的;每个文件一个模块。

让我们调整一下模块示例,使其符合这种格式:

js
module.exports.getName = getName;

// ************************

var records = [
    { id: 14, name: "Kyle", grade: 86 },
    { id: 73, name: "Suzy", grade: 87 },
    { id: 112, name: "Frank", grade: 75 },
    { id: 6, name: "Sarah", grade: 91 },
];

function getName(studentID) {
    var student = records.find((student) => student.id == studentID);
    return student.name;
}
module.exports.getName = getName;

// ************************

var records = [
    { id: 14, name: "Kyle", grade: 86 },
    { id: 73, name: "Suzy", grade: 87 },
    { id: 112, name: "Frank", grade: 75 },
    { id: 6, name: "Sarah", grade: 91 },
];

function getName(studentID) {
    var student = records.find((student) => student.id == studentID);
    return student.name;
}

recordsgetName 标识符位于本模块的顶层作用域,但这不是全局作用域(如第 4 章所述)。因此,这里的所有内容默认都是模块的私有内容。

要在 CommonJS 模块的公共 API 上公开某些内容,需要向作为 module.exports 提供的空对象添加一个属性。在一些较旧的遗留代码中,您可能会遇到对仅有的 exports 的引用,但为了代码的清晰度,您应始终使用 module. 前缀对该引用进行完全限定。

出于风格考虑,我喜欢将 "exports" 放在顶部,而将模块实现放在底部。但这些导出可以放在任何地方。我强烈建议将它们集中在一起,放在文件的顶部或底部。

有些开发人员习惯于替换默认导出为对象,就像这样:

js
// 为 API 定义新对象
module.exports = {
    // ..exports..
};
// 为 API 定义新对象
module.exports = {
    // ..exports..
};

这种方法有一些怪异之处,包括多个此类模块相互循环依赖时出现意外行为。因此,我建议不要替换对象。如果你想使用对象字面风格定义一次分配多个导出,你可以这样做:

js
Object.assign(module.exports, {
    // .. exports ..
});
Object.assign(module.exports, {
    // .. exports ..
});

这里做的事情是在定义 { .. } 然后,Object.assign(..) 将所有这些属性浅拷贝到现有的 module.exports 对象上,而不是替换它。

要在模块/程序中包含另一个模块实例,请使用 Node 的 require(..) 方法。假设该模块位于 "/path/to/student.js",我们可以这样访问它:

js
var Student = require("/path/to/student.js");

Student.getName(73);
// Suzy
var Student = require("/path/to/student.js");

Student.getName(73);
// Suzy

现在,Student 引用了我们示例模块的公共 API。

CommonJS 模块表现为单例,类似于之前介绍的 IIFE 模块定义风格。无论你多少次 require(..) 同一个模块,你都只会得到对单个共享模块实例的额外引用。

require(..) 是一种全有或全无的机制;它包括对模块整个公开 API 的引用。如果只想有效地访问 API 的一部分,典型的方法如下:

js
var getName = require("/path/to/student.js").getName;

// 或者使用:

var { getName } = require("/path/to/student.js");
var getName = require("/path/to/student.js").getName;

// 或者使用:

var { getName } = require("/path/to/student.js");

与传统模块格式类似,CommonJS 模块 API 的公开导出方法对模块内部细节进行闭包。这就是模块单例状态在整个程序生命周期中的维护方式。

注意:
在 Node require("student") 语句中,非绝对路径("student")假定文件扩展名为 ".js",并搜索 "node_modules"。

现代 ES 模块 (ESM)

ESM 格式与 CommonJS 格式有几处相似之处。ESM 基于文件,模块实例是单例,默认情况下所有内容都是私有的。一个显著的不同点是,ESM 文件被假定为严格模式,而无需在顶部添加 "use strict" 编译指示。没有办法将 ESM 定义为非严格模式。

与 CommonJS 中的 module.exports 不同,ESM 使用 export 关键字来公开模块的公共 API。import 关键字取代了 require(..) 语句。让我们调整 "students.js" 以使用 ESM 格式:

js
export { getName };

// ************************

var records = [
    { id: 14, name: "Kyle", grade: 86 },
    { id: 73, name: "Suzy", grade: 87 },
    { id: 112, name: "Frank", grade: 75 },
    { id: 6, name: "Sarah", grade: 91 },
];

function getName(studentID) {
    var student = records.find((student) => student.id == studentID);
    return student.name;
}
export { getName };

// ************************

var records = [
    { id: 14, name: "Kyle", grade: 86 },
    { id: 73, name: "Suzy", grade: 87 },
    { id: 112, name: "Frank", grade: 75 },
    { id: 6, name: "Sarah", grade: 91 },
];

function getName(studentID) {
    var student = records.find((student) => student.id == studentID);
    return student.name;
}

这里唯一的变化是 export { getName } 语句。和以前一样,export 语句可以出现在整个文件的任何地方,但 export 必须位于顶层作用域;它不能位于任何其他代码块或函数内部。

对于如何指定 export 语句,ESM 提供了相当多的写法。例如:

js
export function getName(studentID) {
    // ..
}
export function getName(studentID) {
    // ..
}

尽管 export 出现在这里的 function 关键字之前,但这个表单仍然是一个 function 声明,而且恰好也是导出的。也就是说,getName标识符是函数提升(参见第 5 章),因此它在整个模块作用域内都可用。

另一种写法:

js
export default function getName(studentID) {
    // ..
}
export default function getName(studentID) {
    // ..
}

这就是所谓的「默认导出」,其语义与其他导出不同。从本质上讲,「默认导出」是模块消费者在「导入」时的一种速记,当他们只需要这个单一的默认 API 成员时,会给他们提供一种更简洁的语法。

非默认 (default) 导出称为「命名导出」。

import 关键字与 export 关键字一样,只能在任何模块或函数之外的 ESM 顶层使用,其语法也有多种变化。第一种称为「命名导入」:

js
import { getName } from "/path/to/students.js";

getName(73); // Suzy
import { getName } from "/path/to/students.js";

getName(73); // Suzy

如您所见,这种形式只导入模块中已明确命名的公共 API 成员(跳过任何未明确命名的成员),并将这些标识符添加到当前模块的顶层作用域中。这种导入方式对于那些习惯于使用 Java 等语言中的包导入的人来说并不陌生。

可在 { .. } 集合中列出多个 API 成员,中间用逗号隔开。还可以使用 as 关键字对已命名的导入进行重命名

js
import { getName as getStudentName } from "/path/to/students.js";

getStudentName(73);
// Suzy
import { getName as getStudentName } from "/path/to/students.js";

getStudentName(73);
// Suzy

如果 getName 是模块的「默认导出」,我们可以这样导入它:

js
import getName from "/path/to/students.js";

getName(73); // Suzy
import getName from "/path/to/students.js";

getName(73); // Suzy

这里唯一的区别是去掉了导入周围的 { }。如果要将默认导入与其他已命名的导入混合使用:

js
import { default as getName /* .. others .. */ } from "/path/to/students.js";

getName(73); // Suzy
import { default as getName /* .. others .. */ } from "/path/to/students.js";

getName(73); // Suzy

相比之下,import 的另一个主要变体称为「命名空间导入」:

js
import * as Student from "/path/to/students.js";

Student.getName(73); // Suzy
import * as Student from "/path/to/students.js";

Student.getName(73); // Suzy

显而易见,* 会导入所有输出到 API 的内容,包括默认内容和已命名内容,并将其存储在指定的单一命名空间标识符下。这种方法最接近 JS 历史上大多数传统模块的形式。

注意:
截至本文撰写之时,现代浏览器支持 ESM 已有数年时间,而 Node 对 ESM 的稳定支持则是最近才开始的,并且已经发展了相当长的一段时间。这种演进可能还要持续一年或更长时间;ES6 将 ESM 引入 JS 时,为 Node 与 CommonJS 模块的互操作带来了许多具有挑战性的兼容性问题。有关所有最新细节,请查阅 Node 的 ESM 文档:https://nodejs.org/api/esm.html

周天圆满

无论是使用经典的模块格式(浏览器或 Node)、CommonJS 格式(在 Node 中)还是 ESM 格式(浏览器或 Node),模块都是结构化和组织程序功能和数据的最有效方法之一。

模块模式是我们在本书中学习如何使用词法作用域规则将变量和函数放置在适当位置的旅程的终点。POLE 是我们一贯采取的默认情况下的私有防御姿态,确保我们避免过度暴露,只与最小的公共 API 表面区域进行必要的交互。

在模块之下,利用词法作用域系统的闭包是维护所有模块状态的法宝

正文到此结束。恭喜你完成了这一段旅程!正如我在整个过程中多次说过的,暂停、思考和实践我们刚刚讨论的内容是一个非常好的主意。

当您感到舒适并准备就绪时,请查看附录,这些附录深入探讨了这些主题的一些角落,还通过一些练习题来巩固您所学的知识。