来源: ApacheCN『JavaScript 编程精解 中文第三版』翻译项目原文:Modulesjavascript
译者:飞龙html
协议:CC BY-NC-SA 4.0java
自豪地采用谷歌翻译node
编写易于删除,而不是易于扩展的代码。git
Tef,《Programming is Terrible》程序员
理想的程序拥有清晰的结构。 它的工做方式很容易解释,每一个部分都起到明确的做用。github
典型的真实程序会有机地增加。 新功能随着新需求的出现而增长。 构建和维护结构是额外的工做,只有在下一次有人参与该计划时,才会获得回报。 因此它易于忽视,并让程序的各个部分变得深深地纠缠在一块儿。算法
这致使了两个实际问题。 首先,这样的系统难以理解。 若是一切均可以接触到一切其它东西,那么很难单独观察任何给定的片断。 你不得不全面理解整个东西。 其次,若是你想在另外一个场景中,使用这种程序的任何功能,比起试图从它的上下文中将它分离出来,重写它可能要容易。apache
术语“大泥球”一般用于这种大型,无结构的程序。 一切都粘在一块儿,当你试图挑选出一段代码时,整个东西就会分崩离析,你的手会变脏。npm
模块试图避免这些问题。 模块是一个程序片断,规定了它依赖的其余部分,以及它为其余模块提供的功能(它的接口)。
模块接口与对象接口有许多共同之处,咱们在第 6 章中看到。它们向外部世界提供模块的一部分,并使其他部分保持私有。 经过限制模块彼此交互的方式,系统变得更像积木,其中的组件经过明肯定义的链接器进行交互,而不像泥浆同样,一切都混在一块儿。
模块之间的关系称为依赖关系。 当一个模块须要另外一个模块的片断时,就说它依赖于这个模块。 当模块中明确规定了这个事实时,它能够用于肯定,须要哪些其余模块才能使用给定的模块,并自动加载依赖关系。
为了以这种方式分离模块,每一个模块须要它本身的私有做用域。
将你的 JavaScript 代码放入不一样的文件,不能知足这些要求。 这些文件仍然共享相同的全局命名空间。 他们能够有意或无心干扰彼此的绑定。 依赖性结构仍不清楚。 咱们将在本章后面看到,咱们能够作得更好。
合适的模块结构可能难觉得程序设计。 在你还在探索这个问题的阶段,尝试不一样的事情来看看什么是可行的,你可能不想过多担忧它,由于这可能让你分心。 一旦你有一些感受可靠的东西,如今是后退一步并组织它的好时机。
从单独的片断中构建一个程序,并实际上可以独立运行这些片断的一个优势是,你可能可以在不一样的程序中应用相同的部分。
但如何实现呢? 假设我想在另外一个程序中使用第 9 章中的parseINI
函数。 若是清楚该函数依赖什么(在这种状况下什么都没有),我能够将全部必要的代码复制到个人新项目中并使用它。 可是,若是我在代码中发现错误,我可能会在当时正在使用的任何程序中将其修复,并忘记在其余程序中修复它。
一旦你开始复制代码,你很快就会发现,本身在浪费时间和精力来处处复制并使他们保持最新。
这就是包的登场时机。包是可分发(复制和安装)的一大块代码。 它可能包含一个或多个模块,而且具备关于它依赖于哪些其余包的信息。 一个包一般还附带说明它作什么的文档,以便那些不编写它的人仍然可使用它。
在包中发现问题或添加新功能时,会将包更新。 如今依赖它的程序(也多是包)能够升级到新版本。
以这种方式工做须要基础设施。 咱们须要一个地方来存储和查找包,以及一个便利方式来安装和升级它们。 在 JavaScript 世界中,这个基础结构由 NPM 提供。
NPM 是两个东西:可下载(和上传)包的在线服务,以及可帮助你安装和管理它们的程序(与 Node.js 捆绑在一块儿)。
在撰写本文时,NPM 上有超过 50 万个不一样的包。 其中很大一部分是垃圾,我应该提一下,但几乎全部有用的公开包均可以在那里找到。 例如,一个 INI 文件解析器,相似于咱们在第 9 章中构建的那个,能够在包名称ini
下找到。
第 20 章将介绍如何使用npm
命令行程序在局部安装这些包。
使优质的包可供下载是很是有价值的。 这意味着咱们一般能够避免从新建立一百人以前写过的程序,并在按下几个键时获得一个可靠,充分测试的实现。
软件的复制很便宜,因此一旦有人编写它,分发给其余人是一个高效的过程。但首先把它写出来是工做量,回应在代码中发现问题的人,或者想要提出新功能的人,是更大的工做量。
默认状况下,你拥有你编写的代码的版权,其余人只有通过你的许可才能使用它。可是由于有些人不错,并且因为发布好的软件可使你在程序员中出名,因此许多包都会在许可证下发布,明确容许其余人使用它。
NPM 上的大多数代码都以这种方式受权。某些许可证要求你还要在相同许可证下发布基于那个包构建的代码。其余要求不高,只是要求在分发代码时保留许可证。 JavaScript 社区主要使用后一种许可证。使用其余人的包时,请确保你留意了他们的许可证。
2015 年以前,JavaScript 语言没有内置的模块系统。 然而,尽管人们已经用 JavaScript 构建了十多年的大型系统,他们须要模块。
因此他们在语言之上设计了本身的模块系统。 你可使用 JavaScript 函数建立局部做用域,并使用对象来表示模块接口。
这是一个模块,用于日期名称和数字之间的转换(由Date
的getDay
方法返回)。 它的接口由weekDay.name
和weekDay.number
组成,它将局部绑定名称隐藏在当即调用的函数表达式的做用域内。
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
这种风格的模块在必定程度上提供了隔离,但它不声明依赖关系。 相反,它只是将其接口放入全局范围,并但愿它的依赖关系(若是有的话)也这样作。 很长时间以来,这是 Web 编程中使用的主要方法,但如今它几乎已通过时。
若是咱们想让依赖关系成为代码的一部分,咱们必须控制依赖关系的加载。 实现它须要可以将字符串执行为代码。 JavaScript 能够作到这一点。
有几种方法能够将数据(代码的字符串)做为当前程序的一部分运行。
最明显的方法是特殊运算符eval
,它将在当前做用域内执行一个字符串。 这一般是一个坏主意,由于它破坏了做用域一般拥有的一些属性,好比易于预测给定名称所引用的绑定。
const x = 1; function evalAndReturnX(code) { eval(code); return x; } console.log(evalAndReturnX("var x = 2")); // → 2 console.log(x); // → 1
将数据解释为代码的不太可怕的方法,是使用Function
构造器。 它有两个参数:一个包含逗号分隔的参数名称列表的字符串,和一个包含函数体的字符串。 它将代码封装在一个函数值中,以便它得到本身的做用域,而且不会对其余做用域作出奇怪的事情。
let plusOne = Function("n", "return n + 1;"); console.log(plusOne(4)); // → 5
这正是咱们须要的模块系统。 咱们能够将模块的代码包装在一个函数中,并将该函数的做用域用做模块做用域。
用于链接 JavaScript 模块的最普遍的方法称为 CommonJS 模块。 Node.js 使用它,而且是 NPM 上大多数包使用的系统。
CommonJS 模块的主要概念是称为require
的函数。 当你使用依赖项的模块名称调用这个函数时,它会确保该模块已加载并返回其接口。
因为加载器将模块代码封装在一个函数中,模块自动获得它们本身的局部做用域。 他们所要作的就是,调用require
来访问它们的依赖关系,并将它们的接口放在绑定到exports
的对象中。
此示例模块提供了日期格式化功能。 它使用 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()]; }); };
ordinal
的接口是单个函数,而date-names
导出包含多个东西的对象 - days
和months
是名称数组。 为导入的接口建立绑定时,解构是很是方便的。
该模块将其接口函数添加到exports
,以便依赖它的模块能够访问它。 咱们能够像这样使用模块:
const {formatDate} = require("./format-date"); console.log(formatDate(new Date(2017, 9, 13), "dddd the Do")); // → Friday the 13th
咱们能够用最简单的形式定义require
,以下所示:
require.cache = Object.create(null); function require(name) { if (!(name in require.cache)) { let code = readFile(name); let module = {exports: {}}; require.cache[name] = module; let wrapper = Function("require, exports, module", code); wrapper(require, module.exports, module); } return require.cache[name].exports; }
在这段代码中,readFile
是一个构造函数,它读取一个文件并将其内容做为字符串返回。标准的 JavaScript 没有提供这样的功能,可是不一样的 JavaScript 环境(如浏览器和 Node.js)提供了本身的访问文件的方式。这个例子只是假设readFile
存在。
为了不屡次加载相同的模块,require
须要保存(缓存)已经加载的模块。被调用时,它首先检查所请求的模块是否已加载,若是没有,则加载它。这涉及到读取模块的代码,将其包装在一个函数中,而后调用它。
咱们以前看到的ordinal
包的接口不是一个对象,而是一个函数。 CommonJS 模块的特色是,尽管模块系统会为你建立一个空的接口对象(绑定到exports
),但你能够经过覆盖module.exports
来替换它。许多模块都这么作,以便导出单个值而不是接口对象。
经过将require
,exports
和module
定义为生成的包装函数的参数(并在调用它时传递适当的值),加载器确保这些绑定在模块的做用域中可用。
提供给require
的字符串翻译为实际的文件名或网址的方式,在不一样系统有所不一样。 当它以"./"
或"../"
开头时,它一般被解释为相对于当前模块的文件名。 因此"./format-date"
就是在同一个目录中,名为format-date.js
的文件。
当名称不是相对的时,Node.js 将按照该名称查找已安装的包。 在本章的示例代码中,咱们将把这些名称解释为 NPM 包的引用。 咱们将在第 20 章详细介绍如何安装和使用 NPM 模块。
如今,咱们不用编写本身的 INI 文件解析器,而是使用 NPM 中的某个:
const {parse} = require("ini"); console.log(parse("x = 10\ny = 20")); // → {x: "10", y: "20"}
CommonJS 模块很好用,而且与 NPM 一块儿,使 JavaScript 社区开始大规模共享代码。
但他们仍然是个简单粗暴的黑魔法。 例如,表示法有点笨拙 - 添加到exports
的内容在局部做用域中不可用。 并且由于require
是一个正常的函数调用,接受任何类型的参数,而不只仅是字符串字面值,因此在不运行代码就很难肯定模块的依赖关系。
这就是 2015 年的 JavaScript 标准引入了本身的不一样模块系统的缘由。 它一般被称为 ES 模块,其中 ES 表明 ECMAScript。 依赖和接口的主要概念保持不变,但细节不一样。 首先,表示法如今已整合到该语言中。 你不用调用函数来访问依赖关系,而是使用特殊的import
关键字。
import ordinal from "ordinal"; import {days, months} from "date-names"; export function formatDate(date, format) { /* ... */ }
一样,export
关键字用于导出东西。 它能够出如今函数,类或绑定定义(let
,const
或var
)的前面。
ES 模块的接口不是单个值,而是一组命名绑定。 前面的模块将formatDate
绑定到一个函数。 从另外一个模块导入时,导入绑定而不是值,这意味着导出模块能够随时更改绑定的值,导入它的模块将看到其新值。
当有一个名为default
的绑定时,它将被视为模块的主要导出值。 若是你在示例中导入了一个相似于ordinal
的模块,而没有绑定名称周围的大括号,则会得到其默认绑定。 除了默认绑定以外,这些模块仍然能够以不一样名称导出其余绑定。
为了建立默认导出,能够在表达式,函数声明或类声明以前编写export default
。
export default ["Winter", "Spring", "Summer", "Autumn"];
可使用单词as
重命名导入的绑定。
import {days as dayNames} from "date-names"; console.log(dayNames.length); // → 7
另外一个重要的区别是,ES 模块的导入发生在模块的脚本开始运行以前。 这意味着import
声明可能不会出如今函数或块中,而且依赖项的名称只能是带引号的字符串,而不是任意的表达式。
在撰写本文时,JavaScript 社区正在采用这种模块风格。 但这是一个缓慢的过程。 在规定格式以后,花了几年的时间,浏览器和 Node.js 才开始支持它。 虽然他们如今几乎都支持它,但这种支持仍然存在问题,这些模块如何经过 NPM 分发的讨论仍在进行中。
许多项目使用 ES 模块编写,而后在发布时自动转换为其余格式。 咱们正处于并行使用两个不一样模块系统的过渡时期,而且可以读写任何一种之中的代码都颇有用。
事实上,从技术上来讲,许多 JavaScript 项目都不是用 JavaScript 编写的。有一些扩展被普遍使用,例如第 8 章中提到的类型检查方言。好久之前,在语言的某个计划性扩展添加到实际运行 JavaScript 的平台以前,人们就开始使用它了。
为此,他们编译他们的代码,将其从他们选择的 JavaScript 方言翻译成普通的旧式 JavaScript,甚至是过去的 JavaScript 版本,以便旧版浏览器能够运行它。
在网页中包含由 200 个不一样文件组成的模块化程序,会产生它本身的问题。若是经过网络获取单个文件须要 50 毫秒,则加载整个程序须要 10 秒,或者若是能够同时加载多个文件,则可能须要一半。这浪费了不少时间。由于抓取一个大文件每每比抓取不少小文件要快,因此 Web 程序员已经开始使用工具,将它们发布到 Web 以前,将他们(费力分割成模块)的程序回滚成单个大文件。这些工具被称为打包器。
咱们能够再深刻一点。 除了文件的数量以外,文件的大小也决定了它们能够经过网络传输的速度。 所以,JavaScript 社区发明了压缩器。 经过自动删除注释和空白,重命名绑定以及用占用更少空间的等效代码替换代码段,这些工具使 JavaScript 程序变得更小。
所以,你在 NPM 包中找到的代码,或运行在网页上的代码,经历了多个转换阶段 - 从现代 JavaScript 转换为历史 JavaScript,从 ES 模块格式转换为 CommonJS,打包并压缩。 咱们不会在本书中详细介绍这些工具,由于它们每每很无聊,而且变化很快。 请注意,你运行的 JavaScript 代码一般不是编写的代码。
使程序结构化是编程的一个微妙的方面。 任何有价值的功能均可以用各类方式建模。
良好的程序设计是主观的 - 涉及到权衡和品味问题。 了解结构良好的设计的价值的最好方法,是阅读或处理大量程序,并注意哪些是有效的,哪些不是。 不要认为一个痛苦的混乱就是“它原本的方式”。 经过多加思考,你能够改善几乎全部事物的结构。
模块设计的一个方面是易用性。 若是你正在设计一些旨在由多人使用,或者甚至是你本身的东西,在三个月以内,当你记不住你所作的细节时,若是你的接口简单且可预测,这会有所帮助。
这可能意味着遵循现有的惯例。 ini
包是一个很好的例子。 此模块模仿标准 JSON 对象,经过提供parse
和stringify
(用于编写 INI 文件)函数,就像 JSON 同样,在字符串和普通对象之间进行转换。 因此接口很小且很熟悉,在你使用过一次后,你可能会记得如何使用它。
即便没有能模仿的标准函数或普遍使用的包,你也能够经过使用简单的数据结构,并执行单一的重点事项,来保持模块的可预测性。 例如,NPM 上的许多 INI 文件解析模块,提供了直接从硬盘读取文件并解析它的功能。 这使得在浏览器中不可能使用这些模块,由于咱们没有文件系统的直接访问权,而且增长了复杂性,经过组合模块与某些文件读取功能,能够更好地解决它。
这指向了模块设计的另外一个有用的方面 - 一些代码能够轻易与其余代码组合。比起执行带有反作用的复杂操做的更大的模块,计算值的核心模块适用于范围更广的程序。坚持从磁盘读取文件的 INI 文件读取器, 在文件内容来自其余来源的场景中是无用的。
与之相关,有状态的对象有时甚至是有用的,可是若是某件事能够用一个函数完成,就用一个函数。 NPM 上的几个 INI 文件读取器提供了一种接口风格,须要你先建立一个对象,而后将该文件加载到对象中,最后使用特定方法来获取结果。这种类型的东西在面向对象的传统中很常见,并且很糟糕。你不能调用单个函数来完成,你必须执行仪式,在各类状态中移动对象。并且因为数据如今封装在一个特定的对象类型中,与它交互的全部代码都必须知道该类型,从而产生没必要要的相互依赖关系。
一般,定义新的数据结构是不可避免的 - 只有少数很是基本的数据结构由语言标准提供,而且许多类型的数据必定比数组或映射更复杂。 可是当数组足够时,使用数组。
一个稍微复杂的数据结构的示例是第 7 章的图。JavaScript 中没有一种明显的表示图的方式。 在那一章中,咱们使用了一个对象,其属性保存了字符串数组 - 能够从某个节点到达的其余节点。
NPM 上有几种不一样的寻路包,但他们都没有使用这种图的格式。 它们一般容许图的边带有权重,它是与其相关的成本或距离,这在咱们的表示中是不可能的。
例如,存在dijkstrajs
包。 一种著名的寻路方法,与咱们的findRoute
函数很是类似,它被称为迪科斯特拉(Dijkstra)算法,以首先编写它的艾兹格尔·迪科斯特拉(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"]
这多是组合的障碍 - 当各类包使用不一样的数据结构来描述相似的事情时,将它们组合起来很困难。 所以,若是你想要设计可组合性,请查找其余人使用的数据结构,并在可能的状况下遵循他们的示例。
经过将代码分离成具备清晰接口和依赖关系的块,模块是更大的程序结构。 接口是模块中能够从其余模块看到的部分,依赖关系是它使用的其余模块。
因为 JavaScript 历史上并无提供模块系统,所以 CommonJS 系统创建在它之上。 而后在某个时候,它确实有了一个内置系统,它如今与 CommonJS 系统不兼容。
包是能够自行分发的一段代码。 NPM 是 JavaScript 包的仓库。 你能够从上面下载各类有用的(和无用的)包。
这些是第 7 章的项目所建立的约束:
roads buildGraph roadGraph VillageState runRobot randomPick randomRobot mailRoute routeRobot findRoute goalOrientedRobot
若是你要将该项目编写为模块化程序,你会建立哪些模块? 哪一个模块依赖于哪一个模块,以及它们的接口是什么样的?
哪些片断可能在 NPM 上找到? 你愿意使用 NPM 包仍是本身编写?
roads
模块根据第 7 章中的示例编写 CommonJS 模块,该模块包含道路数组,并将表示它们的图数据结构导出为roadGraph
。 它应该依赖于一个模块./graph
,它导出一个函数buildGraph
,用于构建图。 该函数接受包含两个元素的数组(道路的起点和终点)。
// Add dependencies and exports 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" ];
循环依赖是一种状况,其中模块 A 依赖于 B,而且 B 也直接或间接依赖于 A。许多模块系统彻底禁止这种状况,由于不管你选择何种顺序来加载此类模块,都没法确保每一个模块的依赖关系在它运行以前加载。
CommonJS 模块容许有限形式的循环依赖。 只要这些模块不会替换它们的默认exports
对象,而且在完成加载以后才能访问对方的接口,循环依赖就没有问题。
本章前面给出的require
函数支持这种类型的循环依赖。 你能看到它如何处理循环吗? 当一个循环中的某个模块替代其默认exports
对象时,会出现什么问题?