使用 Vite 过程中遇到的 CommonJS 兼容问题

上一篇文章留下了一个疑问,为什么 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 下直接新建 foobar 包,但这样不方便维护,所以我们将自定义的 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,引用 foobar

// 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 都有对应的配置:

看文档应该设置为 preserveSymlinks: false,意思是使用符号链接对应的原始文件。Vite 的默认配置为 false,没有问题。

optimizeDeps.include https://vitejs.dev/config/#optimizedeps-include 的描述中也提到了符号链接,但该配置项只和依赖预构建有关。

  optimizeDeps: {
    include: ['foo', 'bar']
  }

无论是否设置强制预构建,vite build 都会报错。

如果不使用符号链接,比如将 libs/foolibs/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 配置项自定义参数。

@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. So include and exclude paths should handle real paths rather than symlinked paths (e.g. ../common/node_modules/** instead of node_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. Setting preserveSymlinks to true in your Rollup config will cause import and export 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 就可以正确转换 foobar 包了!

// 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 报错的罪魁祸首是 barfoo 多出来的那段代码:

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') 入口文件。

reactreact-activation 为例:

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: trueexports.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 兼容问题会得到彻底解决。

发邮件与我交流

© 2016 - 2024 Ke Qingrong