跳到主要内容

模块

随着我们的应用越来越大,我们会考虑将其拆分成多个文件,一个模块(module)就是一个文件。模块可以相互加载。

info

很长一段时间,JavaScript 都没有语言级(language-level)的模块语法。这不是一个问题,因为最初的脚本又小又简单,所以没必要将其模块化。但是最终脚本变得越来越复杂,因此社区发明了许多种方法来将代码组织到模块中,使用特殊的库按需加载模块。列举一些(出于历史原因):

  • AMD —— 最古老的模块系统之一,最初由 require.js 库实现。
  • CommonJS —— 为 Node.js 服务器创建的模块系统。浏览器不支持 CommonJS。
  • UMD —— 另外一个模块系统,建议作为通用的模块系统,它与 AMD 和 CommonJS 都兼容。

现在,它们都在慢慢成为历史的一部分,但我们仍然可以在旧脚本中找到它们。

语言级的模块系统在 2015 年的时候出现在了标准(ES6)中,此后逐渐发展,现在已经得到了所有主流浏览器和 Node.js 的支持。

ES module(现代JavaScript模块)

  • export 关键字标记了可以从当前模块外部访问的变量和函数。
  • import 关键字允许从其他模块导入功能。
sayHi.js
export function sayHi(user) {
alert(`Hello, ${user}!`);
}
main.js
import { sayHi } from './sayHi.js';

sayHi('John'); // Hello, John!
  • 由于模块支持特殊的关键字和功能,因此我们必须通过使用 <script type="module"> 来告诉浏览器,此脚本应该被当作模块(module)来对待。浏览器会自动获取并解析导入的模块(如果需要,还可以分析该模块的导入),然后运行该脚本。

    <!doctype html>
    <script type="module">
    import {sayHi} from './say.js';

    document.body.innerHTML = sayHi('John');
    </script>
  • 如果你尝试通过 file:// 协议在本地打开一个网页,你会发现 import``export 指令不起作用。你可以使用本地 Web 服务器,例如 static-server,或者使用编辑器的“实时服务器”功能,例如 VS Code 的 Live Server Extension 来测试模块。

模块核心功能

以下功能对浏览器和服务端的 JavaScript 来说都有效。

始终使用 "use strict"

模块始终在严格模式下运行。例如,对一个未声明的变量赋值将产生错误

<script type="module">
a = 5; // Uncaught ReferenceError: assignment to undeclared variable a
</script>

模块级作用域

每个模块都有自己的顶级作用域(top-level scope)。一个模块中的顶级作用域变量和函数在其他脚本中是不可见的。

index.html
<!doctype html>
<script type="module" src="user.js"></script>
<script type="module" src="hello.js"></script>
hello.js
alert(user); // no such variable (each module has independent variables)
user.js
let user = "John";

模块应该 export 它们想要被外部访问的内容,并 import 它们所需要的内容。对于模块,我们使用导入/导出而不是依赖全局变量。

index.html
<!doctype html>
<script type="module" src="hello.js"></script>
hello.js
import {user} from './user.js';

document.body.innerHTML = user; // John
user.js
export let user = "John";

模块代码仅在第一次导入时被解析

alert.js
alert("Module is evaluated!");

在不同的文件中导入相同的模块:

import `./alert.js`; // Module is evaluated!
import `./alert.js`; // (什么都不显示)
  • 顶层模块代码应该用于初始化,创建模块特定的内部数据结构。如果需要多次调用某些功能,应该将其以函数的形式导出。

    admin.js
    export let admin = {
    name: "John"
    };
    1.js
    import { admin } from './admin.js';
    admin.name = "Pete";
    2.js
    import { admin } from './admin.js';
    alert(admin.name); // Pete

    // admin.js模块仅在第一次被导入时被解析,并创建 admin 对象
    // 1.js 和 2.js 引用的是同一个 admin 对象
    // 在 1.js 中对对象做的更改,在 2.js 中也是可见的
  • 模块可以提供需要配置的通用功能。例如身份验证需要凭证。那么模块可以导出一个配置对象,期望外部代码可以对其进行赋值。

    admin.js
    export let config = { };

    export function sayHi() {
    alert(`Ready to serve, ${config.user}!`);
    }
    init.js
    import { config } from './admin.js';
    config.user = "Pete";
    another.js
    import { sayHi } from './admin.js';

    sayHi(); // Ready to serve, Pete!

import.meta

import.meta对象包含当前模块的信息。它的内容取决于其所在的环境。在浏览器环境中,它包含当前脚本的 URL,或者如果它是在 HTML 中的话,则包含当前页面的 URL。

<script type="module">
alert(import.meta.url); // 脚本的 URL
// 对于内联脚本来说,则是当前 HTML 页面的 URL
</script>

在一个模块中顶级thisundefined

非模块脚本的顶级 this 是全局对象

<script>
alert(this); // window
</script>

<script type="module">
alert(this); // undefined
</script>

模块在浏览器中的特性

与常规脚本相比,拥有 type="module" 标识的脚本在浏览器中有一些特性。

模块是延迟的

模块 总是 被延迟的,与 defer 特性对外部脚本和内联脚本(inline script)的影响相同。

  • 下载外部模块 <script type="module" src="..."> 不会阻塞 HTML 的处理,它们会与其他资源并行加载。
  • 模块会等到 HTML 文档完全准备就绪(即使它们很小并且比 HTML 加载速度更快),然后才会运行。
  • 保持脚本的相对顺序:在文档中排在前面的脚本先执行。
<script type="module">
alert(typeof button); // object,脚本可以“看见”下面的 button
// 因为模块是被延迟的(deferred,所以模块脚本会在整个页面加载完成后才运行
</script>

<!-- 相较于下面这个常规脚本 -->

<script>
alert(typeof button); // undefined,脚本看不到下面的元素
// 常规脚本会立即运行,常规脚本的运行是在在处理页面的其余部分之前进行的
</script>

<button id="button">Button</button>

因为模块脚本是被延迟的,所以要等到 HTML 文档被处理完成才会执行它。而常规脚本则会立即运行,所以我们会先看到常规脚本的输出。上面例子中,会先看到 undefined,然后才是 object

当使用模块脚本时,我们应该知道 HTML 页面在加载时就会显示出来,在 HTML 页面加载完成后才会执行 JavaScript 模块,因此用户可能会在 JavaScript 应用程序准备好之前看到该页面。某些功能可能还无法使用。我们应该放置“加载指示器(loading indicator)”,或者以其他方式(比如骨架屏)确保访问者不会因此而感到困惑。

模块脚本的async特性适用于内联脚本

对于非模块脚本,async特性仅适用于外部脚本。对于模块脚本,async特性也适用于内联脚本。

<!-- 如下模块脚本在所有依赖(analytics.js)都获取完成后脚本开始运行 -->
<!-- 不会等待 HTML 文档或者其他 <script> 标签 -->
<script async type="module">
import {counter} from './analytics.js';

counter.count();
</script>

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>

不允许裸模块(“bare” module)

在浏览器中,import 必须给出相对或绝对的 URL 路径。没有任何路径的模块被称为“裸(bare)”模块,在 import 中不允许这种模块。

import {sayHi} from 'sayHi'; // Error,“裸”模块
// 模块必须有一个路径,例如 './sayHi.js' 或者其他任何路径

某些环境,像 Node.js 或者打包工具(bundle tool)允许没有任何路径的裸模块,因为它们有自己的查找模块的方法和钩子(hook)来对它们进行微调。但是浏览器尚不支持裸模块。

具有相同src的外部模块脚本仅运行一次

<!-- 脚本 my.js 被加载完成(fetched)并只被运行一次 -->
<script type="module" src="my.js"></script>
<script type="module" src="my.js"></script>

如果一个模块脚本是从另一个源获取的,则远程服务器必须提供表示允许获取的 header Access-Control-Allow-Origin

<!-- another-site.com 必须提供 Access-Control-Allow-Origin -->
<!-- 否则,脚本将无法执行 -->
<script type="module" src="http://another-site.com/their.js"></script>

构建工具

在实际开发中,JavaScript模块很少被以“原始”形式进行使用。通常,我们会使用一些特殊工具,例如 Webpack,将它们打包在一起,然后部署到生产环境的服务器。使用打包工具的一个好处是,它们可以更好地控制模块的解析方式,允许我们使用裸模块和更多的功能,例如 CSS/HTML 模块等。

构建工具做以下这些事儿:

  1. 从一个打算放在 HTML 中的 <script type="module"> “主”模块开始。分析它的依赖:它的导入,以及它的导入的导入等。
  2. 使用所有模块构建一个文件(或者多个文件,这是可调的),用打包函数(bundler function)替代原生的 import export语句,以使其正常工作。因此,最终打包好的脚本中不包含任何 import export,它也不需要 type="module",我们可以将其放入常规的 <script>
  3. 还支持像 HTML/CSS 模块等“特殊”的模块类型。
  4. 在处理过程中,可能会应用其他转换和优化:
    • 删除无法访问的代码。
    • 删除未使用的导出(“tree-shaking”)。
    • 删除特定于开发的像 consoledebugger 这样的语句。
    • 可以使用 Babel 将前沿的现代的 JavaScript 语法转换为具有类似功能的旧的 JavaScript 语法。
    • 压缩生成的文件(删除空格,用短的名字替换变量等)。
<!-- 假设我们从诸如 Webpack 这类的打包工具中获得了 "bundle.js" 脚本,则脚本中不包含任何import export,就不需要 `type="module"` -->
<script src="bundle.js"></script>

导出(export)和导入(import)

import export 语句放在脚本的顶部或底部,都没关系。在实际开发中,导入通常位于文件的开头,但是这只是为了更加方便。

在声明之前放置export

通过在声明之前放置 export 来标记任意声明为导出,无论声明的是变量,函数还是类都可以。

// 导出数组
export let months = ['Jan', 'Feb', 'Mar','Apr', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

// 导出 const 声明的变量
export const MODULES_BECAME_STANDARD_YEAR = 2015;

// 导出类(大部分 JavaScript 样式指南都不建议在函数和类声明最后使用分号)
export class User {
constructor(name) {
this.name = name;
}
}

导出与声明分开

// 先声明函数,然后再导出它们
function sayHi(user) {
alert(`Hello, ${user}!`);
}

function sayBye(user) {
alert(`Bye, ${user}!`);
}

export {sayHi, sayBye}; // 导出变量列表

import *

如果有很多要导入的内容,我们可以使用 import * as xxx 将所有内容导入为一个对象

import * as say from './say.js';

say.sayHi('John');
say.sayBye('John');

不建议使用import *,原因如下:现代的构建工具(webpack 和其他工具)将模块打包到一起并对其进行优化,以加快加载速度并删除未使用的代码。这就是所谓的“摇树(tree-shaking)”。而import *有可能导入了所有内容但只使用了其中一部分,由于没有明确导入,所以其他未被使用的不会被删掉,即达不到优化的效果。

import "xxx"

导入模块(其代码,并运行),但不将其任何导出赋值给变量

import "as"

可以使用 as 让导入具有不同的名字。

import {sayHi as hi, sayBye as bye} from './say.js';

hi('John'); // Hello, John!
bye('John'); // Bye, John!

export "as"

可以使用 as 让导出具有不同的名字。

say.js
function sayHi() {}

function sayBye() {}

export {sayHi as hi, sayBye as bye};
// 现在 hi 和 bye 是在外面使用时的正式名称
main.js
import * as say from './say.js';

say.hi('John'); // Hello, John!
say.bye('John'); // Bye, John!

export default

  • 模块提供了一个特殊的默认导出 export default 语法,以使“一个模块只做一件事”的方式看起来更好。

    user.js
    export default class User {
    constructor(name) {
    this.name = name;
    }
    }
  • 每个文件应该只有一个 export default

  • import 默认的导出时不需要花括号

  • 从技术上讲,我们可以在一个模块中同时有默认的导出和命名的导出,但是实际上人们通常不会混合使用它们。模块要么是命名的导出要么是默认的导出。

  • 由于每个文件最多只能有一个默认的导出,因此导出的实体可能没有名称:

    export default class { // 没有类名
    constructor() { ... }
    }
    export default function(user) { // 没有函数名
    alert(`Hello, ${user}!`);
    }
    // 导出单个值,而不使用变量
    export default ['Jan', 'Feb', 'Mar','Apr', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  • 命名的导出会强制我们使用正确的名称进行导入,而默认的导出不会

    import {User} from './user.js';
    // 导入 {MyUser} 不起作用,导入名字必须为 {User}

    对于默认的导出:

    import User from './user.js'; // 有效
    import MyUser from './user.js'; // 也有效
    // 使用任何名称导入都没有问题

default关键字被用于引用默认的导出

在某些情况下,default 关键字被用于引用默认的导出。

function sayHi(user) {
alert(`Hello, ${user}!`);
}

// 就像我们在函数之前添加了 "export default" 一样
export {sayHi as default};
user.js
export default class User {
constructor(name) {
this.name = name;
}
}

export function sayHi(user) {
alert(`Hello, ${user}!`);
}
main.js
import {default as User, sayHi} from './user.js';

new User('John');
other.js
import * as user from './user.js';

let User = user.default; // 默认的导出
new User('John');

重新导出export ... from ...

“重新导出(Re-export)” 允许导入内容,并立即将其导出(可能是用的是其他的名字)

  • 语法 export ... from ...

    export {sayHi} from './say.js'; // 重新导出 sayHi

    export {default as User} from './user.js'; // 重新导出 default
  • 使用场景:假如,我们正在编写一个 “package”:一个包含大量模块的文件夹,其中一些功能是导出到外部的(比如发布到NPM),并且其中一些模块仅仅是供其他 package 中的模块内部使用的 “helpers”。我们希望通过单个入口暴露包的功能,换句话说,想要使用我们的包的人,应该只从“主文件”(比如es/index.js) 导入。“主文件”导出了我们希望在包中提供的所有功能,并保持其他内容“不可见”。由于实际导出的功能分散在 package 中,所以我们可以将它们导入到“主文件”中,然后再从中导出它们。

    es/index.js
    // 导入 login/logout 然后立即导出它们
    import {login, logout} from './helpers.js';
    export {login, logout};

    // 将默认导出导入为 User,然后导出它
    import User from './user.js';
    export {User};

    使用export ... from ...:

    es/index.js
    // 重新导出 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'; // 重新导出默认的导出

动态导入

在 花括号 中的 import export 语句无效。像下面这样有条件的导入是无效的:

if (something) {
import {sayHi} from "./say.js"; // Error: import must be at top level
}

CommonJS