上一篇文章留下了一个疑问,为什么 Vite 在 build 时没有正确处理 CommonJS 模块?本文通过一个简单的 demo 来寻找问题原因。
创建两个 CommonJS 包
文件目录结构如下:
.
├── libs
│ ├── bar
│ │ ├── bar.js
│ │ ├── index.js
│ │ └── package.json
│ └── foo
│ ├── index.js
│ └── package.json
├── node_modules
├── index.html
├── main.js
├── style.css
└── vite.config.js
理论上可以在 node_modules
下直接新建 foo
和 bar
包,但这样不方便维护,所以我们将自定义的 CommonJS 包放到 libs
目录,再进行 link。
其中的 foo
只定义了 index.js
:
// foo/index.js
function foo(name) {
console.log(`Hi ${name}!`)
}
Object.defineProperty(exports, '__esModule', { value: true })
exports.foo = foo
exports.default = foo
这里定义了
__esModule: true
假装是从 ES Module 转换生成的 CommonJS,同时定义了exports.default
,纯的 CommonJS 包使用exports
/module.exports
而不是exports.default
作为默认导出。
bar
的模块和 foo
几乎一致,但多了一次额外的导入导出:
// bar/bar.js
function bar(name) {
console.log(`Hi ${name}!`)
}
Object.defineProperty(exports, '__esModule', { value: true })
exports.bar = bar
exports.default = bar
// bar/index.js
module.exports = require('./bar')
package.json
中使用相对路径来安装 libs
下的两个包:
{
"name": "vite-cjs-demo",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"dependencies": {
"bar": "./libs/bar",
"foo": "./libs/foo"
},
"devDependencies": {
"vite": "^2.6.14"
}
}
main.js
定义为 ES Module,引用 foo
和 bar
:
// main.js
import fooDefault, { foo } from 'foo'
import barDefault, { bar } from 'bar'
foo('foo')
fooDefault('fooDefault')
bar('bar')
barDefault('barDefault')
其他文件见仓库 https://github.com/keqingrong/vite-cjs-demo。
安装构建
下面开始安装!
pnpm install
bar is linked to D:\workspace\vite-cjs-demo\node_modules from D:\workspace\vite-cjs-demo\libs\bar
foo is linked to D:\workspace\vite-cjs-demo\node_modules from D:\workspace\vite-cjs-demo\libs\foo
Already up-to-date
Progress: resolved 22, reused 5, downloaded 0, added 0, done
pnpm 安装后会在 node_modules
目录创建符号链接。此时 vite dev
运行正常,vite build
运行报错。
pnpm build
> vite-cjs-demo@0.0.0 build D:\workspace\vite-cjs-demo
> vite build
vite v2.6.14 building for production...
✓ 5 modules transformed.
'foo' is not exported by libs\foo\index.js, imported by main.js
file: D:/workspace/vite-cjs-demo/main.js:1:20
1: import fooDefault, {foo} from 'foo'
^
2: import barDefault, {bar} from 'bar'
error during build:
Error: 'foo' is not exported by libs\foo\index.js, imported by main.js
at error (D:\workspace\vite-cjs-demo\node_modules\.pnpm\rollup@2.60.1\node_modules\rollup\dist\shared\rollup.js:158:30)
at Module.error (D:\workspace\vite-cjs-demo\node_modules\.pnpm\rollup@2.60.1\node_modules\rollup\dist\shared\rollup.js:12382:16)
at Module.traceVariable (D:\workspace\vite-cjs-demo\node_modules\.pnpm\rollup@2.60.1\node_modules\rollup\dist\shared\rollup.js:12767:29)
at ModuleScope.findVariable (D:\workspace\vite-cjs-demo\node_modules\.pnpm\rollup@2.60.1\node_modules\rollup\dist\shared\rollup.js:11559:39)
at Identifier.bind (D:\workspace\vite-cjs-demo\node_modules\.pnpm\rollup@2.60.1\node_modules\rollup\dist\shared\rollup.js:6419:40)
at CallExpression.bind (D:\workspace\vite-cjs-demo\node_modules\.pnpm\rollup@2.60.1\node_modules\rollup\dist\shared\rollup.js:5025:23)
at CallExpression.bind (D:\workspace\vite-cjs-demo\node_modules\.pnpm\rollup@2.60.1\node_modules\rollup\dist\shared\rollup.js:9396:15)
at ExpressionStatement.bind (D:\workspace\vite-cjs-demo\node_modules\.pnpm\rollup@2.60.1\node_modules\rollup\dist\shared\rollup.js:5025:23)
at Program.bind (D:\workspace\vite-cjs-demo\node_modules\.pnpm\rollup@2.60.1\node_modules\rollup\dist\shared\rollup.js:5021:31)
at Module.bindReferences (D:\workspace\vite-cjs-demo\node_modules\.pnpm\rollup@2.60.1\node_modules\rollup\dist\shared\rollup.js:12378:18)
ELIFECYCLE Command failed with exit code 1.
异常来源于 Rollup,鉴于 Vite 对 Rollup 做了封装,先看 Vite 文档有没有符号链接相关配置项。
果然发现了 resolve.preserveSymlinks
https://cn.vitejs.dev/config/#resolve-preservesymlinks。
底层的 Rollup 和 esbuild 都有对应的配置:
- Rollup
preserveSymlinks
https://rollupjs.org/guide/en/#preservesymlinks- esbuild
--preserve-symlinks
https://esbuild.github.io/api/#preserve-symlinks
看文档应该设置为 preserveSymlinks: false
,意思是使用符号链接对应的原始文件。Vite 的默认配置为 false
,没有问题。
optimizeDeps.include
https://vitejs.dev/config/#optimizedeps-include 的描述中也提到了符号链接,但该配置项只和依赖预构建有关。
optimizeDeps: {
include: ['foo', 'bar']
}
无论是否设置强制预构建,vite build
都会报错。
如果不使用符号链接,比如将
libs/foo
和libs/bar
手动复制到node_modules
,这时运行vite build
不会报错,说明 Vite 在生产构建时也会处理node_modules
下的 CommonJS 包。报 Error: 'foo' is not exported by libs\foo\index.js, imported by main.js 只能是因为符号链接超出了检查范围,以至于没有成功转换 CommonJS 语法。
接下来我们将目光转移到 Rollup,运行 vite build
时,把 CommonJS 转换成 ES Module 的工作是由插件 @rollup/plugin-commonjs
实现的,Vite 支持通过 build.commonjsOptions
配置项自定义参数。
- Vite
build.commonjsOptions
https://cn.vitejs.dev/config/#build-commonjsoptions @rollup/plugin-commonjs
https://github.com/rollup/plugins/tree/master/packages/commonjs#usage-with-symlinks
在 @rollup/plugin-commonjs
的文档中可以搜到 symlink 相关的使用注意项:
Usage with symlinks
Symlinks are common in monorepos and are also created by the npm link command. Rollup with
@rollup/plugin-node-resolve
resolves modules to their real paths by default. Soinclude
andexclude
paths should handle real paths rather than symlinked paths (e.g.../common/node_modules/**
instead ofnode_modules/**
). You may also use a regular expression for include that works regardless of base path. Try this:commonjs({ include: /node_modules/ });
Whether symlinked module paths are realpathed or preserved depends on Rollup's
preserveSymlinks
setting, which is false by default, matching Node.js' default behavior. SettingpreserveSymlinks
to true in your Rollup config will causeimport
andexport
to match based on symlinked paths instead.
这里的 include
应该就是我们需要的。在使用前先看下 Vite 的默认配置,见 https://github.com/vitejs/vite/blob/d2887729911d52e3117a7649e85460b346f04b54/packages/vite/src/node/build.ts#L264-L268。
commonjsOptions: {
include: [/node_modules/],
extensions: ['.js', '.cjs'],
...raw?.commonjsOptions
},
Vite 默认只对 node_modules
文件夹下的 .js
和 .cjs
做转换。如果我们把 libs
文件夹加进去,Rollup 就可以正确转换 foo
和 bar
包了!
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
build: {
commonjsOptions: {
include: /node_modules|libs/,
}
}
})
运行
构建完成后,运行 pnpm serve
,打开浏览器看控制台输出。
Hi foo!
Hi fooDefault!
Hi bar!
Uncaught TypeError: bar is not a function
对照 main.js
来看:
// main.js
import fooDefault, { foo } from 'foo'
import barDefault, { bar } from 'bar'
foo('foo')
fooDefault('fooDefault')
bar('bar')
barDefault('barDefault')
只有通过默认导入的形式使用 bar
包会报错,对应上一篇文章的 import KeepAlive from 'react-activation'
。
在分析 Uncaught TypeError: bar is not a function 错误之前,我们再翻翻 @rollup/plugin-commonjs
的文档。其中有一个 defaultIsModuleExports
配置项,可以控制从 ES Module 中导入 CommonJS 模块时的默认导出对象。
- 为
true
时,使用module.exports
作为默认导出。
// mod.cjs
exports.default = 3;
import foo from './mod.cjs';
console.log(foo); // { default: 3 }
- 为
false
时,使用exports.default
作为默认导出。
// mod.cjs
exports.default = 3;
import foo from './mod.cjs';
console.log(foo); // 3
- 为
"auto"
时,判断 CommonJS 模块是否有exports.__esModule
属性且为true
,若满足,使用exports.default
作为默认导出,否则使用module.exports
。
// mod.cjs
exports.default = 3;
// mod-compiled.cjs
exports.__esModule = true;
exports.default = 3;
import foo from './mod.cjs';
import bar from './mod-compiled.cjs';
console.log(foo); // { default: 3 }
console.log(bar); // 3
defaultIsModuleExports
的默认值为 "auto"
,不得不说是最佳设置,同时它解释了前文的疑惑,原来 Rollup 是通过 exports.__esModule === true
来控制模块默认导出。
部分判断代码如下:
// https://github.com/rollup/plugins/blob/02fb349d315f0ffc55970fba5de20e23f8ead881/packages/commonjs/src/transform-commonjs.js#L152-L162
if (defaultIsModuleExports === false) {
shouldWrap = true;
} else if (defaultIsModuleExports === 'auto') {
if (node.right.type === 'ObjectExpression') {
if (hasDefineEsmProperty(node.right)) {
shouldWrap = true;
}
} else if (defaultIsModuleExports === false) {
shouldWrap = true;
}
}
// https://github.com/rollup/plugins/blob/02fb349d315f0ffc55970fba5de20e23f8ead881/packages/commonjs/src/transform-commonjs.js#L191-L200
if (isDefineCompiledEsm(node)) {
if (programDepth === 3 && parent.type === 'ExpressionStatement') {
// skip special handling for [module.]exports until we know we render this
skippedNodes.add(node.arguments[0]);
topLevelDefineCompiledEsmExpressions.push(node);
} else {
shouldWrap = true;
}
return;
}
// https://github.com/rollup/plugins/blob/02fb349d315f0ffc55970fba5de20e23f8ead881/packages/commonjs/src/ast-utils.js#L111-L123
export function hasDefineEsmProperty(node) {
return node.properties.some((property) => {
if (
property.type === 'Property' &&
property.key.type === 'Identifier' &&
property.key.name === '__esModule' &&
isTruthy(property.value)
) {
return true;
}
return false;
});
}
// https://github.com/rollup/plugins/blob/02fb349d315f0ffc55970fba5de20e23f8ead881/packages/commonjs/src/ast-utils.js#L61-L70
export const KEY_COMPILED_ESM = '__esModule';
export function isDefineCompiledEsm(node) {
const definedProperty =
getDefinePropertyCallName(node, 'exports') || getDefinePropertyCallName(node, 'module.exports');
if (definedProperty && definedProperty.key === KEY_COMPILED_ESM) {
return isTruthy(definedProperty.value);
}
return false;
}
顺便还能看到 Vue.js / Vite 核心团队成员胖茶给 Rollup 提的一个相关 Issue https://github.com/rollup/plugins/issues/939
demo 报错的罪魁祸首是 bar
比 foo
多出来的那段代码:
module.exports = require('./bar')
和 ES Module 不同,前者可以静态分析出依赖关系和导出对象,而 CommonJS 要等到运行时才能确定。这导致 module.exports = require('./bar')
在语法层面缺少 __esModule: true
属性,Rollup 只分析 AST 的话无法直接检测到。
可以单独写一个测试文件,用 Node.js 运行验证:
// test.js
const bar = require('bar')
console.log(Object.keys(bar)) // [ 'bar', 'default' ]
console.log(bar.__esModule) // true
在运行时,bar
模块的 __esModule
属性为 true
。
如果将 defaultIsModuleExports
从 "auto"
改成 false
,配置文件变成:
// vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
build: {
minify: false,
commonjsOptions: {
include: /node_modules|libs/,
defaultIsModuleExports: false
}
}
})
这时 vite build
的结果可以被浏览器正常运行:
Hi foo!
Hi fooDefault!
Hi bar!
Hi barDefault!
不过将 defaultIsModuleExports
设为 false
有点捡了芝麻丢掉西瓜,因为正常的 CommonJS 包并不会导出 exports.default
这么一个为了兼容 ES Module 而存在的属性。
作为库的开发者,不应同时使用 __esModule: true
和额外的 module.exports = require('./index')
入口文件。
以 react
和 react-activation
为例:
- https://unpkg.com/browse/react@17.0.2/cjs/react.development.js
- https://unpkg.com/browse/react-activation@0.9.5/lib/index.js
react
目前是一个纯正的 CommonJS 包,仅支持这么用:
import * as React from 'react'
import { Suspense } from 'react'
至于平时的
import React from 'react'
写法是 webpack、tsc 等工具做了特殊处理,统一包装成{ default: ... }
形式,补上了default
字段,而且 React 的 API 都挂在 React 对象上,其本身不作为函数被调用。
react-activation
既有 __esModule: true
和 exports.default
(因为其源码使用了 ESM,打包时使用 Rollup 做了转换),同时也效仿 react
定义了一个 index.js
:
// https://github.com/CJY0208/react-activation/blob/bd010077fdf1c20cbf45fea24fc0d25c048919b2/index.js
if (process.env.NODE_ENV === 'production') {
module.exports = require('./lib/index.min.js');
} else {
module.exports = require('./lib/index.js');
}
import KeepAlive from 'react-activation'
<KeepAlive></KeepAlive>
报错是情理之中。
结论
所以使用 Vite 过程中遇到 CommonJS 兼容问题怎么解决呢?当然是给 react-activation
这样的库提 Issue 或 PR 了,避免既导出为 CommonJS,又让调用方通过默认导入的形式使用,问题的锅不在 Vite。
与开发模式不同,Vite 生产构建时,CommonJS 模块的转换由 Rollup 接手处理,@rollup/plugin-commonjs
转换时依赖 exports.__esModule
。开发模式不报错,是因为存在冗余的运行时判断,凡是默认导入的 CommonJS 包都会加上三目运算符检查__esModule
属性。
import __vite__cjsImport3_reactActivation from "/node_modules/.vite/react-activation.js?v=5ba8f290";
const KeepAlive = __vite__cjsImport3_reactActivation.__esModule
? __vite__cjsImport3_reactActivation.default
: __vite__cjsImport3_reactActivation;
对应 Vite 中的这段代码 https://github.com/vitejs/vite/blob/d2887729911d52e3117a7649e85460b346f04b54/packages/vite/src/node/plugins/importAnalysis.ts#L685-L688
} else if (importedName === 'default') { lines.push( `const ${localName} = ${cjsModuleName}.__esModule ? ${cjsModuleName}.default : ${cjsModuleName}` ) } else { lines.push(`const ${localName} = ${cjsModuleName}["${importedName}"]`) }
话说回来,如果未来 Vite 全部切到 esbuild
,或者 @rollup/plugin-commonjs
支持注入运行时判断代码,Vite 的 CommonJS 兼容问题会得到彻底解决。