第 10 章模块
一位初学者编写代码就像蚂蚁筑巢,一次只构建一个部分,不考虑更大的结构。她的程序就像流沙。它们可能会持续一段时间,但随着规模的增长,它们会分崩离析。
意识到这个问题,这位程序员将开始花大量时间思考结构。她的程序将被严格地组织,就像岩石雕塑。它们很坚固,但当它们需要改变时,就必须对它们进行暴力操作。
每个程序都有一个形状。在较小的范围内,这个形状由其划分为函数以及这些函数内的代码块来决定。程序员在程序结构方面有很大的自由。形状更多地来自程序员的品味,而不是程序的预期功能。
当整体查看一个大型程序时,单个函数开始融入背景。如果我们有一个更大的组织单元,则可以使这样的程序更具可读性。
模块将程序划分为代码集群,这些代码根据某些标准归在一起。本章探讨了这种划分带来的好处,并展示了在 JavaScript 中构建模块的技术。
为什么模块有帮助
作者将他们的书籍划分为章节和部分的原因有很多。这些划分使读者更容易了解书籍的结构,并更容易找到他们感兴趣的特定部分。它们还有助于作者,为每个部分提供清晰的重点。
将程序组织成多个文件或模块的好处类似。结构有助于不熟悉代码的人找到他们要查找的内容,并使程序员更容易将相关事物放在一起。
一些程序甚至根据传统文本的模型进行组织,读者被鼓励以一个明确定义的顺序浏览程序,并提供大量散文(注释)来提供对代码的连贯描述。这使得阅读程序不那么令人畏惧——阅读未知代码通常令人畏惧——但缺点是设置起来需要更多工作。它还使程序更难更改,因为散文往往比代码更紧密地相互关联。这种风格被称为文学编程。本书的“项目”章节可以看作是文学程序。
作为一般规则,结构化事物会消耗能量。在项目早期,当您还不确定哪些内容应该放在哪里,或者程序到底需要哪些模块时,我建议采用极简主义、无结构的态度。只需将所有内容放在方便放置的地方,直到代码稳定为止。这样,您就不会浪费时间来回移动程序的各个部分,并且不会无意中将自己锁定在不适合您的程序的结构中。
命名空间
大多数现代编程语言都具有全局(每个人都可以看到它)和局部(只有这个函数可以看到它)之间的作用域级别。JavaScript 没有。因此,默认情况下,任何需要在顶层函数的作用域之外可见的内容在任何地方都是可见的。
命名空间污染,即大量不相关的代码必须共享一组全局变量名称的问题,在第 4 章中提到,其中Math
对象被用作一个例子,该对象通过对数学相关功能进行分组来充当模块。
虽然 JavaScript 尚未提供任何实际的模块结构,但对象可用于创建公开可访问的子命名空间,函数可用于在模块内部创建隔离的私有命名空间。在本章的后面,我将讨论一种在 JavaScript 提供的原始概念之上构建相当方便的命名空间隔离模块的方法。
重用
在一个“扁平”项目中,该项目没有被结构化为一组模块,不清楚代码的哪些部分是使用特定函数所必需的。在我的监视敌人的程序中(见第 9 章),我编写了一个用于读取配置文件的函数。如果我想在另一个项目中使用该函数,我必须去复制看起来与我需要的功能相关的旧程序的各个部分,并将它们粘贴到我的新程序中。然后,如果我在该代码中发现错误,我只会修复我当时正在处理的程序,而忘记在另一个程序中也修复它。
一旦您拥有许多这样的共享的、重复的代码片段,您会发现自己浪费大量时间和精力来移动它们并保持它们更新。
将独立的功能片段放入单独的文件和模块中,可以更容易地跟踪、更新和共享它们,因为所有想要使用该模块的各种代码片段都从同一个实际文件加载它。
当模块之间的关系——每个模块依赖于哪些其他模块——被明确说明时,这个想法变得更加强大。然后,您可以自动执行安装和升级外部模块(库)的过程。
更进一步,想象一个在线服务跟踪并分发数十万个这样的库,允许您搜索您需要的功能,并且一旦找到,就将您的项目设置为自动下载它。
这项服务存在。它被称为 NPM (npmjs.org)。NPM 包含一个模块在线数据库,以及一个用于下载和升级您的程序依赖的模块的工具。它起源于 Node.js,我们将在第 20 章中讨论的无浏览器 JavaScript 环境,但对浏览器编程也很有用。
解耦
模块的另一个重要作用是将代码片段彼此隔离,就像第 6 章中的对象接口一样。一个设计良好的模块将为外部代码提供一个接口。随着模块通过错误修复和新功能进行更新,现有的接口保持不变(它是稳定的),因此其他模块可以在不进行任何更改的情况下使用新的改进版本。
请注意,稳定的接口并不意味着没有添加新的函数、方法或变量。它只是意味着现有的功能不会被删除,并且它的含义不会改变。
一个好的模块接口应该允许模块在不破坏旧接口的情况下增长。这意味着尽可能少地公开模块的内部概念,同时使接口公开的“语言”足够强大和灵活,以适应各种情况。
对于公开单个集中概念的接口,例如配置文件读取器,这种设计很自然。对于其他接口,例如文本编辑器,它具有外部代码可能需要访问的许多不同方面(内容、样式、用户操作等),它需要精心设计。
使用函数作为命名空间
函数是 JavaScript 中唯一创建新作用域的东西。所以如果我们希望我们的模块有自己的作用域,我们就必须将它们建立在函数之上。
考虑这个用于将名称与星期几数字(由Date
对象的getDay
方法返回)相关联的微不足道的模块
var names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; function dayName(number) { return names[number]; } console.log(dayName(1)); // → Monday
dayName
函数是模块接口的一部分,但names
变量不是。我们更希望不要将其泄漏到全局作用域中。
var dayName = function() { var names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; return function(number) { return names[number]; }; }(); console.log(dayName(3)); // → Wednesday
现在names
是一个(未命名)函数中的局部变量。这个函数被创建并立即调用,它的返回值(实际的dayName
函数)存储在一个变量中。我们可以在这个函数中包含数页代码,包含 100 个局部变量,它们都将是我们模块的内部变量——对模块本身可见,但对外部代码不可见。
我们可以使用类似的模式来完全隔离外部世界的代码。以下模块将值记录到控制台,但实际上没有为其他模块提供任何可用的值
(function() { function square(x) { return x * x; } var hundred = 100; console.log(square(hundred)); })(); // → 10000
这段代码只是输出 100 的平方,但在现实世界中,它可能是一个模块,该模块将方法添加到某个原型中,或在网页上设置小部件。它被包裹在一个函数中,以防止它使用的内部变量污染全局作用域。
为什么我们将命名空间函数包裹在一对括号中?这与 JavaScript 语法中的一个怪癖有关。如果一个表达式以关键字function
开头,则它是一个函数表达式。但是,如果一个语句以function
开头,则它是一个函数声明,它需要一个名称,并且不是一个表达式,因此不能通过在它后面写括号来调用。您可以将额外的包裹括号视为一种技巧,可以强制将该函数解释为表达式。
对象作为接口
现在想象一下,我们想要向我们的星期几模块添加另一个函数,该函数从星期几名称转换为数字。我们不能简单地返回该函数,而必须将这两个函数包装在一个对象中。
var weekDay = function() { var names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; return { name: function(number) { return names[number]; }, number: function(name) { return names.indexOf(name); } }; }(); console.log(weekDay.name(weekDay.number("Sunday"))); // → Sunday
对于更大的模块,将所有导出的值在函数末尾收集到一个对象中会变得很麻烦,因为许多导出的函数可能很大,你可能更愿意将它们写到其他地方,靠近相关的内部代码。一个便捷的替代方案是声明一个对象(通常命名为exports
),并在定义需要导出的内容时向该对象添加属性。在下面的示例中,模块函数以其接口对象作为参数,允许函数外部的代码创建它并将它存储在一个变量中。(在函数之外,this
指向全局作用域对象。)
(function(exports) { var names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; exports.name = function(number) { return names[number]; }; exports.number = function(name) { return names.indexOf(name); }; })(this.weekDay = {}); console.log(weekDay.name(weekDay.number("Saturday"))); // → Saturday
脱离全局作用域
之前的模式通常由针对浏览器的 JavaScript 模块使用。模块将声明一个全局变量,并将代码包装在一个函数中,以便拥有自己的私有命名空间。但这种模式仍然会导致问题,如果多个模块恰好声明了相同的名称,或者如果你想并排加载两个版本的模块。
通过一些管道,我们可以创建一个系统,允许一个模块直接请求另一个模块的接口对象,而无需通过全局作用域。我们的目标是一个require
函数,当给出模块名时,它将加载该模块的文件(来自磁盘或 Web,取决于我们运行的平台),并返回相应的接口值。
这种方法解决了前面提到的问题,并具有使程序的依赖关系明确化的额外好处,这使得在未说明需要某个模块的情况下意外使用它变得更加困难。
对于require
,我们需要两样东西。首先,我们想要一个函数readFile
,它将给定文件的內容作为字符串返回。(标准 JavaScript 中没有单个这样的函数,但不同的 JavaScript 环境,如浏览器和 Node.js,提供自己的访问文件的方式。现在,让我们假装我们拥有这个函数。)其次,我们需要能够真正地将这个字符串作为 JavaScript 代码执行。
评估数据作为代码
有多种方法可以获取数据(代码字符串)并将其作为当前程序的一部分运行。
最明显的方法是特殊的运算符eval
,它将在当前作用域中执行代码字符串。这通常是一个糟糕的做法,因为它破坏了作用域通常具有的某些合理属性,例如与外部世界隔离。
function evalAndReturnX(code) { eval(code); return x; } console.log(evalAndReturnX("var x = 2")); // → 2
解释数据作为代码的更好方法是使用Function
构造函数。它接受两个参数:包含逗号分隔的函数参数名称列表的字符串,以及包含函数体的字符串。
var plusOne = new Function("n", "return n + 1;"); console.log(plusOne(4)); // → 5
这正是我们模块所需的。我们可以将模块的代码包装在一个函数中,该函数的作用域将成为我们的模块作用域。
Require
function require(name) { var code = new Function("exports", readFile(name)); var exports = {}; code(exports); return exports; } console.log(require("weekDay").name(1)); // → Monday
由于new Function
构造函数将模块代码包装在一个函数中,因此我们不需要在模块文件中本身编写包装命名空间函数。由于我们将exports
作为模块函数的参数,因此模块不必声明它。这从我们的示例模块中删除了许多杂乱内容。
var names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; exports.name = function(number) { return names[number]; }; exports.number = function(name) { return names.indexOf(name); };
var weekDay = require("weekDay"); var today = require("today"); console.log(weekDay.name(today.dayNumber()));
前面给出的require
的简单实现存在几个问题。首先,它会在每次require
时加载并运行模块,因此如果多个模块具有相同的依赖项或require
调用被放在将被多次调用的函数内部,那么时间和精力就会浪费。
这可以通过将已经加载的模块存储在一个对象中,并在多次加载时简单地返回现有值来解决。
第二个问题是,模块无法直接导出exports
对象以外的值,例如函数。例如,模块可能只想导出它定义的对象类型的构造函数。现在,它无法做到这一点,因为require
始终使用它创建的exports
对象作为导出值。
对此的传统解决方案是为模块提供另一个变量module
,它是一个具有属性exports
的对象。该属性最初指向require
创建的空对象,但可以被覆盖为另一个值,以便导出其他内容。
function require(name) { if (name in require.cache) return require.cache[name]; var code = new Function("exports, module", readFile(name)); var exports = {}, module = {exports: exports}; code(exports, module); require.cache[name] = module.exports; return module.exports; } require.cache = Object.create(null);
现在我们有了使用单个全局变量(require
)的模块系统,允许模块在不通过全局作用域的情况下查找和使用彼此。
这种模块系统被称为CommonJS 模块,它以第一个指定它的伪标准命名。它内置在 Node.js 系统中。真实的实现比我展示的示例要多得多。最重要的是,它们具有更智能的方式从模块名称到实际代码片段,允许相对当前文件的路径名和直接指向本地安装模块的模块名称。
慢速加载模块
尽管在为浏览器编写 JavaScript 时可以使用 CommonJS 模块样式,但这有点复杂。这样做的原因是,从 Web 读取文件(模块)比从硬盘读取文件要慢得多。当脚本在浏览器中运行时,出于将在第 14 章中说明的原因,运行它的网站无法执行其他操作。这意味着,如果每个require
调用都从某个遥远的 Web 服务器获取内容,那么页面在加载脚本时会冻结很长时间。
解决此问题的一种方法是在将代码提供给网页之前,在代码上运行类似Browserify的程序。它将查找对require
的调用,解析所有依赖项,并将所需代码收集到一个大的文件中。网站本身只需加载此文件即可获取所需的所有模块。
另一种解决方案是将构成模块的代码包装在一个函数中,以便模块加载器可以首先在后台加载其依赖项,然后在依赖项加载完毕后调用该函数,初始化模块。这就是异步模块定义 (AMD) 模块系统所做的。
define(["weekDay", "today"], function(weekDay, today) { console.log(weekDay.name(today.dayNumber())); });
define
函数是这种方法的核心。它首先接受一个模块名称数组,然后接受一个函数,该函数为每个依赖项接受一个参数。它将在后台加载依赖项(如果它们尚未加载),允许页面在获取文件时继续工作。一旦所有依赖项都加载完毕,define
将调用它接收的函数,并将这些依赖项的接口作为参数。
以这种方式加载的模块本身必须包含对define
的调用。用作其接口的值是传递给define
的函数返回的任何内容。以下是weekDay
模块
define([], function() { var names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; return { name: function(number) { return names[number]; }, number: function(name) { return names.indexOf(name); } }; });
为了能够展示define
的最小实现,我们将假装我们拥有一个backgroundReadFile
函数,它接受一个文件名和一个函数,并在加载完毕后立即用文件的內容调用该函数。(第 17 章将解释如何编写该函数。)
为了在加载模块时跟踪它们,define
的实现将使用描述模块状态的对象,告诉我们它们是否可用,并在可用时提供它们的接口。
getModule
函数在给定名称时,将返回这样一个对象并确保调度加载模块。它使用缓存对象来避免两次加载同一个模块。
var defineCache = Object.create(null); var currentMod = null; function getModule(name) { if (name in defineCache) return defineCache[name]; var module = {exports: null, loaded: false, onLoad: []}; defineCache[name] = module; backgroundReadFile(name, function(code) { currentMod = module; new Function("", code)(); }); return module; }
我们假设加载的文件也包含对define
的(单个)调用。currentMod
变量用于告诉此调用当前正在加载的模块对象,以便它在加载完毕后可以更新此对象。我们稍后将回到这种机制。
define
函数本身使用getModule
来获取或创建当前模块依赖项的模块对象。它的任务是安排moduleFunction
(包含模块实际代码的函数)在这些依赖项加载完毕后运行。为此,它定义了一个函数whenDepsLoaded
,该函数被添加到所有尚未加载的依赖项的onLoad
数组中。如果仍然存在未加载的依赖项,此函数将立即返回,因此它只会在最后一个依赖项加载完毕后执行实际工作。它也从define
本身立即调用,以防没有需要加载的依赖项。
function define(depNames, moduleFunction) { var myMod = currentMod; var deps = depNames.map(getModule); deps.forEach(function(mod) { if (!mod.loaded) mod.onLoad.push(whenDepsLoaded); }); function whenDepsLoaded() { if (!deps.every(function(m) { return m.loaded; })) return; var args = deps.map(function(m) { return m.exports; }); var exports = moduleFunction.apply(null, args); if (myMod) { myMod.exports = exports; myMod.loaded = true; myMod.onLoad.forEach(function(f) { f(); }); } } whenDepsLoaded(); }
当所有依赖项都可用时,whenDepsLoaded
将调用保存模块的函数,并将依赖项的接口作为参数传递给它。
define
所做的第一件事是将currentMod
在被调用时所拥有的值存储在一个名为myMod
的变量中。请记住,getModule
在评估模块的代码之前,将相应的模块对象存储在currentMod
中。这允许whenDepsLoaded
将模块函数的返回值存储在该模块的exports
属性中,将模块的loaded
属性设置为 true,并调用所有等待模块加载的函数。
这段代码比require
函数要难理解得多。它的执行没有遵循简单、可预测的路径。相反,多个操作被设置为在未来的某个不确定的时间发生,这掩盖了代码的执行方式。
真实的 AMD 实现同样比前面展示的实现要聪明得多,它更善于将模块名称解析为实际 URL,并且通常比前面展示的实现更加健壮。RequireJS (requirejs.org) 项目提供了这种模块加载器的流行实现。
接口设计
为模块和对象类型设计接口是编程中比较微妙的方面之一。任何非平凡的功能都可以用各种方式建模。找到一种有效的方法需要洞察力和前瞻性。
学习良好接口设计价值的最佳方法是使用大量接口 - 一些好的,一些坏的。经验会教你什么有效,什么无效。永远不要假设痛苦的接口是“就是这样”。修复它,或者把它包装在一个对你来说更好的新接口中。
可预测性
如果程序员可以预测你的接口的工作方式,他们(或者你)就不会经常因为需要查找如何使用它而偏离主题。因此,尝试遵循约定。当有另一个模块或标准 JavaScript 环境的一部分做类似于你正在实现的事情时,最好让你的接口类似于现有接口。这样,它会让人们感觉熟悉,因为他们知道现有的接口。
可预测性很重要的另一个方面是你的代码的实际行为。为了证明它更方便使用,编写不必要的巧妙接口可能很诱人。例如,你可以接受各种不同类型和组合的参数,并对它们执行“正确的事情”。或者,你可以提供几十个专门的便利函数,它们提供你的模块功能的稍微不同的变体。这些可能会使基于你的接口的代码略短,但它们也会使人们更难建立模块行为的清晰思维模型。
可组合性
在你的接口中,尝试使用最简单的可能的数据结构,并使函数执行一个清晰的单一操作。只要实用,使它们成为纯函数(参见第 3 章)。
例如,模块提供自己的类似数组的集合对象并不罕见,它们有自己的接口用于计数和提取元素。这样的对象将没有map
或forEach
方法,任何期望真实数组的现有函数都无法与它们一起使用。这是一个可组合性差的示例 - 该模块无法轻松地与其他代码组合。
一个例子是用于拼写检查文本的模块,我们可能需要在想要编写文本编辑器时使用它。可以使拼写检查器直接在编辑器使用的任何复杂数据结构上运行,并直接调用编辑器中的内部函数以让用户在拼写建议之间进行选择。如果我们这样做,该模块无法与任何其他程序一起使用。另一方面,如果我们定义拼写检查接口,以便你可以向它传递一个简单的字符串,它将返回它在字符串中找到可能拼写错误的位置,以及一组建议的更正,那么我们就有了一个可以与其他系统组合的接口,因为字符串和数组始终在 JavaScript 中可用。
分层接口
在为复杂的功能(例如发送电子邮件)设计接口时,你经常会遇到两难境地。一方面,你不想用细节来压倒你的接口用户。他们不应该在发送电子邮件之前花 20 分钟的时间研究你的接口。另一方面,你也不想隐藏所有细节 - 当人们需要用你的模块做复杂的事情时,他们应该能够做到。
解决方案通常是提供两个接口:一个用于复杂情况的详细低级接口,以及一个用于常规使用的简单高级接口。第二个通常可以使用第一个提供的工具轻松构建。在电子邮件模块中,高级接口可以只是一个函数,它接受消息、发件人地址和收件人地址,然后发送电子邮件。低级接口将允许完全控制电子邮件标头、附件、HTML 邮件等。
总结
模块通过将代码分离到不同的文件和命名空间来为更大的程序提供结构。为这些模块提供定义明确的接口使其更容易使用和重用,并且可以继续在模块本身演变时使用它们。
虽然 JavaScript 语言在模块方面通常无助,但它提供的灵活函数和对象使定义相当不错的模块系统成为可能。函数作用域可以用作模块的内部命名空间,而对象可以用来存储导出值的集合。
有两种流行的、定义明确的方法来处理此类模块。一种称为CommonJS 模块,围绕一个require
函数展开,该函数按名称获取模块并返回其接口。另一种称为AMD,它使用一个define
函数,该函数接受一个模块名称数组和一个函数,并在加载模块后,使用它们的接口作为参数运行该函数。
练习
月份名称
编写一个类似于weekDay
模块的简单模块,它可以将月份数字(以零为基准,如Date
类型)转换为名称,并且可以将名称转换回数字。为它提供自己的命名空间,因为它将需要一个月份名称的内部数组,并使用纯 JavaScript,无需任何模块加载器系统。
// Your code here. console.log(month.name(2)); // → March console.log(month.number("November")); // → 10
重返电子生命
希望第 7 章仍然有点新鲜,回想一下本章设计的系统,并想出一个将代码分离成模块的方法。为了刷新你的记忆,这些是本章定义的函数和类型,按出现顺序
Vector Grid directions directionNames randomElement BouncingCritter elementFromChar World charFromElement Wall View WallFollower dirPlus LifelikeWorld Plant PlantEater SmartPlantEater Tiger
不要夸大并创建太多模块。如果一本书每页都开始一个新章节,可能会让你感到厌烦,仅仅是因为浪费了太多在标题上的空间。同样,打开 10 个文件来读取一个微小的项目也没有帮助。目标是 3 到 5 个模块。
你可以选择让一些函数成为其模块的内部函数,因此其他模块无法访问它们。
这里没有唯一的正确解决方案。模块组织很大程度上是个人喜好问题。
Module "grid" Vector Grid directions directionNames Module "world" (randomElement) (elementFromChar) (charFromElement) View World LifelikeWorld directions [reexported] Module "simple_ecosystem" (randomElement) [duplicated] (dirPlus) Wall BouncingCritter WallFollower Module "ecosystem" Wall [duplicated] Plant PlantEater SmartPlantEater Tiger
我已经从world
中的grid
模块重新导出了directions
数组,这样建立在它之上的模块(生态系统)就不必知道或担心grid
模块的存在。
我还复制了两个通用且微小的辅助值(randomElement
和Wall
),因为它们在不同的上下文中用作内部细节,并且不属于这些模块的接口。
循环依赖
依赖管理中一个棘手的问题是循环依赖,即模块 A 依赖于 B,而 B 也依赖于 A。许多模块系统简单地禁止这种情况。CommonJS 模块允许一种有限的形式:只要模块不将它们的默认exports
对象替换为另一个值,并且只在加载完成后开始访问彼此的接口,它就可以工作。