ES Module 导出与导入实战:命名导出、默认导出与循环依赖排查
export 和 import 这两个关键字我在带新人的时候几乎每次都要从头讲一遍。语法本身只有几行翻车的地方却集中在别处搞不清什么时候该用命名导出、什么时候该用默认导出路径少写个后缀浏览器里页面一片空白控制台甩出一条 MIME 类型错误同一份代码在 Node 里跑又直接抛 SyntaxError还有人写了个 index 文件想统一出口结果循环依赖绕成一团某个变量在模块加载时莫名其妙是 undefined。这些问题在文档里往往一句话带过真到了项目里却能耗掉半天。这篇内容围绕 ES Module 的 export 和 import 展开从为什么这么设计、两种导出方式的边界、导入的各种形态、再导出的聚合写法一直到最小可运行工程和报错排查。适合已经能写基本 JavaScript、准备把项目拆成多个文件的人看也适合写了很久但一直靠打包器兜底、没仔细想过模块解析规则的人。我会把每个选择背后的取舍讲清楚代码都是可以直接复制跑起来的坑也都是我和身边同行真实踩过的类型。1. 先搞清楚模块化到底解决了哪些真实的麻烦1.1 全局脚本时代留下的三类事故早年间一个页面上挂七八个 script 标签所有文件共享同一个全局作用域。这种做法带来的问题不是代码不好看这么轻描淡写而是三类实打实的事故。第一类是命名冲突A 文件里写了个function init()B 文件里也写了个function init()后加载的把前面的覆盖掉页面表现就是某个功能悄无声息地失效而且不报错只能靠肉眼比对。第二类是加载顺序依赖脚本之间靠隐式的先后关系工作谁在前面定义工具函数、谁在后面调用全靠 HTML 里标签的排列顺序维持一旦有人调整顺序或者做了懒加载整条链路就断。第三类是依赖关系不可见你打开一个文件根本不知道它用了哪些外部变量也不知道自己改动会影响谁项目一大就没人敢动。模块化的本质是把这三件事一次性解决掉每个文件有独立作用域导出的东西才对外可见依赖关系写在文件顶部谁用谁一目了然加载顺序由依赖图决定不由标签顺序决定。理解这一点之后你就不会把 export / import 当成语法糖而会把它当成一种强制的契约声明。1.2 ES Module 的静态特性带来了什么ES Module 和早期的社区方案最大的区别在于静态两个字。所谓静态是指 import 和 export 必须写在模块顶层不能塞进 if 分支里也不能用变量拼出模块路径动态import()是另一回事后面单独讲。这个限制看起来是束缚实际换来三件好事。一是可以在代码执行之前就把依赖图完整解析出来。打包器能在构建阶段知道模块 A 依赖 B 和 C从而做依赖分析、循环检测、代码分割。二是可以做静态的符号检查你写import { fooo } from ./utils.js而那个文件里只有foo工具链能提前告诉你拼错了而不是等到运行时才发现拿到 undefined。三是为 tree-shaking 提供了基础——没有被任何模块引用到的导出理论上可以在打包时被安全移除。这三件事都建立在依赖关系在写代码时就确定这个前提上。如果你习惯性地把所有东西挂到一个全局对象上这些能力一个都用不上。1.3 选型时我为什么倾向 ES Module 而不是继续用 CommonJSNode 生态长期用的是 CommonJSrequire和module.exports那套。两者的关键差异在于加载时机CommonJS 是运行时同步加载require执行到哪一行就加载到哪一行拿到的是整个导出对象的拷贝ES Module 是编译期确定依赖运行时按依赖图求值导入的是活的绑定。活的绑定这个点很容易被忽略但影响很大。举个直观的例子模块 A 导出一个变量count模块 B 导入它之后 A 内部修改countB 里读到的值会跟着变——因为 B 拿到的是指向同一个绑定的引用不是拷贝。而 CommonJS 里拿到的是当时那一刻的值后续修改不会同步。我在做状态共享的简单场景时这个差异直接决定了代码能不能用。至于怎么选我的经验是新项目、浏览器端、需要打包优化的一律 ES Module维护老代码或者要写一些必须在老版本 Node 里直接跑的脚本才用 CommonJS。混合环境下的互操作有规则但很绕能不碰就不碰。2. export 怎么写两种导出方式的边界在哪2.1 命名导出一个文件可以导出多个命名导出就是在声明的同时加export或者集中写一行export { ... }。它的特点是导出名和内部标识符挂钩一个模块可以有任意多个命名导出。// utils/math.js export const PI 3.1415926535; export function circleArea(r) { return PI * r * r; } export function circlePerimeter(r) { return 2 * PI * r; } const VERSION 1.2.0; function internalHelper(n) { return n * 2; } export { VERSION };注意这里的internalHelper没有导出它只在模块内部可用。这一点是模块化的核心价值你有权决定什么暴露、什么隐藏。很多人拆文件时习惯把所有函数都加上export实际上没必要未导出的函数还能让打包器更放心地做优化。命名导出还有一个很实用的写法是导出时改名const internalName () {}; export { internalName as publicName };这在对外提供 API 时有用你可以保留内部命名习惯对外暴露更语义化的名字。需要提醒的是export { x }里的x是绑定而不是值导出之后内部再修改x导入方看到的是新值。这点在写计数器、配置对象之类的模块时经常用到。2.2 默认导出省事但别滥用默认导出是每个模块最多一个的主出口写法是export default。// utils/logger.js export default function log(message, level info) { console.log([${level.toUpperCase()}] ${message}); }或者class HttpClient { constructor(baseURL) { this.baseURL baseURL; } } export default HttpClient;注意export default function log() {}和export default function() {}都可以前者在模块内部还能用log这个名字递归调用自己后者在模块内部就无法引用自身了。另外export default后面跟的表达式在求值时会被当成一个值所以export default { a: 1 }导出的是对象字面量本身而不是它的引用绑定。这一点和命名导出有个微妙区别默认导出绑定的是值命名导出绑定的是标识符。我的使用原则很明确一个模块如果只有唯一的主功能、而且这个功能本身就代表这个模块的身份就用默认导出比如一个类、一个主函数、一个 React 组件在不用编译方案的前提下。如果模块提供了一组相关的工具函数、常量、类型一律用命名导出。混着用最容易出问题后面单独说。2.3 混合导出的正确姿势与一个容易被忽略的陷阱技术上你可以同时写默认导出和命名导出// utils/http.js export const DEFAULT_TIMEOUT 5000; export function createRequest(url) { return { url, timeout: DEFAULT_TIMEOUT }; } export default createRequest;这样导入方有三种写法import createRequest from ./utils/http.js、import { createRequest, DEFAULT_TIMEOUT } from ./utils/http.js、import * as http from ./utils/http.js。看起来灵活但陷阱在于语义重复——同一个东西有两个入口使用方的代码风格会分裂代码搜索时也容易漏。更隐蔽的一个坑是当你用export default导出一个匿名函数或匿名类某些工具在做静态分析时拿不到名字错误堆栈里的函数名会变成anonymous调试体验很差。所以哪怕用默认导出也建议给函数或类起个名字。还有一个必须提的点export default导出的东西导入时可以随便起名。import whatever from ./utils/http.js是合法的但这就丢了名字约束团队里两个人对同一个模块用了不同名字搜代码的时候根本对不上。我的做法是默认导出的名字和文件名保持强相关命名导出则严格用原名或明确别名。对比项命名导出默认导出数量限制一个模块可多个每个模块最多一个导入名字必须匹配或用as改名可任意命名静态分析友好度高符号明确相对低改名后难追踪适合场景工具集、常量表、多 API 模块单一主功能、类、组件重导出便利性export * from直接可用需要显式export { default as X }3. import 的全套用法与那些容易漏掉的细节3.1 最常用的具名导入与别名具名导入用花括号名字必须和导出方一致import { circleArea, circlePerimeter } from ./utils/math.js;如果名字冲突或者想写得更短用asimport { circleArea as area, PI as pi } from ./utils/math.js;这里有个新手常犯的错误把解构赋值和具名导入搞混。import { a } from ./m.js不是解构它是在模块的导出表里查找名为a的导出。所以import { a: b }这种写法是错的必须是import { a as b }。同理import { a 1 }也不合法别指望在这里写默认值。另一个细节具名导入的花括号位置不能随便换行写成对象字面量那种风格虽然语法上没问题但很容易让人误以为是解构读代码时心里要清楚这是两码事。还有一点具名导入的绑定是只读的。你在导入方写circleArea 123会直接抛 TypeError。这不是限制你的自由而是保证模块间的契约不被单方面篡改。如果确实需要一个可变的共享状态应该由导出方提供修改方法而不是让导入方去赋值。3.2 命名空间导入什么时候值得用import * as ns from ./m.js把模块的所有导出收进一个对象通过属性访问import * as math from ./utils/math.js; console.log(math.PI); console.log(math.circleArea(2));这种写法的好处是来源清晰尤其在同时引入多个模块、函数名又相近的时候math.circleArea比裸写circleArea更容易看出出处。坏处是它会破坏 tree-shaking——打包器没办法判断你到底用了哪些属性往往把整个模块都保留下来。我的判断标准是模块很小、导出项都用得上、或者只是临时调试用命名空间导入模块较大且只用到其中一两个导出老老实实具名导入。另外命名空间对象本身是不可扩展的你没法给它加属性也没法重新赋值这算是个保护。3.3 副作用导入这一行到底干了什么import ./polyfill.js这种没有绑定任何名字的写法叫副作用导入。它的意思是执行这个模块的顶层代码但不关心它导出了什么。典型用途是注册类的模块// register.js import { registry } from ./core.js; registry.register(date, (v) new Date(v)); registry.register(json, (v) JSON.parse(v));// main.js import ./register.js; import { registry } from ./core.js; console.log(registry.get(date)(2024-01-01));这里要注意执行顺序副作用导入会先于其他语句执行而且同一个模块无论被导入多少次顶层代码只会执行一次。这个只执行一次的缓存机制是模块系统的基础很多人依赖它来做单例但要小心循环依赖时会拿到未完成初始化的模块后面细说。副作用导入也常被用来引入样式文件在某些构建配置下import ./styles.css就是这么工作的。但要注意浏览器原生不支持解析 CSS 的模块导入必须靠构建工具处理。3.4 动态 import()按需加载与条件加载静态 import 必须在顶层不能用变量拼路径也不能放在条件里。真需要这些能力时用import()函数async function loadChart() { const { renderChart } await import(./chart.js); renderChart(document.querySelector(#app)); } button.addEventListener(click, loadChart);import()返回 Promise解析结果是模块的命名空间对象。它有几个非常实际的用途一是首屏优化把体积大的模块图表库、编辑器、语法高亮推迟到真正需要时再加载二是条件加载根据运行环境或用户权限决定加载哪个实现三是路径拼接比如按语言加载不同的语言包。const lang navigator.language.startsWith(zh) ? zh : en; const messages await import(./locales/${lang}.js);需要注意动态 import 的路径拼接会让打包器无法静态分析通常会为每一个可能匹配的文件生成独立的 chunk或者在无法确定时给出警告。拼接的粒度要控制好别写成import(\./${anyString}.js) 这种完全不可预测的形式那样构建产物会失控。还有一个实践层面的取舍动态 import 会把代码切成多个文件每个文件都是一次网络请求。切得太碎请求数暴涨反而拖慢加载。我的经验是首屏关键路径上的模块全部静态导入非关键的、体积大的才动态导入切分点按功能块而不是按文件来划。4. 再导出与模块聚合入口文件怎么写才不臃肿4.1 export ... from 的三种形态当一个模块只是把别处的导出转手不需要在本地使用可以直接用export ... from// 形态一转手具名导出 export { circleArea, PI } from ./math.js; // 形态二转手并改名 export { circleArea as area } from ./math.js; // 形态三转手全部具名导出 export * from ./math.js;还有转手默认导出的写法这个必须显式写export { default as Logger } from ./logger.js;export * from不会转发默认导出这是规范定的很多人第一次遇到时以为是 bug。另外export *遇到同名导出时会静默冲突——两个模块都导出init聚合后的init是不确定的规范里这种情况会直接排除掉这个冲突的名字你在导入时会发现它不存在。所以聚合大模块时我更倾向显式列出名字。4.2 一个 index 文件聚合多个子模块的写法项目里常见的做法是建一个index.js作为对外出口// utils/index.js export { circleArea, circlePerimeter, PI } from ./math.js; export { formatDate, parseDate } from ./date.js; export { default as Logger } from ./logger.js;这样外部只需要import { formatDate, Logger } from ./utils/index.js。好处是内部结构调整时外部引用路径不变只改 index 就行。坏处也很明显一是 index 文件变成了一个隐性枢纽改一行可能影响所有引用方二是如果 index 用export *把十几二十个模块全聚合打包器很难判断你到底需要哪部分tree-shaking 效果打折三是容易诱发出循环依赖——math.js想用date.js的东西从index.js引而index.js又引了math.js环就形成了。我的做法是只有当这个目录确实是一个对外能力包时才建 index而且显式列出导出名不用export *。目录内部的模块之间互相引用时一律直接指向具体文件不走 index。这条规则听起来琐碎但能挡掉相当一部分难查的问题。5. 从零跑起来一份可以直接照抄的最小工程5.1 目录结构与文件职责我习惯的最简结构是这样demo/ ├── index.html ├── main.js └── utils/ ├── math.js ├── logger.js └── index.jsmath.js放纯函数和常量logger.js用默认导出一个函数index.js做聚合main.js是入口index.html只负责挂载。这个结构小到能一眼看完又能覆盖命名导出、默认导出、再导出、副作用导入这几种形态用来验证理解是否到位刚好。5.2 关键代码实现先写工具模块// utils/math.js export const PI 3.1415926535; export function circleArea(r) { return PI * r * r; } export function circlePerimeter(r) { return 2 * PI * r; } // 这个不导出只在模块内部用 function round2(n) { return Math.round(n * 100) / 100; } export function circleAreaFixed(r) { return round2(circleArea(r)); }// utils/logger.js export default function log(message, level info) { const time new Date().toISOString(); console.log(${time} [${level}] ${message}); }// utils/index.js export { circleArea, circleAreaFixed, circlePerimeter, PI } from ./math.js; export { default as log } from ./logger.js;入口文件// main.js import { circleArea, circleAreaFixed, PI, log } from ./utils/index.js; import * as math from ./utils/math.js; log(圆周率取 ${PI}); log(半径 2 的面积${circleArea(2)}); log(保留两位${circleAreaFixed(2)}); log(走命名空间的算法${math.circleArea(3)}); // 验证命名导出是活绑定 setTimeout(async () { const mod await import(./utils/logger.js); mod.default(动态导入的默认导出也可以调用, debug); }, 1000);HTML 只留一行!DOCTYPE html html langzh-CN head meta charsetUTF-8 title模块演示/title /head body script typemodule src./main.js/script /body /htmltypemodule这个属性不能省它是浏览器识别模块脚本的唯一开关。加上之后脚本默认是延迟执行的等价于defer会等文档解析完再跑同时模块作用域独立不会往window上挂东西。5.3 在浏览器和 Node 两种环境下的运行方式浏览器里必须通过 HTTP 协议访问直接双击打开file://会报错。原因是模块加载受同源策略约束file://上的模块请求会被当成跨源请求拒绝掉。本地起服务很简单# 任选一种Python 自带就够用 python3 -m http.server 8080 # 或者 npx serve .然后在浏览器访问http://localhost:8080。Node 环境下运行 ES Module 有两个前提文件扩展名是.mjs或者最近的package.json里有type: module。{ name: module-demo, type: module, version: 1.0.0 }配好之后node main.js就能跑。但要注意 Node 里的模块解析规则和浏览器不同Node 会补全扩展名、支持目录导入和package.json的exports字段浏览器不会。所以想让同一份代码两边都能跑最稳妥的做法是路径写全后缀不做目录导入。这里顺带说一个体积上的取舍。前面示例里的动态 import 会把logger.js单独切成一个 chunk代价是多一次请求。按常见网络条件估算一个小模块多一次请求大概多花几十毫秒但换来的是首屏少加载不必要的代码。判断标准很朴素模块体积小于三四千字节、又几乎每次都要用就别拆了大模块、命中率低的交互功能才值得动态拆。6. 常见报错与排查技巧实录6.1 报错速查表下面这张表是我遇到频率最高的一批问题基本能覆盖九成以上场景报错或现象根本原因处理方式Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of text/html路径写错服务器返回了 404 页面而不是 JS 文件检查路径大小写、扩展名、相对层级Uncaught SyntaxError: Cannot use import statement outside a module脚本没加typemodule或者 Node 未开启 ESMHTML 加属性Node 配type: moduleThe requested module does not provide an export named xxx导入名和导出名对不上或用了export *但目标只有默认导出核对导出名默认导出要用{ default as X }Uncaught TypeError: Failed to resolve module specifier vue裸模块名浏览器无法解析用完整相对路径或引入 import map / 构建工具Cannot access xxx before initialization循环依赖导致的暂时性死区拆出公共模块改用函数调用延迟求值Assignment to constant variable给导入的绑定赋值导入绑定只读改成由导出方提供修改方法net::ERR_FILE_NOT_FOUND用file://直接打开起本地 HTTP 服务6.2 循环依赖现象、原因和处理方式循环依赖的典型现场是这样的// a.js import { b } from ./b.js; export const a A; export function useB() { return b; } // b.js import { a } from ./a.js; export const b B;如果从a.js开始加载执行顺序是进入a.js发现依赖b.js转去执行b.js而b.js又要a.js但a.js已经在加载队列里了于是b.js拿到的是一个尚未完成初始化的a模块。这时候如果b.js在顶层直接读a的值就会撞上暂时性死区报错如果只是把a存起来稍后再用很多时候又能正常工作——这就是循环依赖最难查的地方症状时有时无。我的处理顺序是先想办法把环打断把双方都要用的部分抽到一个独立的第三个模块里让依赖变成单向实在打不断就把顶层立即读取改成函数内部延迟读取因为函数调用发生在模块初始化完成之后此时绑定已经可用。// 改法不在顶层求值 import * as aModule from ./a.js; export function getA() { return aModule.a; }需要强调的是循环依赖本身不一定是设计错误但一定是需要被解释清楚的东西。如果一段代码必须靠循环依赖才能工作说明职责划分有问题早晚会在别的地方再爆一次。6.3 路径、扩展名与 MIME 类型这几个高频坑浏览器端的模块解析比 Node 严格得多有三条铁律必须写扩展名、必须用相对路径或绝对路径、不能用裸模块名。// 浏览器里这些全是错的 import { x } from ./utils; // 缺少扩展名也不会去找 index.js import { x } from utils/math.js; // 裸模块名除非配了 import map import { x } from ./Math.js; // 大小写不匹配很多服务器区分大小写 // 正确 import { x } from ./utils/math.js;如果确实需要裸模块名可以在 HTML 里声明 import mapscript typeimportmap { imports: { utils/: ./utils/ } } /script但 import map 的兼容性和维护成本要掂量一下正式项目里用构建工具处理更省心。MIME 类型那个报错需要单独强调它的字面意思是服务器返回的不是 JS八成是因为请求了一个不存在的路径服务器兜底返回了 404 的 HTML 页面浏览器拿到text/html就拒收。所以看到这条报错别急着查服务器配置先把网络面板打开看实际请求的 URL 和响应内容九成情况下是路径错了。7. 实战层面的几点心得7.1 导出写法与打包体积的关系打包工具判断某个导出有没有被用到靠的是静态符号分析。命名导出因为名字明确工具能精确追踪默认导出在跨模块改名转发之后追踪链条会断保守起见就把整个模块留下。所以一个高频被引用的工具模块用命名导出往往能得到更小的产物。另一个影响体积的习惯是顶层副作用。如果你的模块在顶层做了window.xxx ...、注册了全局事件、或者读写了localStorage打包工具就不敢把它标记为无副作用哪怕你只导入了其中一个函数整个模块也会被打进去。所以在package.json里声明sideEffects: false同时保证模块顶层是干净的这两件事配合起来效果最明显。7.2 模块拆分的粒度控制拆得太粗一个文件几百行改起来牵一发动全身拆得太细一个函数一个文件导入语句比业务代码还长。我的经验是按变更频率和复用范围两个维度切经常一起改的东西放一起跨三个以上地方用的东西单独拎出来只在一个地方用的辅助函数就留在原地。还有一个容易忽视的点是模块的公共面。一个模块对外暴露的名字越多改动的阻力越大因为每次重命名都要考虑外部引用。所以导出的东西应该是稳定的、有明确语义的内部实现细节尽量不外露。这一点在写库或者多人协作的项目里尤其重要一个人随手export出去的临时函数很可能被别人依赖上然后再也删不掉。7.3 我踩过的几个具体的坑最后分享几个具体的、文档里不太会写的经验。第一个是关于默认导出的调试体验曾经有个模块用匿名默认导出线上报错堆栈只显示anonymous定位花了很久从那以后我要求所有默认导出都必须是具名函数或具名类。第二个是关于动态导入的错误处理。import()返回 Promise如果模块加载失败网络问题、文件不存在不 catch 的话会变成一个未处理的 Promise 拒绝在某些环境下会被静默吞掉。所以每次动态导入我都写成这样try { const mod await import(./feature.js); mod.init(); } catch (err) { console.warn(功能模块加载失败降级处理, err); fallbackInit(); }第三个是关于文件名的。项目里曾经同时存在Utils.js和utils.js在本地 macOS 上跑得好好的部署到区分大小写的 Linux 服务器上立刻全线报错。这跟模块语法无关但它是模块系统暴露出来的问题——以前所有代码在一个文件里路径随便写都无所谓拆分之后路径就成了契约的一部分必须严格对待。现在的做法是文件名统一小写加连字符从源头上避免这类争论。第四个是缓存问题。模块的顶层代码只执行一次这既是优势也是陷阱如果你在模块顶层缓存了某个会变化的值比如读了一次window.innerWidth之后页面尺寸变了读取到的还是旧值。模块顶层只适合放常量和纯函数定义任何与环境状态相关的东西都应该封装成函数调用时再取。这条经验我用了很久才真正形成肌肉记忆之前排查过一个布局错乱的问题最后发现是某个模块在顶层算了一次宽度写进了闭包。