模块入口与 import 后缀:公开 API 和运行环境要分别判断
import 路径同时表达两件事:调用方从哪个公开入口拿能力,以及运行环境怎样找到文件。把这两件事混在一起,容易把「所有地方都不写后缀」或「每个目录都建 barrel」当成统一规范。
我主导的项目里,会用目录入口管理稳定模块公开面;前端 bundler 源码通常省略本地源码后缀。维护其他项目时,先沿用它已有的明确规范和稳定写法。Node ESM 的解析要求则需要单独满足。
用 index.ts 声明模块公开入口
// upload/index.ts:对外只提供稳定能力
export { uploadFile } from './upload-file';
export type { UploadResult } from './types';
// 调用方
import { uploadFile } from './upload';
模块已经通过 index.ts 声明公开 API 时,外部再去引用 upload/internal/normalize-name,会把内部文件路径也变成事实契约。以后换实现或移动文件,就得追着调用方修改。
模块内部文件仍可相互相对引用,不必绕回自己的 barrel。反过来,也不应为了一个文件机械增加入口。入口文件要对应真实模块封装;某个 index.ts 只是框架路由或脚本入口,不能自动推定它是整个目录的访问边界。
@sheng/project-no-deep-import-through-barrel 是采用这套模块边界后的项目检查。已有合法深层入口、生成代码或特殊目录,要按选项划出范围。它不等于 JavaScript 提供了语言层的私有文件访问控制。
bundler 允许省略,不代表语言禁止后缀
TypeScript 的 moduleResolution: "bundler"不要求相对 import 必须有后缀。前端项目可以选择 ./upload,让实际 bundler 按源码和框架规则解析。
这里的「省略后缀」是阅读和项目风格选择;.js specifier 在适当配置下映射到 .ts 源文件也是合法机制。规则不应把另一种合法形式讲成编译器禁止。
包名本来带 .js、静态资源请求、动态 import 和特殊框架入口,也应结合真实解析器判断。不要用全文替换把所有 .js 删掉。
Node ESM 查找的是运行时文件
Node ESM 的相对与绝对 specifier 需要完整后缀,目录入口也需要明确文件名。先经 TypeScript 编译、再由 Node 运行的项目,源码中写 ./format.js 往往是在声明最终产物路径。
// 编译为 dist/main.js 后,Node 将读取 dist/format.js
import { format } from './format.js';
若项目直接运行 TypeScript,或使用 loader、tsx、包导出映射,则按那个实际入口的规则处理。Node 能运行某种模式,不说明另一种构建模式也能运行。
分别配置模块边界与后缀检查
下面的示例把模块公开面和 bundler 后缀检查分开,后者只启用在明确由 bundler 消费的前端目录;Node 构建脚本继续保留自己的解析约定。
读取 https://shengsheng.fun/files/module-public-api-and-import-resolution/kits/module-boundary-examples/ 的 README.md 和 FILES.json,核对实际 bundler 与 Node 入口后迁移 examples/ 配置;保留项目已有稳定约定。
完整实现与接入示例见完整源码与接入方法弹窗。
project-no-deep-import-through-barrel和 project-no-bundler-js-extension-import分别保护上述边界。验证时先做类型检查和前端构建,再用 Node 启动真实产物;只跑编辑器检查,不能证明运行时解析正确。
总结
模块入口约束调用方依赖哪些公开能力,import 后缀则由实际解析和运行方式决定。两者应分别配置、分别验证:采用公开入口不意味着每个目录都要建 barrel,bundler 源码省略后缀的约定也不能直接套到 Node ESM 产物上。维护已有项目时,仍应优先沿用其明确规范和稳定写法。