从 webpack 迁移到 Rspack 实战

从 webpack 迁移到 Rspack 实战

写在前面

这个系列的第一篇《create react app 创建的项目改为使用 rsbuild》里,我记录了一次失败的排障经历——以为是 rsbuild 的锅,最后发现是 nginx 的锅。这篇完整展开那次经历,把根因彻底讲清楚,然后给出完整的迁移指南。

一、完整的故事

1.1 背景

公司有个 2019 年的 React 项目,用 CRA(create-react-app)创建,后来改成了自建 webpack 配置。

症状:

1.2 第一轮排查:以为是 rsbuild 的问题

我第一反应是:“肯定是 rsbuild 的 bug,打包出来的 CSS 文件有问题”。

我检查了:

  1. 打包产物里有 CSS 文件 ✅
  2. HTML 里引入了正确的 CSS 路径 ✅
  3. 网络面板里能看到 CSS 请求
  4. 但是返回 404

1.3 第二轮排查:以为是 CSS Modules 的问题

怀疑是 Rsbuild 的 CSS Modules 配置和 webpack 不一样。

看 Rsbuild 文档,反复改 CSS class 名,问题没解决。

1.4 第三轮:看服务器配置

同事的 nginx 配置:

server {
    listen 80;
    server_name example.com;

    # 关键 - 同事为了简洁把默认配置都注释了!
    # include /etc/nginx/mime.types;
    # default_type application/octet-stream;

    include /etc/nginx/conf.d/*.conf;

    server {
        location / {
            root /usr/share/nginx/html;
            index index.html index.htm;
        }
    }
}

真正的根因:

1.5 那为什么 webpack 项目没问题?

我反复对比 webpack 和 Rsbuild 的产物,产物完全一致。两个项目的产物在 nginx 上同样会返回 application/octet-stream。

但是老项目用的是 webpack-dev-server,新项目用的是 nginx + 静态文件部署。

dev server 自己处理 content-type(默认返回 text/css),所以老项目没问题。

根本不是 webpack vs Rsbuild 的问题,是部署架构的问题。

1.6 教训

  1. 不要在多个变量同时变化时做诊断——同时改构建工具 + 部署方式,出问题很难定位
  2. 先看现象,后看代码——如果浏览器解析失败,先看 content-type
  3. 怀疑一切——同事的"简化配置"是经典坑

二、完整的迁移指南

2.1 迁移前评估

先回答这些问题:

评估完成,90% 的项目可以直接迁移。

2.2 准备环境

# 项目根目录
npm install -D @rspack/core @rspack/cli

# 或者用 Rsbuild(更简单)
npm install -D @rsbuild/core @rsbuild/cli

2.3 修改 package.json

{
  "scripts": {
-   "start": "webpack serve --mode development",
-   "build": "webpack --mode production",
+   "dev": "rsbuild dev",
+   "build": "rsbuild build",
+   "preview": "rsbuild preview"
  }
}

2.4 配置文件迁移

原始 webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { DefinePlugin } = require('webpack');

module.exports = {
    entry: './src/index.jsx',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].[contenthash:8].js',
        publicPath: '/',
        clean: true,
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.jsx', '.js'],
        alias: {
            '@': path.resolve(__dirname, 'src'),
        },
    },
    module: {
        rules: [
            {
                test: /\.(ts|tsx|js|jsx)$/,
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: [
                            ['@babel/preset-env', { targets: '> 0.5%, not dead' }],
                            ['@babel/preset-react', { runtime: 'automatic' }],
                            '@babel/preset-typescript',
                        ],
                        cacheDirectory: true,
                    },
                },
            },
            {
                test: /\.css$/,
                use: [
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    'postcss-loader',
                ],
            },
            {
                test: /\.(png|jpg|jpeg|gif|svg)$/,
                type: 'asset',
                parser: { dataUrlCondition: { maxSize: 8 * 1024 } },
            },
            {
                test: /\.(woff|woff2|eot|ttf|otf)$/,
                type: 'asset/resource',
            },
        ],
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './public/index.html',
        }),
        new MiniCssExtractPlugin({
            filename: '[name].[contenthash:8].css',
        }),
        new DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify('production'),
        }),
    ],
    optimization: {
        splitChunks: {
            chunks: 'all',
            cacheGroups: {
                vendor: {
                    test: /[\\/]node_modules[\\/]/,
                    name: 'vendors',
                    priority: 10,
                },
                react: {
                    test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
                    name: 'react-vendor',
                    priority: 20,
                },
            },
        },
    },
    cache: {
        type: 'filesystem',
    },
};

迁移后的 Rsbuild 配置

// rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';

export default defineConfig({
    plugins: [pluginReact()],

    source: {
        entry: {
            index: './src/index.jsx',
        },
        alias: {
            '@': './src',
        },
        define: {
            'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
        },
    },

    html: {
        template: './public/index.html',
    },

    output: {
        distPath: {
            root: 'dist',
        },
        cleanDistPath: true,
        assetPrefix: '/',
    },

    // 内置 CSS 处理 - 不需要手写 loader
    // Rsbuild 自动配了 css-loader / postcss-loader / sass-loader / less-loader

    // 内置静态资源 - 不需要 file-loader
    // Rsbuild 自动处理 png / svg / font

    performance: {
        chunkSplit: {
            strategy: 'split-by-experience',
            forceSplitting: [
                // React 全家桶独立
                /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/,
            ],
        },
        removeConsole: process.env.NODE_ENV === 'production',
    },

    server: {
        port: 3000,
        open: true,
        historyApiFallback: true,
        proxy: {
            '/api': {
                target: 'http://localhost:8080',
                changeOrigin: true,
            },
        },
    },
});

注意:Rsbuild 把所有常见的 loader/plugin 都内置了,配置比 webpack 简单 50%。

2.5 删除 webpack 相关依赖

# 卸载 webpack
npm uninstall webpack webpack-cli webpack-dev-server

# 卸载 webpack 相关 loader/plugin
npm uninstall html-webpack-plugin mini-css-extract-plugin
npm uninstall babel-loader @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript
npm uninstall css-loader postcss postcss-loader
npm uninstall file-loader url-loader

# 清理 package.json,体积可能下降 200MB+

2.6 测试启动

npm run dev

冷启动从 60 秒降到 6 秒。

三、常见迁移问题

3.1 自定义 loader 的兼容

大部分自定义 loader 直接能用:

// 之前的自定义 loader
module.exports = function(source) {
    return source.replace(/__VERSION__/g, '1.0.0');
};

Rsbuild 100% 兼容。

3.2 自定义 plugin

// 之前的自定义 webpack plugin
class MyPlugin {
    apply(compiler) {
        compiler.hooks.emit.tapAsync('MyPlugin', (compilation, callback) => {
            // 生成 build-info.json
            compilation.assets['build-info.json'] = {
                source: () => JSON.stringify({ time: Date.now() }),
                size: () => 100,
            };
            callback();
        });
    }
}

Rspack 兼容大部分 hook,但 emit hook 改成了 processAssets hook:

class MyPlugin {
    apply(compiler) {
        compiler.hooks.thisCompilation.tap('MyPlugin', (compilation) => {
            compilation.hooks.processAssets.tap(
                {
                    name: 'MyPlugin',
                    stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
                },
                (assets) => {
                    assets['build-info.json'] = {
                        source: () => JSON.stringify({ time: Date.now() }),
                        size: () => 100,
                    };
                }
            );
        });
    }
}

3.3 Babel 插件不兼容

某些特殊 babel 插件 swc 不支持:

遇到不支持的插件,需要换 swc 写法。

3.4 Module Federation

完全支持,但配置语法略有不同:

// webpack 写法
new ModuleFederationPlugin({...})

// Rspack 写法
new rspack.container.ModuleFederationPlugin({...})

3.5 css-loader 配置差异

Rsbuild 的 css-loader 配置通过 tools.cssLoader 暴露:

export default defineConfig({
    tools: {
        cssLoader: {
            modules: {
                localIdentName: '[name]__[local]--[hash:base64:5]',
            },
        },
    },
});

四、性能对比

4.1 冷启动

项目 webpack 5 Rsbuild 提升
中型 React 项目 45s 6s 7.5x
大型 React 项目 90s 12s 7.5x
超大型 monorepo 180s 25s 7.2x

4.2 HMR

项目 webpack 5 Rsbuild 提升
改一行 JSX 5s 300ms 16x
改 CSS 3s 200ms 15x

4.3 生产构建

项目 webpack 5 Rsbuild 提升
中型 60s 18s 3.3x
大型 120s 35s 3.4x

生产构建慢于冷启动是正常的——需要更多优化(code splitting、minify、压缩)。

4.4 内存占用

项目 webpack 5 Rsbuild
dev 内存 2.5GB 1.2GB
build 内存峰值 4GB 2GB

五、迁移后的工程改进

5.1 加 type check

Rsbuild 的 swc 不做类型检查,加 plugin:

import { pluginTypeCheck } from '@rsbuild/plugin-type-check';

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

5.2 加 bundle analyzer

import { pluginBundleAnalyzer } from '@rsbuild/plugin-bundle-analyzer';

export default defineConfig({
    plugins: [
        pluginBundleAnalyzer({
            analyzerMode: 'static',
            reportFilename: 'bundle-report.html',
        }),
    ],
});

5.3 加 CI 检查

# .github/workflows/ci.yml
- name: Type Check
  run: npx tsc --noEmit

- name: Build
  run: npm run build

- name: Test Bundle
  run: npx bundlesize

六、避坑清单

6.1 部署环境要配对

Rsbuild 输出的静态资源和 webpack 输出几乎一样,部署方式不变。

但 Rsbuild 默认输出 dist/,webpack 默认也是 dist/——无需改 nginx 配置。

回到开头的故事:我们的问题是 nginx 的 mime.types 被注释掉,跟 Rsbuild 完全无关。

6.2 NODE_ENV 显式传入

Rsbuild 默认读 process.env.NODE_ENV,但要在脚本里显式设置:

{
    "scripts": {
        "build": "cross-env NODE_ENV=production rsbuild build"
    }
}

6.3 路径分隔符

跨平台路径要用 path.join / path.resolve,Rsbuild 在 Windows 下也能工作。

6.4 HMR 边界

// React 组件 - 直接 HMR
function Counter() {
    const [count, setCount] = useState(0);
    return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// 类组件 - 需要 hot.accept
class MyComponent extends React.Component { ... }

if (import.meta.hot) {
    import.meta.hot.accept();
}

七、其他迁移路径

7.1 直接用 Rspack

如果项目对 webpack 配置有特殊定制,直接用 Rspack 更灵活:

// rspack.config.js
const rspack = require('@rspack/core');

module.exports = {
    entry: './src/index.jsx',
    // ... 和 webpack 一模一样
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                loader: 'builtin:swc-loader',
                options: {
                    jsc: {
                        parser: { syntax: 'typescript', tsx: true },
                        target: 'es2020',
                    },
                },
            },
        ],
    },
    plugins: [
        new rspack.HtmlRspackPlugin({ template: './public/index.html' }),
    ],
};

7.2 评估 Vite

如果项目可以从零开始,Vite 的开发体验比 Rsbuild 还好。

但 Vite 的 webpack 兼容 = 0,要从零写配置。

7.3 评估 Turbopack

如果项目是 Next.js,直接用 Turbopack(默认)。

但 Turbopack 只在 Next.js 生态成熟,独立项目不推荐。

八、本篇小结

主题 关键点
迁移成本 90% 项目 < 1 周
性能提升 冷启动 7x、HMR 16x
配置简化 Rsbuild 比 webpack 简单 50%
兼容 loader 99%,plugin 90%
决策 推荐
存量 webpack Rsbuild(无痛)
新项目 Vite / Rsbuild
自定义多 直接用 Rspack

下一篇讲 Turbopack / Bun / Deno 等新势力——2024 年还有什么值得关注的工具。

参考资料