Migrating to Vite

Alex O'Callaghan

迁移至 Vite

原文由 Alex O'Callaghan 发布,订阅该博客

我们的设计系统 React 组件库之前使用 Webpack 来构建分发的 JS 文件。我们刚刚完成了向 Vite 的迁移,通过支持 tree-shaking 提升了开发体验,并减小了最终应用的打包体积。

为什么选择 Vite?

我们最初使用 Webpack 构建组件库的主要原因之一,是为了保持 Storybook 开发环境与最终库构建之间的一致性。开发过程中,组件的改动往往只在 Storybook 中测试,如果 Storybook 构建和库构建存在差异,组件在应用中使用时就可能出现意外问题。

Storybook 现已支持 Vite 作为构建器,因此我们可以在不同构建之间保持同样的一致性。我们还可以使用 Vitest 在库构建、Storybook 和单元测试之间共享配置。

相比 Webpack,Vite 还带来了一些速度上的提升,并提供了更简单的配置,开箱即支持 ESM、TS 和 JSX。其中 ESM 的吸引力最大,因为我们的应用即便只使用了少数几个组件,也会打包整个组件库。而使用 ESM 和 tree-shaking 可以通过剔除最终应用包中未使用的 JS 来解决这一问题。

迁移过程

库模式与 ESM

首先,我们添加了一个基础的 vite.config.ts 文件来启用 Vite 库模式。我们没有保留原有的模块结构,而是将每个 index.tsindex.js 文件都视为一个入口(参见 rollup 文档)。这样我们就能为库中的每个包或目录构建独立的入口。我们将 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 插件来支持 JSX,并将其配置为 jsxRuntime: 'classic' 以兼容 React 17。我们还设置了 interop: 'auto',以确保在导入 React 时 CommonJS 与 ESM 之间的兼容性。这为仍在使用 React 17 的众多使用者保持了向后兼容(仍在使用 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 包解析 react/jsx-runtime 导入的问题(参见 React 问题)。为了解决这个问题,我们通过 except 选项将这些包有选择地包含在了构建后的库中。

// 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 来为组件设置样式。为了确保样式能随组件一起包含,而不需要在接入应用中单独引入,我们使用了 vite-plugin-lib-inject-css 为每个组件模块添加导入语句。

// 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 仍被包含在最终的应用包中,但这是在从库中导入组件的简便性之间做出的权衡。关于这一问题的复杂性,这里有一些讨论。

我们还不得不将所有的 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 插件将 *.svg 导入转换为原始字符串,而 Vite 通过在导入路径后添加 ?raw 后缀即可支持这一功能(文档)。

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

Jest 不支持这种导入方式,因此我们将测试迁移到了 Vitest,以便在构建和单元测试之间共享配置。

迁移过程相当顺利,只需将 jest 替换为 vi 来处理 mock 和 spy。我们在 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 依赖和配置文件
  • 移除了用于模拟各种 window 属性和全局变量的 Jest 配置文件
  • 简化了 Storybook 配置,因为 Vite 已开箱处理了 CSS 和资源导入

成果

我们得以将这一改动作为次要版本在各个应用中发布,且对使用者没有任何破坏性变更。

在使用该组件库的应用中,我们发现平均打包体积减小了 25%

我们还看到 Storybook 的启动和构建时间得到了改善。以前在开发环境下运行 Storybook 需要约 30 秒的 Webpack 构建才能完成,现在不到 10 秒即可打开(减少 66%)。完整构建 Storybook 现在耗时约 50 秒,而之前约为 115 秒(减少约 57%)。

本文章由 muse-spark-1.2-contributor 进行翻译

评论