ES6 模块和 CommonJS:同一个包为什么在两个环境里两副面孔
同一个 npm 包,在 webpack 项目里能正常 import,到了 Node 脚本里一 require 就拿到 undefined,这类问题表面像“发包发坏了”,本质上几乎都落在模块系统不一致上。
这次出问题的包本身很普通,就是几个团队共用的工具函数。源码用 ES Module 写,在 Vue 项目里通过 import { formatDate } from '@team/utils' 一切正常;但同事的发布脚本是 Node 直接跑的,const { formatDate } = require('@team/utils') 拿到的却是 undefined。同一份代码在两个环境里表现完全不同,问题自然不可能只停留在“少发了哪个文件”。
顺着这个 bug 往下挖,真正要补的是 CommonJS 和 ES Module 的加载模型差异:为什么一个是运行时导出对象,一个是静态结构;为什么会冒出 .default;为什么 tree shaking 更偏爱 ESM;循环依赖又为什么会让两个系统暴露出不同的症状。
十分钟后,负责发布脚本的同事甩过来一张截图:
1TypeError: formatDate is not a function
他的脚本是 Node 直接跑的,const { formatDate } = require('@team/utils'),拿到的 formatDate 是 undefined。同一个包,webpack 项目里活蹦乱跳,Node 里当场去世。我第一反应是发包发漏了文件,翻了一遍 tarball,文件都在。那问题只能出在更根上的地方——我发上去的是 src 里的 ES Module 源码,而他的 Node 根本不认 import 和 export。
这个 bug 花了我小半天,但顺着它挖下去,把我一直糊里糊涂混用的两套模块系统彻底理了一遍。后面就按那次排查的路径,把相关问题一起讲清楚。
同一个项目里的两副面孔
先交代背景。我们的项目里这两种写法天天见面。业务代码里是:
1import Vue from 'vue'
webpack.config.js 和各种构建脚本里是:
1const path = require('path')
我以前的态度是"反正 webpack 和 Babel 都帮我编译了,写哪种都能跑,能跑就不想了"。这次翻车逼我承认:这俩不只是写法的差别,连"什么时候、由谁来解析"都不一样。webpack 项目里我的 import 是被 Babel 编译成了别的东西才跑起来的;同事的 Node 脚本没有任何编译环节,直接裸跑,一碰到 export 关键字就歇菜——而我的包里入口文件第一行就是它。
先把两者的差别列成表,能少走很多弯路。这张表我抄在了补基础那本笔记的扉页上:
| 维度 | CommonJS | ES6 Module |
|---|---|---|
| 加载时机 | 运行时同步加载 | 编译期静态分析、运行时按需 |
| 导出的是 | 值的拷贝 | 值的引用(绑定) |
this 指向 | module.exports | undefined |
| 能否动态路径 | 能,require(变量) | 不能(除了动态 import()) |
| 能否被 tree shaking | 基本不能 | 能(看条件) |
其中"值的拷贝还是引用"这一条最反直觉,也是我后来做实验时最惊讶的一条,先按住,下一节专门做实验。
第一组物证:值的拷贝 vs 活绑定
这条差别可以直接用 Node 跑出来,结论很硬。先看 CommonJS——导出的是值的拷贝:
1// counter.js 2let count = 0 3function inc() { count++ } 4module.exports = { count, inc } // 导出时把 count 的当前值 0 拷了出去 5 6// main.js 7const m = require('./counter.js') 8console.log(m.count) // 0 9m.inc() // 模块内部 count 变成 1 10console.log(m.count) // 还是 0!导出的 count 是当时那个快照,跟内部变量脱钩了
node main.js 打印的是 0 和 0。再看等价的 ES Module 版本。Node 8.5 之后可以用 .mjs 后缀加实验性开关跑原生 ES Module(我机器上是 Node 10,要带 --experimental-modules,还会给你甩一行实验特性的警告):
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!导入的 count 始终指向模块里那个变量,内部一变这里就变
node --experimental-modules main.mjs 打印 0 和 1。同样的逻辑,CJS 给你 0 0、ESM 给你 0 1——ES Module 导出的是 live binding(活绑定),导入的名字始终指向模块内部那个变量本身,不是某一刻的快照。顺带一提,这个绑定在导入方是只读的,你不能在 main.mjs 里写 count = 5 去改它,会直接报错。
跑完这两个实验,我对"两套模块系统"这件事的理解才算从背书变成了亲眼所见。
复盘 CommonJS:require 到底是什么
回到案子。同事的脚本用的是 CommonJS,那就先把 CommonJS 这边盘清楚。基本写法没什么可说的:
1// math.js 2function add(a, b) { 3 return a + b 4} 5 6module.exports = { 7 add 8}
使用:
1const math = require('./math') 2 3console.log(math.add(1, 2))
也可以解构:
1const { add } = require('./math')
但有个我这次才想明白的事:require 和 module 不是 JS 关键字,而是 Node 在每个 CJS 模块外面包了一层函数、注入进来的局部变量。既然是实打实的值,就可以直接打出来看:
1node -e "console.log(typeof require, typeof module)" 2# => function object
require 是个函数、module 是个对象。而在 .mjs 的 ESM 环境里,这俩默认根本不存在——两套模块系统连运行环境注入的东西都不一样,这也从侧面印证了它们不是同一个世界的产物。
CommonJS 是运行时加载。require 执行到那一行时,目标模块的代码被同步加载并执行一遍,返回 module.exports。"同步"两个字很关键——require 是阻塞的,下一行代码要等模块执行完才跑。Node 里模块在本地磁盘上,同步读一下无所谓;但浏览器端不可能让页面同步去网络上拉一个模块再继续渲染,这就是浏览器原生模块必须走异步加载、而 CommonJS 从来没能直接进浏览器的根本原因。
说到浏览器原生模块,现在的 Chrome、Firefox、Safari 其实都已经认 <script type="module"> 了,import/export 可以不经打包直接跑:
1<script type="module" src="/js/main.js"></script> 2<script nomodule src="/js/bundle.legacy.js"></script>
nomodule 是配套的降级开关——认识 module 的新浏览器会忽略它,不认识的老浏览器(比如我们甩不掉的 IE11)只执行它。这套"双构建"的思路社区里已经有人在实践,用来给新浏览器发不带一堆 polyfill 的小包。不过我们的电商后台要伺候 IE11,构建链路也全在 webpack 手里,暂时只是围观,先把这个组合记在笔记里。
module.exports 和 exports 那点旧账
写工具库的时候我还翻出过一笔旧账。exports 一开始只是 module.exports 的一个引用,往 exports 上挂属性是有效的:
1exports.add = add // 有效,等于往 module.exports 上挂
但直接给 exports 重新赋值,就把这个引用关系切断了,外面拿到的还是原来的空对象:
1exports = { add } // 无效!外面 require 到的还是 {} 2module.exports = { add } // 正确,导出整个对象要用这个
我刚工作那会儿写脚本总分不清,一会儿能导出一会儿导不出,后来记死一条规矩:只导单个对象或函数就用 module.exports =,零散挂多个就用 exports.xxx =,两种别混着写。
模块缓存:require 一百次也只执行一次
还有一点容易忽略:CommonJS 模块有缓存。同一个模块被 require 多次,只会执行一次,后面拿到的都是第一次那个 module.exports:
1// counter.js 2let count = 0 3module.exports = { inc: () => ++count, get: () => count } 4 5// a.js 6require('./counter').inc() 7// b.js 8console.log(require('./counter').get()) // 1,拿到的是同一个实例
这个缓存机制很有用——单例配置、数据库连接池都靠它——但也意味着模块里的顶层状态是全进程共享的,写的时候得想清楚。
盘到这里也顺便回答了一个我以前的疑惑:为什么 webpack.config.js 清一色用 CommonJS?因为这个文件是 webpack 启动时由 Node 直接执行的,压根还没进到打包流程里,自然只能用 Node 原生认识的语法。它和 src 里那些会被 Babel 处理的业务代码,处在两个完全不同的执行阶段。
再看 ES Module 这边
我的工具库源码用的是 ES Module:
1// math.js 2export function add(a, b) { 3 return a + b 4} 5 6export function minus(a, b) { 7 return a - b 8}
使用:
1import { add } from './math' 2 3console.log(add(1, 2))
默认导出:
1export default function request() {}
使用:
1import request from './request'
命名导出和默认导出怎么选,我们团队去年吵过一架,最后在 ESLint 里把规则定死了:工具函数模块一律命名导出,禁用 export default。原因是默认导出有个隐患——导入时名字是你自己起的,没有任何约束:
1import whatever from './request' // 这个名字爱叫啥叫啥
结果就是同一个模块在不同文件里被叫成不同名字,全局搜索的时候根本找不全引用。命名导出至少强制大家用同一个名字,想改名也得显式 as,重构和搜索都友好:
1import { request } from './request' 2import { request as req } from './request' // 想改名得显式写出来
例外是"模块本身就是一个主角"的情况——Vue 单文件组件、请求实例,用默认导出是自然的。还有个细节,一个模块可以同时有默认导出和命名导出:
1// request.js 2export default function request() {} 3export const BASE_URL = '/api' 4 5// 用的时候 6import request, { BASE_URL } from './request'
很多三方库就是这么设计的,比如 React:import React, { Component } from 'react'——React 是默认导出,Component 是命名导出。
命名导出多了以后还有两件顺手的工具。一是命名空间导入,把一个模块的所有导出收进一个对象,适合"这个模块我到处都要用一大把"的场景:
1import * as math from './math' 2 3math.add(1, 2) 4math.minus(3, 1)
二是 export ... from 转发导出。整理 @team/utils 的时候我把源码按领域拆成了 date.js、money.js、url.js,入口 index.js 只做汇总,一行业务逻辑都没有:
1// src/index.js —— 包的唯一入口,只负责转发 2export * from './date' 3export * from './money' 4export * from './url'
这样使用方永远只 import { formatDate } from '@team/utils',不用关心内部文件怎么摆;我内部想重新拆文件也不影响任何人。唯一要留神的是 export * 不会转发默认导出,而且两个子模块要是导出了同名函数,谁覆盖谁全凭加载顺序,不会报错——所以入口文件汇总的模块,命名得提前规划好,别撞车。
tree shaking 和 lodash 踩过的亏
盘 ES Module 的时候绕不开它最大的卖点:静态结构。import 和 export 必须写在顶层、路径必须是字符串字面量,所以打包工具在编译阶段、代码还没跑之前,就能分析出完整的依赖图。这给 tree shaking 提供了地基:
1import { add } from './math'
如果 minus 没有被任何人使用,生产构建时它就有机会被摇掉。CommonJS 做不到这一点,因为它是运行时的:
1const method = 'add' 2const result = require('./math')[method]
路径能拼变量、属性能动态取,静态分析对这种代码无能为力,只能整个模块保留。
但"用了 import 就能 tree shaking"是个流传很广的误解,我们项目去年就在这上面翻过车,主角是 lodash。明明只用了一个 cloneDeep,打出来的包却把整个 lodash 几十 KB 全带上了。原因是 import { cloneDeep } from 'lodash' 里的 lodash 主包发布的是 CommonJS 格式,webpack 没法静态分析它的内部结构,只能整包打进来。解决办法两个:
1import cloneDeep from 'lodash/cloneDeep' // 按路径直接引子模块,只打这一个文件 2import { cloneDeep } from 'lodash-es' // ES Module 版,支持 tree shaking
还有个隐形杀手是 sideEffects。webpack 4 默认保守地认为每个模块都可能有副作用——某个文件 import 进来就往全局挂了东西、或者注入了样式——所以不敢乱删。库想让 tree shaking 生效,得在 package.json 里自己表态:
1{ 2 "sideEffects": false 3}
或者列出确实有副作用的文件,典型的就是样式:
1{ 2 "sideEffects": ["*.css", "./src/polyfill.js"] 3}
我在这个字段上吃过一次直接的亏:给一个内部组件库标了 "sideEffects": false,结果业务方按需引入组件后样式全丢了——组件里 import './style.css' 这行被当成无用副作用摇掉了。把 css 加回白名单才好。sideEffects 不是无脑填 false 的,你得清楚自己哪些文件是"引入即生效"的。
import 不能写在 if 里,但有后门
静态结构还有个直接后果:import 必须写在模块顶层,不能出现在条件里:
1if (needMath) { 2 import { add } from './math' // 语法错误 3}
真要按需加载,用动态 import():
1import('./math').then(module => { 2 module.add(1, 2) 3})
要分清的是:import 静态语句和 import() 动态函数是两回事。前者是声明,必须顶层、被静态分析;后者是个返回 Promise 的运行时操作,可以写在任何地方、可以拼路径——但路径里至少要有一段静态字符串,webpack 才知道该把哪些文件打进候选。
动态 import 最高频的用武之地是路由懒加载:
1const UserList = () => import('./views/UserList.vue')
webpack 看到 import() 会自动把这块代码切成单独的 chunk,等真正访问到这个路由才去加载。配合 magic comment 还能给 chunk 起个看得懂的名字,否则打出来一堆 0.js、1.js,线上排查根本对不上号:
1const UserList = () => 2 import(/* webpackChunkName: "user-list" */ './views/UserList.vue')
我们的后台项目去年被产品投诉首屏白屏太久,就是因为几十个页面全打进了一个 app.js,首次进来要下两三 MB。改成路由级动态 import 之后主包瘦了一大半,首屏快了好几秒。代价是切换路由时有一次小小的加载延迟,可以加个 loading,或者对核心页面用 prefetch 预拉。
顺手揪出的循环依赖
排查工具库的时候,构建日志里还顺手揪出一个警告——两个模块互相 import 了。这是个独立的问题,值得单独说清楚。
1// a.js 2import { b } from './b' 3 4export const a = 'a' 5console.log(b)
1// b.js 2import { a } from './a' 3 4export const b = 'b' 5console.log(a)
循环依赖不一定报错,但结果可能和直觉不同,尤其是 CommonJS。CommonJS 遇到 require 时同步执行模块,如果执行到一半又 require 回了正在执行的自己,为了不死循环,Node 会直接把"当前已经导出到一半的 module.exports"返回给你——你可能拿到一个还没填完的半成品:
1// a.js —— 入口,node a.js 2const b = require('./b.js') // 这里会转去执行 b.js 3console.log('a 看到的 b.value:', b.value) 4module.exports = { value: 'a' } 5 6// b.js 7const a = require('./a.js') // a 还没执行完,这里拿到的是 a 的半成品 {} 8console.log('b 看到的 a.value:', a.value) 9module.exports = { value: 'b' }
node a.js 的实际输出是(我跑过验证):
1b 看到的 a.value: undefined 2a 看到的 b.value: b
执行顺序是这样的:跑 a.js,第一行就 require('./b.js') 转去执行 b;b 又 require('./a.js'),但 a 还停在第一行没往下走、module.exports 还是默认的空对象 {},于是 b 拿到的 a.value 是 undefined;b 执行完返回,回到 a,这时 b 已经完整了,所以 b.value 是 'b'。先被"借走"的那一方,拿到的就是半成品。
ES Module 因为是引用绑定而不是值拷贝,循环依赖的容错性反而好一些——只要别在模块顶层立刻去用那个还没初始化的值,等函数真正被调用时绑定已经填好了,就没事。但这也只是症状轻一点,根上还是模块边界没划清。常见解法是把公共部分抽成第三个模块,让双方都依赖它,把环拆开。
我自己排查循环依赖有个土办法:给 webpack 装 circular-dependency-plugin,构建时它会把每个环直接打印出来。真有环的时候顺着它指的链路去拆,比肉眼盯 import 快多了。这次工具库里的那个环,就是它帮我揪出来的。
interop 和 .default 是怎么回事
岔出去的几段查完,回到最初那个问题。同事的 Node 脚本拿到 undefined,直接原因清楚了:包里发的是 ES Module 源码,Node 不经编译跑不了。但这背后还牵着一个更大的疑问——老项目里那些神神叨叨的 .default:
1const value = require('./module').default
以及反过来的:
1import value from './commonjs-module'
为什么有时要 .default、有时不要?这是因为两边的导出模型对不上,中间全靠打包工具做兼容(interop)。简单说,当你 import foo from 'some-cjs-module' 一个 CommonJS 模块时,Babel 编译后的代码会去找它的 module.exports.default——可 CommonJS 根本没有 default 这个概念,它只有一个 module.exports。Babel 为此引入了 __esModule 标记和 interopRequireDefault 的兼容逻辑:模块带了 __esModule: true,就取 .default;否则把整个 module.exports 当成默认导出。问题是不同工具、不同 Babel 版本对这个 interop 的处理不完全一致,玄学就是这么来的。
我被它坑得最惨的一次,是把一处 require 顺手改写成 import:
1// 原来好好的 2const moment = require('moment') 3 4// 改成这样后,moment 突然变成了 undefined 或者 { default: fn } 5import moment from 'moment'
到底该不该带 .default,规律是:看那个包发布的是 ES Module 还是 CommonJS。是 CJS 的,经过 Babel interop 一般 import x from 能正常拿到;但如果构建链路没开 interop,或者用的是不做这种兼容的打包器,就可能要 import * as x 或者手动 .default。老项目里遇到 .default 不要慌,也别凭感觉猜,先把模块原样打出来看:
1console.log(require('xxx')) 2// 是函数就直接用,是 { default: ... } 就取 default,一目了然
包到底该怎么发
原因都理清楚了,剩下的问题是:我的 @team/utils 到底该发什么格式?答案是别让消费方猜,两种都发,用 package.json 的字段各指各的:
1{ 2 "main": "dist/index.cjs.js", 3 "module": "dist/index.esm.js" 4}
main 指向 CommonJS 构建产物,给 Node 直接 require 用;module 指向 ES Module 构建产物,webpack 这类打包工具会优先认这个字段,从而保住 tree shaking 的能力。构建我用了 rollup,它干这种"一份源码出多种格式"的活比 webpack 顺手得多:
1// rollup.config.js —— 注意这个文件本身还是 CommonJS,Node 要直接跑它 2module.exports = { 3 input: 'src/index.js', 4 output: [ 5 { file: 'dist/index.cjs.js', format: 'cjs' }, 6 { file: 'dist/index.esm.js', format: 'es' } 7 ] 8}
如果包还要给 <script> 标签直接引,可以再出一份 umd 格式——UMD 是老一辈的万金油,一段包装代码同时兼容 CommonJS、AMD 和挂全局变量,很多老牌库(jQuery、moment)发的都是它。内部工具库用不上,我就没打。
重新发版,同事的脚本 require 一次通过,webpack 项目那边按需引入也没胖一个字节。群里那张报错截图,从发现问题到修完一共两天。
这次排查留下的笔记
几条结论整理出来,归档进补基础的笔记本。
CommonJS 是运行时同步加载、导出值的拷贝、有模块缓存,Node 和构建脚本的地盘;ES Module 是编译期静态分析、导出活绑定,业务代码和 tree shaking 的地盘。两边模型对不上,中间的 interop 是 .default 玄学的根源。发 npm 包别只发一种格式的源码,main 和 module 各司其职,让 Node 和打包器各取所需。
比这些结论更值钱的是教训本身:"能跑就不想了"欠下的账,迟早会挑一个你最忙的下午来收。模块化这种天天摸的东西,混着用了一年多才被一个 undefined 逼着搞懂,说出来有点丢人——所以写下来,丢人丢得彻底一点,记得也牢一点。