一个功能只有几十行时,把所有 JavaScript 放进同一个文件很省心。变量、函数和事件处理器都在眼前,刷新页面就能看到结果。
项目继续生长后,这种便利会慢慢变成负担:两个文件都声明了 format,后加载的覆盖了前一个;main.js 必须排在 utils.js 后面;某个临时变量意外出现在全局作用域,任何脚本都能改它。问题并不只是“文件太长”,而是代码之间没有清楚的边界。
模块提供了一个更可靠的模型:每个文件先管理自己的内部实现,只通过 export 公开少量接口,再由使用方通过 import 声明依赖。本章会从作用域和 IIFE 出发,一步步走到浏览器与 Node.js 都能使用的 ES modules。
把模块理解成一间有门的工作室:内部变量留在房间里,export 决定门口提供什么,import 决定谁来使用这些能力。文件拆分只是外观,边界与依赖方向才是模块设计的核心。

一个模块可以有很多内部细节,但公开接口应当小而清楚。
完成本章后,你应该能够:
import() 实现按需加载,并处理加载失败;传统网页常按顺序引入多个普通脚本:
<script src="./format.js"></script>
<script src="./discount.js"></script>
<script src="./main.js"></script>如果前两个文件都把同名变量放进共享环境,后执行的代码就可能改变前一个文件的含义:
// format.js
var format = (value) => `原价:${value}`;// discount.js
var format = (value) => `折后价:${value}`;// main.js
console.log(format(80));页面输出:
折后价:80main.js 没有明确说明自己依赖哪一个 format。它得到什么,只取决于脚本标签的排列顺序。这会带来三类常见问题:
JavaScript 查找变量时,会先看当前块或函数,再逐层向外查找。内层可以读取外层变量,但外层不能直接读取内层局部变量:
const currency = "CNY";
function formatPrice(value) {
const rounded = value.toFixed(2);
return `${currency} ${rounded}`;
}
console.log(formatPrice(19.9));
try {
console.log(rounded);
} catch (error) {
输出:
CNY 19.90
ReferenceErrorformatPrice() 可以向外读取 currency,但函数外部看不到局部变量 rounded。如果内外层出现同名变量,内层变量会暂时遮蔽外层变量,而不是修改外层变量:
const label = "全局";
function showLabel() {
const label = "局部";
console.log(label);
}
showLabel();
console.log(label);局部
全局在某些非严格模式的普通脚本中,给未声明的名字赋值还可能意外创建全局属性。ES 模块自动采用严格模式,这类错误会直接抛出 ReferenceError,不会悄悄污染全局环境。
let 和 const 以花括号形成的块为边界,var 则以函数为边界:
function inspectScope() {
if (true) {
const blockOnly = "块内";
var functionWide = "函数内";
console.log(blockOnly);
}
console.log(functionWide);
try {
console.log(blockOnly);
} catch (error) {
console.log(error.name);
}
}
块内
函数内
ReferenceErrorfunctionWide 离开 if 后仍在当前函数内,blockOnly 则已经超出作用域。模块为文件增加了最外层边界,但文件内部仍然遵循这些块作用域和函数作用域规则。新代码通常优先使用 const,需要重新赋值时使用 let,减少 var 带来的宽作用域与提升行为。
ES 模块普及前,开发者常用 IIFE(立即调用函数表达式)创建一层函数作用域。它的结构是“定义一个函数,然后立刻调用”:
(function () {
const message = "初始化完成";
console.log(message);
})();初始化完成外层括号把函数声明位置的语法转换成函数表达式,末尾的 () 立即执行它。箭头函数也可以写成 IIFE:
(() => {
console.log("立即执行");
})();IIFE 可以返回一个公开对象,而让状态留在函数内部:
const counter = (() => {
let value = 0;
function increase() {
value += 1;
return value;
}
return { increase };
})();
console.log(counter.increase());
console.log(counter.increase());
console.log(counter.value);输出:
1
2
undefinedincrease() 形成闭包,所以 IIFE 执行结束后仍能访问 value。公开对象没有 value 属性,外部只能通过公开方法改变状态。这种“隐藏实现,只暴露操作”的思路已经具备模块的雏形。
IIFE 很适合一次性初始化、兼容旧代码或在普通脚本中避免临时变量泄漏,但大型项目仍会遇到限制:
counter,页面仍要共享这个名字;ES modules 把同样的隔离思想变成了语言级语法:文件天然拥有模块作用域,公开和依赖都能写在代码里。

IIFE 依靠函数制造边界;ES 模块直接把文件作为边界。
当运行环境把文件识别为模块后,其中的 import 与 export 会按模块规则解析。模块顶层声明只属于当前模块,不会因为写在文件最外层就自动成为 globalThis 的属性。
创建 secret.mjs:
// secret.mjs
const token = "module-only";
export function readToken() {
return token;
}再创建入口文件 main.mjs:
// main.mjs
import { readToken } from "./secret.mjs";
console.log(readToken());
console.log("token" in globalThis);
try {
console.log(token);
} catch (error) {
console.log(error.name);
}在 Node.js 中运行 node main.mjs:
module-only
false
ReferenceError这个结果说明:
readToken() 在自己的模块内可以访问 token;token 没有挂到全局对象上;main.mjs 只能看到自己声明或明确导入的名字。模块还具备几个重要行为:它自动采用严格模式;静态依赖会在执行前被解析;同一个已解析模块通常只初始化一次,多个使用方会共享它的模块实例。
export 不是“把整个文件公开”,而是列出允许其他模块使用的绑定。一个模块可以保留任意数量的内部辅助变量,只导出真正稳定的能力。
可以在声明前直接写 export:
// price.js
const precision = 2;
export const taxRate = 0.06;
export function subtotal(items) {
return items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
}
export function formatPrice
也可以先声明,最后统一导出:
const taxRate = 0.06;
function addTax(value) {
return value * (1 + taxRate);
}
export { taxRate, addTax };两种写法语义相同。直接导出便于边读边识别接口;底部集中导出便于快速查看模块出口。无论采用哪种风格,都应保持一致。
一个模块最多有一个默认导出:
// receipt.js
export default function createReceipt(total) {
return `应付:¥${total.toFixed(2)}`;
}默认导入不使用花括号,名字由导入方决定:
import createReceipt from "./receipt.js";
import buildReceipt from "./receipt.js";上面两行分别展示可能的命名方式,不应在同一文件里重复导入。它们都指向同一个默认导出。
默认导出适合一个主要组件、类或函数;命名导出适合一组并列工具。团队如果希望同一能力在所有文件里保持同名,可以优先使用命名导出。
花括号不是可有可无的装饰。命名导出与默认导出是两种不同接口:导出方式和导入方式必须配对。
一个聚合文件可以转发其他模块的公开接口:
// index.js
export { subtotal, formatPrice } from "./price.js";
export { default as createReceipt } from "./receipt.js";使用方只需要一个入口:
import { subtotal, formatPrice, createReceipt } from "./index.js";这种文件常被称为“聚合入口”。它适合稳定的小型公共 API,但不应把整个目录无差别导出。出口过多会模糊真实职责,也更容易不小心形成循环依赖。
另外,export ... from ... 只负责转发,不会自动在聚合文件内部创建同名局部变量。若聚合文件自己也要使用某个名字,仍需显式 import。

静态 import 会把“当前文件需要什么”写在模块顶层。运行环境可以在执行代码前建立依赖图,也能更早发现路径或导出名称不匹配的问题。
假设 checkout.js 同时提供默认导出和命名导出:
// checkout.js
export const currency = "CNY";
export function add(a, b) {
return a + b;
}
export default function format(value) {
return `${currency} ${value.toFixed(2)}`;
}可以按需选择导入形式:
// main.js
import formatPrice, {
add,
currency as checkoutCurrency,
} from "./checkout.js";
console.log(add(20, 2));
console.log(formatPrice(26));
console.log(checkoutCurrency);22
CNY 26.00
CNY这里有三条配对规则:
formatPrice 是默认导入,本地名字可以自定;add 是命名导入,默认使用导出时的名字;currency as checkoutCurrency 用 as 解决本地重名或补充语义。还可以把全部公开接口放进模块命名空间对象:
import * as checkout from "./checkout.js";
console.log(checkout.add(3, 4));
console.log(checkout.default(18));命名空间导入适合确实需要一组接口的场景。如果只使用一两个名字,按需命名导入通常更容易读。
导入得到的绑定不是一次性复制。导出模块更新它后,导入方读取到的值也会变化:
// counter.js
export let count = 0;
export function increase() {
count += 1;
}// main.js
import { count, increase } from "./counter.js";
console.log(count);
increase();
console.log(count);
try {
count = 10;
} catch (error) {
console.log(error.name);
}0
1
TypeError导入方能看到 count 从 0 变成 1,却不能直接给导入绑定重新赋值。更稳妥的设计是由拥有状态的模块导出操作函数,让修改集中发生在内部。
有些模块的职责就是注册一次行为,例如安装全局错误监控或注册自定义元素:
// register-elements.js
class UserCard extends HTMLElement {}
customElements.define("user-card", UserCard);使用方可以进行副作用导入:
import "./register-elements.js";这种写法不会创建本地名字,只保证模块被加载并执行。副作用导入应当少而明确;如果一个工具模块仅仅因为被导入就修改大量全局状态,使用和测试都会变得困难。
浏览器需要从 HTML 明确知道入口文件是模块:
<script type="module" src="./main.js"></script>浏览器模块脚本有几项值得记住的默认行为:
this 是 undefined,不是 window。相对路径应写清 ./ 或 ../,并在原生浏览器模块中带上文件扩展名:
import { add } from "./math.js";"math.js" 没有相对路径前缀,会被当成裸模块说明符。浏览器默认不知道它对应哪个文件。
直接双击 HTML 得到的是 file:// 地址。模块加载涉及 URL、同源安全策略和响应类型,文件协议下经常被浏览器限制。使用本地 HTTP 服务器能更接近实际部署环境。
服务器还需要满足两项条件:
如果服务器把不存在的 .js 路径回退成 HTML,浏览器看到的可能是 MIME 类型错误,而真正原因是路径写错了。排查时应先查看开发者工具的 Network 面板,确认请求 URL、状态码和响应内容。
裸模块说明符若要在浏览器中使用,需要构建工具或 import map 映射:
<script type="importmap">
{
"imports": {
"date-utils": "./vendor/date-utils.js"
}
}
</script>
<script type="module" src="./main.js"></script>// main.js
import { formatDate } from "date-utils";import map 只负责把说明符映射到 URL,并不会替你安装包。

浏览器和 Node.js 都支持 import/export,但它们寻找模块的方式不同。浏览器主要处理 URL;Node.js 还要区分文件模块、内置模块、已安装包以及 CommonJS。
最直接的方式是使用 .mjs 扩展名:
main.mjs
math.mjs然后运行:
node main.mjs如果项目希望普通 .js 文件都按 ESM 解析,可以在最近一层 package.json 中声明:
{
"type": "module"
}.mjs 明确表示 ESM,.cjs 明确表示 CommonJS;.js 的解释方式受最近一层 package.json 的 type 字段影响。一个项目应尽量明确选择,避免读者靠猜测判断文件模式。
Node.js 中常见的导入形式如下:
import { readConfig } from "./config.js";
import { readFile } from "node:fs/promises";
import express from "express";./config.js 是相对文件路径,Node.js ESM 通常要求明确扩展名;node:fs/promises 是 Node.js 内置模块;express 是裸包名,需要项目已经安装并且该包提供对应出口。浏览器不会默认解析 node:fs/promises 或 express,因为它既没有 Node.js 内置模块,也不知道 node_modules 的规则。能在 Node.js 运行,不代表能原样放进浏览器。
Node.js ESM 中也没有 CommonJS 提供的 __filename 和 __dirname。需要文件位置时,可以从 import.meta.url 转换:
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
const filename = fileURLToPath(import.meta.url);
const directory = dirname(filename);
console.log(directory);旧式 Node.js 代码常见:
const fs = require("fs");
module.exports = { fs };ESM 则使用:
import fs from "node:fs";
export { fs };在 .mjs 或 type 为 module 的 .js 文件里,require 默认不存在。迁移时应先确认当前文件模式和依赖包提供的出口,再选择 import、动态 import() 或保留明确的 .cjs 边界。不要通过反复增删花括号来碰运气。
静态导入适合启动时必需的依赖,但有些功能只有在用户操作后才需要。例如,用户点开预览面板时才加载 Markdown 渲染器。
动态导入是函数形式的语法,可以放在条件分支或事件处理器里:
const previewButton = document.querySelector("#preview");
const output = document.querySelector("#output");
previewButton.addEventListener("click", async () => {
const markdown = await import("./plugins/markdown.js");
output.innerHTML = markdown.render("preview");
});import() 返回 Promise。Promise 完成后得到模块命名空间对象,因此命名导出 render 可以通过 markdown.render 访问;默认导出则位于 markdown.default。
也可以在模块顶层使用 await:
console.log("before import");
const { render } = await import("./plugins/markdown.js");
console.log(render("preview"));
console.log("after import");若 render() 返回 <strong>PREVIEW</strong>,输出顺序是:
before import
<strong>PREVIEW</strong>
after import顶层 await 只适用于模块。普通函数或事件回调中,仍需要把所在函数声明为 async,或者使用 .then()。
动态导入可能因为文件不存在、网络失败、CORS、MIME 类型或模块执行错误而拒绝 Promise。用户触发的功能应给出可恢复反馈:
previewButton.addEventListener("click", async () => {
previewButton.disabled = true;
try {
const { render } = await import("./plugins/markdown.js");
output.innerHTML = render("preview");
} catch (error) {
output.textContent = "预览功能暂时无法加载";
console.error(error);
同一个已解析模块被重复动态导入时,通常会复用已经初始化的模块,不会每点一次按钮就重新运行顶层代码。
选择导入方式时可以用一个简单判断:
把一个大文件拆成十个文件,不代表设计自然变好。真正需要检查的是:每个模块负责什么,以及箭头指向哪里。
一个小型待办应用可以采用下面的依赖方向:
main.js
├─> ui.js
└─> state.js
└─> storage.jsmain.js 是组合入口,连接页面与业务状态;ui.js 只负责读取用户动作和更新界面;state.js 维护待办数据与业务规则;storage.js 只负责持久化,不知道按钮和 DOM 的存在。下层模块不反向导入入口。这样做的直接收益是:state.js 可以在没有浏览器页面的测试中运行,storage.js 也能被其他功能复用。
检查边界时问一句:把这个模块单独拿出来测试,它需要知道多少外部世界?需要知道得越少,职责通常越清楚。
如果 a.js 导入 b.js,而 b.js 又导入 a.js,依赖图就出现了环:
a.js ──> b.js
^ │
└────────┘ES 模块的实时绑定能处理部分循环,但模块仍有确定的初始化顺序。若一方在另一方完成初始化前就读取尚未初始化的 let、const 或 class,可能触发 ReferenceError。即使当前代码恰好能运行,后续移动一行顶层代码也可能暴露问题。
常见的解环方法有:
main.js 作为组合入口,把回调或依赖传给双方;模块错误常发生在代码真正执行之前。先看报错发生在哪个环境,再沿“运行模式 → 请求路径 → 导出名称 → 初始化顺序”检查,通常比反复改语法更快。
遇到模块加载失败时,按下面顺序缩小范围:
可以先建立一个只有两个文件的最小例子。若最小例子能运行,再逐步恢复依赖;这样能判断问题来自语法、环境还是项目结构。
不要为了让目录看起来复杂而拆文件。下面这些信号出现时,拆分通常会带来真实收益:
优先从没有 DOM 和全局状态的纯计算开始。例如把价格计算放进 price.js:
// price.js
export function subtotal(items) {
return items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
}
export function formatMoney(value) {
return `¥${value.toFixed(2)}`;
入口只负责提供数据和展示结果:
// main.js
import { subtotal, formatMoney } from "./price.js";
const cart = [
{ price: 12, quantity: 2 },
{ price: 8, quantity: 3 },
];
console.log(formatMoney(subtotal(cart)));¥48.00price.js 不知道按钮在哪里,也不负责把文字写进页面。这使它容易复用、测试和替换。若一个模块只有三行但边界清楚,也没有问题;若拆开后两个文件总是一起修改、彼此导入很多内部细节,可能说明切分位置不对。
下面用一个可运行的小项目串联命名导出、默认导出、浏览器模块脚本、动态导入和单向依赖。
module-cart/
index.html
src/
main.js
cart.js
money.js
receipt.js依赖方向如下:
main.js ──> cart.js
│
├──────> money.js
│
└─动态─> receipt.js ──> money.js没有任何下层模块反向导入 main.js。
// src/money.js
export function formatMoney(cents) {
return `¥${(cents / 100).toFixed(2)}`;
}项目用整数“分”保存金额,避免直接用小数累计时出现不直观的浮点误差。
// src/cart.js
const items = [];
export function addItem(item) {
if (!Number.isInteger(item.priceCents) || item.priceCents < 0) {
throw new TypeError("priceCents 必须是非负整数");
}
if (!Number.isInteger(item.quantity) || item.quantity < 1) {
throw
items 没有导出。外部通过函数操作状态,getItems() 还返回对象副本,减少调用方意外修改内部数组的机会。
// src/receipt.js
import { formatMoney } from "./money.js";
export default function createReceipt(items) {
if (items.length === 0) {
return "购物车为空";
}
const lines = items.map((item) => {
const itemTotal = item.priceCents * item.quantity;
return
这里使用默认导出,因为 receipt.js 只有一个主要能力。
// src/main.js
import { addItem, getItems, subtotalCents } from "./cart.js";
import { formatMoney } from "./money.js";
const addButton = document.querySelector("#add-book");
const receiptButton = document.querySelector("#create-receipt");
const countOutput = document.querySelector("#count");
const totalOutput = document.querySelector
入口模块负责 DOM 和组装依赖。receipt.js 只有用户点击“生成收据”后才加载。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>模块化购物车</title>
</head>
<body>
<h1>模块化购物车</
module-cart 目录启动任意本地静态 HTTP 服务器;index.html,不要直接双击文件;0,总价为 ¥0.00;2,总价为 ¥118.00;receipt.js 没有被请求;点击后才出现对应请求;cart.js、money.js 和 receipt.js 都没有导入 main.js。cart.js 增加 removeItem(),但仍不导出内部数组;import "./cart.js" 写成 import "cart.js",根据错误信息完成一次排查;receipt.js 改成命名导出,并同步修改动态导入的解构方式。export 的接口才能被其他模块导入。import;确实可以延后的功能再使用返回 Promise 的 import()。