模块化开发

张玥 2026年9月16日 阅读时间 35分钟
ES6+ 模块化 ES Module 动态导入 Tree-shaking
ES6+新特性之模块化开发

在 ES6 之前,JavaScript 从来没有官方的模块系统。我们靠全局变量、命名空间、IIFE、CommonJS、AMD 这些"民间方案"勉强维持着代码的组织秩序,直到 2015 年 ES Module 正式写进语言规范。它带来了静态的 import / export 语法、实时绑定、循环依赖处理、动态导入、顶层 await,并与打包工具、Tree-shaking、浏览器原生加载机制深度联动。本文将从演进历史讲到语法细节,从加载机制讲到工程实践,用 35 分钟带你彻底吃透 ES6+ 模块化开发。

从全局变量到 ES Module:模块化演进史

要理解 ES Module 的价值,必须先看清它解决了什么问题。在模块化标准出现之前,前端代码组织经历了几个阶段的"自救"。

/* 阶段一:全局函数 —— 命名冲突的重灾区 */
function formatPrice(v) { return '¥' + v; }
function formatPrice(v) { return v.toFixed(2); }
// 后定义的覆盖前者,且毫无提示

/* 阶段二:命名空间 —— 用对象隔离 */
var MyApp = {};
MyApp.utils = {
  format: function (v) { /* ... */ }
};
// 问题:内部实现仍可被外部随意改写

/* 阶段三:IIFE —— 利用闭包实现私有作用域 */
var Counter = (function () {
  var count = 0;
  return {
    inc: function () { return ++count; }
  };
})();
// 问题:依赖顺序靠 script 标签顺序,无法声明依赖
全局函数 命名冲突
命名空间 隔离不彻底
IIFE 私有作用域
CJS / AMD 运行时加载,需工具
ESM 语言级标准 · 静态分析

每一代方案都在解决上一代的痛点

ES Module(简称 ESM)与前几代方案最本质的区别在于:它的依赖关系是静态的——在代码执行之前,引擎就能通过解析源码构建出完整的模块依赖图。这一特性直接催生了 Tree-shaking、代码分割、静态类型分析等一系列现代工程能力。

基础语法:export 与 import

ESM 的核心只有两个关键字:export 用于对外暴露,import 用于引入依赖。它们都必须写在模块顶层,不能放在函数或条件语句内部。

/* math.js —— 具名导出 */
export const PI = 3.14159;

export function add(a, b) {
  return a + b;
}

export class Vector {
  constructor(x, y) { this.x = x; this.y = y; }
}

/* 也可以集中导出 */
const minus = (a, b) => a - b;
export { minus };

/* main.js —— 具名导入 */
import { PI, add, Vector } from './math.js';
console.log(add(1, 2)); // 3
模块的三大特征
① 模块内始终是严格模式,无需 'use strict'
② 顶层 this 是 undefined,不再是 window
③ 模块只求值一次,多次导入共享同一实例
严格模式 单例求值 顶层作用域隔离
/* 默认导出:一个模块只能有一个 */
export default function createApp(options) {
  return { options: options };
}

/* 导入默认导出:名字随便起,不需要花括号 */
import appFactory from './app.js';

/* 默认导出 + 具名导出混用 */
import React, { useState, useEffect } from 'react';

导入导出的进阶用法

真实项目中,模块之间的关系远比"导出—导入"复杂。ESM 提供了重命名、聚合转发、命名空间导入等能力,让模块的依赖拓扑可以灵活组织。

/* 1. 导入时重命名 */
import { add as sum, minus as sub } from './math.js';

/* 2. 导出时重命名 */
export { sum as add };

/* 3. 命名空间导入:把整个模块挂到一个对象上 */
import * as MathUtils from './math.js';
MathUtils.add(1, 2);

/* 4. 仅执行副作用,不导入任何绑定 */
import './polyfill.js';

/* 5. 聚合转发:统一出口,收敛模块边界 */
export { add, minus } from './math.js';
export * from './string.js';
export { default as Logger } from './logger.js';
统一出口(barrel)模式
math.js string.js logger.js
↓ re-export
index.js
↓
业务代码只写一行 import

聚合出口让调用方更简洁,但要注意别造成循环引用

一个容易踩的坑:具名导出的绑定是提升的,但默认导出不是。如果在模块顶层用 export default foo() 调用一个尚未初始化的函数表达式,会直接报错。稳妥做法是先声明、再在末尾统一 export default。

动态导入 import():按需加载的利器

静态 import 必须写在顶层,意味着模块及其依赖会在页面加载时被一并下载。而 import() 是一个返回 Promise 的函数,可以在任何位置调用,从而实现条件加载、路由懒加载、交互触发加载。

/* 基本用法:返回 Promise */
const module = await import('./heavy.js');
module.doWork();

/* 按需加载:只有用户点击才下载 */
btn.addEventListener('click', async () => {
  const { exportPDF } = await import('./pdf-exporter.js');
  exportPDF(data);
});

/* 条件加载:根据能力检测选择实现 */
const codec = supportsAVIF
  ? await import('./codec-avif.js')
  : await import('./codec-webp.js');

/* 路由懒加载(伪代码) */
const routes = {
  '/dashboard': () => import('./views/Dashboard.js'),
  '/settings': () => import('./views/Settings.js')
};

/* 失败兜底 */
try {
  await import('./optional-feature.js');
} catch (err) {
  console.warn('模块加载失败,降级处理', err);
}
打包产物:静态导入 vs 动态导入
静态导入 → 全部打进主包
main.js
动态导入 → 拆分独立 chunk
main.js
chunk A
chunk B

首屏体积更小,加载更快

注意:import() 虽然写法像函数调用,但它不是普通函数,不能被赋值给变量后调用(如 const load = import; load('x') 会报错)。它也不能在非模块环境中使用。另外,参数中不能使用完全动态的表达式,否则打包工具无法预判路径,会退化成"把整个目录都打包"。

模块加载机制与顶层 await

ESM 的加载过程分为三个阶段:解析(Parsing)→ 实例化(Instantiation)→ 求值(Evaluation)。理解这三步,才能理解为什么会有循环依赖、为什么会"暂时性死区"。

/* 阶段一:解析 —— 构建模块图,不执行代码 */
/* 阶段二:实例化 —— 建立绑定关系,分配内存 */
/* 阶段三:求值 —— 深度优先后序遍历,执行代码 */

/* 顶层 await:让模块本身变成异步 */
const res = await fetch('/config.json');
const config = await res.json();

export const API_BASE = config.apiBase;

/* 导入这个模块的一方,会等待其求值完成 */
import { API_BASE } from './config.js';
// 此处 API_BASE 一定已经就绪
① 解析 扫描 import,构建依赖图
② 实例化 建立"实时绑定"的内存槽位
③ 求值 深度优先后序,执行模块代码
⚠ 实践建议:顶层 await 会阻塞依赖它的模块,滥用会拖慢首屏。建议只在配置加载、国际化初始化等必要场景使用。

浏览器中的模块:type="module"

浏览器原生支持 ESM,只需要在 script 标签上添加 type="module"。但随之而来的是一系列必须了解的行为变化。

<!-- 经典脚本:立即执行,阻塞解析 -->
<script src="app.js"></script>

<!-- 模块脚本:自动 defer,等文档解析完再执行 -->
<script type="module" src="app.js"></script>

<!-- 内联模块 -->
<script type="module">
  import { init } from './main.js';
  init();
</script>

<!-- 兼容旧浏览器:nomodule 回退 -->
<script nomodule src="legacy.js"></script>

<!-- 动态导入不需要 type="module" -->
<script>
  import('./lazy.js').then(m => m.run());
</script>
模块脚本的额外约束
自动 defer,无需手动添加
自动应用 CORS,跨域需服务端响应头
必须是 JS MIME 类型(text/javascript)
file:// 协议下无法加载模块
内联模块无法被 defer 之外的方式控制

本地开发请务必使用 HTTP 服务器

Import Maps:让裸模块名可用

浏览器不认识 import x from 'lodash' 这样的裸模块名,它要求路径必须是 ./、../ 或绝对 URL。Import Maps 正是为了解决这个问题而生的——它让浏览器具备了类似打包工具的路径解析能力。

<!-- 必须写在第一个模块脚本之前 -->
<script type="importmap">
{
  "imports": {
    "lodash": "https://cdn.example.com/lodash-es/lodash.js",
    "lodash/": "https://cdn.example.com/lodash-es/",
    "@utils/": "./src/utils/"
  },
  "scopes": {
    "/legacy/": {
      "lodash": "/legacy/lodash-3.js"
    }
  }
}
</script>

/* 之后就可以这样写了 */
import _ from 'lodash';
import { format } from '@utils/format.js';
解析流程
import _ from 'lodash'
↓ importmap 查表
https://cdn.example.com/lodash-es/lodash.js
↓ 发起请求
模块加载完成 ✅

无构建工具也能拥有规范的模块路径

ESM 与 CommonJS 的差异与互操作

Node.js 长期使用 CommonJS(CJS),ESM 与之并存带来了不少"踩坑"时刻。搞清楚两者的差异,是写出可靠 Node 代码的前提。

/* CommonJS:运行时加载,值拷贝 */
const { readFile } = require('fs');
module.exports = { foo: 1 };

/* ESM:静态加载,实时绑定 */
import { readFile } from 'fs';
export const foo = 1;

/* Node 中启用 ESM 的三种方式 */
// 1. 文件后缀 .mjs
// 2. package.json 中 "type": "module"
// 3. 动态 import() 在 CJS 中加载 ESM

/* CJS 中加载 ESM:必须用动态导入 */
(async () => {
  const mod = await import('./esm-module.mjs');
  mod.default();
})();

/* ESM 中获取 __dirname 的替代写法 */
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
CommonJS
· require() 运行时加载
· 值拷贝
· this 指向 exports
· 有 __dirname / __filename
· 同步加载
ES Module
· import 静态解析
· 实时绑定
· this 为 undefined
· 用 import.meta.url
· 支持异步(顶层 await)

新项目优先选择 ESM

循环依赖与实时绑定

ESM 的"实时绑定"(Live Binding)意味着导入方拿到的不是值的拷贝,而是指向导出变量的引用。这既是它强大之处,也是循环依赖问题的根源。

/* counter.js */
export let count = 0;
export function increment() { count++; }

/* main.js */
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 —— 实时绑定,能看到更新!

/* 对比 CommonJS:拿到的是快照 */
// const { count } = require('./counter');
// count 永远是 0,因为解构时复制了值

/* 循环依赖:a.js 与 b.js 互相引用 */
/* a.js */
import { b } from './b.js';
export function a() { return 'a'; }
// 注意:不要在顶层立即调用 b()
实时绑定 vs 值拷贝
export let n
模块内的变量
→
import { n }
同一内存引用
规避循环依赖的三条建议:
① 抽取共享模块到第三方文件
② 用动态 import() 打破静态环
③ 把相互调用延迟到函数执行时

import.meta:模块的元数据入口

import.meta 是 ESM 提供的元数据对象,宿主环境可以往里挂载模块相关信息。浏览器中它至少包含 url 属性。

/* 获取当前模块的完整 URL */
console.log(import.meta.url);
// "https://example.com/src/utils/format.js"

/* 基于模块路径解析同级资源 */
const workerUrl = new URL('./worker.js', import.meta.url);
const worker = new Worker(workerUrl);

/* 读取资源文件 */
const iconUrl = new URL('../assets/icon.svg', import.meta.url).href;

/* 热更新标记(由打包工具注入) */
if (import.meta.hot) {
  import.meta.hot.accept();
}

/* Node.js 20+ 提供 resolve 方法 */
const resolved = import.meta.resolve('./config.json');
meta.url
模块 URL
meta.resolve
路径解析
meta.env
环境变量(工具)
meta.hot
热更新(工具)

它是 ESM 世界中替代 __dirname 的标准方案

Tree-shaking:让打包体积更小

Tree-shaking 依赖 ESM 的静态结构:打包工具在构建阶段分析出哪些导出从未被引用,从而安全地删除它们。但要让 Tree-shaking 真正生效,代码写法很关键。

/* ✅ 推荐:具名导出,可被静态分析 */
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
export function mul(a, b) { return a * b; }

/* 调用方只用了 add,sub 与 mul 会被移除 */
import { add } from './math.js';

/* ❌ 反面案例:整个对象默认导出,无法摇树 */
export default {
  add(a, b) { return a + b; },
  sub(a, b) { return a - b; }
};

/* ⚠ 注意副作用:顶层执行语句会阻止摇树 */
export const VERSION = '1.0.0';
console.log('模块被加载了'); // 副作用!

/* package.json 中标记无副作用 */
// "sideEffects": false
// 或只保留有副作用的文件
// "sideEffects": ["./src/polyfill.js", "*.css"]
摇树前后对比
摇树前:12.4 KB
摇树后:4.1 KB

具名导出 + 无副作用 = 极致体积

工程化实践:模块的组织与拆分

语法只是基础,如何在真实项目中划分模块边界、控制依赖方向,才是模块化设计的核心功力。

/* 推荐的目录结构 */
src/
├── api/ // 接口层,只依赖 utils
├── components/ // 通用组件,不依赖业务
├── features/ // 业务模块,按领域划分
├── hooks/ // 可复用逻辑
├── utils/ // 纯函数工具,零依赖
└── index.js // 应用入口

/* 依赖方向:单向流动,禁止反向依赖 */
/* utils ← hooks ← components ← features ← index */

/* 统一出口:为每个目录提供 index.js */
/* components/index.js */
export { Button } from './Button.js';
export { Modal } from './Modal.js';

/* 调用方 */
import { Button, Modal } from '@/components';
单向依赖分层
index(入口)
↓
features(业务)
↓
components / hooks
↓
utils(零依赖)

依赖只能向下,避免循环引用

最佳实践与总结

  • 优先具名导出:便于静态分析、重命名与 Tree-shaking,默认导出留给"模块只有一个主输出"的场景
  • 控制模块粒度:一个模块只做一件事,导出项不超过 10 个,超过就该考虑拆分
  • 保持依赖单向:杜绝循环依赖,用分层结构约束引用方向
  • 善用动态导入:路由、弹窗、图表库等非首屏资源一律按需加载
  • 避免顶层副作用:模块顶层只做声明,副作用放进函数里,配合 sideEffects 标记
  • 用 import.meta.url 替代相对路径拼接:在 Worker、图片、JSON 加载场景尤其重要
  • 统一出口要克制:barrel 文件方便但会破坏 Tree-shaking,深层目录谨慎使用
  • 顶层 await 用在刀刃上:只在真正的初始化场景使用,避免阻塞关键路径
/* 模块化设计的黄金法则 */
/* 高内聚 → 低耦合 → 单向依赖 → 按需加载 */

/* 一个完整的模块示例 */
// utils/format.js —— 纯函数,零依赖,具名导出
export const formatPrice = (v, currency = '¥') =>
  currency + v.toFixed(2);

export const formatDate = (d) =>
  new Intl.DateTimeFormat('zh-CN').format(d);

// features/order/index.js —— 业务模块,按需使用工具
import { formatPrice, formatDate } from '@/utils/format.js';

export async function renderOrder(order) {
  // 重型依赖延迟加载,不进主包
  const { drawChart } = await import('@/utils/chart.js');
  return {
    price: formatPrice(order.total),
    date: formatDate(order.createdAt),
    chart: drawChart(order.items)
  };
}

模块化开发的价值,从来不只是"把代码拆成多个文件"。它是一套关于边界、依赖与协作的设计方法论。ES Module 用语言级的静态结构,把这种设计方法固化下来,让工具能够分析、优化、校验我们的代码。掌握它,是从"会写 JavaScript"迈向"会设计 JavaScript 应用"的关键一步。