ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ES Module 导出与导入实战:命名导出、默认导出与循环依赖排查

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 而不是继续用 CommonJS

Node 生态长期用的是 CommonJS,require和module.exports那套。两者的关键差异在于加载时机:CommonJS 是运行时同步加载,require执行到哪一行就加载到哪一行,拿到的是整个导出对象的拷贝;ES Module 是编译期确定依赖,运行时按依赖图求值,导入的是活的绑定。

"活的绑定"这个点很容易被忽略但影响很大。举个直观的例子:模块 A 导出一个变量count,模块 B 导入它,之后 A 内部修改count,B 里读到的值会跟着变——因为 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';

如果名字冲突或者想写得更短,用as:

import { 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.js

math.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 lang="zh-CN"> <head> <meta charset="UTF-8"> <title>模块演示</title> </head> <body> <script type="module" src="./main.js"></script> </body> </html>

type="module"这个属性不能省,它是浏览器识别模块脚本的唯一开关。加上之后,脚本默认是延迟执行的,等价于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脚本没加type="module",或者 Node 未开启 ESMHTML 加属性;Node 配"type": "module"
The 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 map:

<script type="importmap"> { "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),之后页面尺寸变了读取到的还是旧值。模块顶层只适合放常量和纯函数定义,任何与环境状态相关的东西都应该封装成函数,调用时再取。这条经验我用了很久才真正形成肌肉记忆,之前排查过一个布局错乱的问题,最后发现是某个模块在顶层算了一次宽度写进了闭包。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进