模块
编写易于删除但不易扩展的代码。
以下是一个用Python展示如何编写相对容易删除但难以扩展的代码示例:
# 此函数专门用于一次性的数据计算
def calculate_specific_data():
data = [1, 2, 3, 4, 5]
result = 0
for num in data:
if num > 2:
result += num * 2
return result
specific_result = calculate_specific_data()
print(specific_result)
解释:
-
易于删除:
calculate_specific_data
函数是自包含的。如果不再需要这个特定的计算,你只需删除函数定义以及调用它的代码行即可。它不依赖复杂的外部模块或全局状态,所以删除起来并不困难。 -
难以扩展:
- 数据列表
[1, 2, 3, 4, 5]
是在函数内部硬编码的。如果你想使用不同的数据,就需要直接修改函数体。 - 计算逻辑(
if num > 2: result += num * 2
)非常特定。添加新的条件或操作需要对函数内现有的循环结构进行重大修改。这里没有像函数参数或回调这样明确的可扩展点,若不触及核心实现,就无法修改其行为。
- 数据列表
在更面向对象或模块化的编程风格中,我们通常会为可扩展性进行设计,但这个示例却违背了这一原则。
以下是等效的JavaScript代码:
function calculateSpecificData() {
let data = [1, 2, 3, 4, 5];
let result = 0;
for (let num of data) {
if (num > 2) {
result += num * 2;
}
}
return result;
}
let specificResult = calculateSpecificData();
console.log(specificResult);
在这段JavaScript代码中,与Python示例一样,存在易于删除和难以扩展的特点。数据和计算逻辑在函数内部紧密耦合,这使得优雅地扩展功能变得困难。
理想情况下,一个程序具有清晰、直接的结构。其运行方式易于解释,并且每个部分都发挥着明确界定的作用。
在实际中,程序是自然生长的。随着程序员发现新的需求,功能片段会不断添加。要保持这样一个程序结构良好,需要持续的关注和投入。而这种投入只有在未来,下次有人对该程序进行开发时才会得到回报,所以很容易让人忽视它,任由程序的各个部分变得错综复杂。
这会引发两个实际问题。首先,理解一个错综复杂的系统很困难。如果所有部分都能相互影响,那么就很难孤立地审视任何一个特定部分。你不得不对整个系统形成一个全面的理解。其次,如果你想在其他情境中使用这样一个程序的任何功能,重写它可能比试图将其从原有的上下文环境中剥离出来更容易。
“一团乱麻”这个说法常被用来形容这类庞大且无结构的程序。所有东西都粘连在一起,当你试图挑出一块时,整个东西就散架了,而你最终只会弄得一团糟。
模块化程序
模块就是为避免这些问题而产生的。一个模块是程序的一个组成部分,它会指明自己依赖哪些其他部分,以及为其他模块提供哪些可使用的功能(即它的接口)。
正如我们在第6章所了解到的,模块接口与对象接口有很多共同之处。它们将模块的一部分功能对外开放,而将其余部分设为私有。
然而,模块为其他模块提供的可用接口只是其中一方面。一个优秀的模块系统还要求模块指明它们使用了其他模块的哪些代码。这些关系被称为依赖关系。如果模块A使用了模块B的功能,那么就说模块A依赖于模块B。当这些依赖关系在模块自身中被清晰地指定后,就可以据此确定要使用某个特定模块还需要哪些其他模块,并能自动加载其依赖项。
当模块之间的交互方式明确时,一个系统就更像乐高积木,各部件通过定义明确的连接件相互作用,而不再像一团乱麻,所有东西都混在一起。
ES 模块
最初的 JavaScript 语言并没有模块的概念。所有脚本都在相同的作用域中运行,要访问另一个脚本中定义的函数,需通过引用该脚本创建的全局绑定来实现。这实际上助长了代码间不经意且难以察觉的纠缠,并引发了诸如不相关脚本试图使用相同绑定名称之类的问题。
自 ECMAScript 2015 起,JavaScript 支持两种不同类型的程序。脚本的行为方式依旧沿用旧有模式:它们的绑定在全局作用域中定义,且无法直接引用其他脚本。而模块拥有自己独立的作用域,并支持 import
和 export
关键字(脚本中无法使用这两个关键字),用于声明其依赖关系和接口。这种模块系统通常被称为 ES 模块(其中 ES 代表 ECMAScript)。
一个模块化程序由许多这样的模块组成,这些模块通过导入(import)和导出(export)相互连接。
以下示例模块用于在日期名称和数字(如 Date
的 getDay
方法返回的数字)之间进行转换。它定义了一个不属于其接口的常量,以及两个属于接口的函数。该模块没有依赖项。
// 定义一个包含星期名称的数组
const names = ["Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"];
// 导出一个函数,该函数根据传入的数字返回对应的星期名称
export function dayName(number) {
return names[number];
}
// 导出一个函数,该函数根据传入的星期名称返回对应的数字
export function dayNumber(name) {
return names.indexOf(name);
}
上述代码定义了一个模块,其中包含一个私有常量 names
(因为没有通过 export
暴露出去),以及两个导出的公共函数 dayName
和 dayNumber
。dayName
函数接受一个数字参数,返回对应的星期名称;dayNumber
函数接受一个星期名称参数,返回对应的数字索引。
export
关键字可以置于函数、类或绑定定义之前,用以表明该绑定是模块接口的一部分。这使得其他模块能够通过导入操作来使用该绑定。
import {dayName} from "./dayname.js";
let now = new Date();
console.log(`Today is ${dayName(now.getDay())}`);
// → Today is Monday
这段代码的作用是从一个名为dayname.js
的模块中导入dayName
函数,然后使用这个函数来获取当前日期是星期几,并将结果打印到控制台。
以下是详细解释:
-
import {dayName} from "./dayname.js";
- 这行代码使用ES模块的
import
语句,从当前目录下的dayname.js
文件中导入dayName
函数。{}
用于指定要导入的具体绑定(这里是dayName
函数)。
- 这行代码使用ES模块的
-
let now = new Date();
- 创建一个
Date
对象now
,它代表当前的日期和时间。
- 创建一个
-
console.log(
Today is ${dayName(now.getDay())});
-
now.getDay()
获取当前日期是一周中的第几天(0代表星期日,1代表星期一,以此类推)。 - 然后
dayName
函数接收这个数字,并返回对应的星期名称。 - 最后,使用模板字面量将结果打印到控制台,例如,如果今天是星期一,控制台将输出
Today is Monday
。
-
整体来说,这段代码展示了如何在一个模块中导入另一个模块的函数,并利用它进行日期相关的操作。
import
关键字后面跟着花括号内的绑定名称列表,可使来自另一个模块的绑定在当前模块中可用。模块通过带引号的字符串来标识。
不同平台将模块名解析为实际程序的方式有所不同。浏览器将它们视为网址,而Node.js则将它们解析为文件。当你运行一个模块时,它所依赖的所有其他模块,以及这些其他模块所依赖的模块都会被加载,并且导出的绑定可供导入它们的模块使用。
import
和export
声明不能出现在函数、循环或其他代码块内部。无论模块中的代码如何执行,在模块加载时,它们都会立即被解析。为体现这一点,它们必须仅出现在模块的外部主体中。
因此,一个模块的接口由一组命名绑定组成,依赖该模块的其他模块可以访问这些绑定。导入的绑定可以使用as
关键字在其名称之后进行重命名,从而赋予它们一个新的本地名称。
import {dayName as nomDeJour} from "./dayname.js";
console.log(nomDeJour(3));
// → Wednesday
在这段代码中,import {dayName as nomDeJour} from "./dayname.js";
语句从 "./dayname.js"
模块导入 dayName
函数,并将其重命名为 nomDeJour
。这意味着在当前模块中,nomDeJour
就代表了从 dayname.js
模块导入的 dayName
函数。
接着,console.log(nomDeJour(3));
调用重命名后的函数 nomDeJour
,并传入参数 3
。由于 dayName
函数(在这里以 nomDeJour
的名字被调用)会根据传入的数字返回对应的星期名称,数字 3
对应的是星期三,所以最终控制台会输出 "Wednesday"
。
这种导入时重命名的方式在多个模块可能存在命名冲突,或者希望使用更符合本地语义的名称时非常有用。
一个模块还可以有一个名为 default
的特殊导出,这通常用于只导出单个绑定的模块。要定义默认导出,你可以在表达式、函数声明或类声明之前写上 export default
。
export default ["Winter", "Spring", "Summer", "Autumn"];
这样的绑定在导入时,导入名称周围无需使用花括号。
import seasonNames from "./seasonname.js";
要一次性导入一个模块的所有绑定,可以使用 import *
。你提供一个名称,该名称将绑定到一个包含该模块所有导出内容的对象上。当你要使用许多不同的导出时,这种方式会很有用。
import * as dayName from "./dayname.js";
console.log(dayName.dayName(3));
// → Wednesday
软件包
将程序构建成一个个独立的部分,并能让其中一些部分独立运行,这样做的好处之一是,你或许能在不同程序中复用同一个部分。
但要如何实现呢?比如说,我想在另一个程序中使用第9章中的parseINI
函数。如果明确知道该函数依赖什么(在这个例子中,不依赖任何东西),我可以直接把那个模块复制到我的新项目中使用。但这样一来,如果我发现代码中有个错误,我很可能只在当时正在处理的那个程序中修复它,而忘记在另一个程序中也进行修复。
一旦开始复制代码,你很快就会发现自己在浪费时间和精力来四处转移代码副本,并让它们保持更新。这就是软件包发挥作用的地方。软件包是一段可分发(复制和安装)的代码。它可能包含一个或多个模块,并且包含关于它依赖哪些其他软件包的信息。软件包通常还会附带文档,解释其功能,这样即便不是编写者本人,其他人也有可能使用它。
当在软件包中发现问题或添加新功能时,软件包就会更新。现在依赖它的程序(这些程序本身也可能是软件包)可以复制新版本,从而获得代码改进带来的好处。
以这种方式工作需要相应的基础设施。我们需要一个存储和查找软件包的地方,以及一种方便的安装和升级方式。在JavaScript领域,这个基础设施由NPM(https://npmjs.com)提供。
NPM有两个含义:一是一个在线服务,你可以在上面下载(和上传)软件包;二是一个程序(与Node.js捆绑在一起),帮助你安装和管理软件包。
在撰写本文时,NPM上有超过三百万个不同的软件包。说实话,其中很大一部分没什么用。但几乎所有有用的、公开可用的JavaScript软件包都能在NPM上找到。例如,一个类似于我们在第9章构建的INI文件解析器,可在名为ini
的软件包中找到。
第20章将展示如何使用npm
命令行程序在本地安装此类软件包。
有高质量的软件包可供下载非常有价值。这意味着我们常常可以避免重复编写已经有一百个人写过的程序,只需按几个键就能获得一个可靠且经过良好测试的实现。
软件复制成本很低,所以一旦有人编写完成,将其分发给其他人是一个高效的过程。不过,首先编写软件是一项工作,而回应那些发现代码问题或想要提出新功能的人则更是一项工作。
默认情况下,你拥有自己编写的代码的版权,其他人只有在获得你许可的情况下才能使用。但由于有些人很友善,而且发布优秀的软件可以让你在程序员群体中稍微出名一点,许多软件包都是在明确允许他人使用的许可协议下发布的。
NPM上的大多数代码都是以这种方式授权的。有些许可协议要求你在同一许可下发布基于该软件包构建的代码。其他许可协议要求则没那么严格,只要求你在分发代码时保留许可协议。JavaScript社区大多使用后一种类型的许可协议。在使用他人的软件包时,要确保你了解它们的许可协议。
现在,我们不用自己编写INI文件解析器了,可以使用NPM上的解析器。
import {parse} from "ini";
console.log(parse("x = 10\ny = 20"));
// → {x: "10", y: "20"}
这段代码使用了来自 ini
包的 parse
函数来解析INI格式的字符串。
-
import {parse} from "ini";
- 这里使用ES模块的
import
语句,从名为ini
的包中导入parse
函数。在JavaScript中,当从包中导入模块时,不需要像导入本地模块那样指定文件路径。ini
包是一个在NPM上可用的包,提供了处理INI文件格式的功能。
- 这里使用ES模块的
-
console.log(parse("x = 10\ny = 20"));
- 调用导入的
parse
函数,并传入一个包含INI格式数据的字符串"x = 10\ny = 20"
。parse
函数会解析这个字符串,并将其转换为JavaScript对象。 - 最后,使用
console.log
将解析后的对象打印到控制台。在这个例子中,输出为{x: "10", y: "20"}
,展示了parse
函数成功将INI格式字符串解析为JavaScript对象,其中键x
和y
分别对应其在INI字符串中的值。
- 调用导入的
这段代码展示了如何利用NPM包中提供的功能,简化对特定格式数据的处理,避免了自行编写复杂的解析逻辑。
CommonJS模块
在2015年之前,JavaScript语言还没有内置的模块系统,但人们已经开始用JavaScript构建大型系统。为了让这切实可行,他们需要模块。
社区在JavaScript语言基础上自行设计了临时的模块系统。这些系统利用函数为模块创建局部作用域,并使用普通对象来表示模块接口。
最初,人们只是手动将整个模块包裹在一个“立即调用函数表达式”中,以此创建模块作用域,并将他们的接口对象赋值给一个全局变量。
const weekDay = function() {
const names = ["Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"];
return {
name(number) { return names[number]; },
number(name) { return names.indexOf(name); }
};
}();
console.log(weekDay.name(weekDay.number("Sunday")));
// → Sunday
这段代码定义了一个模拟模块功能的结构,虽然在ES6模块之前没有原生模块系统,但通过这种方式实现了类似模块的封装效果。以下是详细解释:
-
定义模块函数并立即调用:
const weekDay = function() { ... }();
- 这里定义了一个匿名函数,然后通过末尾的
()
立即调用它,并将返回值赋值给weekDay
变量。这个匿名函数内部的代码就像是模块内部的代码,具有自己的局部作用域。
-
模块内部的实现:
const names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
- 在函数内部定义了一个
names
数组,这个数组在函数外部是不可直接访问的,类似于模块内部的私有数据。 return { name(number) { return names[number]; }, number(name) { return names.indexOf(name); } };
- 函数返回一个对象,这个对象包含两个方法
name
和number
。这两个方法就构成了类似模块的接口,外部代码可以通过weekDay
变量访问到这两个方法,而names
数组对外部是隐藏的。
-
使用模块功能:
console.log(weekDay.name(weekDay.number("Sunday")));
- 首先调用
weekDay.number("Sunday")
,这个方法会返回Sunday
在names
数组中的索引(这里是0
)。 - 然后将这个索引作为参数传递给
weekDay.name
方法,weekDay.name(0)
会返回names
数组中索引为0
的元素,即"Sunday"
。最后通过console.log
将结果打印出来。
总的来说,这段代码通过立即调用函数表达式模拟了模块的封装,将数据和功能封装在一个作用域内,并通过返回的对象提供了外部可访问的接口。
这种模块风格在一定程度上提供了隔离性,但它并未声明依赖关系。相反,它只是将其接口放入全局作用域,并期望其依赖项(如果有的话)也这样做。这并不理想。
如果我们实现自己的模块加载器,就能做得更好。在JavaScript中,最广泛使用的附加模块方法被称为CommonJS模块。Node.js从一开始就使用这种模块系统(尽管它现在也知道如何加载ES模块),并且它是NPM上许多软件包所使用的模块系统。
一个CommonJS模块看起来就像一个普通脚本,但它可以访问两个用于与其他模块交互的绑定。第一个是一个名为require
的函数。当你使用依赖模块的名称调用这个函数时,它会确保该模块已加载,并返回其接口。第二个是一个名为exports
的对象,它是该模块的接口对象。它初始为空,你可以向其添加属性来定义导出的值。
这个CommonJS示例模块提供了一个日期格式化函数。它使用了NPM上的两个软件包——ordinal
用于将数字转换为诸如“1st”和“2nd”这样的字符串,以及date - names
用于获取工作日和月份的英文名称。它导出一个名为formatDate
的函数,该函数接受一个Date
对象和一个模板字符串。
模板字符串可能包含用于指示格式的代码,例如YYYY
表示完整年份,Do
表示月份中的序数日期。你可以给它一个像“MMMM Do YYYY
”这样的字符串,以获得像“November 22nd 2017”这样的输出。
const ordinal = require("ordinal");
const {days, months} = require("date-names");
exports.formatDate = function(date, format) {
return format.replace(/YYYY|M(MMM)?|Do?|dddd/g, tag => {
if (tag == "YYYY") return date.getFullYear();
if (tag == "M") return date.getMonth();
if (tag == "MMMM") return months[date.getMonth()];
if (tag == "D") return date.getDate();
if (tag == "Do") return ordinal(date.getDate());
if (tag == "dddd") return days[date.getDay()];
});
};
这段代码是一个使用CommonJS模块规范的JavaScript模块,用于格式化日期。以下是对代码的详细解释:
-
引入依赖模块:
-
const ordinal = require("ordinal");
:使用require
函数引入名为ordinal
的NPM包,该包用于将数字转换为序数形式,如“1st”“2nd”等,并将其赋值给ordinal
变量。 -
const {days, months} = require("date - names");
:从date - names
包中解构出days
(用于获取星期几的名称)和months
(用于获取月份的名称),并分别赋值给同名变量。
-
-
定义导出函数:
-
exports.formatDate = function(date, format) {... };
:在exports
对象上定义一个名为formatDate
的函数,这是该模块对外暴露的接口。这个函数接受两个参数:date
(一个Date
对象)和format
(一个用于指定日期格式的模板字符串)。
-
-
日期格式化逻辑:
-
return format.replace(/YYYY|M(MMM)?|Do?|dddd/g, tag => {... });
:使用replace
方法对format
模板字符串进行替换操作。replace
的第一个参数是一个正则表达式,用于匹配各种日期格式标签。第二个参数是一个回调函数,当正则表达式匹配到内容时,会将匹配到的标签(tag
)传入回调函数进行处理。
-
-
具体标签替换逻辑:
-
if (tag == "YYYY") return date.getFullYear();
:如果匹配到YYYY
标签,返回date
对象的完整年份。 -
if (tag == "M") return date.getMonth();
:如果匹配到M
标签,返回date
对象的月份(从0开始)。 -
if (tag == "MMMM") return months[date.getMonth()];
:如果匹配到MMMM
标签,返回对应月份的英文全称,通过months
数组和date
对象的月份索引获取。 -
if (tag == "D") return date.getDate();
:如果匹配到D
标签,返回date
对象的日期。 -
if (tag == "Do") return ordinal(date.getDate());
:如果匹配到Do
标签,使用ordinal
函数将date
对象的日期转换为序数形式并返回。 -
if (tag == "dddd") return days[date.getDay()];
:如果匹配到dddd
标签,返回对应星期几的英文全称,通过days
数组和date
对象的星期索引获取。
-
总的来说,这个模块通过引入外部包并定义一个格式化函数,实现了根据给定的模板字符串格式化Date
对象的功能。
ordinal
的接口是一个单一函数,而 date - names
导出的是一个包含多个内容的对象 —— days
和 months
是名称数组。在为导入的接口创建绑定关系时,解构非常方便。
该模块将其接口函数添加到 exports
中,以便依赖它的模块能够访问该函数。我们可以像这样使用该模块:
const {formatDate} = require("./format-date.js");
console.log(formatDate(new Date(2017, 9, 13),
"dddd the Do"));
// → Friday the 13th
这段代码展示了如何使用前面定义的 format - date.js
模块中的 formatDate
函数来格式化日期。
-
导入模块:
-
const {formatDate} = require("./format - date.js");
使用require
函数从当前目录下的format - date.js
文件中导入formatDate
函数。这里使用了解构赋值,直接将导入的formatDate
函数赋值给同名变量formatDate
。
-
-
调用格式化函数并输出结果:
console.log(formatDate(new Date(2017, 9, 13), "dddd the Do"));
-
new Date(2017, 9, 13)
创建一个Date
对象,表示2017年10月13日(月份从0开始计数,所以9代表10月)。 - 然后将这个
Date
对象和模板字符串"dddd the Do"
作为参数传递给formatDate
函数。formatDate
函数会根据模板字符串中的标签对日期进行格式化。 - 最后,使用
console.log
将格式化后的日期字符串输出到控制台。在这个例子中,输出为"Friday the 13th"
,符合模板字符串的格式要求。
这段代码演示了如何在CommonJS模块系统中导入和使用自定义模块来实现日期格式化功能。
CommonJS是通过一个模块加载器来实现的。当加载一个模块时,模块加载器会将模块的代码包装在一个函数中(赋予其自身的局部作用域),并将require
和exports
绑定作为参数传递给该函数。
如果我们假设可以使用readFile
函数,该函数通过文件名读取文件并返回文件内容,那么我们可以像这样定义一个简化版的require
函数:
function require(name) {
if (!(name in require.cache)) {
let code = readFile(name);
let exports = require.cache[name] = {};
let wrapper = Function("require, exports", code);
wrapper(require, exports);
}
return require.cache[name];
}
require.cache = Object.create(null);
这段代码定义了一个简化的 require
函数,模拟了CommonJS模块加载机制。以下是对代码的详细解释:
-
检查缓存:
-
if (!(name in require.cache)) {... }
:require.cache
是一个对象,用于存储已经加载过的模块。这行代码检查名为name
的模块是否已经在缓存中。如果不在缓存中,则执行后续的加载操作。
-
-
读取模块代码:
-
let code = readFile(name);
:假设存在readFile
函数,它根据模块名name
读取相应文件的内容,并将内容赋值给code
变量。这里的readFile
函数是自定义的,在实际实现中应提供从文件系统读取文件的逻辑。
-
-
初始化缓存和导出对象:
-
let exports = require.cache[name] = {};
:在require.cache
中为当前模块创建一个缓存条目,并初始化一个空的exports
对象。这个exports
对象将用于存储模块导出的内容。
-
-
创建包装函数:
-
let wrapper = Function("require, exports", code);
:使用Function
构造函数创建一个新的函数wrapper
。这个函数接受require
和exports
作为参数,函数体是从文件中读取的模块代码code
。这相当于将模块代码包装在一个函数中,为模块提供了自己的局部作用域。
-
-
执行包装函数:
-
wrapper(require, exports);
:执行wrapper
函数,并传入require
和exports
对象。模块代码在执行过程中,可以通过require
引入其他模块,通过向exports
对象添加属性来导出内容。
-
-
返回缓存中的模块:
-
return require.cache[name];
:无论模块是否已经在缓存中,最后都返回require.cache
中对应模块的导出内容。
-
-
初始化缓存对象:
-
require.cache = Object.create(null);
:初始化require.cache
为一个空对象,用于存储加载过的模块。Object.create(null)
创建的对象没有原型链,相比于普通的空对象{}
,在某些场景下可以避免意外的属性访问问题。
-
这个简化的 require
函数展示了CommonJS模块加载器的基本工作原理,通过缓存机制避免重复加载模块,通过包装函数为模块提供独立的作用域,并管理模块之间的依赖关系。
函数构造器 Function
Function
是JavaScript的内置函数,它接受一系列参数(以逗号分隔的字符串形式)以及包含函数体的字符串,并返回一个具有这些参数和函数体的函数值。这是一个有趣的概念,它允许程序从字符串数据创建新的程序片段,但同时也是危险的。因为如果有人能诱使你的程序将他们提供的字符串放入 Function
中,他们就能让程序做任何他们想做的事。
标准JavaScript并没有提供像 readFile
这样的函数,但不同的JavaScript环境,比如浏览器和Node.js,都有各自访问文件的方式。这里只是假设 readFile
存在。
require
与模块缓存
为了避免多次加载同一个模块,require
维护了一个已加载模块的存储(缓存)。每次调用时,它首先检查请求的模块是否已被加载,如果没有,则进行加载。这包括读取模块代码、将其包装在一个函数中并调用该函数。
通过将 require
和 exports
定义为生成的包装函数的参数(并在调用时传递适当的值),加载器确保这些绑定在模块作用域中可用。
与ES模块的区别
这个系统与ES模块之间一个重要的区别在于,ES模块的导入在模块脚本开始运行之前就发生了,而 require
是一个普通函数,在模块已经运行时被调用。与 import
声明不同,require
调用可以出现在函数内部,并且依赖项的名称可以是任何能求值为字符串的表达式,而 import
只允许普通的带引号字符串。
JavaScript社区从CommonJS风格过渡到ES模块的过程缓慢且有些艰难。幸运的是,现在NPM上大多数流行的软件包都将其代码作为ES模块提供,并且Node.js允许ES模块从CommonJS模块导入。虽然你仍可能遇到CommonJS代码,但实际上已经没有理由再以这种风格编写新程序了。
构建与打包
从技术上讲,许多JavaScript软件包并非用JavaScript编写。像TypeScript这样的语言扩展(第8章提到的类型检查方言)被广泛使用。人们还常常在实际运行JavaScript的平台添加某些新语言特性之前很久,就开始使用这些特性。为了实现这一点,他们会编译代码,将所选的JavaScript方言转换为普通的旧版JavaScript,甚至转换为JavaScript的早期版本,以便浏览器能够运行。
在网页中包含一个由200个不同文件组成的模块化程序会带来一些问题。如果通过网络获取单个文件需要50毫秒,加载整个程序则需要10秒,如果你能同时加载几个文件,可能会缩短一半时间。这浪费了大量时间。由于获取单个大文件往往比获取许多小文件更快,网页程序员开始使用工具,在将程序发布到网页之前,将他们精心拆分成模块的程序合并为一个大文件。这样的工具被称为打包器。
我们还可以更进一步。除了文件数量,文件大小也决定了它们通过网络传输的速度。因此,JavaScript社区发明了压缩器。这些工具获取一个JavaScript程序,通过自动删除注释和空白、重命名绑定以及用占用空间更少的等效代码替换部分代码,使程序变小。
在NPM软件包中或在网页上运行的代码,经过多个转换阶段并不少见——从现代JavaScript转换为旧版JavaScript,将模块合并为单个文件,然后压缩代码。本书不会深入介绍这些工具的细节,因为它们有很多,而且流行的工具经常变化。只需知道有这些工具存在,需要时查阅相关资料即可。
模块设计
程序结构设计是编程中较为微妙的一个方面。任何非简单的功能模块都可以通过多种方式进行组织。
良好的程序设计具有主观性——其中涉及权衡取舍,也关乎个人偏好。学习良好结构设计价值的最佳方式,就是阅读大量程序或参与众多编程项目,留意哪些设计有效,哪些无效。不要认为一团糟的代码 “只能这样”。只要多花些心思,几乎所有代码的结构都能得到改善。
模块设计的一个重要方面是易用性。如果你设计的内容打算供多人使用,或者即便只是供自己在三个月后使用,那时你可能已经不记得自己当初具体做了什么,那么一个简单且可预测的接口会很有帮助。
这可能意味着遵循现有的惯例。ini
包就是一个很好的例子。这个模块模仿标准的JSON对象,提供了 parse
和 stringify
(用于编写INI文件)函数,并且和JSON一样,在字符串和普通对象之间进行转换。它的接口简洁且为人熟知,一旦你使用过一次,就很可能记住如何再次使用它。
即使没有可模仿的标准函数或广泛使用的包,你也可以通过使用简单的数据结构并专注于单一功能,来使你的模块具有可预测性。例如,NPM上的许多INI文件解析模块都提供了一个函数,该函数直接从硬盘读取这样的文件并进行解析。这使得在浏览器中无法使用这些模块,因为在浏览器中我们没有直接访问文件系统的权限,而且这种做法增加了复杂性,若将该模块与一些文件读取函数组合使用,原本可以更好地解决这个问题。
这就引出了模块设计的另一个有益方面——与其他代码的可组合性。专注于计算值的模块,相比那些执行复杂操作且有副作用的大型模块,能在更广泛的程序中适用。一个坚持从磁盘读取文件的INI文件读取器,在文件内容来自其他来源的场景中就毫无用处。
与此相关的是,有状态的对象有时很有用,甚至是必要的,但如果能用函数完成的事情,就使用函数。NPM上的一些INI文件读取器提供了一种接口风格,要求你首先创建一个对象,然后将文件加载到该对象中,最后使用特定方法获取结果。这种方式在面向对象编程传统中很常见,但却很糟糕。你不是简单地调用一个函数就完成任务,而是必须按部就班地让对象经历各种状态。而且由于数据现在被封装在一种特定的对象类型中,所有与之交互的代码都必须了解该类型,从而产生了不必要的相互依赖。
通常,定义新的数据结构是无法避免的——语言标准只提供了少数几种基本数据结构,而许多类型的数据必须比数组或映射更复杂。但如果数组就足够了,那就使用数组。
稍微复杂一点的数据结构的一个例子是第7章中的图。在JavaScript中,没有一种单一的、显而易见的方式来表示图。在那一章中,我们使用了一个对象,其属性值是字符串数组——表示从该节点可到达的其他节点。
NPM上有几个不同的路径查找包,但它们都没有使用这种图的表示格式。它们通常允许图的边带有权重,即与之相关的成本或距离。在我们的表示方式中这是不可能实现的。
例如,有一个 dijkstrajs
包。一种著名的路径查找方法,与我们的 findRoute
函数非常相似,叫做迪杰斯特拉算法(Dijkstra's algorithm),以首次提出该算法的Edsger Dijkstra命名。js
后缀通常添加到包名中,以表明它们是用JavaScript编写的。这个 dijkstrajs
包使用了一种与我们类似的图格式,但它不是使用数组,而是使用对象,其属性值是数字——表示边的权重。
如果我们想使用那个包,就必须确保我们的图以它期望的格式存储。由于我们简化的模型将每条路的成本都视为相同(一次转弯),所以所有边的权重都相同。
const {find_path} = require("dijkstrajs");
let graph = {};
for (let node of Object.keys(roadGraph)) {
let edges = graph[node] = {};
for (let dest of roadGraph[node]) {
edges[dest] = 1;
}
}
console.log(find_path(graph, "Post Office", "Cabin"));
// → ["Post Office", "Alice's House", "Cabin"]
这段代码使用了 dijkstrajs
包中的 find_path
函数来在一个图中查找从 “Post Office” 到 “Cabin” 的路径。以下是详细解释:
-
导入函数:
const {find_path} = require("dijkstrajs");
- 使用
require
函数从dijkstrajs
包中解构导入find_path
函数。这是CommonJS模块系统中导入模块功能的方式。
-
构建图数据结构:
let graph = {};
- 初始化一个空对象
graph
,用于表示符合dijkstrajs
包要求格式的图。 for (let node of Object.keys(roadGraph)) {
- 遍历
roadGraph
对象的所有键(假设roadGraph
是一个已定义的表示图结构的对象)。对于每个节点:let edges = graph[node] = {};
- 在
graph
中为当前节点创建一个空的邻接边对象。 for (let dest of roadGraph[node]) {
- 对于当前节点在
roadGraph
中的每个邻居节点dest
:edges[dest] = 1;
- 在
graph
中,为当前节点与邻居节点之间的边赋予权重1
。这意味着在这个图中,每条边的成本是相同的。
-
查找路径并输出:
console.log(find_path(graph, "Post Office", "Cabin"));
- 调用
find_path
函数,传入构建好的图graph
以及起始节点 “Post Office” 和目标节点 “Cabin”。该函数会在图中查找从起始节点到目标节点的路径,并返回路径数组。 - 最后使用
console.log
将路径打印到控制台,例如["Post Office", "Alice's House", "Cabin"]
就是找到的一条路径。
这段代码展示了如何将现有的图数据结构(roadGraph
)转换为 dijkstrajs
包所需的格式,并使用该包提供的功能来查找路径。
这可能会成为组合的障碍——当不同的软件包使用不同的数据结构来描述相似的事物时,将它们组合起来就会很困难。因此,如果你希望设计出具有可组合性的模块,就要了解其他人正在使用哪些数据结构,并在可能的情况下,效仿他们的做法。
为一个程序设计合适的模块结构可能很困难。在你仍处于探索问题、尝试不同方法以找出可行方案的阶段时,或许不必过于担心模块结构,因为保持一切有条理可能会分散你太多精力。一旦你有了感觉可靠的成果,这时就是退后一步并进行整理的好时机。
总结
模块通过将代码分隔为具有清晰接口和依赖关系的部分,为大型程序提供结构。接口是模块对其他模块可见的部分,而依赖则是它所使用的其他模块。
由于JavaScript在历史上并未提供模块系统,因此在其基础上构建了CommonJS系统。后来在某个阶段,JavaScript获得了内置的模块系统,目前该系统与CommonJS系统共存,但并不融洽。
软件包是一段可独立分发的代码。NPM是JavaScript软件包的存储库。你可以从它那里下载各种有用(以及无用)的软件包。
练习:模块化机器人
这是第7章项目创建的绑定:
roads
buildGraph
roadGraph
VillageState
runRobot
randomPick
randomRobot
mailRoute
routeRobot
findRoute
goalOrientedRobot
如果要将该项目编写为模块化程序,可以考虑以下模块划分:
1. 图相关模块
-
模块名称:
graphModule
-
功能:负责处理图的构建和相关操作。包含
buildGraph
函数,用于根据道路数据构建图结构;roadGraph
可以作为该模块导出的一个预构建好的图实例。 - 依赖:无
- 接口:
// graphModule.js
function buildGraph(edges) {
// 构建图的逻辑
}
const roadGraph = buildGraph(/* 道路数据 */);
exports.buildGraph = buildGraph;
exports.roadGraph = roadGraph;
2. 机器人运行相关模块
-
模块名称:
robotRunnerModule
-
功能:管理机器人的运行逻辑,包含
runRobot
函数,它需要使用图结构(从graphModule
导入)以及机器人的行为逻辑(可能从其他模块导入)来运行机器人。 -
依赖:
graphModule
- 接口:
// robotRunnerModule.js
const { roadGraph } = require('./graphModule.js');
function runRobot(state, robot, memory) {
// 运行机器人的逻辑
}
exports.runRobot = runRobot;
3. 机器人行为相关模块
-
模块名称:
robotBehaviorModule
-
功能:定义不同机器人的行为函数,如
randomRobot
、routeRobot
、goalOrientedRobot
。这些函数可能依赖于图结构(从graphModule
导入)以及路径查找功能(可能从其他模块导入)。 -
依赖:
graphModule
- 接口:
// robotBehaviorModule.js
const { roadGraph } = require('./graphModule.js');
function randomRobot(state) {
// 随机选择路径的机器人逻辑
}
function routeRobot(state, memory) {
// 根据固定路线运行的机器人逻辑
}
function goalOrientedRobot(state, memory) {
// 以目标为导向的机器人逻辑
}
exports.randomRobot = randomRobot;
exports.routeRobot = routeRobot;
exports.goalOrientedRobot = goalOrientedRobot;
4. 辅助函数模块
-
模块名称:
utilityModule
-
功能:包含一些辅助函数,如
randomPick
用于随机选择元素,findRoute
用于在图中查找路径。这些函数可能依赖于图结构(从graphModule
导入)。 -
依赖:
graphModule
- 接口:
// utilityModule.js
const { roadGraph } = require('./graphModule.js');
function randomPick(array) {
// 随机选择数组元素的逻辑
}
function findRoute(graph, from, to) {
// 在图中查找路径的逻辑
}
exports.randomPick = randomPick;
exports.findRoute = findRoute;
5. 村庄状态相关模块
-
模块名称:
villageStateModule
-
功能:定义
VillageState
类,用于表示村庄的状态,包括位置、邮件等信息。 - 依赖:无
- 接口:
// villageStateModule.js
class VillageState {
constructor(place, parcels) {
// 初始化村庄状态的逻辑
}
}
exports.VillageState = VillageState;
哪些部分可能在NPM上已预先编写?
-
图相关功能:图的构建和操作在NPM上可能有成熟的包,例如
graphlib
等包可以处理图的各种操作,可能包含类似buildGraph
的功能。 -
路径查找功能:像
dijkstrajs
这样的包提供了路径查找算法,类似findRoute
的功能可能已经存在。
更倾向于使用NPM包还是自己编写?
-
使用NPM包的优势:
- 节省时间:可以快速获取经过测试和优化的代码,减少开发时间。
- 可靠性:通常由社区维护,经过多人使用和验证,更可靠。
-
自己编写的优势:
- 定制性:可以根据项目的具体需求进行定制化开发,更好地贴合项目逻辑。
- 学习机会:有助于深入理解相关算法和功能的实现原理。
综合考虑,如果项目时间紧张且对功能定制性要求不高,优先选择使用NPM包。如果希望深入理解原理或者项目有特殊需求,自己编写可能是更好的选择。
道路模块
基于第7章的示例编写一个ES模块,该模块包含道路数组,并将表示这些道路的图数据结构作为 roadGraph
导出。它依赖于一个 ./graph.js
模块,该模块导出一个 buildGraph
函数,用于构建图。此函数需要一个由二元数组组成的数组(道路的起点和终点)作为参数。
import { buildGraph } from './graph.js';
const roads = [
"Alice's House-Bob's House",
"Alice's House-Cabin",
"Alice's House-Post Office",
"Bob's House-Town Hall",
"Daria's House-Ernie's House",
"Daria's House-Town Hall",
"Ernie's House-Grete's House",
"Grete's House-Farm",
"Grete's House-Shop",
"Marketplace-Farm",
"Marketplace-Post Office",
"Marketplace-Shop",
"Marketplace-Town Hall",
"Shop-Town Hall"
];
const roadGraph = buildGraph(roads.map(road => road.split('-')));
export { roads, roadGraph };
In this code:
- We first import the
buildGraph
function from the./graph.js
module. This function is expected to build a graph from an array of two - element arrays representing the start and end points of roads. - We have the
roads
array defined as in the problem statement. - We transform the
roads
array into the format expected bybuildGraph
(an array of two - element arrays) usingmap
andsplit('-')
. Then we build theroadGraph
using thebuildGraph
function. - Finally, we export both the
roads
array and theroadGraph
object so that other modules can use them.
循环依赖
循环依赖是指模块A依赖于模块B,而模块B也直接或间接地依赖于模块A的情况。许多模块系统干脆禁止这种情况,因为无论你选择以何种顺序加载此类模块,都无法确保每个模块在运行前其依赖项已全部加载。
CommonJS模块允许一种有限形式的循环依赖。只要这些模块在完成加载之前不访问彼此的接口,循环依赖就是可行的。
本章前面给出的require
函数支持这种类型的循环依赖。你能看出它是如何处理循环的吗?
-
require
函数处理循环依赖的关键机制:-
缓存机制:
require
函数维护了一个require.cache
对象,用于存储已经加载过的模块。当加载一个模块时,首先会检查require.cache
中是否已经存在该模块。 -
部分初始化:在处理循环依赖时,当模块A开始加载模块B,而模块B又依赖模块A时,模块A在加载模块B之前,会先在
require.cache
中为自己创建一个缓存条目(此时该条目对应的模块尚未完全初始化)。 -
延迟访问接口:当模块B尝试加载模块A时,由于模块A已在
require.cache
中,尽管尚未完全初始化,但模块B可以获取到这个缓存条目(即模块A的一个“占位符”)。模块B不会等待模块A完全初始化完成就继续执行自身的加载逻辑,只要模块B在加载过程中不访问模块A的接口(直到模块A完全加载完成),就不会出现问题。 - 最终完成初始化:模块B加载完成后,模块A继续完成剩余的加载步骤,最终所有模块都能正确加载并使用彼此的接口。
-
缓存机制:
例如,假设存在模块A和模块B,模块A依赖模块B,模块B依赖模块A:
// 模块A
const b = require('B');
// 这里假设模块A在后续才会使用b的接口,而不是立即使用
exports.aValue = '一些值';
// 模块B
const a = require('A');
// 这里假设模块B在后续才会使用a的接口,而不是立即使用
exports.bValue = '其他值';
在这种情况下,当开始加载模块A时:
-
require('B')
会开始加载模块B。 - 在加载模块B时,
require('A')
发现模块A已经在require.cache
中(虽然未完全初始化),模块B继续加载自身逻辑。 - 模块B加载完成后,模块A继续完成加载,最终两个模块都能正常使用彼此导出的接口。
-
总结:
- 总之,
require
函数通过require.cache
中的缓存机制,允许模块在未完全初始化时就被其他模块引用,只要模块在加载过程中避免过早访问彼此未初始化完成的接口,就能处理循环依赖的情况。这种方式为开发者提供了一定的灵活性,使得在某些场景下可以实现循环依赖,但也要求开发者小心编写代码,确保模块之间的加载顺序和接口访问时机不会导致错误。
(出自原文https://eloquentjavascript.net/10_modules.html)
- 总之,