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));
这里的关键不是 export 和 import 两个关键字本身,而是 app.js 明确声明了自己依赖 math.js 的 add。依赖关系不再藏在全局变量或加载顺序里,工具和人都能一眼看出来。
我接手过一个比较老的后台项目,里面有个几千行的 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 Promise 为 true,所以才能 await 或 .then。这个区别很重要,后面判断"什么时候该用动态 import"的时候还会再提。
ESM 和 CommonJS 的核心区别:值拷贝 vs 实时绑定
前端现在最常见的是 ESM,也就是 import 和 export。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 和一个 inc,main.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 输出 0 和 0——因为 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 输出 0 和 1。import 进来的 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_ESM 和 Cannot use import statement outside a module 这两个报错折磨过不止一次。它们看起来像语法错,其实根子是格式不匹配:前者是 CommonJS 代码 require 了一个纯 ESM 的包(比如某些库新版本只发 ESM),后者是在被当成 CommonJS 解析的文件里写了 import。判断一个文件被当成什么格式,规则其实很死板:.mjs 一定是 ESM,.cjs 一定是 CommonJS,.js 则看最近的 package.json 里 type 是不是 "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,某些依赖同时提供 main、module、exports。如果不了解这些入口字段,很容易遇到"本地 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 语义的打包配置,还是得先跑一遍构建确认没问题,不能想当然地当成随处可用的语法。
模块拆分也不是越细越好
模块化的目标是清晰,不是把文件切得无限碎。如果一个功能被拆成大量细小文件,但每个文件都彼此强依赖,阅读成本反而会上升。所以模块拆分还有一个更关键的问题是按职责,而不是按行数。
我通常会按这些维度拆模块:业务能力(比如 userService、articleService)、通用工具(比如 formatDate、parseQuery)、UI 组件(比如 ArticleCard、SearchInput)、常量配置(比如 roleMap、routeConfig)。不要为了"每个文件不超过多少行"而机械拆分,如果拆完以后读一个流程要打开十几个文件,说明拆分的方式可能有问题。
一个模块最好有清楚的主题。比如 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';
这类写法不是不能用,但要克制。副作用模块会让"导入即执行"的行为变多,读代码的人不一定能马上看出影响范围。
如果一个模块会修改全局对象、注册事件、初始化监控,最好在命名和位置上明确表达它的用途。比如 setupErrorTracking 比 utils/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,继承来的不算
这两个都是很小的方法,但在工具模块里写多了判断和取值逻辑之后,用起来比老写法顺手不少,也算是这段时间顺带积累的一点习惯。
回到开头那个 require 和 import 的差异:值拷贝还是实时绑定,这类问题看似是语法细节,但只要项目里同时存在两种模块格式,或者拆分模块时把依赖方向搞反,它就会变成排查半天都摸不到头绪的真实 bug。会不会写 import 从来都不是难点,格式判断、依赖方向、循环引用这几件具体的事有没有想清楚,才是模块化真正要处理的东西。