10. 模块

        理想情况下,程序的结构清晰明了。它的运作方式易于解释,每个部分都发挥着明确的作用。

        实际上,程序是有机生长的。当程序员发现新的需求时,就会添加新的功能。要使程序保持良好的结构,需要持续的关注和工作。这些工作只有在将来,也就是下一次有人处理程序时才会有回报,因此很容易被忽视,让程序的各个部分深深地纠缠在一起。

        这会导致两个实际问题。首先,理解一个纠缠不清的系统非常困难。如果所有事物都能与其他事物发生联系,那么就很难孤立地看待任何特定部分。你不得不建立起对整个事物的整体理解。其次,如果你想在另一种情况下使用这样一个程序的任何功能,重写它可能比试图将它从上下文中分离出来要容易得多。“一团烂泥"(big ball of mud)这句话经常被用来形容这种大型、无结构的程序。所有的东西都粘在一起,当你试图抠出其中一块时,整个东西就会散开,你只能成功地把它弄得一团糟。

模块化程序

        模块试图避免这些问题。模块是一个程序片段,它规定了它依赖于哪些其他片段,以及它为其他模块提供了哪些功能(它的接口)。

        模块接口与我们在第 6 章中看到的对象接口有很多共同之处。它们使模块的一部分对外开放,其余部分则保持私有。

        但是,模块提供给他人使用的接口只是事情的一半。一个好的模块系统还要求模块指定使用其他模块的哪些代码。这些关系被称为依赖关系。如果模块 A 使用了模块 B 的功能,则称其依赖于该模块。当模块本身明确说明了这些关系时,就可以利用它们来确定使用某个模块需要哪些其他模块,并自动加载依赖关系。

        如果模块之间的交互方式是明确的,那么系统就会变得更像乐高(LEGO),通过定义明确的连接器进行交互,而不像泥巴(Mud),所有东西都会混在一起。

ES 模块

        最初的 JavaScript 语言没有模块的概念。所有脚本都在同一范围内运行,访问另一个脚本中定义的函数时,需要引用该脚本创建的全局绑定。这种做法助长了意外的、难以察觉的代码纠缠,并引发了不相关的脚本试图使用相同绑定名称等问题。

        自 ECMAScript 2015 起,JavaScript 支持两种不同类型的程序。脚本的行为方式还是老样子:它们的绑定定义在全局作用域中,无法直接引用其他脚本。模块有自己独立的作用域,并支持脚本中没有的导入和导出关键字,以声明它们的依赖关系和接口。这种模块系统通常称为 ES 模块(ES 代表 ECMAScript)。

        一个模块化程序由多个此类模块组成,并通过导入和导出连接在一起。

        下面6的示例模块在日期名称和数字(由 Date 的 getDay 方法返回)之间进行转换。它定义了一个不属于接口的常量和两个属于接口的函数。该模块没有依赖关系。

        export 关键字可以放在函数、类或绑定定义的前面,表示该绑定是模块接口的一部分。这样,其他模块就可以通过导入该绑定来使用它。

译者注:如果是在node环境运行js,需要把被调用的模块改后缀名(.mjs)。

        import 关键字后面是一个大括号中的绑定名称列表,它使当前模块可以使用另一个模块中的绑定。模块用引号字符串标识。

        不同平台将模块名称解析为实际程序的方式各不相同。浏览器将它们视为网址,而 Node.js 则将它们解析为文件。当你运行一个模块时,它所依赖的所有其他模块以及这些模块所依赖的模块都会被加载,导出的绑定会提供给导入它们的模块。

        导入和导出声明不能出现在函数、循环或其他块中。无论模块中的代码如何执行,它们都会在模块加载时立即被解析。为了反映这一点,导入和导出声明必须只出现在外部模块体中。

        因此,模块接口由一系列已命名的绑定组成,依赖于该模块的其他模块可以访问这些绑定。导入的绑定可以重命名,以在名称后使用 as 来命名新的本地名称。

        模块还可以有一个名为 default 的特殊导出,通常用于只导出单一绑定的模块。要定义默认导出,可在表达式、函数声明或类声明前写入 export default。

省略导入名称前后的大括号即可导入这种绑定。

        要同时导入模块的所有绑定,可以使用 import *。您只需提供一个名称,该名称就会绑定到一个包含该模块所有导出的对象上。这在使用大量不同导出时非常有用。

        用独立片段构建程序并能单独运行其中一些片段的好处之一是,你可以在不同的程序中使用同一个片段。但如何设置呢?假设我想在另一个程序中使用第 9 章中的 parseINI 函数。如果该函数依赖于什么是明确的(在本例中,什么都不依赖),我可以直接将该模块复制到我的新项目中并使用它。但是,如果我发现代码中有错误,我很可能会在当时正在运行的程序中修正它,而忘记在另一个程序中也修正它。

        一旦你开始复制代码,你很快就会发现自己在浪费时间和精力到处移动副本并保持更新。这就是软件包的用武之地。软件包是可以分发(复制和安装)的代码块。它可能包含一个或多个模块,并有关于它依赖于哪些其他软件包的信息。软件包通常还会附带文档,解释软件包的作用,以便非软件包编写者也能使用它。

        当在软件包中发现问题或添加新功能时,软件包就会被更新。现在,依赖它的程序(也可能是软件包)可以复制新版本,以获得代码的改进。

        以这种方式工作需要基础架构。我们需要一个存储和查找软件包的地方,以及安装和升级它们的便捷方法。在 JavaScript 世界中,NPM(npm | Home)提供了这种基础架构。

        NPM 有两个功能:一个是可以下载(和上传)软件包的在线服务,另一个是帮助您安装和管理软件包的程序(与 Node.js 捆绑)。

        在撰写本文时,NPM 上有三百多万种不同的软件包。公平地说,其中很大一部分都是垃圾。但几乎所有有用的、公开可用的 JavaScript 软件包都能在 NPM 上找到。例如,INI文件解析器(类似于我们在第9章中构建的那个)就可以在名为ini的软件包下找到。

第 20 章将介绍如何使用 npm 命令行程序在本地安装此类软件包。

        有高质量的软件包可供下载是非常有价值的。这意味着我们通常可以避免重新发明一个 100 人都写过的程序,只需按几个键就能得到一个可靠的、经过良好测试的实现。

        软件的复制成本很低,因此一旦有人编写了软件,将其分发给其他人就是一个高效的过程。不过,首先编写程序是一项工作,而回应那些发现代码中存在问题或希望提出新功能的人则更是一项工作。

        默认情况下,你拥有自己编写的代码的版权,其他人只有在得到你的许可后才能使用。不过,由于有些人就是好心,而且发布好软件能让你在程序员中小有名气,因此许多软件包都是在明确允许他人使用的许可下发布的。

        NPM 上的大多数代码都是这样授权的。有些许可证要求你同时以相同的许可证发布你在软件包之上构建的代码。另一些则要求较低,只要求你在发布代码时保留许可证。JavaScript 社区大多使用后一种许可证。在使用其他人的软件包时,请确保您了解他们的许可证。

现在,我们可以使用 NPM 提供的 INI 文件解析器,而不用自己编写 INI 文件解析器。

译者注:需要在终端使用:npm install ini 命令下载这个包。

CommonJS 模块

        2015 年之前,当 JavaScript 语言还没有内置模块系统时,人们已经在用 JavaScript 构建大型系统了。为了使其可行,他们需要模块。社区在语言之上设计了自己的简易模块系统。这些系统使用函数为模块创建本地范围,并使用常规对象表示模块接口。

        最初,人们只是手动将整个模块封装在一个 “立即调用的函数表达式 ”中,以创建模块的作用域,并将接口对象分配给一个全局变量。

        这种类型的模块在一定程度上提供了隔离性,但并不声明依赖关系。相反,它只是将自己的接口放到全局范围内,并希望它的依赖对象(如果有的话)也这样做。这并不理想。

        如果我们实现自己的模块加载器,就能做得更好。使用最广泛的 JavaScript 附加模块方法称为 CommonJS 模块。Node.js 从一开始就使用这种模块系统(尽管它现在也知道如何加载 ES 模块),NPM 上的许多软件包也使用这种模块系统。

        CommonJS 模块看起来就像一个普通的脚本,但它可以访问两个绑定,用来与其他模块交互。第一个绑定是一个名为 require 的函数。当你用依赖模块的名称调用它时,它会确保模块已加载并返回其接口。第二个是名为 exports 的对象,它是模块的接口对象。一开始它是空的,你可以向它添加属性来定义导出值。

        这个 CommonJS 示例模块提供了一个日期格式化功能。它使用 NPM-ordinal 中的两个包将数字转换为字符串,如 “1st ”和 “2nd”,并使用 date-names 获取工作日和月份的英文名称。它只导出一个函数 formatDate,该函数接收一个日期对象和一个模板字符串。

        模板字符串可能包含指示格式的代码,如 YYYY 表示整年,Do 表示月的序数日。你可以给它一个类似 “MMMM Do YYYY ”的字符串,以获得类似 2017 年 11 月 22 日的输出。

译者注:需要导入require,ordinal和date-names包;使用commonJs时,文件的后缀名需要改回为.js,而不是.mjs,并删除掉package-json中的type=module。

        ordinal 的接口是一个函数,而 date-names 则导出一个包含多个内容的对象--日和月是名称数组。在为导入的接口创建绑定时,去结构化非常方便。

模块将其接口函数添加到导出中,这样依赖它的模块就可以访问它。我们可以这样使用模块:

        CommonJS 是通过模块加载器实现的,加载模块时,加载器会将模块代码封装在一个函数中(赋予其本地作用域),并将 require 和 exports 绑定作为参数传递给该函数。

        如果我们假定可以访问 readFile 函数,该函数会读取文件名并给出文件内容,那么我们就可以这样定义 require 的简化形式:

        Function 是一个内置的 JavaScript 函数,它接收一个参数列表(以逗号分隔的字符串)和一个包含函数体的字符串,并返回一个包含这些参数和函数体的函数值。这是一个有趣的概念--它允许程序从字符串数据中创建新的程序片段,但同时也是一个危险的概念,因为如果有人能欺骗你的程序,将他们提供的字符串放入 Function 中,他们就能让程序做任何他们想做的事情。

        标准 JavaScript 没有提供 readFile 这样的函数,但不同的 JavaScript 环境(如浏览器和 Node.js)都提供了自己的文件访问方式。本示例只是假装 readFile 存在。

        为避免多次加载同一模块,require 会保存已加载模块的存储空间(缓存)。调用时,它会首先检查请求的模块是否已加载,如果没有,则加载它。这包括读取模块的代码,将其封装在一个函数中,然后调用它。

        通过将 require 和 exports 定义为生成的封装函数的参数(并在调用时传递相应的值),加载器可确保这些绑定在模块的作用域中可用。

        该系统与 ES 模块的一个重要区别是,ES 模块的导入发生在模块脚本开始运行之前,而 require 是一个普通函数,在模块已经运行时调用。与导入声明不同的是,require 调用可以出现在函数内部,依赖关系的名称可以是任何求值为字符串的表达式,而导入只允许使用纯引号字符串。

        JavaScript 社区从 CommonJS 风格向 ES 模块的过渡是缓慢而艰难的。幸运的是,现在 NPM 上的大多数流行软件包都以 ES 模块的形式提供代码,Node.js 也允许 ES 模块从 CommonJS 模块导入。虽然 CommonJS 代码仍然是你会遇到的东西,但已经没有真正的理由再用这种风格编写新程序了。

构建与捆绑

        许多 JavaScript 包在技术上并不是用 JavaScript 编写的。第 8 章中提到的类型检查方言 TypeScript 等语言扩展被广泛使用。人们往往在计划中的新语言特性被添加到实际运行 JavaScript 的平台之前,就已经开始使用它们了。为了实现这一点,他们会编译代码,将其从所选的 JavaScript 方言翻译成普通的旧 JavaScript,甚至是 JavaScript 的过去版本,以便浏览器可以运行。

        在网页中包含一个由 200 个不同文件组成的模块化程序本身就会产生问题。如果通过网络获取单个文件需要 50 毫秒,那么加载整个程序则需要 10 秒,如果同时加载多个文件,则可能只需要一半的时间。这就浪费了很多时间。因为获取一个大文件往往比获取许多小文件要快,所以网络程序员开始使用一些工具,在将程序发布到网络之前,将程序(他们费尽心机地将程序分割成模块)合并成一个大文件。这种工具被称为捆绑工具。

        我们还可以更进一步。除了文件的数量,文件的大小也决定了它们在网络上的传输速度。因此,JavaScript 社区发明了最小化工具。这些工具可以通过自动删除注释和空白、重命名绑定和替换代码片段来缩小 JavaScript 程序。

模块设计

        结构化程序是编程中比较微妙的一个方面。任何非微不足道的功能都可以用不同的方式组织起来。

        好的程序设计是主观的--其中涉及权衡和品味问题。了解结构合理的设计价值的最佳方法是阅读或处理大量程序,并注意哪些有效,哪些无效。不要认为令人痛苦的混乱就是 “本来的样子”。只要多花些心思,几乎所有东西的结构都能得到改善。

        模块设计的一个方面是易用性。如果你设计的东西是供多人使用的,甚至是供你自己使用的,那么在三个月后,当你不再记得你做了什么的时候,如果你的界面是简单和可预测的,那就会很有帮助。

        这可能意味着要遵循现有的惯例。ini 包就是一个很好的例子。该模块模仿标准 JSON 对象,提供解析和字符串化(写入 INI 文件)函数,并像 JSON 一样在字符串和纯对象之间进行转换。它的界面小巧而熟悉,只要使用过一次,就能记住如何使用。

        即使没有可以模仿的标准函数或广泛使用的软件包,你也可以通过使用简单的数据结构和做单一而集中的事情来保持模块的可预测性。例如,NPM 上的许多 INI 文件解析模块都提供了一个直接从硬盘读取此类文件并进行解析的函数。这样一来,我们就无法在浏览器中使用这类模块,因为在浏览器中我们无法直接访问文件系统,而且还增加了复杂性,而如果将模块与某些文件读取功能结合在一起,就能更好地解决这个问题。

        这就说明了模块设计的另一个有益方面--模块与其他代码的易组合性。与执行复杂操作并产生副作用的大型模块相比,计算数值的重点模块适用于更多程序。坚持从磁盘读取文件的 INI 文件阅读器在文件内容来自其他来源的情况下毫无用处。

        与此相关的是,有状态的对象有时是有用的,甚至是必要的,但如果某些事情可以用函数来完成,那就使用函数。NPM的一些 INI 文件阅读器提供了一种界面风格,要求你首先创建一个对象,然后将文件加载到你的对象中,最后使用专门的方法来获取结果。这种做法在面向对象的传统中很常见,但却很糟糕。你不得不在对象的各种状态中移动对象,而不是调用一个函数就继续工作。而且,由于数据现在被封装在一个专门的对象类型中,所有与之交互的代码都必须了解该类型,这就产生了不必要的相互依赖。

        通常,定义新的数据结构是无法避免的,语言标准只提供了一些基本的数据结构,而许多数据类型必须比数组或映射更复杂。但是,如果数组就足够了,那就使用数组。

        第 7 章中的图形就是一个稍微复杂的数据结构示例。在 JavaScript 中并没有一种明显的方法来表示图形。在该章中,我们使用了一个对象,该对象的属性包含字符串数组--从该节点可以到达的其他节点。

        npm有几种不同的寻路软件包,但没有一种使用这种图格式。它们通常允许图的边具有权重,即与之相关的成本或距离。这在我们的表示法中是不可能的。

        例如,dijkstrajs 软件包。Dijkstra 算法是一种著名的寻路方法,与我们的 findRoute 函数十分相似。js 后缀通常被添加到软件包名称中,以表明它们是用 JavaScript 编写的。这个 dijkstrajs 软件包使用的图格式与我们的类似,但它使用的不是数组,而是属性值为数字(即边的权重)的对象。

译者注:没想到后面居然还是提到了迪杰斯特拉算法。^^

        如果我们想使用该软件包,就必须确保我们的图形以它所期望的格式存储。所有边的权重相同,因为我们的简化模型将每条道路视为具有相同的成本(一个转弯)。

        当各种软件包使用不同的数据结构来描述相似的事物时,很难将它们组合起来。因此,如果你想为可组合性而设计,那就找出其他人正在使用的数据结构,并在可能的情况下效仿他们的做法。

        为程序设计一个合适的模块结构可能很困难。在你还在探索问题、尝试不同的方法以了解哪种方法有效的阶段,你可能不需要太担心这个问题,因为让所有东西都井井有条会让你分心。一旦你有了感觉可靠的东西,那就是退一步整理的好时机。

总结

        模块通过将代码分割成具有明确接口和依赖关系的片段,为大型程序提供结构。接口是模块中对其他模块可见的部分,而依赖关系则是模块所使用的其他模块。

        由于 JavaScript 历史上没有提供模块系统,CommonJS 系统是在其基础上构建的。后来,JavaScript 终于有了一个内置的系统,但现在却与 CommonJS 系统不和谐地共存着。

软件包是可以独立发布的代码块。NPM 是 JavaScript 软件包的存储库。你可以从中下载各种有用(或无用)的软件包。

练习
模块化机器人

这些是第 7 章中的项目创建的绑定:

如果将该项目写成模块化程序,你会创建哪些模块?哪个模块会依赖于其他哪个模块,它们的接口是什么样的?

哪些模块可能已经在 NPM 上预写好了?你会选择使用 NPM 软件包还是自己编写?

道路模块

根据第 7 章中的示例编写一个 ES 模块,其中包含道路数组,并以 roadGraph 输出表示道路的图形数据结构。该模块依赖于 ./graph.js 模块,该模块导出用于构建图形的函数 buildGraph。该函数需要一个包含两个元素的数组(道路的起点和终点)。

循环依赖

循环依赖是指模块 A 依赖于模块 B,而模块 B 也直接或间接地依赖于模块 A。许多模块系统都禁止循环依赖,因为无论选择哪种顺序加载此类模块,都无法确保每个模块的依赖关系都已在运行前加载完毕。

CommonJS 模块允许有限形式的循环依赖。只要模块在加载完成之前不访问彼此的接口,循环依赖就没有问题。

本章前面给出的 require 函数支持这种类型的循环依赖。你能看出它是如何处理循环的吗?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值