JavaScript 模块化基础:为什么前端工程越来越离不开模块边界

同一段 require('./counter')import { count } from './counter.mjs',改动源模块之后表现完全不一样——前者拿到的是改动前的旧值,后者能实时感知到变化。这两种模块系统混在一个项目里的时候,这个差异经常是排查诡异 bug 的起点,但很多人对模块化的理解还停留在"就是 import/export 两个关键字"这一层,遇到这种行为不一致就懵了。

模块化解决的其实是语法好不好看之外的问题——每一块代码归谁负责、能被谁调用。我们团队做电商中后台这五年,前几年代码都还带着一股"全局变量拼起来"的味道,这两年才算是真正把模块划分当回事。今天就把这背后的机制、常见的坑,和我们团队踩过的具体教训过一遍。

模块化到底解决了什么

一个模块通常要说明三件事:它暴露什么、它依赖什么、它内部细节不希望外界直接碰什么。这样做的最大价值不是语法更高级,而是系统更容易组织。一个最简单的 ES Module 长这样:

1// math.js
2export function add(a, b) {
3  return a + b;
4}
5
6// app.js
7import { add } from './math.js';
8
9console.log(add(1, 2));

这里的关键不是 exportimport 两个关键字本身,而是 app.js 明确声明了自己依赖 math.jsadd。依赖关系不再藏在全局变量或加载顺序里,工具和人都能一眼看出来。

我接手过一个比较老的后台项目,里面有个几千行的 common.js,几乎每个页面都要引它。想拆的时候发现根本不知道哪些页面用了它的哪些函数——大家都是直接 window.formatMoney() 这么调的,全靠全局变量串起来。最后只能靠全文搜索一个个函数名去数引用,搜一次心惊一次。那次之后我明白了一件事:模块系统最值钱的地方是让"谁用了什么"这件事可以被工具查出来,不用再靠人去记,写法好不好看反而是次要的。

没有模块划分时会发生什么

早期页面里常见类似代码:

1<script src="/utils.js"></script>
2<script src="/request.js"></script>
3<script src="/page.js"></script>

page.js 里能用什么,取决于前面两个文件有没有提前把变量挂到 window 上。顺序错了页面就可能报错,代码审查时也很难看出一个文件到底依赖了哪些全局变量。

我吃过最哑巴的一次亏:给一个页面加了个第三方统计脚本,随手放在了 </body> 前面,结果它内部用了某个工具库挂在 window 上的方法,而工具库的 script 标签在它后面。本地开发因为有缓存看不出来,一上线偶发报 xxx is not a function——线上 CDN 两个文件的返回速度不一样,加载完成的先后顺序根本不可控,这种 bug 没法稳定复现,排查起来格外费劲。本质上就是依赖关系藏在了加载顺序里。

模块化之后,文件加载顺序不再靠人脑维护,交给了构建工具或浏览器的模块系统。浏览器原生的 <script type="module"> 还会自动按 defer 的语义执行,并且对同一个模块只求值一次:

1<script type="module" src="/app.js"></script>

app.js 里 import 了什么,浏览器自己会去解析依赖图、按拓扑顺序加载,不需要我再手动排 script 的先后。

type="module" 还顺手改了两个默认行为,都可以当场验证。一是它默认就是 defer:即使把 <script type="module"> 写在 <head> 里、不加任何属性,脚本也会等 HTML 解析完才执行,所以模块里直接 document.querySelector 拿后面的 DOM 节点不会拿到 null,而普通 <script><head> 里这么写就会翻车。二是模块默认走严格模式,最直观的现象是顶层 this 不再是 window

1<script type="module">
2  console.log(this); // undefined(模块顶层 this 不是 window)
3</script>
4<script>
5  console.log(this); // window(普通脚本顶层 this 是 window)
6</script>

动态 import() 和静态 import 不一样,是个返回 Promise 的函数式调用,import('./mobile.js') 的返回值 instanceof Promisetrue,所以才能 await.then。这个区别很重要,后面判断"什么时候该用动态 import"的时候还会再提。

ESM 和 CommonJS 的核心区别:值拷贝 vs 实时绑定

前端现在最常见的是 ESM,也就是 importexport。Node.js 里过去长期使用 CommonJS:

1// CommonJS
2const fs = require('fs');
3
4module.exports = {
5  readFile,
6};

ESM 的导入导出更适合静态分析:

1// ESM
2import fs from 'fs';
3
4export function readFile() {}

这也是现代前端构建工具普遍偏爱 ESM 的原因,它更容易在构建阶段分析依赖关系、拆包和清理无用代码。但两者还有个更底层、也更容易在面试里被问到的差别:CommonJS 的导入是值拷贝,ESM 是实时绑定(live binding)。这个不用背,拿 Node 两套文件跑一下就分清了。

CommonJS 版(counter.cjs 导出一个 count 和一个 incmain.cjs 解构后调用 inc):

1// counter.cjs
2let count = 0;
3function inc() { count++; }
4module.exports = { count, inc };
5
6// main.cjs
7const { count, inc } = require('./counter.cjs');
8console.log(count); // 0
9inc();
10console.log(count); // 还是 0

node main.cjs 输出 00——因为 require 那一刻把 count 的值(0)拷了一份过来,模块内部后来怎么改都和这个副本无关。

ESM 版同样的结构:

1// counter.mjs
2export let count = 0;
3export function inc() { count++; }
4
5// main.mjs
6import { count, inc } from './counter.mjs';
7console.log(count); // 0
8inc();
9console.log(count); // 1

node main.mjs 输出 01import 进来的 count 不是副本,而是对源模块那个变量的只读引用,源头变了这边读到的就跟着变。还有个能一眼区分运行环境的小测试:node -e "console.log(typeof require)" 打印 function(CommonJS 作用域里有 require),而 node --input-type=module -e "console.log(typeof require)" 打印 undefined(ESM 作用域里根本没有 require 这个东西)。

不过实际项目里经常会同时遇到两种模块格式,尤其是工具脚本、老依赖、Node 环境。遇到模块格式报错时,不要只盯着语法,要同时检查 package.json 里的 type、文件后缀、构建工具配置和依赖本身的导出方式。

我被 ERR_REQUIRE_ESMCannot use import statement outside a module 这两个报错折磨过不止一次。它们看起来像语法错,其实根子是格式不匹配:前者是 CommonJS 代码 require 了一个纯 ESM 的包(比如某些库新版本只发 ESM),后者是在被当成 CommonJS 解析的文件里写了 import。判断一个文件被当成什么格式,规则其实很死板:.mjs 一定是 ESM,.cjs 一定是 CommonJS,.js 则看最近的 package.jsontype 是不是 "module"

ESM 和 CommonJS 还有个容易忽略的差异:ESM 是异步、静态求值的,所以没法在顶层随便写 if 来决定要不要 import,import 必须在模块顶层。要条件加载得用动态 import()

1// 这种条件导入在 ESM 里不合法
2// if (isMobile) import './mobile.js';
3
4// 要用动态 import,它返回 Promise
5if (isMobile) {
6  const { initMobile } = await import('./mobile.js');
7  initMobile();
8}

这几年 Node 生态也在慢慢往 ESM 靠,但迁移不会一夜完成。很多项目会出现这样的组合:浏览器端代码是 ESM,构建脚本还是 CommonJS,某些依赖同时提供 mainmoduleexports。如果不了解这些入口字段,很容易遇到"本地 dev 正常,打包后引用到另一份代码"的问题。

我现在排查这类问题,会先看依赖包的 package.json

1{
2  "main": "./dist/index.cjs",
3  "module": "./dist/index.mjs",
4  "exports": {
5    ".": {
6      "import": "./dist/index.mjs",
7      "require": "./dist/index.cjs"
8    }
9  }
10}

这些字段决定不同环境到底会加载哪份产物。前端工程里很多"模块找不到""默认导出不对""tree shaking 失效",最后都能追到入口声明和模块格式上。

什么时候该用动态 import()

静态 import 和动态 import() 不是风格选择,而是两种场景各有各的适用场合。判断标准我一般按这三条走:

第一,是不是首屏必须的代码。路由级组件、弹窗里才用得到的重型库(图表、富文本编辑器),这类东西没必要跟着主包一起下载,动态 import 天然配合构建工具做代码分割:

1// 路由懒加载,Vue Router / React Router 都是这个思路
2const routes = [
3  {
4    path: '/report',
5    component: () => import('./views/Report.vue'),
6  },
7];

第二,依赖是否要按条件加载。前面提到的移动端/桌面端分支就是典型例子,静态 import 没法写条件,动态 import() 恰好补上这个能力。

第三,是不是要处理加载失败的情况。动态 import 返回 Promise,天然能接 .catch,静态 import 加载失败是没法用同步语义优雅处理的:

1import('./heavy-chart.js')
2  .then((mod) => mod.renderChart(data))
3  .catch(() => {
4    // 网络问题或者 CDN 挂了,降级成纯文字展示
5    renderFallbackText(data);
6  });

反过来,如果一个模块是页面渲染必须的、体积也不大,硬要包一层动态 import 只会增加一次网络往返和一层 Promise 心智负担,没有实际收益。团队里有阵子流行"能动态就动态",把一些几 KB 的工具函数也拆成单独 chunk,结果首屏反而多了几个请求。动态加载的收益要建立在"确实能省下不用的部分"这个前提上。

Tree Shaking 这件事我也踩过坑。有次发现打包后体积莫名其妙偏大,排查半天才发现是引了个老 lodash,用的是 import _ from 'lodash' 然后 _.debounce。CommonJS 那种整体导出的写法构建工具没法静态判断我到底用了哪几个函数,只能整包打进来。换成按需路径就好了:

1// 整包进来,摇不掉
2import _ from 'lodash';
3_.debounce(fn, 300);
4
5// 只打进用到的部分
6import debounce from 'lodash/debounce';

这也是为什么我现在选库会特意看一眼它有没有提供 ESM 构建产物、package.json 里有没有 "sideEffects": false。这俩字段直接决定了 Tree Shaking 能不能生效——前者让工具能做静态分析,后者告诉工具"删掉没用到的导出是安全的,不会漏掉副作用"。

顶层 await:原生 ESM 场景下的新选择

顶层 await 这个提案早就到了 Stage 4(今年晚些时候会正式写进 ES2022 标准,不过引擎这边已经按 Stage 4 提案实现了,不用等标准正式发布),这个东西只能在模块顶层用,普通脚本或函数体外用不了。它最直接的场景是原生 ESM、或者像 Vite 这种默认走原生 ESM 的开发服务器:

1// config.js,走原生 ESM 或 Vite 的场景可以这么写
2const res = await fetch('/api/config.json');
3export const config = await res.json();

不用顶层 await 之前,这种"模块初始化时要先拿到异步结果"的需求只能包一层立即执行的 async 函数,再把结果通过回调或者 Promise 导出,用的地方还得再 await 一次:

1// 没有顶层 await 时的写法
2let config;
3async function init() {
4  const res = await fetch('/api/config.json');
5  config = await res.json();
6}
7export const ready = init();
8// 使用方还得 await ready 之后才能读 config,多一层心智负担

顶层 await 目前只在 Vite 开发环境和支持原生 ESM 的场景里比较放心用,如果代码要经过某些还不支持顶层 await 语义的打包配置,还是得先跑一遍构建确认没问题,不能想当然地当成随处可用的语法。

模块拆分也不是越细越好

模块化的目标是清晰,不是把文件切得无限碎。如果一个功能被拆成大量细小文件,但每个文件都彼此强依赖,阅读成本反而会上升。所以模块拆分还有一个更关键的问题是按职责,而不是按行数。

我通常会按这些维度拆模块:业务能力(比如 userServicearticleService)、通用工具(比如 formatDateparseQuery)、UI 组件(比如 ArticleCardSearchInput)、常量配置(比如 roleMaprouteConfig)。不要为了"每个文件不超过多少行"而机械拆分,如果拆完以后读一个流程要打开十几个文件,说明拆分的方式可能有问题。

一个模块最好有清楚的主题。比如 utils.js 这种文件很容易越长越失控,后面什么都往里放。更好的做法是按能力拆成小主题:

1utils/
2  date.ts
3  number.ts
4  url.ts
5services/
6  user.ts
7  article.ts

这样不是为了追求目录漂亮,而是让依赖关系更容易被理解。

默认导出和命名导出

ESM 里有默认导出和命名导出:

1export default function Button() {}
2export function createButtonTheme() {}

默认导出适合一个模块只有一个核心产物,比如一个组件文件导出一个组件。命名导出适合工具函数、常量、服务方法。我更偏向在工具模块里使用命名导出,因为它在导入处更明确,也更方便重构:

1import { formatDate } from './date';

默认导出的名字可以在导入时随意改,灵活但也可能带来命名不一致。

我们团队后来基本上把工具和服务模块的默认导出都禁掉了,原因是默认导出在重构时太"滑"。改文件名或者改导出物的名字,命名导出会因为引用对不上而直接报错,编辑器也能一键全局重命名;默认导出因为导入名是各处自己起的,改了源头那边毫无感知,全靠人去翻。还有个小细节:默认导出对 IDE 的自动导入、自动补全也不太友好,敲一半经常补不出来。组件文件我留默认导出(配合命名约定还算清楚),其余一律命名导出。

循环依赖要警惕

模块化不代表所有依赖都天然健康。最常见的坑之一是循环依赖:

1// a.js
2import { b } from './b.js';
3export const a = 'a';
4
5// b.js
6import { a } from './a.js';
7export const b = 'b';

简单循环不一定马上报错,但复杂循环会导致某些值在初始化阶段不可用,问题很隐蔽。

我真正栽过的一次是这样的:a.js 在模块顶层就用了从 b.js 导入的常量,而 b.js 又依赖 a.js。因为 ESM 是按依赖图顺序求值的,轮到 a.js 执行那一行时 b.js 还没初始化完,导入进来的值是 undefined。报错信息只说某个变量是 undefined,完全不会提"循环依赖"四个字,盯着那行代码看半天都看不出问题。后来才反应过来要去看整条 import 链。

有意思的是,如果把对那个常量的使用从顶层挪进函数体里,反而就好了——因为函数是调用时才执行,那会儿模块早就初始化完了。但这只是绕过症状,治标不治本。真正的解法还是抽出共同依赖、反转调用方向,或者把共享的类型和常量沉到一个更底层、谁都能引但它谁也不引的模块里。madge 这类工具可以把循环依赖直接画出来,项目大了之后我会拿它在 CI 里扫一遍。

后来我会给业务模块加一个简单分层约定:

1pages -> features -> services -> shared

上层可以依赖下层,下层不要反过来 import 上层。这个规则不复杂,但能挡住很多"图方便直接引用页面工具函数"的问题。模块化最后拼的不是文件数量,而是依赖方向是否稳定。

副作用模块要明确

有些模块导入后不是为了拿导出值,而是为了执行副作用:

1import './global.css';
2import './setupErrorTracking';

这类写法不是不能用,但要克制。副作用模块会让"导入即执行"的行为变多,读代码的人不一定能马上看出影响范围。

如果一个模块会修改全局对象、注册事件、初始化监控,最好在命名和位置上明确表达它的用途。比如 setupErrorTrackingutils/init 更清楚。

副作用还会影响 tree shaking。构建工具只有在确认模块没有副作用时,才更敢删除未使用代码。库项目通常会在 package.json 里声明:

1{
2  "sideEffects": false
3}

但这个声明要谨慎。如果你的包里有全局样式、polyfill、初始化脚本,随便写 false 可能导致构建时把必要代码摇掉。业务项目不一定直接维护 npm 包,但理解这个字段有助于排查"为什么某个样式没打进去"。

模块划分清不清楚,直接影响维护体验

一个职责清楚的模块,通常更容易做到只改局部、独立测试、替换实现、复用逻辑。反过来,如果模块之间相互穿透,虽然代码形式上用了 import/export,实际维护体验还是很差。

一个常见反例是页面直接 import 服务模块内部工具:

1import { buildUserCacheKey } from '@/services/user/internal';

如果 internal 本来只是服务内部实现,页面层直接依赖它,就等于把内部细节暴露成了外部契约。后面服务层想改缓存策略,会被页面层牵住。

模块的对外接口稳定下来之后,外部只依赖公开 API:

1import { getUserProfile } from '@/services/user';

内部怎么拼 URL、怎么缓存、怎么处理错误,都可以在服务模块里调整。

桶文件用着爽,但要心里有数

写多了模块化,很容易爱上"桶文件"(barrel file)——也就是用一个 index.ts 把目录里的东西统一再导出一遍:

1// components/index.ts
2export { ArticleCard } from './ArticleCard';
3export { SearchInput } from './SearchInput';
4export { UserAvatar } from './UserAvatar';

这样外部就能 import { ArticleCard } from '@/components',导入路径干净,对外也算是一层统一出口。我一开始很喜欢这么干,但后面踩了两个坑。

一是它特别容易制造循环依赖。目录里某个组件如果反过来从 index 引同目录的另一个组件,链路一下就绕回来了。二是它可能把 Tree Shaking 拖垮——你只想要一个 ArticleCard,但导入桶文件会让构建工具先把整个 index 的依赖都拉进来分析,配置稍有不慎就整包打进去了。所以现在我对桶文件比较克制:对外暴露的统一入口上用一层还行,目录内部互相引用尽量走具体文件路径,别图省事都走 index

顺手记两个 ES2022 的小工具方法

拆模块、写工具函数的时候,最近也顺手用上了两个 ES2022 里新定下来的方法,虽然和模块化本身没有直接关系,但都是这段时间写代码时天天碰得到的:

Array.prototype.at 可以用负数索引直接取数组倒数第几项,不用再写 arr[arr.length - 1] 这种容易在边界条件出错的表达式:

1const list = [1, 2, 3, 4, 5];
2console.log(list.at(-1)); // 5,等价于 list[list.length - 1]
3console.log(list.at(-2)); // 4

Object.hasOwn(obj, key) 用来替代 Object.prototype.hasOwnProperty.call(obj, key) 这种写法,语义更直接,也不用担心某个对象自己重写了 hasOwnProperty 导致误判:

1const obj = { a: 1 };
2console.log(Object.hasOwn(obj, 'a')); // true
3console.log(Object.hasOwn(obj, 'toString')); // false,继承来的不算

这两个都是很小的方法,但在工具模块里写多了判断和取值逻辑之后,用起来比老写法顺手不少,也算是这段时间顺带积累的一点习惯。

回到开头那个 requireimport 的差异:值拷贝还是实时绑定,这类问题看似是语法细节,但只要项目里同时存在两种模块格式,或者拆分模块时把依赖方向搞反,它就会变成排查半天都摸不到头绪的真实 bug。会不会写 import 从来都不是难点,格式判断、依赖方向、循环引用这几件具体的事有没有想清楚,才是模块化真正要处理的东西。