导读:这篇讲 ES Modules 怎么把隐式依赖变成显式声明,怎么按依赖方向把代码分层,以及为什么分层之后测试突然变得好写了。最后两节是真实案例——一个查表函数在特定输入下会返回一个函数而不是文案,另一个计数器会把数字拼成字符串。基础同学可以顺着读,有经验的同学可以直接跳到第六节看那个坑。
一、从「按顺序摆 script 标签」说起
刚开始写前端的时候,多文件是这么组织的:
<script src="data.js"></script>
<script src="app.js"></script>
data.js 里写:
const 文档列表 = [
{ id: 1, 标题: "季度复盘.md" },
{ id: 2, 标题: "架构评审记录" },
];
app.js 里直接就能用 文档列表。看起来很方便,但这个「能用」是隐式的——它靠的是两件事同时成立:
- 两个
<script>都没有作用域隔离,变量全挂在全局 data.js恰好写在app.js前面
这两条随便破一条,程序就坏。而且坏的方式很讨厌:
<!-- 顺序写反了 -->
<script src="app.js"></script>
<script src="data.js"></script>
浏览器不会报「你依赖搞错了」,它会在 app.js 里告诉你 文档列表 is not defined。你得自己从这句话反推到「哦,是标签顺序」。
文件多起来之后还有第二个麻烦。假设 data.js 和另一个 utils.js 都定义了 const 配置,后加载的那个会静默覆盖前一个——没有警告,没有报错,只有某个功能莫名其妙不对了。
问题的根源是:依赖关系存在于你的脑子里和 HTML 的标签顺序里,但没有写在代码里。代码本身看不出「我需要谁」。
二、ES Modules:把依赖写进代码里
ES Modules 是 JavaScript 的官方模块系统。核心变化只有一句:每个文件是独立作用域,靠 export 声明对外给什么,靠 import 声明自己要什么。
同样的东西,改成模块之后是这样:
// documents.js
export const 文档列表 = [
{ id: 1, 标题: "季度复盘.md" },
];
// app.js
import { 文档列表 } from "./documents.js";
console.log(文档列表.length);
HTML 里只留一个入口,并且要加 type="module":
<script type="module" src="./app.js"></script>
浏览器读到这行,会自己去解析 app.js 里的 import,发现它需要 documents.js,再去解析 documents.js 的 import,一路递归下去,把整张依赖图抓齐之后才开始执行。
顺序由依赖关系决定,不再由你的标签顺序决定。 你把 import 写在文件哪一行都不影响,因为浏览器是先把图建好再执行的。
变量覆盖的问题也消失了:每个模块有自己的作用域,两个模块各自的 const 配置 互不干扰,除非你显式 export 并 import。
这里有个新手必踩的坑
改成 type="module" 之后,直接双击 HTML 文件用浏览器打开会失败。控制台会报 CORS 相关的错误。
原因是模块加载受同源策略约束,而 file:// 协议不满足。这不是配置问题,是规范要求。
所以你必须起一个本地服务器。最简单的办法:
npx serve .
# 或者 python -m http.server
然后访问 http://localhost:3000 而不是双击文件。很多人在这里卡住并以为「模块坏了」,其实只是协议不对。
三、import/export 是语法,不是函数
这一节是整个 ES Modules 最本质的地方。理解它,后面所有特性都是推论。
import 长得有点像函数调用,但它不是。它是语法结构,在代码执行之前就要能被静态分析出来。所以有这些限制:
// 合法
import { foo } from "./a.js";
// 语法错误:路径必须是字面量,不能是变量
const 路径 = "./a.js";
import { foo } from 路径;
// 语法错误:只能写在模块顶层
if (需要吗) {
import { foo } from "./a.js";
}
第一眼看这些限制挺烦的。但换个角度:正因为路径必须是字面量、位置必须在顶层,不执行任何代码就能知道整张依赖图长什么样。
这一条推出了三个好处:
打包器能删掉没人用的代码。 你的工具库导出了 50 个函数,项目只 import 了 3 个,构建时另外 47 个可以被安全删除——因为静态分析能确定没人引用它们。这叫 tree-shaking。
浏览器能提前并行下载。 依赖图已知,所以所有模块可以同时发请求,不用「执行完这个才知道下一个要什么」。
导入一个不存在的名字,加载期就报错。 如果 documents.js 里没有 export 叫 文档列表 的东西,你会在模块加载阶段就拿到明确的错误,而不是等运行到某一行才发现是 undefined。
老一代的 Node 模块系统 require() 做不到这些。因为 require() 是一个普通函数,路径可以是变量、可以写在 if 里、可以在循环里调用。灵活,代价是构建期什么都推不出来——你没法确定哪些代码没人用,也没法提前知道要下载什么。
一句话记住:
import的限制不是缺陷,是它能被静态分析的代价,也是所有工程化好处的来源。
顺便说清楚一个常见误解
「静态结构」不等于「循环依赖会被拦住」。
依赖图已知,所以 A 依赖 B、B 又依赖 A 这种循环能被解析,但 ES Modules 并不拒绝它。实际表现是:函数声明因为会被提升,往往还能正常跑通;而 let / const 如果在初始化之前被访问,会抛 ReferenceError。
所以循环依赖是「能跑但脆弱」,不是「被静态检查拦下」。它仍然是设计信号——出现循环,通常说明有一块职责放错了位置。
还有两件容易忽略的事
模块只求值一次,结果被缓存。 不管多少个文件 import 同一个模块,那个模块的顶层代码只跑一遍,所有人拿到的是同一份实例。
想验证的话,在某个模块顶层写一句 console.log("loaded"),然后从三个地方 import 它——控制台只会打印一次。
这意味着模块天然是单例。你想让一份状态或一份常量表全应用共享,什么都不用做,export 出来就行。
import 拿到的是活绑定,不是拷贝。 导出方的变量后来变了,导入方看到的是新值。这和 const b = a 的赋值语义不一样。
四、分层的判据是依赖方向,不是文件数量
有了模块,下一个问题是:该拆成几个文件、怎么分?
很多人的做法是按类型分:所有工具函数放 utils.js,所有常量放 constants.js。这样分到后期,utils.js 会变成一个几百行的杂物间。
更好的判据是依赖方向。
想象一条工厂流水线。原料从一头进,每个工位只做一件事:从上游接料,加工,往下游送。工位之间不许横向伸手——第三个工位不会跑去翻第一个工位的工具箱。
我在项目里就是这么切的,四层:
src/lib/ 纯数据加工。不碰 DOM,不碰网络
src/api/ 对外通信。超时、取消、错误分类都只在这里写
src/state/ 状态机。持有当前状态,调用 api 层
src/ui/ 渲染。接收 state,产出 DOM
src/main.js 接线。把上面几层连起来,不含业务逻辑
依赖单向朝下:ui 可以用 state,state 可以用 api,api 可以用 lib。反过来不行。
lib/ 是最上游的工位,它的代码里一行 document. 都没有,一行 fetch 都没有:
// src/lib/documents.js
export function 收敛文档列表(原始数据) {
if (!Array.isArray(原始数据)) {
throw new TypeError("文档列表响应不是数组");
}
return 原始数据
.filter((项) => 项 != null && 项.id != null)
.map((项) => ({
id: 项.id,
标题: 项.标题?.trim() || "未命名文档",
}));
}
main.js 是最下游,它只做接线:
// src/main.js
import { 创建文档仓库 } from "./state/documentStore.js";
import { 渲染列表 } from "./ui/documentList.js";
const 仓库 = 创建文档仓库();
仓库.订阅((状态) => {
渲染列表(document.getElementById("list"), 状态.文档);
});
这个文件里一旦出现 fetch 或者数据加工逻辑,就说明该往上层挪了。
分层的收益不是「看起来整齐」,而是下面这条:
哪一层难测,就说明哪一层的职责混了。
五、纯函数为什么是测试的最佳落点
lib/ 里的函数都是纯函数:没有副作用,给定同样的输入必定得到同样的输出。
这个性质让测试变得极其简单。测一个纯函数需要的全部东西就是:调用它,比较返回值。
import test from "node:test";
import assert from "node:assert/strict";
import { 收敛文档列表 } from "../src/lib/documents.js";
test("缺 id 的条目会被丢弃", () => {
const 结果 = 收敛文档列表([
{ id: 1, 标题: "有 id" },
{ 标题: "没有 id" },
]);
assert.equal(结果.length, 1);
});
注意这个测试不需要浏览器、不需要启动服务器、不需要 mock 任何东西。因为 lib/ 不 import 任何 DOM 或网络代码,它在 Node 里能直接跑。
对比一下如果不分层会怎样。假设收敛逻辑直接写在渲染函数里:
// 混在一起的写法
function 渲染文档列表(容器, 原始数据) {
const 有效数据 = 原始数据.filter((项) => 项.id != null);
容器.innerHTML = 有效数据.map((项) => `<li>${项.标题}</li>`).join("");
}
现在要测「缺 id 的条目会被丢弃」这条规则,你得先造一个 DOM 容器,调用完再去解析 innerHTML 里有几个 <li>。测试变成了三倍长,而且它同时在测两件事——过滤规则和渲染格式。改了 HTML 结构,这个测过滤规则的测试也会挂。
所以把纯函数抽出来,本身就是为了让规则变成可断言的东西。测试驱动的其实是分层。
纯函数无副作用、输入决定输出,所以测试不用搭环境、不用清状态、不会因为执行顺序而时好时坏。
六、测试抓到的第一个 bug:查表函数的洞
理论说完了,看真实收益。
我给 lib/ 补测试的时候,有个函数是状态到中文文案的映射,写法非常常见:
const 状态文案 = {
completed: "已就绪",
processing: "处理中",
failed: "处理失败",
unknown: "状态未知",
};
export function 取状态文案(状态) {
return 状态文案[状态] ?? 状态文案.unknown;
}
逻辑看着无懈可击:查表,查不到就用兜底文案。?? 就是为这个场景设计的。
我随手写了个测试,验证「任何不认识的状态都返回兜底文案」:
test("不认识的状态返回兜底文案", () => {
const 兜底 = 取状态文案("unknown");
for (const 奇怪输入 of ["pending", "", null, undefined, 0, "toString"]) {
assert.equal(取状态文案(奇怪输入), 兜底);
}
});
前五个都过了。"toString" 挂了。报错是这样的:
+ actual - expected
+ [Function: toString]
- '状态未知'
它返回了一个函数。不是文案,是函数。
为什么会这样
要解释这个,得先说一句原型链。
JavaScript 里几乎每个对象都有一个「上级对象」,叫它的原型。你用 { ... } 字面量创建对象时,它的上级自动是 Object.prototype——一个内置对象,上面挂着 toString、valueOf、hasOwnProperty、constructor 这些方法。
这就是为什么任何对象都能调 .toString():不是每个对象自己都存了一份,而是自己身上找不到时,会顺着原型往上找。
const 空对象 = {};
console.log(空对象.自己没有的属性); // undefined
console.log(空对象.toString); // [Function: toString] ← 从原型上找到的
平时这个机制很有用。但 状态文案[状态] 这种写法,键名来自外部数据。当状态恰好是 "toString" 时:
状态文案自己身上没有toString这个键- 于是顺着原型链往上找,在
Object.prototype上找到了 - 找到的是一个函数,函数是 truthy
??只在左边是null或undefined时才用右边——现在左边是个函数,所以??不生效- 函数被原样返回,最后被塞进界面文案
'constructor'、'valueOf'、'hasOwnProperty' 全都会这样。
修法
只认对象自己的属性,不看继承来的:
export function 取状态文案(状态) {
return Object.hasOwn(状态文案, 状态)
? 状态文案[状态]
: 状态文案.unknown;
}
Object.hasOwn 是 ES2022 加的,比老写法 Object.prototype.hasOwnProperty.call(obj, key) 好读得多。
这个 bug 有多严重
诚实地说:后端返回一个叫 toString 的状态的概率极低。这个 bug 在实际运行中大概永远不会被触发。
但它值得修,理由有两个。
一是这类写法是原型污染的标准入口。同样的模式如果出现在「按用户提交的字段名查配置」「按 URL 参数查路由表」这种地方,攻击者可以主动喂 constructor 这样的键名。这次是文案错乱,换个场景可能就是绕过校验。
二是改它的成本是一行。同一个洞在不同上下文里危害差别巨大,而识别成本和修复成本都极低——那就没有理由留着它。
任何拿外部输入当对象键去查表的写法,都要问一句:查到的会不会是原型上继承的东西?
七、第二个 bug:计数器变成了字符串拼接
同一个洞还有另一副面孔。旁边有个统计各状态数量的函数:
export function 统计各状态数量(文档列表) {
const 计数 = { completed: 0, processing: 0, failed: 0 };
for (const 文档 of 文档列表) {
计数[文档.状态] = (计数[文档.状态] ?? 0) + 1;
}
return 计数;
}
?? 0 看起来处理了「这个状态还没出现过」的情况。但如果状态是 "toString":
计数["toString"]从原型上拿到函数?? 0不生效(函数不是 nullish)函数 + 1——JavaScript 会把函数转成字符串再拼接
实际输出:
统计各状态数量([{ id: 1, 状态: "toString" }]);
// { completed: 0, processing: 0, failed: 0,
// toString: "function toString() { [native code] }1" }
一个计数字段变成了一句话。而且这次连报错都没有,+ 对函数和数字是合法操作。
修法:让对象没有原型
这里更适合的修法是从根上断掉原型链:
export function 统计各状态数量(文档列表) {
const 计数 = Object.assign(Object.create(null), {
completed: 0,
processing: 0,
failed: 0,
});
for (const 文档 of 文档列表) {
计数[文档.状态] = (计数[文档.状态] ?? 0) + 1;
}
return 计数;
}
Object.create(null) 创建的对象没有上级,原型链到它就断了。所以 计数["toString"] 老老实实返回 undefined,?? 0 正常生效。
什么时候用 Object.hasOwn、什么时候用 Object.create(null)?
| 场景 | 选择 |
|---|---|
| 固定的查表映射,键名写死在代码里 | Object.hasOwn 判断 |
| 键名来自数据、要往里动态写入 | Object.create(null) 建对象 |
区别是前者只读、键是已知的,加个判断就够;后者要拿外部数据当键写入,让它从一开始就没有继承成员更彻底。
有一个副作用要知道:Object.create(null) 的对象没有 toString,所以不能直接 console.log 出好看的结果,也不能直接和普通对象做严格深比较。写测试时要么展开一层 { ...计数 } 再比,要么逐个字段断言。
一个诚实的补充
第二个 bug 在实际调用路径上到不了——那个计数函数只吃收敛过的数据,状态一定是四个已知值之一。
我还是改了。因为它和第一个是同一类问题,一起改的成本几乎为零,而留着它意味着以后有人直接调用这个函数时会踩坑。
八、小结
回过头看,这一天做的三件事其实是一件事的三个层次。
ES Modules 把依赖从「脑子里和标签顺序里」搬进了代码。代价是 import 必须是静态的——路径不能是变量、位置只能在顶层。换来的是工具链能在不执行代码的前提下理解你的程序。
分层 把「依赖方向」变成了明确约束。判据不是文件多少,是谁能引用谁。上游那层不碰 DOM 不碰网络,不是为了好看,是为了它能被单独拉出来验货。
测试 是前两步的兑现。纯函数无副作用,所以断言只需要「调用,比较」;而它之所以是纯函数,正是因为分层把它和环境隔开了。
然后测试抓出了两个我自己读代码不会发现的 bug——两个都是 对象[外部输入] ?? 兜底 这个写法在原型链上漏的。它们在正常运行中几乎不会触发,但暴露了一个我原本没意识到的心智模型缺口:我以为 ?? 能兜住「查不到」,实际上「查不到」和「从原型上查到了别的东西」是两件事。
拆分代码不是为了整齐,是为了让每一块能被单独提问。能被提问的代码,才能被验证。
如果你现在的项目还在用 <script> 按顺序摆,第一步不必急着上打包工具。先加 type="module",起一个本地服务器,把最上游那些纯数据加工的函数抽成一个不碰 DOM 的文件——然后给它写第一个测试。多半也能抓到点什么。

没有回应