JavaScript 模块
随着我们的应用越来越大需要将其拆分成多个文件,即所谓的模块 module,一个模块 module 就是一个文件/脚本,通常包含一个类或一个函数库。语言级的模块系统在 2015 年的时候出现在了标准(ES6)中,此后逐渐发展,现在已经得到了所有主流浏览器和 Node.js 的支持。
模块使用命令 export
和 import
来实现代码的交换功能
export
关键字标记了从当前模块外部可访问的变量和函数。import
关键字标记从其他模块导入的变量和函数。
导出
在声明之前放置 export
来标记任意声明(变量、函数、类)为导出。
// 导出数组
export let months = ['Jan', 'Feb', 'Mar','Apr', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// 导出 const 声明的变量
export const MODULES_BECAME_STANDARD_YEAR = 2015;
// 导出类
export class User {
constructor(name) {
this.name = name;
}
}
Tip
导出 class/function
后没有分号,这是因为在类或者函数前的 export
不会让它们变成 函数表达式,尽管被导出了但它仍然是一个类/函数声明。
重命名导出
也可以将导出和(变量或对象)声明分开,先声明函数然后再导出它们(从技术上讲也可以把 export
语句放置在函数声明之前),通过这种方式导出时可以为变量重命名,使用关键字 as
,称为命名的导出,需要使用花括号 {}
将这些导出的变量或对象的名称包括起来,一般用于「批量」导出或为导出「重命名」。
function sayHi(user) {
alert(`Hello, ${user}!`);
}
function sayBye(user) {
alert(`Bye, ${user}!`);
}
// 重命名导出
export {sayHi as hi, sayBye as bye}; // 导出变量列表
import {hi, bye} from './say.js';
// 导入后使用的名称是重命名后的名称
hi('John'); // Hello, John!
bye('John'); // Bye, John!
默认的导出
在实际中,主要有两种模块
- 包含库或函数包的模块,像上面的
say.js
。 - 声明单个实体的模块,例如模块
user.js
仅导出class User
。
大部分情况下,开发者倾向于使用第二种方式,以便每个实体都存在于它自己的模块中(这需要大量文件,因为每个东西都需要自己的模块,如果文件具有良好的命名,即导入的变量应与文件名相对应,并且文件夹结构得当,那么代码导航 navigation 会变得更容易)。模块针对这种一个模块只含一个实体的代码导出提供了特殊的默认导出 export default
语法,使得导出语法更灵活,在导入默认导出时不需要花括号。
// 默认导出
export default class User {
constructor(name) {
this.name = name;
}
}
import User from './user.js'; // 不需要花括号
new User('John');
Warning
每个文件可能只有一个 export default
,因此默认的导出允许可以没有名称。
export default class { // 没有类名
constructor() { ... }
}
export default function(user) { // 没有函数名
alert(`Hello, ${user}!`);
}
// 导出单个值,而不使用变量
export default ['Jan', 'Feb', 'Mar','Apr', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
从技术上讲,我们可以在一个模块中同时有默认的导出和命名的导出,但是实际上人们通常不会混合使用它们。模块要么是命名的导出要么是默认的导出。
// 默认的导出
export default class User {
constructor(name) {
this.name = name;
}
}
// 命名的导出
export function sayHi(user) {
alert(`Hello, ${user}!`);
}
// 导入默认的导出和命名的导出
import {default as User, sayHi} from './user.js';
new User('John');
Tip
命名的导出会明确地命名它们要导出的内容,它强制我们使用正确的名称进行导入;而对于默认的导出则可以在导入时选择名称(相当于在导入默认导出时可以同时进行「重命名」)
// 导入 user.js 默认导出
import User from './user.js'; // 有效
import MyUser from './user.js'; // 也有效
// 使用任何名称导入都没有问题
命名的导出 | 默认的导出 |
---|---|
export class User {...} | export default class User {...} |
import {User} from ... | import User from ... |
Tip
虽然在导入默认的导出时允许取任意的变量名,但推荐遵循导入的变量应与文件名相对应原则使代码保持一致
import User from './user.js';
import LoginForm from './loginForm.js';
import func from '/path/to/func.js';
// ...
默认导出也支持将声明和导出语句分离,default
关键词被用于引用默认的导出,如在导入时使用字符 *
创建一个对象包含所有内容的对象时,对象的属性 .default
就是引用默认的导出。
// 导出
function sayHi(user) {
alert(`Hello, ${user}!`);
}
export {sayHi as default};
// 导入全部
import * as user from './user.js';
let User = user.default; // 引用默认的导出
new User('John');
导入
使用关键字 import
导入,并将需要导入的东西列在花括号里 {...}
(对于命名的导出而言)
// 导出
function sayHi(user) {
alert(`Hello, ${user}!`);
}
function sayBye(user) {
alert(`Bye, ${user}!`);
}
export {sayHi, sayBye}; // 导出变量列表
// 导入
import {sayHi, sayBye} from './say.js';
sayHi('John'); // Hello, John!
sayBye('John'); // Bye, John!
重命名导入
可以使用 as
让导入具有不同的名字。
// 重命名
import {sayHi as hi, sayBye as bye} from './say.js';
hi('John'); // Hello, John!
bye('John'); // Bye, John!
导入全部
如果有很多要导入的内容可以使用 import * as <obj>
将所有内容(模块支持导出的内容)导入为一个对象,再通过调用该对象的属性来使用相应的代码
import * as say from './say.js';
say.sayHi('John');
say.sayBye('John');
Warning
虽然「通通导入」看起来很方便,但是通常依然推荐要明确列出我们需要导入的内容,因为现代的构建工具(webpack 和其他工具)将模块打包到一起并对其进行优化,如果分别明确列出了所导入的函数,这就允许优化器 optimizer 检测到它,而那些导出却未被使用的函数可以被删除,从而使构建更小以加快加载速度,这就是所谓的「摇树」tree-shaking。此外明确列出要导入的内容会使得名称较短,如「重命名」为 sayHi()
而不是通过对象作为「中介」来访问函数 say.sayHi()
。另外导入的显式列表可以更好地概述代码结构(使用的内容和代码所在的模块位置),它使得代码重构起来更容易。
重新导出
重新导出 Re-export 语法 export ... from ...
允许在脚本中导入模块内容并立即将其导出,主要的实际使用场景是我们常常有一个 package,一个包含大量模块的文件夹,其中一些功能是导出到外部的,但有一些模块仅仅是供 package 中其他模块(内部)使用的 helpers,开发者应该避免搜索/操作 package 文件夹中的文件,干预其内部结构。
// 文件结构可能是这样的
auth/
index.js
user.js
helpers.js
tests/
login.js
providers/
github.js
facebook.js
...
可以通过单个入口,如主文件 auth/index.js
,访问/导入 package 文件夹中可供开发者使用的模块,并再将其「重新导出」,它作为一个模块访问的「中介」,而其他内部的模块保持「不可见」。
// 导入 login/logout 然后立即导出它们
export {login, logout} from './helpers.js';
// 将默认导出导入为 User,然后导出它
export {default as User} from './user.js';
// ...
Warning
在重新导出时,默认导出也需要使用花括号 {}
包括;如果使用字符 *
重新导出模块的所有导出时,会忽略默认的导出,默认导出需要单独重新导出。
export * from './user.js'; // 重新导出命名的导出
export {default} from './user.js'; // 重新导出默认的导出
导入导出总结
导出
- 在声明一个 class/function/… 之前:
export [default] class/function/variable ...
- 独立的导出:
export {x [as y], ...}
.
- 重新导出:
export {x [as y], ...} from "module"
export * from "module"
(不会重新导出默认的导出)。export {default [as y]} from "module"
(重新导出默认的导出)。
导入
- 模块中命名的导出:
import {x [as y], ...} from "module"
- 默认的导出:
import x from "module"
import {default as x} from "module"
- 所有:
import * as obj from "module"
- 导入模块(它的代码,并运行),但不要将其赋值给变量:
import "module"
Tip
我们把 import/export
语句放在脚本的顶部或底部,都没关系;但请注意在 {...}
中的 import/export 语句无效。
模块的核心功能
与一般的脚本相比,模块具有特定的功能
始终使用 “use strict”
模块始终默认使用 use strict
,如对一个未声明的变量赋值将产生错误
模块级作用域
每个模块都有自己的独立顶级作用域 top-level scope,即一个模块中的顶级作用域变量和函数在其他脚本中是不可见的。
<script type="module">
// 变量仅在这个 module script 内可见
let user = "John";
</script>
<script type="module">
alert(user); // Error: user is not defined
</script>
Tip
如果需要创建一个 window-level 的全局变量,可以在模块中将其明确地赋值给 window
,并以 window.user
来访问它。但是这需要你有足够充分的理由,否则就不要这样做。
模块代码仅在第一次导入时被解析
如果同一个模块被导入到多个其他位置,那么它的代码仅会在第一次导入时被解析(执行),然后将导出 export 的内容(结果)共享给所有的导入 importer,即如果某个地方修改了模块的导出内容,其他的引用了该模块的代码也能看到这个修改。
// 导出
alert("Module is evaluated!");
// 在不同的文件中导入相同的模块
// 📁 1.js
import `./alert.js`; // Module is evaluated!
// 📁 2.js
import `./alert.js`; // (什么都不显示,因为多次导入时,模块只解析/执行一次)
Tip
模块代码仅在第一次导入时被解析,多次导入都是共享第一次解析的结果,这种行为让我们可以在首次导入时 设置 模块,只需要设置其属性一次,然后在进一步的导入中就都可以直接使用了。
// 导出
export let admin = {
name: "John"
};
// 导入
import {admin} from './admin.js';
admin.name = "Pete";
// 另一导入
import {admin} from './admin.js';
alert(admin.name); // Pete
Warning
由于 1.js
和 2.js
导入的是同一个对象,因此在 1.js
中对于对象做的更改,在 2.js
中也是可见的
import meta
import.meta
对象包含关于当前模块的信息,它的内容取决于其所在的环境。在浏览器环境中,它包含当前脚本的 URL;如果它是在 HTML 中的话,则包含当前页面的 URL。
<script type="module">
alert(import.meta.url); // 脚本的 URL(对于内嵌脚本来说,则是当前 HTML 页面的 URL)
</script>
模块的 this
非模块脚本进行比较会发现,非模块脚本的顶级 this
是全局对象;但在一个模块中,顶级 this
是 undefined
<script>
alert(this); // window
</script>
<script type="module">
alert(this); // undefined
</script>
模块在浏览器的特定功能
与常规脚本相比,拥有 type="module"
标识的脚本有一些特定于浏览器的差异。
模块脚本是延迟的
模块脚本 总是 被延迟的,即模块脚本会等到 HTML 文档完全准备就绪(即使它们很小并且比 HTML 加载速度更快)然后才会运行,因此下载外部模块脚本 <script type="module" src="...">
不会阻塞 HTML 的处理,它们会与其他资源并行下载;而且保持脚本的相对顺序,在文档中排在前面的模块脚本先执行。
Tip
模块脚本的加载顺序,使得它总是会「看到」已完全加载的 HTML 页面,包括在它们下方的 HTML 元素。
<script type="module">
alert(typeof button); // object:脚本可以「看见」下面的 button
// 因为模块是被延迟的(deferred0,所以模块脚本会在整个页面加载完成后才运行
</script>
<!-- 相较于下面这个常规脚本:-->
<script>
alert(typeof button); // Error: button is undefined,脚本看不到下面的元素
// 常规脚本会立即运行,常规脚本的运行是在在处理页面的其余部分之前进行的
</script>
<button id="button">Button</button>
Warning
由此造成它的一个副作用是用户可能会在 JavaScript 应用程序准备好之前看到该页面,某些功能那时可能还无法正使用,应该放置 加载指示器 loading indicator,如在脚本加载完成前为页面添加一层半透明浅灰色的遮罩,否则会使用户感到困惑。
Async 适用于内联脚本
异步脚本会在准备好后立即运行,独立于其他脚本或 HTML 文档,常常见于广告,文档级事件监听器脚本。对于非模块脚本 async
特性 attribute 仅适用于外部脚本;而对于模块脚本,它也适用于内联脚本。
<!-- 模块中所有依赖(analytics.js)都获取完成然后脚本开始运行 -->
<!-- 不会等待 HTML 文档或者其他 <script> 标签 -->
<script async type="module">
import {counter} from './analytics.js';
counter.count();
</script>
外部脚本
具有 type="module"
的外部脚本 external script 在两个方面有所不同
- 具有相同
src
的外部脚本仅运行一次html<!-- 脚本 my.js 被加载完成(fetched)并只被运行一次 --> <script type="module" src="my.js"></script> <script type="module" src="my.js"></script>
- 从另一个源(例如另一个网站)获取的外部脚本需要 CORS header,即如果一个模块脚本是从另一个源获取的,则远程服务器必须提供表示允许获取的 header
Access-Control-Allow-Origin
。html<!-- another-site.com 必须提供 Access-Control-Allow-Origin --> <!-- 否则,脚本将无法执行 --> <script type="module" src="http://another-site.com/their.js"></script>
不允许裸模块
在浏览器中,import
必须给出相对或绝对的 URL 路径(没有任何路径的模块被称为「裸模块」bare module。在 import
中不允许这种模块。
import {sayHi} from 'sayHi'; // Error,裸模块
// 模块必须有一个路径,例如 './sayHi.js' 或者其他任何路径
Tip
某些环境,像 Node.js 或者打包工具(bundle tool)允许没有任何路径的裸模块,因为它们有自己的查找模块的方法和钩子(hook)来对它们进行微调。但是浏览器尚不支持裸模块。
兼容性 nomodule
旧时的浏览器不理解 type="module"
未知类型的脚本会被忽略,可以使用 nomodule
特性来提供一个后备。
<script type="module">
alert("Runs in modern browsers");
</script>
<script nomodule>
alert("Modern browsers know both type=module and nomodule, so skip this")
alert("Old browsers ignore script with unknown type=module, but execute this.");
</script>
构建工具
在实际开发中,浏览器模块很少被以「原始」形式进行使用。通常会使用一些特殊工具,如 Webpack,将它们打包在一起,然后部署到生产环境的服务器。
使用打包工具的一个好处是它们可以更好地控制模块的解析方式,允许我们使用裸模块和更多的功能,如 CSS/HTML 模块等。
构建工具做以下这些事儿:
- 从一个打算放在 HTML 中的
<script type="module">
「主」模块开始。 - 分析它的依赖,它的导入,以及它的导入的导入等。
- 使用所有模块构建一个文件(或者多个文件,这是可调的),并用打包函数(bundler function)替代原生的
import
调用,以使其正常工作。还支持像 HTML/CSS 模块等「特殊」的模块类型。 - 在处理过程中,可能会应用其他转换和优化:
- 删除无法访问的代码。
- 删除未使用的导出(“tree-shaking”)。
- 删除特定于开发的像
console
和debugger
这样的语句。 - 可以使用 Babel 将前沿的现代的 JavaScript 语法转换为具有类似功能的旧的 JavaScript 语法。
- 压缩生成的文件(删除空格,用短的名字替换变量等)。
如果我们使用打包工具,那么脚本会被打包进一个单一文件(或者几个文件),在这些脚本中的 import/export
语句会被替换成特殊的打包函数 bundler function。因此,最终打包好的脚本中不包含任何 import/export
,它也不需要 type="module"
,我们可以将其放入常规的 <script>
:
<!-- 假设我们从诸如 Webpack 这类的打包工具中获得了 "bundle.js" 脚本 -->
<script src="bundle.js"></script>
动态导入
使用关键字 import
只支持模块的「静态」导入,该语法非常简单且严格,它不能动态生成 import 的任何参数,由于其模块路径必须是原始类型字符串,而且无法根据条件或者在运行时导入,这是因为 import
/export
旨在提供代码结构的主干,这样便于分析代码结构可以允许构建工具收集模块打包到一个文件中,并删除未使用的导出 tree-shaken,这些操作都只有在 import
/export
结构简单且固定的情况下才能够实现。
如果需要在运行时动态导入模块可以使用 import(module)
表达式,它加载模块并返回一个 promise
,该 promise
resolve 为一个包含其所有导出的模块对象(包括默认的导出)。我们可以在代码中的任意位置动态地使用它。
let modulePath = prompt("Which module to load?");
import(modulePath)
.then(obj => <module object>)
.catch(err => <loading error, e.g. if no such module>)
Warning
尽管 import()
看起来像一个函数调用,但它只是一种特殊语法,只是恰好使用了括号(类似于 super()
)。因此我们不能将 import
复制到一个变量中,或者对其使用 call/apply
。
可以在异步 Async 函数中动态加载模块
// 模块
export function hi() {
alert(`Hello`);
}
export function bye() {
alert(`Bye`);
}
export default function() {
alert("Module loaded (export default)!");
}
<!doctype html>
<!-- 异步动态导入模块 -->
<script>
async function load() {
let say = await import('./say.js');
say.hi(); // Hello!
say.bye(); // Bye!
say.default(); // Module loaded (export default)!
}
</script>
<button onclick="load()">Click me</button>
Tip
动态导入在常规脚本中工作时,它们不需要 type="module"
标记