ES Module 实战指南:从基础语法到项目优化技巧
先看效果,再看代码
我最近在重构一个老项目,前端模块管理还是靠全局变量和手动 script 标签引入,简直灾难。改用 ES Module 后,整个结构清爽多了。别被“模块化”吓到,其实上手非常快。下面这段代码,你复制粘贴就能跑:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>ESM Demo</title>
</head>
<body>
<button id="btn">点我</button>
<script type="module" src="./main.js"></script>
</body>
</html>
// utils.js
export function formatDate(date) {
return date.toISOString().split('T')[0];
}
export const API_URL = 'https://jztheme.com/api';
// main.js
import { formatDate, API_URL } from './utils.js';
document.getElementById('btn').addEventListener('click', () => {
console.log('今天日期:', formatDate(new Date()));
console.log('API地址:', API_URL);
});
注意那个 type="module",这是关键。没有它,浏览器会当成普通脚本执行,import/export 直接报错。亲测有效,现在所有现代浏览器都支持,除非你还在维护 IE(那另说)。
这个场景最好用
我特别喜欢用 ES Module 处理配置和工具函数。比如项目里有多个页面都要调同一个 API,以前得 copy-paste 配置,现在直接 export 一个常量,import 就行。改一次,全站生效。
更爽的是动态导入(dynamic import)。比如某个功能只在特定条件下才需要,比如用户点了“导出数据”才加载 Excel 生成库:
// 只在需要时才加载
async function exportToExcel() {
const { generateExcel } = await import('./excel-utils.js');
generateExcel(data);
}
这样首屏加载更快,包体积也小。我之前在一个报表项目里这么干,首屏 JS 从 1.2MB 降到 600KB,老板直呼内行。
踩坑提醒:这三点一定注意
ES Module 看着简单,但有几个坑我踩过好几次,不吐不快:
- 路径必须带后缀名:你不能写
import { foo } from './utils',必须写import { foo } from './utils.js'。这点和 Node.js 不一样,Node 里可以省略 .js,但浏览器不行。我第一次迁移时忘了加,控制台报错 “Failed to resolve module specifier”,折腾了半小时。 - 本地文件必须用 HTTP 服务:直接双击打开 HTML 文件会报 CORS 错误。因为 ES Module 要求通过 HTTP(S) 加载,file:// 协议不被允许。解决方法很简单:用
npx serve或python -m http.server起个本地服务就行。别傻乎乎地以为是代码问题。 - 默认是严格模式:ES Module 自动开启严格模式,有些老代码里的隐式全局变量会直接报错。比如
function foo() { x = 1; }这种,在模块里会抛异常。建议提前用 ESLint 扫一遍,或者把老代码包一层 IIFE 再逐步迁移。
高级技巧:命名导出 vs 默认导出?
很多人纠结该用命名导出(named export)还是默认导出(default export)。我的经验是:优先用命名导出。
为什么?因为命名导出更明确,IDE 自动补全更准,重构也更安全。比如:
// 推荐:命名导出
export function calculateTax(amount) { /* ... */ }
export function formatCurrency(amount) { /* ... */ }
// 使用
import { calculateTax, formatCurrency } from './finance.js';
而默认导出容易导致命名混乱:
// utils.js
export default function helper() { /* ... */ }
// main.js
import whateverNameIWant from './utils.js'; // 名字随便起,容易混淆
当然,如果你的模块就导出一个类或一个主函数(比如一个 Vue 组件),默认导出也 OK。但多数情况下,命名导出更清晰。我现在的项目里,默认导出基本只用于 React/Vue 组件文件。
和打包工具怎么配合?
有人问:现在都用 Webpack/Vite 了,还用原生 ES Module 干嘛?
其实两者不冲突。Vite 本身就是基于原生 ES Module 的开发服务器,开发时直接用浏览器原生能力,构建时再打包。而 Webpack 虽然内部用 CommonJS,但最终输出也可以是 ES Module(通过 output.libraryType: 'module')。
更重要的是,写代码时按 ES Module 规范写,未来迁移成本最低。不管底层是 Vite、Webpack 还是纯原生,你的源码结构都不用大改。我之前一个项目从 Webpack 切到 Vite,业务代码一行没动,只改了配置文件,就是因为一直用标准 ES Module 语法。
顺便提一句,如果你用 TypeScript,.ts 文件也支持 ES Module 语法,编译后会自动处理成目标环境兼容的模块格式,完全不用操心。
最后的小遗憾
ES Module 也不是万能的。比如它不支持 JSON 直接导入(虽然 Chrome 已经支持,但其他浏览器还不行),这时候还得用 fetch:
// 暂时不能这么写(除非用构建工具)
// import data from './config.json' assert { type: 'json' };
// 得这么干
const res = await fetch('./config.json');
const data = await res.json();
另外,循环依赖在 ES Module 里行为比较诡异,虽然不会直接报错,但可能拿到 undefined。我的建议是:尽量避免循环依赖,实在不行就用函数延迟引用。
总的来说,ES Module 是现代前端的基石。哪怕你用框架,底层也是它。早点掌握,少走弯路。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多(比如用 import map 做微前端),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论