JavaScript ESM模块化:export/import与循环依赖实战

发布时间:2026/11/3 4:06:11
JavaScript ESM模块化:export/import与循环依赖实战

javascript 里模块化这件事,绕到最后都绕不开两个关键字:export和import。学了四十多期语法,函数、对象、原型、异步都过了一遍,真正让我在实际项目里反复翻文档、反复踩坑的,反而是这两个看起来最简单的词。它属于 ES Module(简称 ESM)规范,是语言层面的官方模块系统,不是某个库造的轮子。它能干的事说起来朴素:把一个文件里想给别人用的东西标出来,再在另一个文件里把它取过来用。但真正解决的是全局污染、加载顺序、依赖关系隐式化这三件让人头疼的老问题。这篇东西适合刚把 JS 基础语法过完、准备写多文件项目的人,也适合写了几年但一直靠打包工具自动处理、没认真想过 import 背后发生了什么的开发者。我下面写的是自己这些年踩过的坑和最后沉淀下来的用法习惯,不是语法手册的复述。

1. 模块化到底在解决什么问题

1.1 从脚本拼接说到模块的诞生逻辑

早年间写页面,几个 script 标签依次排下来,后一个文件用前一个文件的函数,靠的是window上的全局变量。看起来能用,但问题在于依赖关系是隐式的:谁先谁后、谁依赖谁,全靠在 HTML 里排顺序和脑子里记。文件一多,删一个函数不知道会不会崩,改一个变量名不知道会不会误伤,这就是典型的"命名空间污染 + 依赖不可见"。

后来有了立即执行函数包裹(IIFE)的写法,把变量关进函数作用域,只往全局暴露一个对象。这个思路解决了一部分污染问题,但依赖声明仍然靠人为约定,加载顺序该错还是错。再往后是 CommonJS,用require同步加载、module.exports导出,服务端跑得很舒服,浏览器上却得先打包成一个大文件才能用。

ESM 的设计和前面这些都不一样,它走的是静态声明这条路。import和export必须写在模块顶层,不能塞进if里,不能放进函数里,也不能用变量拼出模块名——因为语法层面就卡死了这种写法。这个限制乍看很别扭,实际是整个体系最重要的一步:因为模块名和导入的标识符在代码还没运行的时候就已经确定了,工具才能在不执行代码的前提下,把整张依赖图画出来。打包器能把没用的代码摇掉、编辑器能跳转到定义、构建时能做静态检查,全部建立在这个前提上。

所以理解 ESM 的第一个坎不是记语法,而是接受一个思维转变:模块的依赖关系是"写死的",不是"运行时算出来的"。想动态,就得换import()这个函数形式,那是另一条路径,后面会展开。

1.2 export 和 import 各自承担的角色

一个模块文件在 ESM 眼里就是一个独立作用域。文件里声明的变量、函数、类,默认都是私有的,外面看不见。想让别人看见,必须显式export。这个默认私有、显式公开的设计,是它和早期全局脚本最本质的区别——你不是在"往全局挂东西",而是在"声明这个模块对外提供什么"。

而import做的事是从别的模块里取绑定。这里有个关键点很多人一开始没意识到:import拿到的不是值的拷贝,是活绑定(live binding)。也就是说,如果模块 A 导出了一个变量,模块 B 导入它,之后 A 里把这个变量改了,B 那边看到的值也会变。这跟 CommonJS 的"取一份当时的值"完全不同。我第一次遇到这个行为时非常困惑,明明 B 里没动过那个变量,它自己就变了,后来才明白这是设计使然。

理解了这两点,再去记export和import的写法,就不是死背,而是知道每种写法对应什么语义。下面我就按实际使用频率,从导出写到导入,把容易混的地方一个个拆开。

2. export 的四种写法与选型判断

2.1 命名导出:一个模块可以提供多个

命名导出是最常规的形式,声明的时候直接加export前缀就行:

export const API_TIMEOUT = 5000; export function formatDate(date) { /* ... */ } export class RequestPool { /* ... */ }

也可以先声明再集中导出,两种写法完全等价:

const API_TIMEOUT = 5000; function formatDate(date) { /* ... */ } export { API_TIMEOUT, formatDate };

我个人的习惯是:声明处不加export,统一在文件末尾集中一行导出。理由有两个。第一,扫代码的时候末尾那一行就是模块的"对外接口清单",一眼能看全这个文件给外面提供了什么,比在几百行代码里找export关键字高效得多。第二,中途调整对外暴露的范围时,只改末尾一行就行了,不用回到声明处去删关键字,改动集中、diff 干净。

命名导出能被导入方按名字精确取用,也天然对 Tree shaking 友好——打包器知道哪些名字被引用了、哪些没有。所以一个模块如果对外提供的是多个平等的能力,命名导出是第一选择。

2.2 默认导出:一个模块只给一个"主菜"

export default每个模块最多一个,导出的是"这个模块的代表"。常见的写法有三种:

// 写法一:匿名函数表达式 export default function () { /* ... */ } // 写法二:具名函数(名字只在模块内部有效) export default function initApp() { /* ... */ } // 写法三:导出一个已有绑定 const config = { mode: 'prod' }; export default config;

第三种写法有个很重要的差异:export default config导出的是值本身(对基本类型来说),而不是一个叫default的活绑定。对象因为是引用,改属性还能反映出来,但如果是let count = 0; export default count;,之后模块内把count改成 1,导入方看到的还是 0。这个坑我在一个计数器模块里踩过,排查了半天才发现默认导出和命名导出的语义不同。

还有个等价写法值得记住:export { config as default }。某些需要同时提供默认导出和命名导出的场景,用这个写法比混着写更整齐。

什么时候用默认导出?我现在的判断标准很窄:这个模块整体就代表"一个东西",比如一个组件文件、一个类文件、一个配置对象文件。如果一个文件能自然地从嘴里说出"它导出一个 X",那默认导出合适。反之,如果要说"它导出 A、B、C 三样",就别硬套默认导出,老老实实用命名导出。

顺带说个团队协作上的教训:默认导出在重构时很不友好。导入方可以随便给它起名字,同一个东西在不同文件里可能叫utils、helper、tools,全项目搜不到统一关键词。命名导出强制大家用同一个名字,可搜索性完全不是一个量级。所以后来我在带人时,宁可一开始就要求全用命名导出,也不图那点书写方便。

2.3 导出时重命名:给外部一个更合适的名字

内部名字和外部名字未必一致,as就是干这个的:

const _internalCache = new Map(); export { _internalCache as cache };

这个用法的价值不只是"改名好看"。更实际的场景是:内部实现用下划线前缀表示"别碰",但对外的确要给一个正经名字;或者内部有个同名变量冲突了,导出时换个名避开。还有一种情况是同一份逻辑要在不同语境下暴露成不同名字(虽然这种情况我建议直接拆模块,别在一个文件里玩花活)。

需要注意,重命名只是改了导出后的名字,模块内部的引用不受影响,而且绑定关系仍然是活的。

2.4 聚合导出:把多个模块收拢成一个入口

一个目录下有十几个模块,使用者总不能一个个去 import。这时候需要一个入口文件当中转站:

export { formatDate, parseDate } from './date.js'; export { default as HttpClient } from './http/client.js'; export * from './constants.js'; export * as validators from './validators/index.js';

这几种写法差别的细节必须说清楚,不然很容易翻车:

  • export { x } from './a.js'是转手导出,不会在当前模块内创建x这个变量,当前作用域里用不了它。想用就得单独再import一次。
  • export * from会把目标模块的所有命名导出都转出去,但不会转出 default。这是规范明确规定的,不支持讨论。
  • 同名冲突时,export *的行为是"静默忽略",不报错。所以两个模块都有init,全部export *出来,最后哪个生效完全看运气。我一般要求:入口文件里尽量写显式的export { x } from,只在结构非常稳定、命名有前缀规范的常量模块上才用export *。
  • export * as ns from是相对新的语法,把一个模块整体打包成一个命名空间对象导出,适合"这个模块的 API 我要以一坨的形式暴露"的场景。老一些的构建环境可能不认,用之前确认一下目标环境的支持情况。

聚合入口最大的风险是不小心把内部的实现细节也暴露成公共 API。写完入口文件后,我通常会反过来问自己一句:这个文件导出的东西,我愿不愿意承诺未来一直维护它?不愿意的就别往外放。

3. import 的写法与那些绕不过去的细节

3.1 静态导入的五种形态

import的静态形式在语法上只有顶层合法,写法上可以分成几类:

// 1. 默认导入 import HttpClient from './client.js'; // 2. 命名导入(可重命名) import { formatDate, parseDate as parse } from './date.js'; // 3. 混合导入 import HttpClient, { TIMEOUT } from './client.js'; // 4. 命名空间导入 import * as dateUtils from './date.js'; // 5. 纯副作用导入 import './polyfills.js';

第五种很多人不太用,但它很重要。有些模块的作用不是"导出什么",而是"执行一下产生副作用",比如注册全局样式、往原型上加方法、初始化某个单例。这时候直接import './xxx.js',不接收任何绑定。这种写法有个副作用:它的执行时机跟其他导入一样,会被提升到模块顶部,也就是说不管写在文件的第几行,都会在模块代码执行前先跑完。这一点在依赖初始化顺序的时候要特别小心。

命名空间导入要提醒一句:import * as ns拿到的对象是"密封"的,你不能给它加属性,也不能改已有属性的绑定(虽然改对象内部的属性是可以的),而且它对 Tree shaking 不友好——打包器没法确定你到底用了哪个属性,通常把这个模块整包留下。所以能具名就具名,别图省事。

3.2 导入的绑定是只读的,但只读的边界很微妙

导入进来的标识符不能重新赋值:

import { count } from './counter.js'; count = 10; // 报错:Assignment to constant variable

但注意,"不能重新赋值"和"不能改"不是一回事:

import { config } from './config.js'; config.name = 'changed'; // 完全合法,改的是对象的属性

这条边界的坑在于,它让"只读"看起来形同虚设。模块导出一个对象,任何导入方都能改它的属性,这在多人协作里是很危险的设计。我的做法是:对外导出的配置对象一律Object.freeze处理,或者干脆只导出获取函数,不直接暴露可变引用。后者更彻底,也更好排查——谁改的、什么时候改的,加个日志就清楚了。

还有一个容易忽略的点:import的绑定是活绑定,但只针对模块顶层作用域的let/var/function/class这类绑定。如果导出的是个解构出来的值或者基本类型的表达式,那么它就不具备"被后续修改"的能力,因为根本没有对应变量存在。理解这个的前提是记住:export导出的是"绑定",不是"值的快照"(默认导出部分除外)。

3.3 路径解析:扩展名、目录、裸模块

这块是新手最常翻车的地方,因为浏览器、Node 环境、打包工具的解析规则各有各的规矩。

浏览器里,import './utils.js'是必须带扩展名的。写import './utils'会直接报错,报错信息大概是Failed to resolve module specifier,而且它不会像某些工具那样"帮你补全"。同理,import './utils/index.js'里的目录索引也不会自动找,必须写全。

裸模块(比如import _ from 'lodash')在浏览器里也不能直接用,浏览器不知道去哪找。要么用 import maps 声明映射关系,要么交给打包工具处理。

Node 环境这两个问题都在逐步放宽,但具体行为取决于版本和配置文件。能确定的是:package.json里的type字段决定了.js文件按哪种模块系统解析,写"type": "module"就按 ESM 走,不写则默认 CommonJS。而.mjs永远按 ESM 处理,.cjs永远按 CommonJS 处理,跟type字段无关。这套扩展名约定我一开始觉得多余,后来做混合项目的时候才发现它救了不少命——目录里同时有两种模块,光看扩展名就一目了然。

实操建议:不管目标环境支不支持省略扩展名,统一把扩展名写全。理由不是"这样能跑",而是搬代码的时候不会因为环境不同而突然崩。省那两个字符,换来的是半年后排查一小时的代价,不值。

4. 动态 import 与按需加载

4.1 import() 为什么是函数而不是语法

静态import必须是顶层声明,模块路径必须是字符串字面量。一旦需要"根据运行时条件决定加载什么",静态导入就无能为力了。这时候用import():

const module = await import('./feature-xxx.js'); module.run();

它有几个和静态导入完全不同的特征,必须一次说清:

第一,import()是函数调用,返回 Promise,所以可以写在任何地方——函数里、if里、事件回调里都行。第二,路径可以用变量拼接,动态生成。第三,它不会被执行前的依赖分析算进初始依赖图里,也就是说模块图会在运行时被扩展。第四,正因为如此,动态导入的目标模块不参与初始加载的 Tree shaking 判断,它会单独打包成一个块,需要的时候再去拿。

这个"单独打包成块"的行为,就是前端做代码分割的基础。路由懒加载、大型图表库按需加载、只在特定弹窗里才用到的富文本编辑器,全靠它。我第一次用动态导入把首屏体积砍掉将近一半时的感觉,比背十个语法特性都实在。

4.2 实际用法与几个必须处理的问题

一个典型的路由级懒加载写法大概是这样:

async function loadRoute(name) { const map = { report: () => import('./views/report.js'), settings: () => import('./views/settings.js') }; const loader = map[name]; if (!loader) { throw new Error(`未注册的路由:${name}`); } const mod = await loader(); return mod.default || mod; }

这里有几个我踩过的细节。

一是要处理"模块没有默认导出"的情况。mod.default || mod这个兜底很常见,但我建议别长期这么写,它会掩盖模块契约不清晰的问题。正确的做法是约定好懒加载模块一律默认导出,或者一律具名导出,然后在调用处固定写法。

二是错误处理不能省。动态导入是网络请求,会失败——资源 404、部署后旧页面引用了新哈希文件名、网络抖动,都会让 Promise reject。稳妥的做法是包一层 try/catch,失败时降级到一个简单的错误组件或者重试一次。重试逻辑要注意别无限循环,加次数上限。

三是注意并发问题。同一个模块被并发import()多次,实际只会发起一次请求,历史上多次调用会拿到同一个模块实例。这是规范保证的,不用自己做去重池。但反过来说,如果你期望每次import()都拿到"新的状态",那是想多了,模块只执行一次。

还有一点,动态导入在循环里写的时候要小心作用域,for里await import变量路径是没问题的,但如果用var加闭包就容易出岔子,这种老问题换到 import 上照样成立。

5. 模块图的构建与循环依赖

5.1 一个模块的执行顺序是什么样的

import语句会被提升到模块顶部,这是很多人没意识到的。下面这段代码能正常跑:

console.log(square(4)); // 16,能跑 import { square } from './math.js';

虽然 import 写在后面,但它在模块代码执行前就已经处理完了。这个提升行为对函数声明有效,但如果你在 import 之前访问的是一个const导出的绑定,那就不行了——因为存在暂时性死区,会抛ReferenceError: Cannot access before initialization。

整个加载流程大致分阶段:先解析入口模块,找出它所有的静态 import,递归解析每个依赖,构建出完整的模块图;然后深度优先地把模块实例化,为每个导出创建绑定占位;最后按依赖的逆序执行模块体。这个"先全部实例化、再依次执行"的顺序,是理解循环依赖表现的关键。

5.2 循环依赖:不是不能用,但要知道为什么能用

模块 A 导入 B,B 又导入 A,这就是循环依赖。ESM 对它是有定义的:不会死锁,也不会直接报错,但结果是否如你所愿,取决于你访问的是什么。

因为实例化阶段所有模块的绑定都已经建好了,但执行阶段是深度优先的,所以先执行的那个模块里,很可能引用了还没初始化的东西。此时:

  • 如果引用的是function声明,没问题,函数声明在执行前就已经提升并赋值了,直接可用。
  • 如果引用的是const/let变量,在对方模块还没执行到那一行时访问,会抛暂时性死区错误。
  • 如果只是把它存起来、延迟到函数调用时才用,那通常也没问题,因为调用发生时通常都初始化完了。

我在实际项目里遇到循环依赖,基本都是架构问题,而不是语法问题。常见的解法有这么几种:

第一种,把公共部分抽出去。A 和 B 都依赖 C,就把公共逻辑抽到 C 里,让依赖变成单向的 A→C、B→C。这是最干净的解法,成本也最低,多数情况一抽就完事了。

第二种,延迟引用。把原来在模块顶层直接使用的导入,改成在使用它的函数内部再引用。不过对于静态导入,这招意义有限,更常见的做法是改成import()动态引入,等真正需要的时候再加载,天然避开初始化顺序问题。

第三种,反转依赖。原本 A 直接调用 B,改成 A 注册回调、B 在合适的时机触发。这在事件驱动的模块里很自然,也能把依赖图理成单向。

需要提醒的是:不要依赖"循环依赖能跑通"这件事。它能跑,是因为执行顺序恰好在那个版本、那个打包策略下是对的。换个构建配置、调整一下导出顺序,可能就崩了。发现循环依赖就当成技术债处理掉,别留。

6. ESM 与 CommonJS 的互操作

6.1 两套模块系统的核心差异对照

老项目里require和module.exports到处都是,新代码又想用 ESM 的静态分析能力,两套系统必然要碰面。先把差异摆清楚:

维度ESMCommonJS
语法import/exportrequire/module.exports
加载时机静态声明,执行前解析完整依赖图运行时同步调用,走到哪加载到哪
导出内容活绑定导出时的值(对象引用)
顶层await支持不支持
是否可条件写静态形式不行,需用import()可以写在任意位置
常见宿主浏览器、现代打包环境Node 传统生态
Tree shaking天然支持效果很差甚至不支持

这张表里最容易被低估的是"静态声明"这一行。它带来的是构建期的可分析性,也是整个前端工程化能玩出代码分割、依赖预构建这些花样的前提。CommonJS 的运行时加载虽然灵活,但工具在分析阶段根本不知道你会require什么,只能保守处理。

6.2 混用时的现实做法

先说方向。

CommonJS 里想用 ESM,不能写静态import,只能动态import(),因为require是同步的,而 ESM 的加载在规范上是异步的。所以老模块里想调新模块,写法是:

async function loadNew() { const mod = await import('./new-module.mjs'); return mod.default; }

ESM 里用 CommonJS 相对宽松一些,import一个 CommonJS 模块,把整体当成默认导出来拿:

import cjsModule from './legacy.cjs'; cjsModule.someMethod();

这里要注意,import { someMethod } from './legacy.cjs'这种写法在很多环境下不保证有效。工具会尽量做静态分析去推断module.exports上的属性名,推断不出来的时候就只能拿到默认导出。所以稳妥写法是默认导入再取属性,别赌具名导入能成。

还有一个常见的坑:在 ESM 环境里写require会报require is not defined in ES module scope。遇到这个报错,先看package.json有没有"type": "module",再看文件扩展名是不是.mjs。定位路径就这两条。

至于__dirname和__filename,在 ESM 里也不存在了,替代方案是从import.meta.url推出来:

import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);

import.meta是 ESM 专属的元信息对象,url是当前模块的完整地址。除了拿目录,还可以用它来拼相对资源路径,比写死路径可靠得多。

7. 常见问题与排查技巧实录

7.1 高频报错速查

下面这些报错信息,我几乎每次带新人都会遇到一轮。整理成表,遇到时按图索骥比自己瞎猜快得多。

报错信息关键词大概率原因处理方式
Cannot use import statement outside a module浏览器没加type="module",或 Node 没声明"type": "module"检查 script 标签属性或 package.json 字段
Failed to resolve module specifier浏览器里用了裸模块名,或路径没写扩展名补全相对路径与扩展名,或用 import maps 映射
does not provide an export named 'xxx'导出/导入名不匹配,或把默认导出当命名导出导了对照源文件确认导出形式,默认导出要写mod.default
Duplicate export of 'xxx'同一模块内重复导出同名标识符合并或重命名其一
Cannot access before initialization循环依赖下访问了未初始化的const/let抽公共模块或改用动态导入
require is not defined in ES module scope在 ESM 文件里写了 require换成 import,或调整该文件的模块类型
Assignment to constant variable试图给导入的绑定赋值改副本,或由源模块提供设置函数

排查顺序我一般固定成三步:先确认文件被当成什么模块解析(看扩展名和type字段),再确认路径能不能解析到真实文件,最后才查导出名对不对。九成的报错在前两步就解决了,直接跳到第三步去抠名字,往往白费功夫。

7.2 几个只有真做过项目才会遇到的问题

第一个,export *造成的隐蔽覆盖。一个入口文件里连续export *了三个模块,其中两个恰好都有init。规范规定冲突名字会被排除,结果就是init从公共 API 里"消失"了,导入方报does not provide an export named 'init',而你去三个源文件里查,每个都有init,怎么看都对。这种问题的排查成本极高,唯一的办法是避免大面积export *,或者写个脚本检查入口的导出集合。

第二个,Tree shaking 失效。明明只用了模块里的一个函数,打包体积还是把整个库都带进来了。常见原因有三个:库本身有副作用且没有在package.json里声明sideEffects,导致工具不敢删;你用的是命名空间导入import * as ns,工具无法判断用了哪个;或者模块顶层有立即执行的副作用代码(比如直接往原型上挂东西),工具出于正确性考虑必须保留。第三个原因最容易被忽略,我自己就写过在模块顶层注册全局事件的代码,结果整个模块怎么都摇不掉。

第三个,构建产物里模块重复。某个依赖被两个不同路径引用,node_modules里存在多个版本,最后打进去两份实现。表现出来的症状往往是"单例不单例"——配置读写不一致、事件总线两边收不到消息。排查方式是看产物里的模块路径,通常一眼就能看出重复。处理办法是在包管理层面统一版本,而不是在代码里打补丁。

第四个,动态导入的路径写成了变量拼接。import('./views/' + name + '.js')这种写法,不同工具的推断能力差别很大,有的能把整个目录都打进去,有的直接失败,还有的只在特定写法下生效。稳妥做法是把所有可能的模块写成一个显式的映射表,也就是第 4.2 节那种写法,让工具能静态看到所有候选路径。

最后分享一个我坚持了很多年的习惯:每新建一个模块文件,先在末尾把打算对外暴露的东西写出来,再回头填实现。这个顺序逼着我在动手前想清楚"这个模块的职责边界在哪",比写完几百行再回头决定导出什么,返工率低太多了。模块化的本质不是语法,是边界感,export那一行就是你画边界的地方。