export 这个关键字,我见过太多人把它当成“把函数扔出去”的开关来用。平时写点小页面没什么感觉,一旦项目大起来、模块之间开始互相引用,那些对 export 理解还停留在表面的写法,就会变成一颗颗埋在依赖图里的雷。今天这篇东西,我打算从 ES6 模块系统为什么要设计 export 讲起,把命名导出、默认导出、重导出这些写法的使用边界、底层原因和工程里的连环坑都过一遍。不管你是刚接触模块化的新手,还是写过一阵子前端项目但一直没搞懂 import/export 细节的同学,这篇都值得你花十分钟慢慢看。
1. 先搞清楚 export 到底在解决什么问题
1.1 模块化之前的混乱
在 ES6 模块标准之前,前端写代码最头疼的问题不是逻辑复杂度,而是如何避免全局变量互相踩踏。那个时候的常见做法是往 window 上挂对象,比如window.utils = { format: ... },然后再按顺序加载一堆 script 标签。问题在于,只要某个第三方库也定义了同名变量,页面就会在运行时出现诡异报错,而且这种报错很难追溯。
后来有了 IIFE(立即执行函数)和 CommonJS / AMD 这些社区方案,才把“作用域隔离”这个基础问题初步解决。但前端的模块化标准化,最终还是要靠 ES6 自带的那套 import/export 来完成。这套标准的核心逻辑很简单:一个文件就是一个模块,模块内部的所有声明默认是私有的,只有通过export导出的部分,才能被其他模块通过import使用。
export不只是“暴露内容”的语法开关,它本质上是模块系统的对外契约。这个契约一旦设计得不好,后续的重构、按需加载、依赖升级都会跟着遭殃。所以理解export的底层设计,是写出可维护模块的第一步。
1.2 ES6 模块与 CommonJS 的核心差异
很多在 Node.js 里写过 CommonJS 的同学,会对module.exports = ...比较熟悉,然后觉得 ES6 的export只是换了种写法。但实际上,两者底层机制差别很大。
CommonJS 的require是运行时同步加载的。module.exports本质上是一个普通对象,你在模块里给它赋值,其他模块在 require 时拿到的就是这个对象的快照。这种机制在服务端没问题,因为文件都在本地磁盘,读取快;但在浏览器端就会造成阻塞,而且无法静态分析依赖关系。
ES6 模块走的完全是另一套设计。import和export是在解析阶段就确定的静态结构,JS 引擎在执行代码之前,就能通过关键字建立起整个依赖图。这个差异带来两个直接后果:一是浏览器可以做异步加载和并行加载,二是工具链可以做 Tree Shaking,把没用到的导出从最终打包文件里剔除。
还有一个很重要的细节:ES6 模块的export绑定的是“实时绑定”(live binding)。也就是说,导入方拿到的不是值拷贝,而是对导出模块内部变量的引用。导出模块里改了变量,导入方读到的值会同步变化。这在某些场景下非常有用,但也让循环依赖的问题变得更复杂,后面第三节会专门展开。
| 对比项 | CommonJS | ES6 模块 |
|---|---|---|
| 加载时机 | 运行时同步加载 | 解析时静态分析 |
| 导出机制 | 对象赋值,导入拿到快照 | 实时绑定,导入拿到引用 |
| 浏览器支持 | 需要通过打包器转换 | 原生支持,可异步加载 |
| Tree Shaking | 难以静态分析,很难优化 | 天然支持 |
| 导入写法 | require('xxx') | import xxx from 'xxx' |
1.3 为什么 export 必须写在模块顶层
如果你试过在if语句里写export,大概率会看到控制台报错。这不是语法设计得不够灵活,而是有意为之。export必须在模块顶层声明,因为 JS 引擎需要在执行代码之前,通过扫描顶层语句来建立模块的依赖关系图。如果export出现在条件分支里,引擎就无法静态判断到底导出了哪些名字,整个优化链条也就断了。
同理,import也只能出现在顶层。这条限制是 ES6 模块设计初期的关键决策,也是后来各种打包工具能做摇树优化的地基。理解这一点之后,你就不会再写出if (condition) { import ... }这种注定报错的代码,也不会困惑为什么动态加载要用import()这个函数式 API。
2. export 的几种写法,以及对应的 import 姿势
2.1 具名导出:最常用也是最稳妥的写法
具名导出(named export)大概是实际项目里出现频率最高的写法。常见形式有三种:
// 写法一:直接导出声明 export const PI = 3.14159; export function getRadius(diameter) { return diameter / 2; } export class Circle { constructor(radius) { this.radius = radius; } } // 写法二:先声明,再统一导出 const PI = 3.14159; function getRadius(diameter) { return diameter / 2; } export { PI, getRadius }; // 写法三:导出时重命名 const PI = 3.14159; export { PI as piValue };这三种写法最终效果等价,选哪种主要看个人习惯。我自己的经验是:模块内部声明太多的话,用“统一导出”的写法更清晰,因为所有对外暴露的名字集中在文件底部,一眼就能看到这个模块提供了什么;模块本身比较短小,就直接在每个声明前加上export,省去底部重复维护。
要特别注意写法三的重命名。export { PI as piValue }这种语法在你需要对外提供语义化 API 时很实用。比如内部变量名是data,但导出后你想让外部使用时觉得语义更清晰,就可以export { data as userData }。这种重命名不会影响模块内部代码,外部 import 时只能通过userData这个名字来引用。
对应的 import 写法也很直接:
import { PI, getRadius, Circle } from './shapes.js'; import { piValue as pi } from './constants.js';2.2 默认导出:一个模块只能有一个
默认导出(default export)用export default表示。和具名导出最关键的区别是:一个模块只能有一个默认导出,而且导入方给这个导出取什么名字都行。
// confirm.js export default function confirmDialog(message) { return window.confirm(message); } // main.js import confirmDialog from './confirm.js'; // 或者换个名字 import confirmDialogFn from './confirm.js';默认导出比较适合那些“一个模块只做一件事”的场景,比如一个组件、一个工具函数。因为导入方不需要记住准确的名字,只要知道路径就能导入,心智负担会小一些。但它也有代价:默认导出的名字只在模块内部有定义,外部没有固定标识,这在有些工具链里会影响到 Tree Shaking 的精确性。
还有一点,export default后面的语法比较灵活,可以接一个表达式,也可以接声明。但如果你用export default function foo() {},这个 foo 在模块外部是拿不到的,外部只能通过默认导入来引用。
我之前遇到过一个坑:在同一个文件里同时使用export default和具名导出,然后在导入时忘记加花括号,结果把整个默认值当成具名导出对象来用,调试了十分钟才反应过来。所以这里直接给你一个对照表:
| 模块导出方式 | 正确的导入方式 | 容易出错的写法 |
|---|---|---|
export default fn | import fn from './x.js' | import { fn } from './x.js' |
export { fn } | import { fn } from './x.js' | import fn from './x.js' |
| 两种同时存在 | import fn, { other } from './x.js' | 忘记区分默认和具名 |
2.3 混合导出和一系列进阶导入写法
实际项目中,一个模块同时有默认导出和具名导出的情况并不少见。比如一个组件模块,默认导出一个组件主体,具名导出几个辅助函数。
// Button.js export default function Button(props) { // ... } export function buttonStyles() { // ... } export const buttonSizeMap = { small: '12px', medium: '16px', large: '20px' };对应的导入方式可以混着写:
import Button, { buttonStyles, buttonSizeMap as sizeMap } from './Button.js';除此之外还有几个容易忽略的空的切换点。import * as utils from './utils.js'会把模块所有导出都打包成一个对象,然后在代码里用utils.format()这种方式访问。这种写法虽然省事,但如果是纯工具库,会破坏 Tree Shaking 的效果,因为打包工具无法静态得知你具体用到了哪些属性。
还有一种是无绑定导入:import './polyfill.js'。这种导入不关心模块导出了什么,纯粹是让模块的副作用代码执行一遍。比如某些全局样式、某些原型扩展,就适合这种写法。但要注意,副作用导入在 Tree Shaking 时往往会被直接保留,除非打包器配置了 sideEffects 字段来标记“这个模块没有副作用”。
import * as math from './math.js'; import './polyfills.js'; console.log(math.sum(1, 2));3. 工程实战里的关键细节与避坑指南
3.1 具名导出和默认导出如何取舍
我见过不少团队在代码规范里写“默认导出只能用于组件”,这句话有一定道理,但也不绝对。从我踩过的坑来看,默认导出的核心问题是:它把你的模块成员“匿名化”了,这对静态分析不友好,也让 IDE 的自动补全和跳转变得不那么聪明。
举个例子,如果某模块同时默认导出了一个很大函数,又具名导出了几个小工具,打包工具在做 Tree Shaking 时,对默认导出会倾向于“全部保留”,因为它不知道你到底会不会使用这个默认导出里的全部逻辑。相反,具名导出可以被逐个分析,没用到的方法就会在打包时被删掉。
所以我的个人原则是:
- 工具函数库、业务逻辑模块:一律用具名导出,方便 Tree Shaking 和重构。
- 组件文件、页面入口:允许默认导出,这是主流框架和路由配置的惯例。
- 对外发布的开源库:可以考虑同时提供默认导出和具名导出,但对 Tree Shaking 的要求很高时,优先具名导出。
3.2 循环依赖:export 里最经典的坑
循环依赖指的是 A 模块引用了 B 模块,B 模块又引用了 A 模块。在小项目里出现频率不高,但一旦模块图复杂起来,就会遇到。
看看下面这个例子:
// a.js import { bValue } from './b.js'; export const aValue = `a uses ${bValue}`; // b.js import { aValue } from './a.js'; export const bValue = `b uses ${aValue}`;这段代码在浏览器控制台里通常不会直接报错,但实际运行时你会拿到undefined。原因在于 ES6 模块的初始化过程是深度优先的:当 a.js 被作为入口加载时,它会先去加载 b.js;在加载 b.js 的过程中,b.js 又试图去导入 a.js 里的aValue,但是这个时候 a.js 还没有初始化完成,aValue处于未绑定状态,所以 b.js 拿到的就是一个undefined。
解决思路有几个方向:一是把公共依赖拆到第三个模块,二是把引用点从“模块初始化阶段”推迟到“函数调用阶段”。也就是说,不要在模块顶层直接使用导入的值,而是放到函数体内去使用,这样即使模块顺序有问题,调用时也已经完成初始化了。
个人经验:循环依赖一旦出现,不要抱着侥幸心理去修补导入顺序,因为随着项目迭代,这种问题只会越来越隐蔽。最稳妥的办法是优先重构模块边界,把公共部分抽出去,让依赖图保持单向流动。
3.3 Tree Shaking 对 export 写法的影响
Tree Shaking 已经成为现代前端工程里绕不开的话题。它的原理并不复杂:借助 ES6 模块的静态结构,打包器可以识别哪些导出从未被 import,然后在构建时把这些代码从产物里剔除。
但是,Tree Shaking 的生效是有条件的。第一个条件是必须使用 ES6 模块语法,不能使用 CommonJS 的module.exports。第二个条件是导出结构必须清晰。
最典型的反面教材是这样的:
// utils.js export default { formatDate(date) { /* ... */ }, sortList(list) { /* ... */ }, debounce(fn, wait) { /* ... */ } }; // main.js import utils from './utils.js'; utils.formatDate(new Date());这种写法表面上只用了formatDate,但打包器无法判断这个默认导出的对象是否还有其他副作用,通常会把整个对象完整保留,三个函数都会出现在产物里。而如果改成具名导出:
// utils.js export function formatDate(date) { /* ... */ } export function sortList(list) { /* ... */ } export function debounce(fn, wait) { /* ... */ } // main.js import { formatDate } from './utils.js'; formatDate(new Date());打包器就能精准地只保留formatDate,其他两个方法从产物图里直接抹掉。这个差别在小型工具库里可能只有几 KB,但在大型业务项目里累计起来非常可观。
3.4 重导出与 barrel 文件的正确打开方式
重导出(re-export)的语法长这样:
export { formatDate } from './date.js'; export { sortList } from './array.js';很多项目习惯在所有模块的目录下放一个 index.js,然后统一把子模块导出再聚合,这种文件通常叫 barrel 文件。举个例子:
// utils/index.js export * from './date.js'; export * from './array.js'; export * from './string.js';这样做的好处很明显:外部引用路径干净,比如import { formatDate } from '../utils',不需要一层层找文件。
但 barrel 文件也有坑。一个很大的 index.js 把所有模块全部export *了自己的功能,看起来方便,实际上会拖慢构建,甚至在部分打包器里造成循环引用问题。尤其是在大型业务工程里,成千上万个模块都经过一个大 barrel 再引用,解析阶段的开销会成倍增加。
我的建议是:小型的工具集合可以用 barrel;大型业务模块尽量直接 import 具体文件路径,或者只聚合对外公开的 API,而内部实现走内部路径。维护者要克制“把一切变得方便”的冲动,有时候多敲几个字符反而更健康。
4. 不同项目形态下的 export 应用
4.1 工具函数库的导出策略
如果你自己写了一个工具函数库,最理想的状态是:使用方想按需引入就按需引入,想全量引入就全量引入,依赖方的打包器还能自动做 Tree Shaking。
这种情况下,我推荐所有函数都用具名导出,同时提供一个聚合入口。比如在包的入口文件里:
export { formatDate } from './src/date.js'; export { debounce } from './src/function.js'; export { getQueryParams } from './src/url.js';然后配合 package.json 里的模块配置,让现代打包器能够直接使用 ES6 模块版本。这里有个容易忽略的细节:包的代码要同时保留 ESM 和 CommonJS 两种版本,否则在 Node 环境里引用时找不到对应格式会报错。
另一个导出策略是子路径导出。也就是说,除了根入口之外,还允许用户直接import { debounce } from 'my-utils/function',相当于在 package.json 里配置多个导出路径。这种方式对构建工具更友好,因为依赖方可以绕过不必要的聚合文件。
4.2 组件与页面场景下的导出习惯
在组件框架类项目里,一个文件对应一个组件,组件通常作为默认导出对外提供。因为路由懒加载、组件注册这类场景都需要“根据字符串或者路径找到组件”,默认导出让导入名可以任意定义,跟需求和配置代码更容易对齐。
但组件目录里往往还有类型定义、样式相关的常量、辅助工具函数,这些更适合具名导出。我记得有个项目,组件文件里同时默认导出了组件本身,具名导出了 props 类型、默认 props、样式计算函数,这样的组合用起来很顺手。
需要注意:具名导出的类型和常量,如果只是组件内部使用,不要随意导出,否则会把组件的公共 API 面撑得很大,后面维护的时候每改一个内部变量都要担心是不是影响到外部引用方。
4.3 Node.js 环境与 CommonJS 的兼容问题
Node.js 从 12 版本开始原生支持 ES6 模块,但默认情况下.js文件仍然按照 CommonJS 解析。要让 Node 识别你的.js文件是 ES6 模块,需要在 package.json 里加"type": "module",或者直接把文件后缀改成.mjs。
对于之前的 CommonJS 项目,如果你想逐步迁移到 ES6 模块,要注意两种模块系统互相引用时的差异。在 ESM 文件里可以通过import pkg from 'some-commonjs-lib'去导入 CommonJS 模块的默认导出,这通常没有问题。但反过来,在 CommonJS 文件里用require()引入 ESM 模块,在早期版本会直接报错,只能改用动态import()。
还有一个细节:ESM 中不能用__dirname和__filename,因为这两个变量是 CommonJS 注入的。改用import.meta.url来获取当前模块路径。这个改名让很多迁移项目的同事踩了坑,迁移时建议提前搜索全项目对这两个变量的依赖情况。
4.4 TypeScript 中的 export type 与值导出
TypeScript 里,类型和值都有关键字导出,写法上有点容易混淆。如果你导出的是一个 interface 或者 type alias,通常建议使用export type或者export interface,这样编译成 JavaScript 后会被完全擦除,不会产生多余的运行时代码。
export interface UserProfile { id: string; name: string; } export type UserId = string; // 按需类型导入 import type { UserProfile, UserId } from './types.js';引入import type的写法是为了明确告知编译器:我只导入类型,不要生成运行时引用。这在某些编译配置下能显著减少最终产物体积,也让代码意图更清晰。如果你的项目开启了隔离模块(isolatedModules),那export type和import type这类显式写法几乎是必须的。
我经常看到同事把类型和值混在一个 export 语句里,短时间没问题,但哪天类型改了个名字,会在编译阶段收到一堆语法错误。所以我的建议是:类型导出单独写一段,和值导出分开,模块结构更清晰。
5. 常见问题与排查实录
5.1 各种 SyntaxError 的真实场景
最经典的报错是SyntaxError: Unexpected token 'export'。出现这个错误一般有三种原因。
第一种是最基础的原因:你在普通 script 标签里直接写了 ES6 模块代码,却没有加type="module"。浏览器把文件当成普通脚本解析,遇到export就会语法报错。
第二种是 Node.js 环境里,.js文件没有声明为模块。解决方法是 package.json 加上"type": "module"或者把文件后缀改成.mjs。
第三种是打包配置问题。比如配置文件里没有把第三方库的 ES6 代码放进编译范围,结果那部分代码原封不动输出了浏览器或旧工具链,也会触发这个错误。
排查思路很简单:先看一下当前文件解析时被当作什么模块系统,再看执行环境支持什么语法。浏览器控制台、Node 的报错信息通常都会提示具体的文件和位置,顺着报错定位到具体文件,往往一眼就能看出原因。
5.2 导入得到 undefined 的排查清单
import之后拿到undefined,这个问题的成因比语法错误要多很多。我做了一个速查表,基本涵盖了大多数情况:
| 现象 | 常见原因 | 解决方式 |
|---|---|---|
| 导入具名导出为 undefined | 模块里没有对应名字的导出 | 检查拼写和大小写 |
| 默认导入为 undefined | 模块没有默认导出,只有具名导出 | 改成具名导出的导入写法 |
| 导入的值初始化时是 undefined | 循环依赖 | 抽离公共模块或延迟引用 |
| 导入对象属性不存在 | 导出了类,但类实例化失败 | 检查构造函数执行顺序 |
| 动态 import 后拿到的模块对象为 undefined | 导出的是 async 方法调用结果 | 检查 await 的使用方式 |
遇到这类问题时,最快的排查手段是在导出模块和导入模块里分别打印:导出端打console.log('a module loaded'),确认模块有没有执行;导入端打整个导入对象,确认拿到了什么形状。用二分法逐步缩小范围,基本上几分钟内能找到问题来源。
5.3 循环依赖问题的定位与修复实录
循环依赖的报错往往很含蓄,不是直接说“这里有环”,而是运行时某个值变成undefined。有一次我在项目里碰到一个页面偶尔白屏,控制台报错指向一个纯函数,但函数本身的实现看起来没问题。后来才发现,这个纯函数所在的模块和另一个工具模块形成了循环依赖,两个模块顶层都会在初始化时使用对方导出的常量,导致加载顺序不稳定。
定位循环依赖的工具,我常用 Webpack 的 circular-dependency-plugin 或者在 Node 里用 madge 检查依赖图。修复思路按优先级排列:
- 把公共依赖抽取到独立的模块,让依赖方向变成单向。
- 如果实在不能抽,把导入的值放到函数体内使用,不要在模块顶层执行。
- 用依赖注入的方式,把需要的依赖作为参数传进去,避免模块之间直接互相引用。
我之前也见过有人用删除某个 import 的方式来“修好”问题,结果只是碰巧让初始化顺序变了,问题还在那里潜伏。等下一次有人调整模块顺序,它又会冒出来。所以遇到循环依赖,我一般都会花时间彻底清理掉,而不是用侥幸方案带病上线。
5.4 我实际使用的排查工具组合
最后分享一套我平时排查模块问题的组合方式。
第一层是浏览器的 Network 面板,网络面板里能看到所有模块的加载顺序。如果某个模块一直卡在加载中,或者加载时间异常,通常就说明依赖图有问题。
第二层是 DevTools 的 Sources 面板,打断点观察模块初始化顺序。有时候直接打印各个被导入对象在初始化前长什么样,比看错误栈更直观。
第三层是打包器和 Node 的命令行工具。Webpack 的 stats 输出能看到模块间的依赖关系,Node 端用--trace-warnings或--trace-module能看到模块解析路径。
这套组合在大多数场景下都够用了。核心思路是:先确认模块有没有加载,再确认加载顺序对不对,最后确认导出内容和导入内容对没对上,基本上能覆盖 90% 的 export 相关问题。
从我个人的实际项目经验来看,export 相关的坑大多数不是语法问题,而是模块边界设计问题。模块的公共 API 越小,依赖关系越单向,后面踩雷的概率就越低。我现在写新模块时的默认原则是:能用具名导出就不用默认导出,能不跨层引用就不跨层引用,能抽公共模块就抽公共模块。这套习惯帮我省下了大量排错时间,也让我对“export 不只是一个关键字,而是一种模块设计思维”这句话体会越来越深。