我把大型团队项目从 vite 前端迁移到了 rsbuild,收益如何?

背景

我们团队的项目一直是使用 vite 作为打包工具的,由于 vite 开发环境和生产环境的打包策略不同(开发环境按需打包,生产环境全量打包),所以存在开发环境和线上环境打包产物表现不一致的风险。其中 css 样式不一致的问题尤为容易发生,目前在需求开发过程中,已多次遇到本地开发环境 css 样式符合 UI 稿,发布到测试环境或者线上之后就出现了差异的场景。

基于上述背景,我们期望迁移至一款开发和生产环境打包产物一致的工具。

方案调研

除上述背景外,我们期望的打包工具还应该满足以下特性:

  • 开发和生产环境打包产物一致
  • 打包性能优异
  • 生态良好、项目足够稳定
  • 迁移成本较低

要满足打包性能优异的特性,除 vite 外,我们的打包工具范围最终锁定在由 rust 开发项目内:

Turbopack Farm Rsbuild(基于rspack)
产物一致性 ❌ 不一致 ✅ 一致 ✅ 一致
打包性能 ✅ 快 ✅ 快 ✅ 快
生态及稳定性 ❌ 全新生态,生态链较差 ✅ 兼容 vite 插件(先试验了下 farm,遇到的坑太多了且解决不了,根据 github 的用户数以及 npm 下载量,感觉目前尚未达到稳定可用状态) ✅ 兼容 webpack 插件且已有众多线上项目验证
迁移成本 ❌ 高(没有对应的插件) ❌ 高(踩坑太多且无解决思路,应该是farm本身的坑) ✅ 中

经过调研和实践,最终选定 rsbuild 作为最终的工具。

最终收益

  • 打包时长:300s 左右 > 60s 左右,性能提升大概 400% 左右

    • 迁移前 vite 打包时长:292s
    • 迁移后 webpack 打包时长:66s
  • 流水线构建时长:360s 左右 > 130s 左右,减少 150%(整条流水线上除了打包之外,还有拉代码仓库、代码质量检测、安装依赖、上传 CDN 等其他工作,而构建工具只是减少了打包这一阶段的工作时长,但打包依然是占最大头的,所以收益还是很明显)

  • 冷启动时长:

    • 首页面渲染:10s 左右 > 25s 左右(这里指的是从执行 npm run dev 到页面渲染出内容的时长)
    • 其他页面首次切换:5s 左右 > 1s 内(vite 由于资源是按需加载,在加载其他页面时需要对新页面资源进行 esbuild 构建,所以会慢)

    可以看到首屏冷启动时长性能有比较大的劣化,这是因为 vite 是根据页面按需资源打包,而 rsbuild 是全量打包。(但全量打包目前应该是解决开发和生产产物不一致的必要条件,为解决我们的背景问题必不可少)

    冷启动时长综合对比,如果在开发过程中切换页面比较多的话,最终的等待时长 rspack 会胜于 vite;如果只关注于一个页面的开发,vite 这方面占优

  • 热更新:0.5s 内 > 1.5s 内(都是很低的数值,体感没有差别)

  • 资源分包数:以我们项目的首屏为例,可以看到 rsbuild 的分包策略要由于 vite(打包总体积几乎无变化的情况下,分包数少了很多):

    • css 文件数: 38项 > 9 项
    • js 文件数:81项 > 55 项

迁移过程及踩坑

安装相关依赖

安装 rsbuild 核心包:

sql 复制代码
yarn add @rsbuild/core @rsbuild/plugin-babel @rsbuild/plugin-less @rsbuild/plugin-sass @rsbuild/plugin-vue @rsbuild/plugin-vue-jsx -D

其他插件根据自己项目的需要进行安装,可以在 rsbuild 官网找到。常用的有:

less 复制代码
@rsbuild/plugin-babel
@rsbuild/plugin-less
@rsbuild/plugin-sass
@rsbuild/plugin-vue
@rsbuild/plugin-vue-jsx

更改打包配置文件

下面是以我们的项目为例(一些敏感代码没有在上面展示),将 vite.config.ts 迁移到 rsbuild.config.ts ,可以进行参考:

  • vite.config.ts:

    ts 复制代码
    import { defineConfig, loadEnv, UserConfigExport } from 'vite';
    import vue from '@vitejs/plugin-vue';
    import vueJsx from '@vitejs/plugin-vue-jsx';
    import Inspect from 'vite-plugin-inspect';
    import AutoImport from 'unplugin-auto-import/vite';
    import { codeInspectorPlugin } from 'code-inspector-plugin';
    import path from 'path';
    
    const isProduction = env => env === 'production';
    const cdn = 'https://xxx.com/xyz/';
    
    export default ({ mode }: { mode: string }): UserConfigExport =>
        defineConfig({
            base: isProduction(mode) ? cdn : '/',
            plugins: [
                vue(),
                vueJsx(),
                codeInspectorPlugin({
                    bundler: 'vite',
                }), // 开发环境 Meta + shift 切换开启/关闭,点击 dom 跳转源代码
                AutoImport({
                    imports: ['vue', 'vue-router'],
                    dts: 'src/auto-import.d.ts',
                }),
            ],
            resolve: {
                alias: [
                    {
                        find: '@',
                        replacement: path.resolve(__dirname, './src'),
                    },
                ],
            },
            server: {
                host: '0.0.0.0',
                port: 4000,
                open: true,
                cors: true,
                proxy: {
                    '/api/': {
                        target: loadEnv(mode, process.cwd()).VITE_APP_HOST, // 转发域名
                        changeOrigin: true,
                        headers: {
                            referer: loadEnv(mode, process.cwd()).VITE_APP_REFERER,
                            host: loadEnv(mode, process.cwd()).VITE_APP_HOST,
                            Cookie: loadEnv(mode, process.cwd()).VITE_APP_COOKIE, // 转发
                        },
                    },
                },
            }
        });
  • rsbuild.config.ts:

    ts 复制代码
    import { defineConfig } from '@rsbuild/core';
    import { pluginVue } from '@rsbuild/plugin-vue';
    import { pluginVueJsx } from '@rsbuild/plugin-vue-jsx';
    import { pluginBabel } from '@rsbuild/plugin-babel';
    import { pluginLess } from '@rsbuild/plugin-less';
    import AutoImport from 'unplugin-auto-import/rspack';
    import { codeInspectorPlugin } from 'code-inspector-plugin';
    import path from 'path';
    
    
    const isProduction = env => env === 'production';
    const cdn = 'https://xxxx.com/xyz/';
    
    const { VITE_APP_HOST, VITE_APP_COOKIE, VITE_APP_REFERER } = import.meta.env;
    
    export default defineConfig(({ env }) => ({
        output: {
            assetPrefix: isProduction(env) ? cdn : '/',
            distPath: {
                root: 'dist',
                html: './',
                js: './',
                jsAsync: './',
                css: 'assets',
                cssAsync: 'assets',
                svg: 'assets',
                font: 'assets',
                image: 'assets',
                media: 'assets',
            },
            sourceMap: {
                js: isProduction(env) ? 'source-map' : 'cheap-module-source-map',
                css: true,
            },
        },
        plugins: [
            pluginLess({
                lessLoaderOptions: {
                    lessOptions: {
                        javascriptEnabled: false,
                    },
                    implementation: require.resolve('less'),
                },
            }),
            pluginBabel({
                include: /.(?:jsx|tsx)$/,
            }),
            pluginVue(),
            pluginVueJsx(),
        ],
        tools: {
            htmlPlugin: {
                template: './index.html',
            },
            rspack: {
                plugins: [
                    AutoImport({
                        imports: ['vue', 'vue-router'],
                        dts: 'src/auto-import.d.ts',
                    }),
                    codeInspectorPlugin({
                        bundler: 'rspack',
                    }),
                ],
                module: {
                    rules: [
                        {
                            resourceQuery: /raw/,
                            type: 'asset/source',
                        },
                    ],
                },
            },
        },
        source: {
            alias: {
                '@': path.resolve(__dirname, './src'),
            },
            define: {
                'import.meta.env.VITE_APP_HOST': `'${VITE_APP_HOST}'`,
            },
        },
        server: {
            host: '0.0.0.0',
            port: 4000,
            open: true,
            cors: true,
            proxy: {
                '/api/': {
                    target: loadEnv(mode, process.cwd()).VITE_APP_HOST, // 转发域名
                    changeOrigin: true,
                    headers: {
                        referer: loadEnv(mode, process.cwd()).VITE_APP_REFERER,
                        host: loadEnv(mode, process.cwd()).VITE_APP_HOST,
                        Cookie: loadEnv(mode, process.cwd()).VITE_APP_COOKIE, // 转发
                    },
                },
            },
        },
    }));

修改对应指令

json 复制代码
// package.json
{
  "scripts": {
    "dev": "rsbuild dev",
    "build": "rsbuild build",
    "preview": "rsbuild preview",
    // others...
  },
  // ...others
}

修改 index.html

移除掉 index.html 之前的 <script src="main.ts"></script> 引入代码。

踩坑

import.meta 不兼容

import.meta 是浏览器 module 模块中独有的语法,rsbuild 在 rsbuild.config.js 中可以使用 import.meta,但是在项目源代码中并不支持,所以我们需要对原本代码中的 import.meta 进行适配。

以我们项目为例,大部分代码中的 import.meta.XXX 都是在本地的 .env.local 文件中定义的,我们可以借助 source.define 去进行全局的声明:

ts 复制代码
import { defineConfig } from '@rsbuild/core';

const { VITE_APP_HOST } = import.meta.env;

export default defineConfig(({ env }) => ({
    source: {
        define: {
            'import.meta.env.VITE_APP_HOST': `'${VITE_APP_HOST}'`,
        },
    },
}));

另外我们项目还用到了 import.meta.url 去引入资源,这种需要改为 import 方式去引入:

javascript 复制代码
// 改之前:
const href = new URL('../assets/icon.png', import.meta.url).href;

// 改之后:
import IconPNG from '../assets/icon.png';
const href = IconPNG;

:deep 和 & 兼容问题

目前已知 @rsbuild/plugin-less 插件对于在 :deep 内部使用 less 的 & 符号会有问题,例如:

  • case1: 生效
less 复制代码
.container {
    .content {
        &_title {
            font-weight: bold;
        }
    }
}
  • case2: 生效
less 复制代码
.container {
    :deep(.content) {
        .content_title {
            font-weight: bold;
        }
    }
}
  • case3: 不生效
less 复制代码
.container {
    :deep(.content) {
        &_title {
            font-weight: bold;
        }
    }
}

后面会去 github 提个 issue,我们项目内部这种使用情况很少,所以暂时手动修改为不使用 &

css 中使用 v-bind 不支持三元运算符

不支持在 css 中 v-bind 使用三元运算符,可以改成在 js 中使用三元运算符替代

less 复制代码
// 改之前:
.container {
  height: v-bind(title ? '24px' : '48px');
}

// 改之后:
const containerHeight = title ? '24px' : '48px'; // js中写
.container {
  height: v-bind(containerHeight);
}

结论

我们的项目目前有 2500+ 文件、30w+ 行代码,算是一个很大的项目了。对于这样一个大型项目而言,从 vite 迁移 rsbuild 的过程中遇到的坑可以算很少了,而且迁移后的收益很明显,足以证明 rsbuild 及 rspack 是一个十分优秀的项目,且完全可以在生产环境中去应用了。

对于 webpack 构建的项目,我相信收益会更加明显,有折腾想法的朋友可以年底迁移一波作为 KPI 试试

最后的最后,快手基础平台团队招前端,欢迎在平台私信我投简历~

相关推荐
腾讯TNTWeb前端团队41 分钟前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰4 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪4 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪4 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy5 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom6 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom6 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom6 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom6 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom6 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试