跳到主要内容

Webpack 迁移到 Vite

问题

如何将一个已有的 Webpack 项目迁移到 Vite?迁移过程中有哪些坑点?如何制定渐进式迁移策略?

答案

Webpack 项目迁移到 Vite 是当下前端工程化升级的热门话题。Vite 利用浏览器原生 ESM 和 esbuild 预构建,在开发阶段可以实现近乎即时的冷启动和毫秒级 HMR。但迁移并非简单的"换个构建工具",需要系统性地评估、规划和执行。

核心结论

Webpack → Vite 的迁移本质上是 从 Bundle-based 开发模式切换到 Native ESM 开发模式。开发环境收益巨大(启动和 HMR 快 10-100 倍),生产环境改善相对温和。

为什么要迁移

Webpack 的痛点

随着项目规模增长,Webpack 在开发体验上暴露出明显瓶颈:

typical-webpack-problems.ts
// 1. 冷启动慢:中大型项目 30s-2min
// Webpack 需要解析整个依赖图 → 编译所有模块 → 打包 → 启动 DevServer

// 2. HMR 慢:修改一个文件后等待 2-10s
// 即使只改了一行代码,也可能触发大量模块重新编译

// 3. 配置复杂:各种 Loader/Plugin 的组合
const config = {
module: {
rules: [
{ test: /\.tsx?$/, use: 'ts-loader' },
{ test: /\.scss$/, use: ['style-loader', 'css-loader', 'sass-loader'] },
{ test: /\.svg$/, use: '@svgr/webpack' },
// ... 数十个规则
],
},
plugins: [
new HtmlWebpackPlugin(),
new MiniCssExtractPlugin(),
new DefinePlugin(),
// ... 十几个插件
],
};

// 4. 构建产物体积优化需要额外配置
// splitChunks、terser、css-minimizer 等一堆优化插件

Vite 的优势

维度WebpackVite
冷启动需要打包所有模块(30s-2min)原生 ESM 按需加载(<1s)
HMR 速度重新编译受影响模块(2-10s)精确更新单个模块(<50ms)
配置复杂度高(Loader + Plugin 组合)低(开箱即用,约定优于配置)
TypeScript需要 ts-loader 或 babel原生支持(esbuild 编译)
CSS 预处理器需要配置多个 Loader只需安装预处理器本身
生态最成熟,插件最多快速增长,兼容 Rollup 插件
Vite 不是万能的

Vite 的 No-Bundle 策略在超大型项目(数万模块)中可能出现浏览器请求瀑布问题。对于 IE11 等旧浏览器兼容需求,Webpack 仍是更稳妥的选择。

迁移前评估

迁移前需要从多个维度评估可行性,避免盲目迁移导致项目陷入困境:

评估维度

评估项低风险高风险
项目规模中小型、模块数 < 5000超大型 monorepo
依赖兼容性纯 ESM 依赖为主大量 CommonJS-only 依赖
浏览器兼容现代浏览器需要支持 IE11
自定义 Webpack 配置标准配置深度定制 Loader/Plugin
团队熟悉度熟悉 Vite/Rollup只熟悉 Webpack
CI/CD 环境灵活可调Node 版本受限

决策流程图

迁移步骤

以一个典型的 React + TypeScript + Webpack 项目为例,详细介绍每个迁移步骤。

第一步:安装 Vite 及相关依赖

npm install vite @vitejs/plugin-react -D

如果项目使用了 SCSS、SVG 等,还需安装对应依赖:

npm install sass vite-plugin-svgr -D

同时可以卸载不再需要的 Webpack 相关依赖:

渐进式迁移时先保留

如果采用渐进式迁移策略(先迁开发环境),暂时不要卸载 Webpack 相关依赖,等生产环境也迁移完成后再清理。

npm uninstall webpack webpack-cli webpack-dev-server html-webpack-plugin mini-css-extract-plugin css-loader style-loader sass-loader ts-loader babel-loader @svgr/webpack

第二步:创建 vite.config.ts

在项目根目录创建 vite.config.ts,将原有 Webpack 配置逐项迁移:

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

export default defineConfig({
plugins: [react(), svgr()],

resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},

css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss";`,
},
},
},

server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},

define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
},

build: {
outDir: 'build',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
router: ['react-router-dom'],
},
},
},
},
});

第三步:修改 index.html

Vite 要求 index.html 位于项目根目录(而非 public/),并使用原生 ESM 引入入口脚本:

迁移脚本示例
import fs from 'fs';
import path from 'path';

// 1. 将 index.html 从 public/ 移动到项目根目录
const source = path.resolve('public/index.html');
const target = path.resolve('index.html');
fs.renameSync(source, target);

// 2. 修改 index.html 内容
let html = fs.readFileSync(target, 'utf-8');

// 移除 Webpack 的模板语法
html = html.replace(/<%= htmlWebpackPlugin\.options\.title %>/g, '我的应用');
html = html.replace(/%PUBLIC_URL%/g, '');

// 在 </body> 前添加入口 script
html = html.replace(
'</body>',
' <script type="module" src="/src/main.tsx"></script>\n</body>'
);

fs.writeFileSync(target, html);

迁移前后的 index.html 对比:

Webpack 版 public/index.html
<!DOCTYPE html>
<html>
<head>
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
</head>
<body>
<div id="root"></div>
<!-- Webpack 自动注入 script 标签 -->
</body>
</html>
Vite 版 index.html(根目录)
<!DOCTYPE html>
<html>
<head>
<title>我的应用</title>
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

第四步:环境变量迁移

Webpack 使用 process.env,Vite 使用 import.meta.env,且前缀规则不同:

Webpack(CRA)Vite说明
REACT_APP_VITE_客户端暴露前缀
process.env.REACT_APP_API_URLimport.meta.env.VITE_API_URL访问方式
process.env.NODE_ENVimport.meta.env.MODE当前模式
process.env.NODE_ENV === 'production'import.meta.env.PROD是否生产环境
process.env.NODE_ENV === 'development'import.meta.env.DEV是否开发环境
.env 文件迁移
// .env(Webpack / CRA)
// REACT_APP_API_URL=https://api.example.com
// REACT_APP_VERSION=$npm_package_version

// .env(Vite)
// VITE_API_URL=https://api.example.com
// VITE_APP_VERSION=$npm_package_version

批量替换代码中的环境变量引用:

env-migration.ts
import fs from 'fs';
import path from 'path';
import { glob } from 'glob';

async function migrateEnvVars(): Promise<void> {
const files = await glob('src/**/*.{ts,tsx}');

for (const file of files) {
let content = fs.readFileSync(file, 'utf-8');

// 替换环境变量前缀和访问方式
content = content.replace(
/process\.env\.REACT_APP_(\w+)/g,
'import.meta.env.VITE_$1'
);
content = content.replace(
/process\.env\.NODE_ENV\s*===\s*['"]production['"]/g,
'import.meta.env.PROD'
);
content = content.replace(
/process\.env\.NODE_ENV\s*===\s*['"]development['"]/g,
'import.meta.env.DEV'
);
content = content.replace(
/process\.env\.NODE_ENV/g,
'import.meta.env.MODE'
);

fs.writeFileSync(file, content);
}
}

migrateEnvVars();

为了获得 TypeScript 类型提示,需要添加环境变量类型声明:

src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_VERSION: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}

第五步:路径别名迁移

webpack.config.ts → vite.config.ts 路径别名
// ❌ Webpack
const webpackConfig = {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@components': path.resolve(__dirname, 'src/components'),
'@utils': path.resolve(__dirname, 'src/utils'),
},
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
};

// ✅ Vite
import { defineConfig } from 'vite';
import path from 'path';

export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@components': path.resolve(__dirname, 'src/components'),
'@utils': path.resolve(__dirname, 'src/utils'),
},
// Vite 默认支持 .ts/.tsx/.js/.jsx,无需额外配置 extensions
},
});
同步更新 tsconfig.json

路径别名需要在 tsconfig.json 中同步配置 paths,否则 TypeScript 编译器无法识别:

tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
}
}
}

第六步:CSS / 资源处理迁移

Vite 对 CSS 和资源的处理大幅简化,大部分场景开箱即用:

CSS 处理对比
// ❌ Webpack 需要配置多个 Loader
const webpackConfig = {
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader'],
},
{
test: /\.scss$/,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
},
{
test: /\.module\.scss$/,
use: [
'style-loader',
{ loader: 'css-loader', options: { modules: true } },
'sass-loader',
],
},
],
},
};

// ✅ Vite 开箱即用
// 只需安装 sass 依赖即可,无需任何配置:
// pnpm add -D sass
// CSS Modules 自动识别 *.module.scss 文件
// PostCSS 自动读取 postcss.config.js

静态资源处理对比:

资源导入方式对比
// Webpack 中的资源导入(需要对应 Loader)
import logo from './logo.png'; // file-loader / url-loader
import styles from './app.module.css'; // css-loader + modules
import rawText from '!!raw-loader!./template.html'; // raw-loader

// Vite 中的资源导入(内置支持,无需配置)
import logo from './logo.png'; // 直接支持
import styles from './app.module.css'; // 自动识别 .module
import rawText from './template.html?raw'; // ?raw 后缀
import workerUrl from './worker.ts?worker&url'; // Web Worker

第七步:代理配置迁移

webpack.config.ts → vite.config.ts 代理配置
// ❌ Webpack DevServer
const webpackConfig = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
};

// ✅ Vite Server(几乎相同,注意 pathRewrite → rewrite)
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
});

第八步:全局变量处理

Webpack 的 DefinePlugin 对应 Vite 的 define 选项:

全局变量迁移
// ❌ Webpack DefinePlugin
const webpack = require('webpack');

module.exports = {
plugins: [
new webpack.DefinePlugin({
__DEV__: JSON.stringify(process.env.NODE_ENV === 'development'),
__APP_VERSION__: JSON.stringify(require('./package.json').version),
'process.env.API_URL': JSON.stringify('https://api.example.com'),
}),
],
};

// ✅ Vite define
export default defineConfig({
define: {
__DEV__: JSON.stringify(true),
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
// 注意:Vite 的 define 做的是纯文本替换
// 字符串值必须用 JSON.stringify 包裹
},
});
define 的文本替换陷阱

Vite 的 define编译时纯文本替换,不像 DefinePlugin 会做语法分析。如果 define 一个对象,需要 JSON.stringify 整个对象,否则会报错:

// ❌ 错误写法
define: {
__CONFIG__: { apiUrl: 'https://api.example.com' }
}

// ✅ 正确写法
define: {
__CONFIG__: JSON.stringify({ apiUrl: 'https://api.example.com' })
}

Webpack 配置 → Vite 配置完整映射表

Webpack 配置Vite 配置备注
entry不需要Vite 从 index.html 中的 <script> 自动识别
output.pathbuild.outDir默认 dist
output.publicPathbase默认 /
output.filenamebuild.rollupOptions.output.entryFileNamesVite 自动生成带 hash 的文件名
resolve.aliasresolve.alias用法基本一致
resolve.extensionsresolve.extensionsVite 默认支持 .ts/.tsx/.js/.jsx
module.rules(各种 Loader)插件 或 内置支持大部分开箱即用
devServer.proxyserver.proxypathRewriterewrite 函数
devServer.portserver.port用法一致
devServer.httpsserver.https用法一致
plugins: [HtmlWebpackPlugin]不需要直接使用根目录 index.html
plugins: [DefinePlugin]define注意 JSON.stringify
plugins: [CopyWebpackPlugin]vite-plugin-static-copy或直接放 public/ 目录
plugins: [MiniCssExtractPlugin]不需要生产构建自动提取 CSS
optimization.splitChunksbuild.rollupOptions.output.manualChunksAPI 不同,需要重写
optimization.minimizerbuild.minify默认 esbuild,可切换 terser
externalsbuild.rollupOptions.external配合 CDN 使用
devtoolbuild.sourcemap开发模式默认开启
stats无直接对应Vite 日志更简洁

常见坑点与解决方案

坑点一:CommonJS 依赖不兼容

Vite 开发模式基于原生 ESM,遇到纯 CJS 的依赖会报错。

vite.config.ts
export default defineConfig({
optimizeDeps: {
// 强制预构建 CJS 依赖,将其转换为 ESM
include: [
'lodash', // 纯 CJS 包
'moment', // 有 CJS 主入口
'react-dom', // 嵌套依赖
'some-lib > nested-cjs-dep', // 嵌套的 CJS 依赖
],
},
});
排查技巧

如果启动后页面报 require is not definedmodule is not defined 错误,将对应的包加入 optimizeDeps.include 即可。

坑点二:require() 和 require.context 替换

Vite 不支持 CommonJS 的 require() 语法,需要替换为 ESM:

require 迁移
// ❌ Webpack 的 require
const logo = require('./logo.png');
const config = require('./config.json');

// ✅ Vite 的 ESM import
import logo from './logo.png';
import config from './config.json';

// ❌ Webpack 的 require.context(批量导入)
const context = require.context('./modules', true, /\.ts$/);
context.keys().forEach((key: string) => {
const module = context(key);
// ...
});

// ✅ Vite 的 import.meta.glob
const modules = import.meta.glob('./modules/**/*.ts', { eager: true });
for (const [path, module] of Object.entries(modules)) {
// module 就是导出内容
console.log(path, module);
}

// 懒加载版本(返回 Promise)
const lazyModules = import.meta.glob('./modules/**/*.ts');
for (const [path, importFn] of Object.entries(lazyModules)) {
const module = await importFn();
}

坑点三:环境变量前缀变化

除了前面提到的 REACT_APP_VITE_ 替换外,还有一些容易遗漏的场景:

容易遗漏的环境变量场景
// ❌ 在非组件文件中使用(如 utils、config)
const apiUrl = process.env.REACT_APP_API_URL;

// ✅ 统一替换
const apiUrl = import.meta.env.VITE_API_URL;

// ❌ 动态拼接环境变量名(Vite 不支持)
const key = 'API_URL';
const value = process.env[`REACT_APP_${key}`]; // Webpack 可以

// ✅ Vite 中需要显式访问
const value = import.meta.env.VITE_API_URL; // 必须直接写完整名称
安全提醒

VITE_ 前缀的环境变量会被编译到客户端代码中,切勿放入密钥、密码等敏感信息。服务端专用的变量不要加 VITE_ 前缀。

坑点四:SVG 处理

Webpack 项目常用 @svgr/webpack 将 SVG 作为 React 组件导入,Vite 中需要使用 vite-plugin-svgr

vite.config.ts
import svgr from 'vite-plugin-svgr';

export default defineConfig({
plugins: [
react(),
svgr({
svgrOptions: {
// SVGR 配置选项
icon: true,
dimensions: false,
},
}),
],
});
SVG 导入方式
// 作为 React 组件导入
import Logo from './logo.svg?react';

// 作为 URL 导入(默认行为)
import logoUrl from './logo.svg';

function App(): JSX.Element {
return (
<div>
<Logo className="icon" />
<img src={logoUrl} alt="Logo" />
</div>
);
}

坑点五:全局 SCSS 变量注入

Webpack 中通常使用 sass-loaderadditionalData 注入全局变量,Vite 的配置方式类似但位置不同:

vite.config.ts
export default defineConfig({
css: {
preprocessorOptions: {
scss: {
// 注入全局 SCSS 变量/Mixin(每个 .scss 文件编译前自动注入)
additionalData: `
@import "@/styles/variables.scss";
@import "@/styles/mixins.scss";
`,
},
},
},
});
注意循环引用

被注入的文件中不要有实际的 CSS 输出(只放变量和 mixin),否则每个文件都会重复输出这些样式,导致产物体积膨胀。

坑点六:动态 import 路径

Vite 对动态 import() 的路径有更严格的要求:

动态 import 限制
// ❌ 完全动态的路径(Vite 无法分析)
const module = await import(someVariable);

// ❌ 模板字符串中包含变量目录
const module = await import(`./pages/${dir}/${file}.tsx`);

// ✅ 使用 import.meta.glob 替代
const pages = import.meta.glob('./pages/**/*.tsx');
const module = await pages[`./pages/${dir}/${file}.tsx`]();

// ✅ 动态路径只允许一层变量
const module = await import(`./pages/${name}.tsx`);

渐进式迁移策略

对于大型项目,推荐分阶段迁移,降低风险:

第一阶段:开发环境迁移

目标:让团队在开发时使用 Vite,生产构建仍用 Webpack,两套配置并存。

package.json
{
"scripts": {
"dev": "vite", // 开发使用 Vite
"dev:webpack": "webpack serve", // 保留 Webpack 作为备用
"build": "webpack --mode production", // 生产仍用 Webpack
"preview": "vite preview"
}
}
并行配置共存

在这个阶段,webpack.config.tsvite.config.ts 同时存在。团队日常开发用 pnpm dev(Vite),遇到问题可以随时切回 pnpm dev:webpack

第二阶段:验证与优化

  • 全团队使用 Vite 开发 1-2 周,收集兼容性问题
  • 修复所有 CJS 兼容性、环境变量、路径别名等问题
  • 比较 Vite 和 Webpack 的构建产物,确保功能一致

第三阶段:生产环境迁移

确认开发环境稳定后,将生产构建也迁移到 Vite:

package.json
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build", // 生产也使用 Vite
"build:webpack": "webpack --mode production", // 保留备用
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx"
}
}

第四阶段:清理与优化

  • 移除 Webpack 相关配置文件和依赖
  • 清理不再需要的 Loader/Plugin
  • 优化 Vite 配置(chunk 拆分、预构建优化)
npm uninstall webpack webpack-cli webpack-dev-server html-webpack-plugin css-loader style-loader sass-loader ts-loader babel-loader

迁移效果对比

以一个中型 React 项目(约 800 个模块、50+ 路由页面)的真实迁移数据为参考:

指标Webpack 5Vite 5提升
冷启动时间38s1.2s快 31 倍
HMR 更新2.8s50ms快 56 倍
生产构建65s42s快 35%
构建产物体积2.1 MB1.9 MB小 10%
dev 依赖数量47 个12 个减少 74%
配置文件行数320 行65 行减少 80%
数据说明

以上数据基于典型中型项目,实际效果因项目规模、依赖数量和配置复杂度而异。冷启动和 HMR 提升通常最为显著,生产构建的提升相对温和。


常见面试问题

Q1: 如何将一个 Webpack 项目迁移到 Vite?有哪些关键步骤?

答案

迁移的核心思路是 从 Bundle-based 开发模式切换到 Native ESM 开发模式,关键步骤分为八步:

  1. 安装依赖:安装 vite 和对应框架插件(如 @vitejs/plugin-react),按需安装 CSS 预处理器和功能插件
  2. 创建 vite.config.ts:将 Webpack 配置项逐一映射到 Vite 配置
  3. 迁移 index.html:从 public/ 移到根目录,移除 Webpack 模板语法,添加 <script type="module"> 入口
  4. 环境变量process.envimport.meta.envREACT_APP_VITE_ 前缀
  5. 路径别名resolve.alias 配置迁移,同步更新 tsconfig.jsonpaths
  6. CSS / 资源处理:移除多余 Loader 配置,Vite 大部分开箱即用
  7. 代理配置devServer.proxyserver.proxypathRewrite 改为 rewrite 函数
  8. 全局变量DefinePlugindefine 选项

推荐采用渐进式迁移策略:先开发环境用 Vite、生产环境保留 Webpack,验证稳定后再全量迁移。

迁移核心配置示例
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, 'src') },
},
server: {
port: 3000,
proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } },
},
define: { __APP_VERSION__: JSON.stringify('1.0.0') },
});

Q2: 迁移过程中最常见的兼容性问题有哪些?如何解决?

答案

最常见的六大兼容性问题及解决方案:

问题原因解决方案
require is not definedCJS 依赖未转换optimizeDeps.include 强制预构建
require.context 不可用Webpack 专有 API替换为 import.meta.glob
环境变量读取失败前缀和访问方式变了REACT_APP_VITE_process.envimport.meta.env
SVG 组件导入报错缺少 SVGR 插件安装 vite-plugin-svgr,使用 ?react 后缀
全局 SCSS 变量丢失注入方式不同配置 css.preprocessorOptions.scss.additionalData
动态 import 路径报错Vite 对动态路径限制更严使用 import.meta.glob 替代完全动态路径

其中最隐蔽的是 CJS 兼容性问题,因为很多常用的 npm 包仍然使用 CJS 格式(如 lodashmoment)。排查方法是观察浏览器控制台错误信息,将报错的包加入 optimizeDeps.include

vite.config.ts
export default defineConfig({
optimizeDeps: {
include: [
'lodash',
'moment',
'classnames',
'some-lib > nested-cjs-dep', // 嵌套的 CJS 依赖也要显式声明
],
},
});

Q3: 迁移后的效果如何量化评估?

答案

开发体验构建产物两个维度来量化:

开发体验指标(改善最明显):

performance-metrics.ts
interface MigrationMetrics {
// 1. 冷启动时间:从 npm run dev 到页面可交互的时间
coldStart: { webpack: '30-60s'; vite: '< 2s' };

// 2. HMR 时间:修改代码到页面更新的时间
hmrLatency: { webpack: '2-5s'; vite: '< 100ms' };

// 3. 依赖安装时间:devDependencies 数量和安装耗时
devDepsCount: { webpack: '40-60'; vite: '10-20' };
}

构建产物指标(改善温和):

指标测量方法预期改善
构建时间CI 流水线耗时20-40% 提升
产物体积builddist/ 总大小5-15% 减少
首屏加载Lighthouse Performance基本持平
chunk 数量构建日志manualChunks 配置而定

评估方法

  1. 迁移前记录基准数据(冷启动、HMR、构建时间、产物体积)
  2. 迁移后在相同条件下重新测量
  3. 关注 CI/CD 流水线耗时变化
  4. 团队主观体验(DX 满意度调查)
关键判断标准

迁移是否值得,主要看开发体验提升。生产构建的改善是锦上添花,但冷启动从 30s 降到 1s、HMR 从 3s 降到 50ms——这种量级的提升对开发效率和团队幸福感的影响是巨大的。

相关链接