静态导入正在削弱 JavaScript 的同构性
Source: Dev.to
请提供您希望翻译的正文内容,我将按照要求保留原始格式、代码块和链接,仅翻译文本部分。
TL;DR
- 静态导入在模块加载时绑定依赖。
- 早期绑定会编码平台假设。
- 声明式依赖将这些决定移至组合根。
- 这不是新的模块系统;它是标准依赖注入在模块层面的应用。
JavaScript 在浏览器和服务器上都能原生运行,使得真正的同构成为可能。然而,现代 JavaScript 架构常常与之背道而驰。
import fs from "node:fs";
这行代码直接在模块中嵌入了仅 Node 可用的功能。浏览器默认无法满足 "node:fs",因此该模块不再是同构的。
问题不在于 fs;问题在于 早期绑定。静态导入在模块求值期间解析依赖,在代码运行之前就固定了依赖图。如果某个依赖是平台特定的,那么该模块也会变成平台特定的。
明确依赖关系
// user-service.mjs
export const __deps__ = {
fs: "node:fs",
logger: "./logger.mjs",
};
export default function makeUserService({ fs, logger }) {
return {
readUserJson(path) {
const raw = fs.readFileSync(path, "utf8");
logger.log(`Read ${raw.length} bytes`);
return JSON.parse(raw);
},
};
}
该模块不直接导入任何内容。它声明了一个依赖契约,并从外部接收具体实现。这是在模块层面上使用的依赖注入;组合根(composition root)决定传入什么实现。
手动组合根
Node
// node-entry.mjs
import fs from "node:fs";
import logger from "./logger.mjs";
import makeUserService from "./user-service.mjs";
const service = makeUserService({ fs, logger });
浏览器
// browser-entry.mjs
import fsAdapter from "./browser-fs-adapter.mjs";
import logger from "./logger.mjs";
import makeUserService from "./user-service.mjs";
const service = makeUserService({
fs: fsAdapter,
logger,
});
模块本身没有改变;只有组合根发生变化。平台决策保持在系统的边缘,并且由于依赖被显式注入,测试可以直接传入假实现,而不需要对模块导入进行模拟。
自动化组合
因为合约通过 __deps__ 暴露,组合根可以变为数据驱动:
// link.mjs
export async function link(entrySpecifier, overrides = {}) {
const mod = await import(entrySpecifier);
const depsSpec = mod.__deps__ ?? {};
const deps = {};
for (const [name, specifier] of Object.entries(depsSpec)) {
const finalSpecifier = overrides[specifier] ?? specifier;
const imported = await import(finalSpecifier);
deps[name] = imported.default ?? imported;
}
return mod.default(deps);
}
Node
const service = await link("./user-service.mjs");
Browser
const service = await link("./user-service.mjs", {
"node:fs": "./browser-fs-adapter.mjs",
});
绑定变为显式的程序逻辑,而不是加载器的副作用。
这与 import maps 和 exports 的区别
| 机制 | 它控制的内容 |
|---|---|
| Import maps | 加载时的标识符解析(主机层面)。 |
package.json exports | 每个环境的入口点(包层面)。 |
| Bundlers | 构建时的依赖图优化。 |
| Composition root + DI | 模块在运行时接收的具体能力(应用层面)。 |
- Import maps 的答案是:这个模块在哪里?
- Composition root 的答案是:这个模块获得了什么能力?
不同层次,关注点不同。
权衡
- 静态可分析性和 tree‑shaking 精度降低。
- TypeScript 集成变得更手动。
- 对于小型或纯单运行时的应用来说是多余的。
- 引入了架构纪律(组合根)。
这是一种工具,而非默认选项。
何时使用它
使用场景:
- 您需要真正的跨运行时模块(Node、浏览器、Edge)。
- 您希望将环境决策集中管理。
- 可测试性很重要且不想进行大量模拟。
- 您倾向于明确的能力边界。
不适用场景:
- 您的应用只针对单一运行时。
- 构建时优化和树摇(tree‑shaking)是主要关注点。
- 简单性胜过架构灵活性。
静态导入并没有错——它们高效且符合惯例。但它们在早期绑定,隐含平台假设。如果我们在意保持 JavaScript 的同构性,就应当有意识地决定绑定发生的时机。