
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。也就是说导入方拿到的不是值拷贝而是对导出模块内部变量的引用。导出模块里改了变量导入方读到的值会同步变化。这在某些场景下非常有用但也让循环依赖的问题变得更复杂后面第三节会专门展开。对比项CommonJSES6 模块加载时机运行时同步加载解析时静态分析导出机制对象赋值导入拿到快照实时绑定导入拿到引用浏览器支持需要通过打包器转换原生支持可异步加载Tree Shaking难以静态分析很难优化天然支持导入写法require(xxx)import xxx from xxx1.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 fnimport fn from ./x.jsimport { fn } from ./x.jsexport { fn }import { fn } from ./x.jsimport 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 模块代码却没有加typemodule。浏览器把文件当成普通脚本解析遇到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 不只是一个关键字而是一种模块设计思维”这句话体会越来越深。