模块化编程的目标是能够用不同作者和来源的代码模块组装成大型程序。实践中,模块化的作用主要体现在封装和隐藏私有实现细节,以及保证全局命名空间清洁上,因而模块之间不会意外修改各自定义的变量、函数和类。
直到几年前,JavaScript还没有内置对模块的支持。大型项目的程序员想方设法地利用类、对象和闭包的弱模块化能力。由于打包工具的支持,基于闭包的模块化在实践中成为常用模块化形式,核心是沿用了 Node 的 require()
函数。基于 require()
的模块是 Node 编程环境的基础,但并未被作为 JavaScript
语言的官方部分采用。
ES6 使用 import 和 export 关键字定义了自己的模块。尽管 import 和 export 在多年前就已经被列为这门语言的关键字,但直到最近才真正被浏览器和 Node 实现。实践中,JavaScript 的模块化仍然依赖代码打包工具。
基于类、对象和闭包的模块
类的一个重要特性,就是它们充当了自己方法的模块。不相关的类的方法之所以能够相互独立,是因为每个类的方法都被定义为独立原型对象的属性。而对象也可以充当模块:给一个 JavaScript 对象定义属性非常像声明变量,但给对象添加属性不影响程序的全局命名空间,也不影响其他对象的属性。
使用类和对象实现模块化是 JavaScript 编程中常见且有用的技术,但这还不够。特别地,类和对象没有提供任何方式来隐藏模块的内部实现细节。
在函数中声明的局部变量和嵌套函数都是函数私有的。这意味着我们可以使用立即调用的函数表达式来实现某种模块化,把实现细节和辅助函数隐藏在包装函数中,只将模块的公共 API 作为函数的值返回。
例如:
1 | const BitSet = (function() { |
如果需要暴露多个值,可以将其以对象的属性导出,例如:
1 | const module = (function() { |
基于闭包的自动模块化
在一个 JavaScript 代码文件开头和末尾插入一些文本,把它转换为类似的模块是一个相当机械的过程。这里所需要的就是对 JavaScript 代码文件设定一些规则,按照规则可以指定哪些值要导出,哪些值不导出。
可以想象有一个工具:
- 它能解析代码文件,把每个文件的内容包装在一个立即调用的函数表达式中
- 还可以跟踪每个函数的返回值,并将所有内容拼接为一个大文件
结果可能如下:
1 | const modules = {}; |
把所有模块都打包到类似上面的单个文件中之后,可以像下面这样写代码来使用它们:
1 | const stats = require("stats.js"); |
以上代码展示了针对浏览器的代码打包工具(如 webpack 和 Parcel)的基本工作原理,也是对 Node 程序中使用的 require() 函数的一个简单介绍。
Node 中的模块
编写 Node 程序时,可以随意将程序拆分到任意多个文件中。这些 JavaScript 代码文件被假定始终存在于一个快速文件系统中。与通过相对较慢的网络连接读取 JavaScript 文件的浏览器不同,把所有 Node 代码都写到一个 JavaScript 文件中既无必要也无益处。
在Node中,每个文件都是一个拥有私有命名空间的独立模块。在一个文件中定义的常量、变量、函数和类对该文件而言都是私有的,除非该文件会导出它们。而被模块导出的值只有被另一个模块显式导入后才会在该模块中可见。
Node 的导出
Node 定义了一个全局 exports 对象,这个对象始终有定义。如果要写一个导出多个值的 Node 模块,可以直接把这些值设置为 exports 对象的属性。
有时候只想模块导出一个函数或者类,只要把想导出的值直接赋给 module.exports
即可。module.exports
的默认值与 exports
引用的是同一个对象。
所以可以通过如下方式导出:
1 | exports.mean = function() {}; |
或者:
1 | module.exports.mean = function() {}; |
1 | const mean = function() {}; |
Node 的导入
Node 模块通过调用 require() 函数导入其他模块。这个函数的参数是要导入模块的名字,返回值是该模块导出的值(通常是一个函数、类或对象)。
- 如果想导入 Node 内置的系统模块或通过包管理器安装在系统上的模块,可以使用模块的非限定名,即不带会被解析为文本系统路径的
/
字符的模块名:
1 | const fs = require("fs"); |
- 如果想导入你自己代码中的模块,则模块名应该是指向包含模块代码的模块文件的路径(相对于当前模块文件)。通常使用相对路径:
1 | const stats = require("./stats.js"); |
- 虽然省略导入文件的
.js
后缀,Node 仍然可以找到这些文件,但包含这些文件扩展名还是很常见的
如果模块只导出一个函数或类,则只要调用 require()
取得返回值即可。如果模块导出一个带多个属性的对象,则有两个选择:一是导入整个对象;二是(通过解构赋值)只导入打算使用的特定属性:
1 | // 导入整个对象 |
在 Web 上使用 Node 风格的模块
基于 Exports 对象和 require() 函数的模块机制是内置于 Node 中的。如果使用 webpack 等打包工具来处理代码,也可以对浏览器中运行的代码使用这种风格的模块。目前,这种做法仍然非常常见,很多在浏览器上运行的代码都是这么做的。
JavaScript 有自己的标准模块语法,开发者即便使用打包工具,通常也会在自己的代码中使用基于 import 和 export 语句的官方 JavaScript 模块。
ES6 模块
ES6 为 JavaScript 添加了 import 和 export 关键字,终于将模块作为核心语言特性来支持了。ES6 模块化与 Node 的模块化在概念上是相同的:
- 每个文件本身都是模块,在文件中定义的常量、变量、函数和类对这个文件而言都是私有的,除非它们被显式导出
- 一个模块导出的值只有在显式导入它们的模块中才可以使用
ES6 模块与 Node 模块的区别在于导入和导出所用的语法,以及浏览器中定义模块的方式。
首先要注意,ES6 模块与常规 JavaScript 脚本
也有很多重要的区别:
- 在常规脚本中,顶级声明的变量、函数和类会进入被所有脚本共享的全局上下文。而在模块中,每个文件都有自己的私有上下文,可以使用 import 和 export 语句,当然这正是模块应有之义。
- ES6 模块中的代码(与 ES6 的 class 定义中的代码类似)自动应用严格模式
- ES6 模块甚至比严格模式还要更严格:
- 在严格模式下,在作为函数调用的函数中 this 是 undefined
- 而在模块中,即便在顶级代码中 this 也是 undefined(相对而言,浏览器和 Node 中的脚本都将 this 设置为全局对象)
在 webpack 等打包工具的帮助下,开发者早已开始在 Web 项目中使用模块了。打包工具负责把独立的 JavaScript 代码模块组合成一个大型非模块化的包,以便包含在网页中。但是 ES6 模块也基本得到了所有刘浏览器的原生支持,Node 13开始支持 ES6 模块,但目前绝大多数 Node 程序使用的仍然是 Node 模块。
ES6 的导出
要从 ES6 模块导出常量、变量、函数或类,只要在声明前加上 export 关键字即可:
1 | export const PI = 3.14; |
或者在一条 export 语句中导出多个值(注意,这里的花括号并不会定义对象字面量,这个导出语法仅仅是要求在一对花括号中给出一个逗号分隔的标识符列表):
1 | export { PI, sum }; |
一个模块只导出一个值(通常是一个函数或类)的情况是很常见的,此时通常可以使用 export default
而不是 export:
- 与非默认导出相比,默认导出(
export default
)在导入时稍微简单一些 - 使用
export
的常规导出只对有名字的声明有效 - 而使用
export default
的默认导出则可以导出任意表达式,包括匿名函数表达式和匿名类表达式 export default
只能有一个
要注意 export 关键字只能出现在 JavaScript 代码的顶层,不能在类、函数、循环或条件内部导出值(这是 ES6 模块系统的重要特性,用以支持静态分析:模块导出的值在每次运行时都相同,而导出的符号可以在模块实际运行前确定)
ES6 的导入
导入其他模块导出的值要使用 import 关键字。假设该模块提供了默认导出值,则导入时:
- 首先是 import 关键字,跟着一个标识符,再跟着一个 from 关键字,最后的字符串字面值是要导入模块的名字。,此时该模块的默认导出值就会成为当前模块中的指定标识符
1 | import BitSet from "./bitset.js"; |
- 获得导入值的标识符是一个常量,就像是使用 const 关键字声明的一样
- 与导出类似,导入也只能出现在模块顶层,不允许在类、函数、循环或条件中出现。
- 通常,一个模块所需的导入都应该放在这个模块的开头(不是强制的的,导入与函数声明类似,会被
提升
到顶部,因此所有导入的值在模块代码运行时都是可用的)
要导入的模块名以常量字符串字面量的形式在单引号或双引号中给出(不能使用变量或者其他值为字符串的表达式,也不能使用反引号,因为它支持变量插入)。在浏览器中,这个字符串会被解释为一个相对于导入模块位置的 URL。
- ES6 规范不允许类似
util.js
的非限定模块标识符字符串,因为它存在歧义:它是当前模块同级目录下的一个模块呢,还是安装在特殊位置的某个系统模块呢 - 如果想从当前模块的同级目录导入模块,只需要在模块名前面加上
./
,也就是使用./util.js
而非util.js
如果导出模块导出了多个值,要从导出多个值的模块导入值,就要使用稍微不一样的语法:
1 | import { mean, stddev } from "./stats.js"; |
非默认导出在导出它们的模块中则是有名字的,在导入这些值时,需要通过名字引用它们。花括号中的标识符都会被提升到导入模块顶部,行为类似常量。如下 import 语句可以导入所有模块的值:
1 | import * as stats from "./stats.js"; |
像这样一条 import 语句可以创建一个对象,并将其赋值给一个名为 stats 的常量。被导入模块的每个非默认导出都会变成这个 stats 对象的一个属性。
模块通常要么定义一个默认导出,要么定义多个命名导出。一个模块同时使用 export 和 export default 虽然合法,但并不常见。不过要是真有模块这么做,也可以只通过一条 import 语句同时导入默认值和命名值:
1 | import Histogram, {mean, stddev} from "./stats.js"; |
import 语句还有另一种形式,用于导入没有任何导出的模块。要在程序中包含没有导出的模块,只要在 import 关键字后面直接写出模块标识符即可:
1 | import `./analytics.js`; |
这样的模块会在被首次导入时运行一次(之后再导入时则什么也不做)。如果想在模块中运行一些代码,那么这种导入也是很有用的。虽然模块是自包含的,不需要导出任何值,但仍然需要通过 import 导入才能让它作为程序的一部分运行。
即使模块有导出,也可以使用该语法导入该模块,只是我们不需要它的任何导出值。
导入和导出时重命名
可以在命名导入时使用as关键字对导入值进行重命名:
1 | import { render as renderUI } from "./render.js"; |
导入模块在导入默认导出时始终需要选择一个名字,因此这种情况下不需要特殊语法。尽管如此,导入时重命名的机制也为同时定义了默认导出和命名导出的模块提供了另一种导入方式:它将 default 充当一个占位符,允许我们指明想导入模块的默认导出并为其提供一个名字。
1 | import { default as Histogram, mean, stddev } from "./stats.js"; |
导出值时也可以重命名,但仅限于使用 export 语句的花括号形式:
1 | export { |
需要牢记,虽然这里的花括号看起来像对象字面量,但其实并不是。而且,export 关键字需要 as 前面是一个标识符,而非表达式。
再导出
有时候我们会这样定义我们的模块:
1 | import { mean } from "./stats/mean.js"; |
ES6 模块预见到了这个使用场景,并为此提供了一种特殊语法。这种语法不需要先导入再导出,而是把导入和导出合二为一,通过组合 export 和 from 关键字构造一条 再导出
语句:
1 | export { mean } from "./stats/mean.js"; |
如果不需要选择性地再导出,而是希望导出另一个模块的所有命名值,则可以使用通配符:
1 | export * from "./stats/mean.js"; |
再导出语法允许使用 as 进行重命名,例如如果例子重新导出了 mean
,同时又为该函数定义了一个别名 average
:
1 | export { mean, mean as average } from "./stats/mean.js"; |
如果是要再导出其他模块的默认导出:
1 | export { default as mean } from "./stats/mean.js"; |
如果想将另一个模块的命名符号再导出为当前模块的默认导出,可以在 import 语句后面加一个 export default;或者,可以像下面这样组合这两个语句:
1 | export { mean as default } from "./stats/mean.js"; |
最后,要把另一个模块的默认导出再导出为当前模块的默认导出:
1 | export { default } from "./stats/mean.js"; |
在网页中使用 JavaScript 模块
尽管在线上部署时还要依赖打包工具,但鉴于目前浏览器对 JavaScript 模块的原生支持,开发期间它们已经不是必需的了。
因为模块代码必须与传统非模块代码以不同方式运行,所以必须修改 HTML 和 JavaScript 才能使用模块。如果想在浏览器中以原生方式使用 import 指令,必须通过 <script type="module">
标签告诉浏览器你的代码是一个模块。
ES6 模块的一个非常棒的特性是每个模块的导入都是静态的。因此只要有一个起始模块,浏览器就可以加载它导入的所有模块。使用 <script type="module">
标签定义模块化 JavaScript 程序的主入口可以像下面这样简单:
1 | <script type="module">import "./main.js";<script> |
带有 type="module"
属性的脚本会像带有 defer 属性的脚本一样被加载和执行:
- HTML 解析器一碰到
<script>
标签,就会开始加载代码 - 对于模块而言,加载代码可能是一个递归加载多个 JavaScript 文件的过程
- 代码执行则会推迟到 HTML 解析完成才开始。HTML 解析一完成,脚本(包括模块和非模块)就会按照它们在HTML 文档中出现的顺序执行
添加 async
属性可以改变执行模块代码的时机。这个属性会像对常规脚本一样对模块起作用:
- 添加了 async 属性的模块会在代码加载完毕后立即执行,而不管 HTML 解析是否完成,同时也有可能改变脚本执行的相对顺序
支持 <script type="module">
的浏览器必须也支持 <script nomodule>
:
- 支持模块的浏览器会忽略带有 nomodule 属性的脚本,不执行它们
- 不支持模块的浏览器因为不认识 nomodule 属性,所以会忽略这个属性的存在而运行其脚本
这样就为兼容旧版本浏览器提供了一个强大的技术:
- 支持 ES6 模块的浏览器也支持类、箭头函数和
for/of
循环等其他现代 JavaScript 特性 - 如果用
<script type="module">
来加载现代 JavaScript 代码,你就知道它只会在支持它的浏览器中运行 - 而为了向后兼容,可以使用 Babel 和 webpack 等工具把代码转换为非模块化的 ES5 代码,然后通过
<script nomodule>
来加载这些效率没那么高的转换代码
常规脚本与模块脚本的另一个重要区别涉及跨源加载。常规 <script>
标签可以从互联网上的任何服务器加载 JavaScript 代码文件,但 <script type="module">
增加了跨源加载的限制,即只能从包含模块的 HTML 文档所在的域加载模块,除非服务器添加了适当的 CORS 头部允许跨源加载。这个新的安全限制带来了一个副作用,就是不能在开发模式下使用 file:URL
来测试 ES6 模块。为此在使用 ES6 模块时,需要启动一个静态 Web 服务器来测试。
有些程序员喜欢使用扩展名 .mjs
来表示模块化 JavaScript 文件,使用 .js
扩展名表示的常规、非模块化 JavaScript 文件。对浏览器和 <script>
标签而言,文件扩展名其实无关紧要。Node 对 ES6 模块的支持则依赖文件扩展名,即要靠扩展名来区分要加载的文件使用了哪种模块系统。
通过 import() 动态导入
前面说到 ES6 的 import 和 export 指令都是静态的,因此 JavaScript 解释器和其他 JavaScript 工具可以通过简单的文本分析确定加载之后模块之间的关系,而不必实际执行模块代码。静态导入的模块可以保证导入的值在任何模块代码运行之前就可以使用。
静态模块导入需要先加载完全部程序再执行。但是对于 Web 应用来说,先加载足够的代码用于渲染用户可见的第一个页面是很常见的。这样,当用户有了可以交互的预备内容后,可以再开始加载 Web 应用所需的其他代码。
使用浏览器提供的 DOM API 向当前 HTML 文档注入新 <script>
标签可以方便地动态加载代码,Web 应用在很多年前就已经开始这么做了。虽然浏览器很早就可以动态加载脚本了,但 JavaScript 语言本身却一直不支持动态导入。
随着 ES2020 引入 import(),这个局面终于被扭转了。传给 import()
一个模块标识符,它就会返回一个期约对象,表示加载和运行指定模块的异步过程。动态导入完成后,这个期约会 兑现
并产生一个对象,与使用静态导入语句 import * as
得到的对象类似。
传给 import()
的参数应该是一个模块标识符,对于 import()
,则没有使用常量字符串字面量的限制。换句话说,任何表达式只要可以求值为一个字符串且格式正确,就没问题。import()
是一个操作符,而圆括号则是这个操作符语法必需的部分。
要注意动态 import()
不仅在浏览器中有,webpack 等打包工具也在积极地利用它。使用打包工具最简单的方式是告诉它程序的主入口,让它找到所有静态 import 指令并把所有代码汇总为一个大文件。而通过有意识地使用动态 import()
调用,可以把这样一个大文件拆分成多个小文件,实现按需加载。
import.meta.url
在 ES6 模块(而非常规 <script>
或通过 require()
加载的 Node 模块)中,import.meta
这个特殊语法引用一个对象,这个对象包含当前执行模块的元数据。其中,这个对象的 url 属性是加载模块时使用的 URL(Node 中是 file://URL
)。
import.meta.url
的主要使用场景是引用与模块位于同一(或相对)目录下的图片、数据文件或其他资源。使用 URL()
构造函数可以非常方便地相对于 import.meta.url
这样的绝对 URL 来解析相对 URL。
1 | function localStringURL(locale) { |