Migrating to Vite

Alex O'Callaghan

迁移到 Vite

我们的 design system(设计系统) React component library(组件库)之前使用 Webpack 构建分发的 JS 文件。我们刚刚完成了迁移,改用 Vite,通过支持 tree-shaking(摇树优化)改善了开发体验,并减小了最终应用的 bundle(打包文件)体积。

为什么选择 Vite?

我们使用 Webpack 构建组件库的主要原因之一,是为了确保 Storybook 开发环境与最终库构建之间的一致性。开发过程中,组件变更通常只会在 Storybook 中进行测试,而 Storybook 构建与组件库构建之间的差异,可能会在组件被应用使用时导致意外问题。

Storybook 现在支持将 Vite 作为构建器,因此我们可以让两种构建保持一致。我们还可以使用 Vitest,在组件库构建、Storybook 和单元测试之间共享配置。

相比 Webpack,Vite 还可以提升一些速度,并且开箱即用地支持 ESM、TS 和 JSX,配置也更简单。ESM 是最吸引我们的地方,因为我们的应用会将整个组件库打包进去,即使它们只使用了其中几个组件。使用 ESM 和 tree-shaking,就可以通过丢弃最终应用 bundle 中未使用的 JS 来解决这个问题。

迁移

Library mode(库模式)与 ESM

首先,我们添加了一个基础的 vite.config.ts 文件,以启用 Vite 的 library mode。我们没有保留模块结构,而是将每个 index.tsindex.js 文件视为一个 entry point(入口点)(参见 rollup 文档)。这样,我们就可以为组件库中的每个 package 或文件夹构建相互独立的入口点。我们将 Vite 配置为同时输出 CommonJS(cjs)和 ES module(es)格式,并将文件名设置为 ${filename}.${format}.js。这有助于避免某些使用方应用中的 .mjs 兼容性问题。

// vite.config.ts
import { defineConfig } from 'vite';
import { dirname, resolve, relative, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { globSync } from 'glob';

const __dirname = dirname(fileURLToPath(import.meta.url));

export default defineConfig({
  build: {
    outDir: resolve(__dirname, 'dist/package'),
    lib: {
      entry: {
        'our-package-name': resolve(__dirname, 'index.ts'),
        ...globSync('src/**/index.{ts,js}').reduce((acc, file) => {
          acc[
            relative('src', file.slice(0, file.length - extname(file).length))
          ] = fileURLToPath(new URL(file, import.meta.url));
          return acc;
        }, {}),
      },
      name: 'our-package-name',
      fileName: (format, fileName) => `${fileName}.${format}.js`,
      formats: ['cjs', 'es'],
    },
  },
  ...
});

最后,我们更新了 package.json,为 main 和 module 字段设置了适当的值,以便使用方解析正确的格式。

{
  "name": "our-package-name",
  "main": "dist/package/our-package-name.cjs.js",
  "module": "dist/package/our-package-name.es.js"
}

React

我们使用官方的 Vite React plugin 支持 JSX,并将其配置为 jsxRuntime: 'classic',以兼容 React 17。我们还将 interop: 'auto' 设置为确保导入 React 时 CommonJS 与 ESM 之间的兼容性。这让许多仍在使用 React 17 的使用方能够保持向后兼容。

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        globals: {
          react: "React",
          "react-dom": "ReactDOM",
        },
        interop: "auto",
      },
    },
  },
  plugins: [
    react({
      jsxRuntime: "classic",
    }),
  ],
});

外部依赖

为了防止将外部依赖打包到最终输出中,实现类似 webpack-node-externals 的效果,我们使用了 vite-plugin-externalize-deps。不过,在使用较旧版本的 React 时,我们遇到了 radix-ui package 解析 react/jsx-runtime 导入的问题(参见 React issue)。为解决这个问题,我们通过 except 选项,有选择地将这些 package 包含在构建后的组件库中。

// vite.config.ts
import { defineConfig } from "vite";
import { externalizeDeps } from "vite-plugin-externalize-deps";

export default defineConfig({
  plugins: [
    externalizeDeps({
      except: [/radix-ui/],
    }),
  ],
});

样式

我们的组件库使用 SASS 和 CSS modules(CSS 模块)为组件设置样式。为了确保样式会随组件一起包含,而不要求使用方应用单独导入,我们使用了 vite-plugin-lib-inject-css,将 import 语句添加到每个组件模块中。

// vite.config.ts
import { defineConfig } from "vite";
import { libInjectCss } from "vite-plugin-lib-inject-css";

export default defineConfig({
  plugins: [libInjectCss()],
});

为了避免 CSS 文件在 tree-shaking 过程中被移除,我们还在 package.json 中添加了 sideEffects

{
  "sideEffects": ["**/*.css"]
}

遗憾的是,这会导致未使用组件的 CSS 仍然包含在最终应用 bundle 中,但这是从组件库导入组件的简便性与此问题之间的权衡。关于这个问题的复杂性,可以在这里进行讨论。

我们还必须将所有 SCSS 文件重命名为带有 .module.scss 后缀的文件,因为 Vite 正是通过这种方式确定是否使用 CSS modules:

find . -type f -name "*.scss" -exec sh -c 'mv "$0" "${0%.scss}.module.scss"' {} \;

类型定义

我们还为组件库提供类型定义,并选择使用 vite-plugin-dts,这样类型定义就会在执行 vite build 命令时生成,而不需要单独调用 tsc

// vite.config.ts
import { defineConfig } from "vite";
import { libInjectCss } from "vite-plugin-lib-inject-css";

export default defineConfig({
  plugins: [
    dts({
      tsconfigPath: "./tsconfig.json",
    }),
  ],
});

我们还确保组件库 package.json 文件中的 types 字段指向入口 .d.ts 文件:

{
  "name": "our-package-name",
  "types": "dist/package/our-package-name.d.ts"
}

SVG 导入与 Vitest

之前,我们使用 Babel plugin 将 *.svg 导入转换为原始字符串,但 Vite 通过在导入路径中添加 ?raw 后缀即可支持此功能(参见文档)。

import icon from "./icon.svg"; // before
import icon from "./icon.svg?raw"; // after

Jest 不支持这种导入方式,因此我们将测试迁移为使用 Vitest,从而可以在构建和单元测试之间共享配置。

迁移过程相当直接,只需将用于 mock 和 spy 的 jest 替换为 vi。我们在 vite.config.ts 中添加了一些基础测试配置,以启用全局变量、使用 jsdom,并将 radix-ui 依赖内联,以避免组件库构建期间出现类似的 react/jsx-runtime 解析错误。

// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  test: {
    globals: true,
    environment: "jsdom",
    deps: {
      inline: [/radix-ui/],
    },
  },
});

清理

迁移后,我们得以清理大量不再需要的配置文件和依赖:

  • 移除直接使用的 Babel 依赖及配置文件
  • 移除用于 mock 各种 window 属性和全局变量的 Jest 设置文件
  • 简化 Storybook 配置,因为 Vite 开箱即用地处理 CSS 和资源导入

结果

我们能够以次要版本发布的方式将这项变更推广到各个应用中,不会给使用方带来任何破坏性变更。

我们发现,使用该组件库的应用 bundle 体积平均减少了 25%

Storybook 的启动和构建时间也有所改善。之前,在开发模式下运行 Storybook 需要等待 Webpack 构建完成,耗时约 30 秒;现在则可以在 <10 秒内打开(减少 66%)。构建整个 Storybook 现在大约需要 50 秒,之前则需要约 115 秒(减少约 57%)。

原文由 Alex O'Callaghan 发布

本文章由 gpt-5.6-luna 进行翻译