模块化革命:从零搞懂 ES6 的 import 与 export
你有没有遇到过这样的场景?
在项目里写了一堆工具函数,结果同事一引入就报错:“add is not defined?”
或者打包后发现,明明只用了一个小函数,最终包体积却大得离谱?
甚至调试时一头雾水——“这个变量到底是在哪个文件改的?”
这些问题的背后,其实都指向同一个根源:缺乏模块化的代码组织方式。
直到 ES6 出现,JavaScript 才真正拥有了语言级别的模块系统。而import和export,就是这场变革的核心武器。
为什么我们需要模块化?
在 ES6 之前,JS 并没有原生的模块机制。我们靠什么?
Node.js 用 CommonJS(require/module.exports),浏览器端有人搞 AMD、UMD……
但这些方案本质都是“补丁”。它们依赖运行时加载器,无法被静态分析,导致:
- 构建工具难优化;
- Tree Shaking 做不了;
- 跨平台兼容性差;
- 多人协作容易踩坑。
ES6 模块(ESM)改变了这一切。它不是库,也不是框架,而是语言标准本身的一部分。这意味着:
无论你在浏览器还是 Node.js,只要环境支持,语法完全一致。
更重要的是,它是静态的——编译阶段就能确定依赖关系,这让现代构建工具可以做很多聪明的事,比如删掉没用的代码、按需分割资源。
export:如何正确地“暴露”你的功能
一个.js文件就是一个模块,里面的变量默认都是私有的。想让别人用?必须主动export。
1. 命名导出(Named Exports)
最常见的方式,一个模块可以有多个命名导出:
// math.js export const PI = 3.14159; export function add(a, b) { return a + b; } export function multiply(a, b) { return a * b; }这种方式的好处是清晰明了:谁导入就知道能拿到哪些名字。
💡 小技巧:你可以把
export放在声明后面,也可以统一导出:```js
function subtract(a, b) { return a - b; }
function divide(a, b) { return a / b; }export { subtract, divide };
```
2. 默认导出(Default Export)
每个模块最多只能有一个default导出:
class Calculator { // ... } export default Calculator;对应的导入会更简洁:
import Calc from './calculator.js'; // 名字可以随便起听起来很方便?但别滥用!
⚠️ 实践建议:只对“主出口”使用 default,比如组件库中的主组件、工具类的主类。否则会让使用者难以猜测导出了什么。
3. 重新导出(Re-export)——打造聚合入口
大型项目中,我们常看到这种结构:
utils/ ├── string.js ├── array.js ├── number.js └── index.js这时可以在index.js中统一对外暴露:
// utils/index.js export { formatName } from './string.js'; export { shuffleArray } from './array.js'; export { roundNumber } from './number.js'; // 或者批量转发 export * from './string.js'; // 导出所有命名项这样外部就可以优雅地写:
import { formatName, shuffleArray } from '@/utils';是不是清爽多了?
import:不只是“拿过来”,更是“建立连接”
import不是简单的复制粘贴,而是一种绑定引用。理解这一点至关重要。
动态绑定:值是实时的!
来看个例子:
// counter.js let count = 0; export function increment() { count++; } export { count };另一个模块导入:
// app.js import { count, increment } from './counter.js'; console.log(count); // 0 increment(); console.log(count); // 还是 0??等等!为什么还是 0?
因为count是导出时的快照吗?不是。
真实情况是:export { count }导出的是对count变量的动态绑定。但注意,这里的count是基本类型(数字),赋值操作不会改变原绑定。
正确的做法是导出一个对象或通过 getter:
// 正确方式:通过函数获取最新值 export const getCount = () => count;这说明了一个关键点:
✅ESM 的导出是活的(live binding),但它绑定的是变量本身,而不是值的副本。
所以如果你导出的是对象或数组,修改其属性是可以被观察到的。
import 的五种姿势,你真的都会吗?
1. 混合导入(默认 + 命名)
import DefaultClass, { namedFunction, CONSTANT } from './module.js';这是非常常见的写法,尤其在 React 组件开发中。
2. 命名空间导入(Namespace Import)
当一个模块导出太多东西,不想一个个列出来时:
import * as MathUtils from './math.js'; MathUtils.add(1, 2); MathUtils.PI;适合用于工具库的整体调用,也方便做插件系统。
3. 无副作用导入(Side-effect Import)
有些模块的作用不是提供 API,而是执行一些初始化逻辑:
// initSentry.js console.log('Initializing Sentry...'); trackPageView(); // 在主文件中: import './initSentry.js'; // 只为触发副作用这类模块通常用于注册全局监听器、打点埋点、样式注入等。
4. 动态导入(Dynamic Import)
前面说的import都是静态的,必须写在顶层。但如果我想按需加载呢?
比如点击按钮才加载某个功能模块?
这就轮到动态导入登场了:
async function loadAnalytics() { const { trackEvent } = await import('./analyticsModule.js'); trackEvent('button_clicked'); }它的返回值是一个 Promise,因此可以用在异步上下文中。
🌟 典型应用场景:
- 路由懒加载(React.lazy)
- 条件加载大型库(如 PDF.js、CodeMirror)
- A/B 测试中动态加载不同版本
5. 重命名导入(Import As)
避免命名冲突的好帮手:
import { default as MyComponent } from './ui.js'; import { fetchData as apiFetch } from './api.js';特别是在整合第三方库时特别有用。
工程实践中的那些“坑”和“秘籍”
❌ 坑点一:循环依赖怎么破?
A 模块 import B,B 又 import A —— 很常见,但也危险。
// a.js import { bFunc } from './b.js'; export function aFunc() { return 'a'; } // b.js import { aFunc } from './a.js'; // aFunc 此时为 undefined! export function bFunc() { return aFunc(); } // 报错!虽然 ESM 支持循环引用(因为是动态绑定),但初始值可能是undefined。
✅ 解决方案:
- 尽量重构代码打破循环;
- 使用函数式导出(延迟求值);
- 或将共享逻辑抽成第三个模块。
✅ 秘籍一:Tree Shaking 真的生效了吗?
你有没有发现,即使没用某个函数,它还是被打包进去了?
那很可能是因为:
- 使用了
default export且未启用sideEffects: false; - 导出的是带有副作用的语句(如直接执行函数);
- 构建配置未开启 production mode。
确保你的package.json加上:
{ "sideEffects": false }并使用 Rollup 或 Webpack 生产模式构建,才能真正实现“用多少,打多少”。
✅ 秘籍二:合理设计导出策略
| 场景 | 推荐做法 |
|---|---|
| 工具函数库 | 全部使用命名导出,提高可发现性 |
| 单一类/组件模块 | 可设为 default,简化导入 |
| 聚合入口文件(index.js) | 使用 re-export 整合子模块 |
| 类型定义(TypeScript) | 同步导出类型,便于消费 |
记住一句话:
命名导出更适合库,default 导出更适合应用。
Node.js 和浏览器都支持了吗?
当然!如今主流环境均已支持 ESM:
- 浏览器:现代 Chrome/Firefox/Safari 原生支持
<script type="module"> - Node.js:从 v12 开始稳定支持,只需:
- 文件扩展名为
.mjs,或 - 在
package.json中设置"type": "module"
示例:
{ "name": "my-app", "type": "module" }从此,.js文件也能使用import/export了!
不过要注意:CommonJS(require)和 ESM 不能混用在同一文件中,需要适配层或构建工具处理。
写在最后:模块化思维比语法更重要
掌握import和export的语法只是第一步。
真正的价值在于养成模块化思维:
- 如何划分职责边界?
- 如何设计清晰的接口?
- 如何减少耦合,提升复用?
当你开始思考这些问题的时候,你就不再只是一个“写代码的人”,而是一个系统设计者。
下次写代码前,不妨先问自己:
“这部分功能,应该作为一个独立模块存在吗?它的‘出口’应该是什么?”
也许答案会让你写出更优雅的架构。
如果你正在使用 React、Vue 或任何现代前端框架,那么你已经每天都在和 ESM 打交道。现在,是时候真正理解它背后的原理了。
毕竟,好的代码,从来都不是堆出来的,而是“搭”出来的。