ES Modules:文件之间传递的到底是什么
一个文件写得太长,可以剪成三个文件。但如果三个文件仍偷偷修改同一个全局变量,复杂度只不过换了三个住址。模块首先解决的是依赖和公开边界:谁需要谁、谁允许别人使用什么。
从两个文件看见实时绑定
把下面两个文件放在同一目录,用 node main.mjs 运行。.mjs 明确告诉 Node 按 ES 模块解析。
// stock.mjs
export let remaining = 3;
export function take() {
if (remaining > 0) remaining -= 1;
}
const internalLabel = '仓库内部编号';// main.mjs
import { remaining, take } from './stock.mjs';
console.log(remaining); // 3
take();
console.log(remaining); // 2如果导入只是把数字 3 复制过来,第二次就不会得到 2。ESM 导入的是指向导出绑定的连接,通常称为 live binding,实时绑定。导出模块修改变量,导入方下一次读取时能看见新值。
这根连接不能由导入方重新接线。把 remaining = 99 写进 main.mjs 会抛出 TypeError。但若导出的是对象,导入方仍可能修改对象属性:只读绑定不等于冻结对象。想约束修改方式,可以只导出查询函数和操作函数,别直接暴露可变对象。
没有导出的 internalLabel 留在模块内部。模块顶层的声明也不会自动成为 window 的属性。
实时连接与一次赋值,为什么结果不同
仍在原来的 stock.mjs、main.mjs 目录,新建 snapshot.mjs:
import { remaining, take } from './stock.mjs';
const copied = remaining;
const readNow = () => remaining;
take();
console.log(copied, readNow()); // 3 2copied 初始化时执行一次读取,把当时的数字写入自己的绑定;readNow 每次调用才读取导入绑定。两者都符合赋值规则,实时绑定不会追踪“所有曾经读过它的变量”。模块给的是连接,不是给整个程序安装自动同步器。
默认导出也要看具体写法:export { remaining as default } 暴露的是这个绑定;export default remaining 则求值表达式,把那个时刻的值作为默认导出值。默认导出的函数和类声明另有声明语义,不应仅用“有没有 default”判断是否保存快照。
如果 remaining 改成对象,const copied = remaining 复制的是引用;之后改对象属性,两边能看见同一个对象的变化,但重新绑定 remaining 不会重新绑定 copied。分清“绑定里的值变了”与“值所指对象变了”,才能解释模块状态更新。
命名、默认和重新导出
// format.mjs
export function cents(value) { return `${value} 分`; }
export default function title(value) { return `《${value}》`; }// 使用方:默认导入名可以自己取,命名导入需匹配导出名
import bookTitle, { cents as formatCents } from './format.mjs';
console.log(bookTitle('作用域')); // 《作用域》
console.log(formatCents(120)); // 120 分import { cents } 的花括号不是对象解构语法;它是一条导入声明的一部分,不能在里面写解构默认值。import * as format 得到模块命名空间对象,可以通过 format.cents 访问,但不能随意增删导出项。
// index.mjs:明确列出对外接口
export { cents } from './format.mjs';
export { default as bookTitle } from './format.mjs';export * from 不转发默认导出。多个星号转发又出现同名导出时,也可能形成歧义。一个小而清楚的导出列表,往往比“全部转发,以后再说”更容易维护。
从加载到求值,模块经历了什么
假设入口是 main.mjs,它导入 stock.mjs。运行环境不能把 import 当成普通函数调用,执行到那一行才临时决定是否需要库存模块。它需要先理解这张依赖图。
- 解析与加载:读取入口,解析源码,找到静态导入;把模块说明符解析为目标地址,再继续取得依赖。文件不存在、网络失败、MIME 错误属于这一带的问题。
- 链接:把导入的名称与导出绑定接起来,建立模块环境。
import { missing }而依赖没有该导出时,无法建立需要的连接,入口正文不会先跑到一半再发现缺名字。 - 求值:执行模块正文,初始化变量与产生副作用。普通无环同步依赖要先求值依赖再执行使用方;环和顶层 await 需要更完整的依赖处理,不能只靠文件先后排列猜测。
这是一张理解职责的过程图,不表示浏览器实现必须逐个文件串行下载。可并行的获取工作仍可并行。
新建两个独立文件,运行 node entrance.mjs:
// supplies.mjs
console.log('库存模块正文');
export const available = 2;// entrance.mjs
console.log('入口正文第一行');
import { available } from './supplies.mjs';
console.log('库存', available);
// 库存模块正文
// 入口正文第一行
// 库存 2import 写在 console 后面,依赖正文仍先执行。再把 available 改成不存在的 absent:这次发生链接错误,入口的第一条日志也不会输出。由此可知,“我把初始化写在 import 上面”不能保证它先于依赖执行。
相反,若 supplies.mjs 在正文主动 throw new Error(...),导出名可以成功连接,失败发生在求值阶段。依赖没有成功完成,入口依然不会照常执行。找故障时先区分文件没找到、名字接不上、代码执行失败,不要将三种情况都归因于路径别名。
这也解释为什么静态 import/export 必须位于顶层:依赖和公开名字是模块结构的一部分。条件加载使用后文的 import() 表达式。
对于解析到同一个模块身份的导入,模块通常只求值一次,多个导入方共享其状态。前提是“同一身份”:浏览器和 Node 的 ESM 都涉及 URL 身份,改变查询参数可能产生不同模块实例;Worker 等其他执行上下文也不自动共享这个实例。不要在模块顶层悄悄启动永久定时器,又指望每个使用方各得到一份独立服务。
模块自动启用严格模式,顶层 this 是 undefined。模块顶层变量也不自动成为全局对象的属性。
循环依赖不是一定失败,但读取时机可能失败
// a.mjs
import { b } from './b.mjs';
export const a = b + 1;// b.mjs
import { a } from './a.mjs';
export const b = a + 1;运行 node a.mjs 时,至少一方会在另一方的 const 尚未初始化时读取它,触发暂时性死区的 ReferenceError。模块连接已经存在,值却还没准备好,像接通了电话却发现对方还没起床。
如果双方只是定义函数,等所有初始化结束后才互相调用,循环可能工作。但不应把“这次执行顺序刚好成功”当作设计保证。更稳妥的做法是把双方共享的常量或纯函数抽到第三个模块,或者由入口把需要的依赖作为参数传入。
动态导入和顶层 await
沿用前面的 format.mjs,新建 lazy.mjs:
const enabled = true;
if (enabled) {
const { cents } = await import('./format.mjs');
console.log(cents(80)); // 80 分
}运行 node lazy.mjs。import() 是返回 Promise 的表达式,可以放在条件分支里;加载、链接或求值失败都会导致拒绝,需要在实际入口处理错误。这里的 await 位于模块顶层,普通脚本不能照抄。
顶层 await 会推迟依赖这个模块的模块继续求值,不等于冻结整个 JavaScript 运行环境。它适合必须在导出可用前完成的初始化,但如果把不必要的网络请求放进去,整个依赖链都会等它。相互等待的模块还可能无法完成初始化。
动态路径也要有边界:用户选择“简体”应映射到你定义的模块地址,不能直接把任意用户输入拼进模块加载路径。
练习
为什么导入变量不能重新赋值,但导入对象的属性仍可能被修改?给出限制修改入口的方案。
把上面的循环依赖改成共享常量模块,说明为什么消除了初始化时机问题。
入口第一行console写在静态import之前,为什么依赖仍先求值?不存在的导出名与依赖正文throw分别属于哪一步失败?