从 Vite 构建出的产物看 export default 存在的问题

问题

同事最近将某项目的构建工具从 webpack 迁移到了 Vite,开发和构建过程非常丝滑,但部署时发现构建出的静态文件在浏览器上运行报错:

Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object.

神奇的是原先使用 webpack 可以正确打包,唯独使用 Vite 有问题。他排查以后发现是项目中的 react-activation 依赖引起,这个库实现了类似 Vue 的 <keep-alive /> 功能:

import React, { useState } from 'react'
import ReactDOM from 'react-dom'
import KeepAlive, { AliveScope } from 'react-activation'

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      count: {count}
      <button onClick={() => setCount((count) => count + 1)}>add</button>
    </div>
  )
}

function App() {
  const [show, setShow] = useState(true)

  return (
    <AliveScope>
      <button onClick={() => setShow((show) => !show)}>Toggle</button>
      <div>without {`<KeepAlive>`}</div>
      {show && <Counter />}
      <div>with {`<KeepAlive>`}</div>
      {show && (
        <KeepAlive>
          <Counter />
        </KeepAlive>
      )}
    </AliveScope>
  )
}

ReactDOM.render(<App />, document.getElementById('root'))

报错的是 <KeepAlive></KeepAlive> 代码,但 KeepAlive 组件本身没有问题。

原因

要确定错误的原因很简单,从 React 报错的 Stack Trace 中点进去,设置断点,便可以发现:

react-activation-default-exports.png

如 React 的错误提示所说,这里的 KeepAlive 是个对象,不是 React 期望的函数或者类。

修改模块的引入方式,问题就可以解决(值得庆幸的是 react-activation 除了将 KeepAlive 作为默认导出,还进行了单独导出)。

import KeepAlive from 'react-activation'
// 改为
import { KeepAlive } from 'react-activation'

react-activation 的 issue 中原作者同样给出了这个解决方法,见 https://github.com/CJY0208/react-activation/issues/132

重新梳理

问题虽然解决了,但借此可以详细写一写个中缘由。

react-activation 是如何导出模块的

首先看 react-activation 的导出(见 https://github.com/CJY0208/react-activation/blob/bd010077fdf1c20cbf45fea24fc0d25c048919b2/src/index.js#L12-L25):

// src/index.js
export default KeepAlive
export {
  KeepAlive,
  AliveScope,
  withActivation,
  fixContext,
  autoFixContext,
  useActivate,
  useUnactivate,
  createContext,
  withAliveScope,
  useAliveController,
  NodeKey,
}

导出了所有对外 API,同时将 KeepAlive 作为默认导出,构建后变成(见 https://unpkg.com/browse/react-activation@0.9.5/lib/index.js):

// lib/index.js
Object.defineProperty(exports, '__esModule', { value: true });

exports.AliveScope = AliveScope;
exports.KeepAlive = KeepAlive$1;
exports.NodeKey = NodeKey;
exports.autoFixContext = autoFixContext;
exports.createContext = createContext;
exports.default = KeepAlive$1;
exports.fixContext = fixContext;
exports.useActivate = useActivate;
exports.useAliveController = useAliveController;
exports.useUnactivate = useUnactivate;
exports.withActivation = withActivation;
exports.withAliveScope = withAliveScope;

再结合 react-activation 的入口文件(见 https://github.com/CJY0208/react-activation/blob/bd010077fdf1c20cbf45fea24fc0d25c048919b2/index.js#L3-L7):

// index.js
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./lib/index.min.js');
} else {
  module.exports = require('./lib/index.js');
}

可以发现 react-activation 采用了和 react 一样的 CommonJS 模块导出方式。

Vite 是如何构建的

对于如下代码:

import KeepAlive from 'react-activation'

开发模式下,Vite 会将其处理成:

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;

这里的 KeepAlive 始终是函数。

但生产模式下,Vite 构建出的结果是:

import {
  j as jsxRuntime, r as react, K as KeepAlive, R as ReactDOM, a as reactActivation
} from "./vendor.8e846e2e.js";
// ...
const jsx = jsxRuntime.exports.jsx;
const jsxs = jsxRuntime.exports.jsxs;
function Counter() {
  const [count, setCount] = react.exports.useState(0);
  return /* @__PURE__ */ jsxs("div", {
    children: [/* @__PURE__ */ jsxs("p", {
      children: ["count: ", count]
    }), /* @__PURE__ */ jsx("button", {
      onClick: () => setCount((count2) => count2 + 1),
      children: "Add"
    })]
  });
}
function App() {
  const [show, setShow] = react.exports.useState(true);
  return /* @__PURE__ */ jsxs("div", {
    children: [/* @__PURE__ */ jsx("button", {
      onClick: () => setShow((show2) => !show2),
      children: "Toggle"
    }), show && /* @__PURE__ */ jsx(KeepAlive, {
      children: /* @__PURE__ */ jsx(Counter, {})
    })]
  });
}
ReactDOM.render(/* @__PURE__ */ jsx(reactActivation.exports.AliveScope, {
  children: /* @__PURE__ */ jsx(App, {})
}), document.getElementById("root"));
// vendor.8e846e2e.js
// ...
var reactActivation = { exports: {} };
// ...
var KeepAlive = reactActivation.exports;
// ...
export {
  KeepAlive as K, ReactDOM as R, reactActivation as a, jsxRuntime as j, react as r
};

这里的 KeepAlive 实际是:

{
  AliveScope: ƒ ()
  KeepAlive: ƒ ()
  NodeKey: ƒ ()
  autoFixContext: ƒ ()
  createContext: ƒ ()
  default: ƒ ()
  fixContext: ƒ ()
  useActivate: ƒ ()
  useAliveController: ƒ ()
  useUnactivate: ƒ ()
  withActivation: ƒ ()
  withAliveScope: ƒ ()
  __esModule: true
}

所以 React 才会报 Element type is invalid 错误。

另外,如果使用了 @vitejs/plugin-legacy,对 import/export 语法进行转义,降级成 SystemJS 效果一样(因为语义不变)。

System.register(["./vendor-legacy.6f14fe2e.js"], (function() {
    "use strict";
    var e, t, n, r;
    return {
        setters: [function(c) {
            e = c.j,
            t = c.r,
            n = c.a,
            r = c.R
        }
        ],
        execute: function() {
            const c = e.exports.jsx
              , o = e.exports.jsxs;
            function i() {
                const [e,n] = t.exports.useState(0);
                return o("div", {
                    children: [o("p", {
                        children: ["count: ", e]
                    }), c("button", {
                        onClick: ()=>n((e=>e + 1)),
                        children: "Add"
                    })]
                })
            }
            function s() {
                const [e,r] = t.exports.useState(!0);
                return o("div", {
                    children: [c("button", {
                        onClick: ()=>r((e=>!e)),
                        children: "Toggle"
                    }), e && c(n, {
                        children: c(i, {})
                    })]
                })
            }
            r.render(c(n.AliveScope, {
                children: c(s, {})
            }), document.getElementById("root"))
        }
    }
}
));

其中的变量 nKeepAlive,它来源于 vendor-legacy.6f14fe2e.js 模块导出的 a 字段。

再看 vendor-legacy.6f14fe2e.js,为了方便定位关键字 KeepAlive,这里对 react-activation 做了改动,实际打包的是 lib/index.js 而不是 lib/index.min.js,以下是节选代码:

// vendor-legacy.6f14fe2e.js
var Jc = {};
Object.defineProperty(Jc, "__esModule", {
    value: !0
});
Jc.AliveScope = ih;
Jc.KeepAlive = Eh;
Jc.NodeKey = jd;
Jc.autoFixContext = Pp;
Jc.createContext = function() {...};
Jc.default = Eh;
Jc.fixContext = xp;
Jc.useActivate = Zp;
Jc.useAliveController = function() {...};
Jc.useUnactivate = Jp;
Jc.withActivation = Xp;
Jc.withAliveScope;
e("a", Jc);

可以看到除了使用 SystemJS 做降级,和未使用 @vitejs/plugin-legacy 之前的逻辑一样。

Vite 在生产模式下使用 Rollup 对应用进行打包,在处理 react-activation 模块时缺失了对 __esModule 的判断和对 default 属性的处理,导致开发和构建不一致。这究竟是 Vite 的问题还是 Rollup 的问题有待后续跟进。

Vite 与 webpack 的差别

webpack 会将 ES Module 和 CommonJS 全部统一处理成 ComomJS,最后的 bundle 文件中是级联的 CommonJS 模块,运行时根据 __esModule 判断原模块是否是 ES Module。Vite 和 webpack 走的是相反的一条路,它跟 Snowpack 一样选择了面向未来,将 CommonJS 转成 ES Module。

总结

export default 的问题,归根结底是 CommonJS 和 ES Module 的互操作性问题,二者不完全兼容。也就是 import KeepAlive from 'react-activation'const reactActivation = require('react-activation') 不是直接对应关系。

二者实际关系,参考以下代码:

默认导入(default import):

import KeepAlive from 'react-activation'
// 近似于
const KeepAlive = require('react-activation').default

命名空间导入(namespace imports):

import * as reactActivation from 'react-activation'
// 近似于
const reactActivation = require('react-activation')

有名导入(named imports):

import { KeepAlive } from 'react-activation'
// 近似于
const { KeepAlive } = require('react-activation')

如今浏览器和 Node.js 都已原生支持 ES Module,但 npm 上依然有着存量巨大的 CommonJS 包,为了避免互操作时掉进默认导出的坑,日常开发更推荐使用有名导出。