记一次中大型 Vue2 项目迁移 Rsbuild 的过程

记一次中大型 Vue2 项目迁移 Rsbuild 的过程

背景

公司项目是一个有几年历史的 Vue2 + Webpack5 项目,代码量如下图,可以算是一个中大型项目了。写法也是千奇百怪,所以我认为比较有代表性。

去年以来随着代码量的增加,逐渐显现出 dev 启动慢、构建慢、启动时内存占用过大(超过 6G)的问题。

在我的电脑(比较高配置了 M3 Max 48G)上,冷启动 dev 需要 90s 以上,hot reload 需要 5s,build 需要 100s。 在同事使用公司发的电脑上(10 代 i5 16G)就非常慢,基本上要 300s,特别是启动项目加一个调试页面再加上几个网页基本上内存就爆了。 更不用说 Jenkins 打包服务器了,打包基本上需要 5 分钟以上了。

所以为了解决这些问题,我决定迁移到 Rsbuild 上,其中也遇到了一些问题,下面记录一下迁移步骤,希望对大家有所帮助。

迁移后的效果也比较理想,基本上所有的时间都缩短到了之前的 30% 以下(性能提升了 4 倍以上),在低配电脑上很明显,而且内存占用也少了很多。 总的来说还是值得一试的。

过程

1.添加依赖

添加 Rsbuild 相关依赖,包括 vue2、jsx、worker 的插件

sql 复制代码
pnpm -F warrenq add @rsbuild/core @rsbuild/plugin-less @rsbuild/plugin-vue2 @rsdoctor/rspack-plugin @rsdoctor/webpack-plugin worker-rspack-loader

2. 移除 webpack 相关依赖

这一步看需求,我们在几周内没有移除,为了保证线上代码不出错,我们 Jenkins 上还是用的 Webpack5 打包。过了几周后经过全面回归后就换成了 Rsbuild 打包了。

arduino 复制代码
pnpm -F warrenq remove webpack webpack-cli webpack-dev-server

3. 修改 package.json

json 复制代码
"rsbuild:dev": "rsbuild dev",
"rsbuild:build": "rsbuild build"

4. 添加 rsbuild.config.ts

css 复制代码
import { defineConfig, rspack } from '@rsbuild/core'
import path from 'path'
import { pluginVue2 } from '@rsbuild/plugin-vue2'
import { pluginBabel } from '@rsbuild/plugin-babel'
import { pluginVue2Jsx } from '@rsbuild/plugin-vue2-jsx'
import { pluginLess } from '@rsbuild/plugin-less'
import proxyConfig from './proxy.conf'
​
export default defineConfig({
  source: {
    decorators: {
      version: 'legacy' // TypeScript 装饰器使用 legacy 模式
    },
    entry: {
      index: './src/main/main.ts'
    },
    define: {  // 需要手动指定环境变量
      'process.env': JSON.stringify({}),
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
      'process.env.DEBUG': JSON.stringify(process.env.DEBUG),
      'process.env.apiEnv': JSON.stringify(process.env.apiEnv)
    }
  },
  plugins: [
    pluginLess(),
    // jsx、tsx 还是需要 babel 处理
    pluginBabel({
      include: /.(?:jsx|tsx)$/
    }),
    pluginVue2()
  ],
  server: {
    cors: process.env.NODE_ENV === 'development',
    // 开发服务器的代理配置,参考文档配置即可,和 webpack 没什么区别
    proxy: proxyConfig
  },
  html: {
    // 使用内置的 html 功能,不使用 webpack html 插件,另外 index.html 中也要按照 Rsbuild 的规范来写
    template: path.resolve('public/index.html'),
    filename: 'index.html',
    inject: 'body',
  },
  tools: {
    rspack: {
      module: {
        rules: [
          {
            // 处理 worker
            test: /.worker.(js|ts)$/,
            use: {
              loader: 'worker-rspack-loader',
              options: {
                inline: 'fallback'
              }
            }
          }
        ]
      },
      externals: {
        // 处理外部依赖
        xlsx: 'XLSX'
      },
      resolve: {
        // 处理别名
        alias: {
          '@': path.resolve(__dirname, './src'),
          config: path.resolve(__dirname, './config'),
          '@ckeditor': path.resolve(__dirname, './src/components/ck/@ckeditor'),
          'worker-loader': require.resolve('worker-rspack-loader')
        },
        fallback: {
          fs: false,
          crypto: false,
          path: false
        }
      }
    }
  }
})
​

5. 将 /deep/ 和 :deep() 迁移为 ::v-deep

和下面的的步骤可以一起用代码自动处理

6. 将使用 jsx 语法的文件后缀名改为 .jsx 或 .tsx。对于 Vue SFC 中使用 jsx 语法的情况,需要将 <script> 标签改为 <script lang="jsx">

因为前期的不规范,很多 js 文件中都使用了 jsx 语法。在 sfc 中使用 jsx 时也没指定 script 的 lang。 之前因为所有文件都会走 babel 所以没什么问题,现在迁移到 Rsbuild 后,我们希望只有 jsx 代码走 babel,如果所有代码走 babel 的话会慢很多,所以需要这一步。

我写了一个 Rust 程序(使用 oxc 解析)来检测所有使用 jsx 语法的文件,并自动重命名或者加上 lang="jsx" ,顺便处理了 deep 相关问题。

toml 复制代码
[package]
name = "vue-sfc-jsx"
version = "0.1.0"
edition = "2021"
​
[dependencies]
walkdir = "2.3"
regex = "1"
oxc = { version = "*", features = ["full"] }
rayon = "1.10.0"
rust 复制代码
use std::fs;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use regex::Regex;
use walkdir::{DirEntry, WalkDir};
​
mod jsx;
​
fn is_ignored(entry: &DirEntry, ignore_patterns: &[Regex]) -> bool {
    let path_str = entry.path().to_str().unwrap_or("");
    for pattern in ignore_patterns {
        if pattern.is_match(path_str) {
            return true;
        }
    }
    false
}
​
fn is_vue_file(entry: &DirEntry) -> bool {
    entry.path().extension().map_or(false, |ext| ext == "vue")
}
​
fn is_js_file(entry: &DirEntry) -> bool {
    entry.path().extension().map_or(false, |ext| ext == "js")
}
​
fn extract_script_content(vue_file_content: &str) -> String {
    let mut script_content = Vec::new();
    let mut in_script = false;
    let mut current_script = String::new();
    let mut has_lang_attribute = false;
​
    for line in vue_file_content.lines() {
        if line.trim_start().starts_with("<script") {
            in_script = true;
            has_lang_attribute = line.contains(" lang=");
            continue;
        }
​
        if in_script {
            current_script.push_str(line);
            current_script.push('\n');
        }
​
        if in_script && line.trim().ends_with("</script>") {
            in_script = false;
            if !has_lang_attribute {
                script_content.push(current_script.clone());
            }
            current_script.clear();
        }
    }
​
    script_content.join("\n").replace("</script>", "")
}
​
fn contains_jsx(script_content: &str) -> Result<bool, String> {
    jsx::contains_jsx_inner(script_content)
}
​
fn transform_style_deep_syntax(vue_file_content: &str) -> String {
    let mut result = String::new();
    let mut in_style = false;
    let deep_regex = Regex::new(r":deep((.*?))").unwrap();
    let deep_slash_regex = Regex::new(r"/deep/(.*?)").unwrap();
​
    for line in vue_file_content.lines() {
        let mut current_line = line.to_string();
​
        if line.trim_start().starts_with("<style") {
            in_style = true;
        }
​
        if in_style {
            current_line = deep_regex
                .replace_all(&current_line, "::v-deep $1")
                .to_string();
​
            current_line = deep_slash_regex
                .replace_all(&current_line, "::v-deep")
                .to_string();
        }
​
        if line.trim().ends_with("</style>") {
            in_style = false;
        }
​
        result.push_str(&current_line);
        result.push('\n');
    }
​
    result
}
​
fn check_vue_file_for_jsx(file_path: &str, vue_file_content: &String) -> Option<String> {
    let script_content = extract_script_content(&vue_file_content);
​
    match contains_jsx(&script_content) {
        Ok(true) => {
            println!(
                "{} ,该文件的 script 部分使用了 JSX 语法, 但未指定 lang 属性",
                file_path
            );
​
            let modified_content = vue_file_content
                .replace("<script lang="ts">", "<script lang="tsx">")
                .replace("<script>", "<script lang="jsx">");
            fs::write(file_path, &modified_content).expect("Unable to write file");
            println!("已修改文件: {}", file_path);
​
            Some(modified_content)
        }
        Ok(false) => None,
        Err(err) => {
            eprintln!("{} ,解析失败: {}", file_path, err);
            None
        }
    }
}
​
fn check_js_file_for_jsx(file_path: &str) {
    let js_file_content = fs::read_to_string(file_path).expect("Unable to read file");
    match contains_jsx(&js_file_content) {
        Ok(true) => {
            println!("{} ,该文件的 script 部分使用了 JSX 语法", file_path);
            let new_path = file_path.replace(".js", ".jsx");
            fs::rename(file_path, &new_path).expect("Unable to rename file");
            println!("已将文件重命名为: {}", new_path);
        }
        Ok(false) => {}
        Err(err) => {
            eprintln!("{} ,解析失败: {}", file_path, err);
        }
    }
}
​
fn main() {
    let directory_to_walk = "!!项目的绝对路径!!";
    let ignore_patterns = vec![
        Regex::new(r"^.*.git$").unwrap(),
        Regex::new(r"^.*.DS_Store$").unwrap(),
        Regex::new(r"^.*\node_modules").unwrap(),
        Regex::new(r"^.*\dist").unwrap(),
        Regex::new(r"vendors").unwrap(),
        Regex::new(r"static").unwrap(),
    ];
​
    let mut js_file_paths = vec![];
    let mut vue_file_paths = vec![];
​
    for entry in WalkDir::new(directory_to_walk)
        .into_iter()
        .filter_entry(|e| !is_ignored(e, &ignore_patterns))
    {
        match entry {
            Ok(entry) => {
                if entry.file_type().is_file() {
                    if is_vue_file(&entry) {
                        vue_file_paths.push(entry.path().to_str().unwrap().to_string());
                    } else if is_js_file(&entry) {
                        js_file_paths.push(entry.path().to_str().unwrap().to_string());
                    }
                }
            }
            Err(err) => eprintln!("Error: {}", err),
        }
    }
​
    js_file_paths.par_iter().for_each(|file_path| {
        check_js_file_for_jsx(file_path);
    });
​
    vue_file_paths.par_iter().for_each(|file_path| {
        let mut vue_file_content = fs::read_to_string(file_path).expect("Unable to read file");
        if let Some(modified_content) = check_vue_file_for_jsx(file_path, &vue_file_content) {
            vue_file_content = modified_content;
        }
​
        // 转换 :deep() 语法
        let transformed_content = transform_style_deep_syntax(&vue_file_content);
        if transformed_content.trim() != vue_file_content.trim() {
            fs::write(file_path, transformed_content).expect("Unable to write file");
            println!("已转换 :deep() 语法: {}", file_path);
        }
    });
}
​
rust 复制代码
use oxc::{
    allocator::Allocator,
    ast::{ast::JSXElement, visit::walk, Visit},
    parser::{Parser, ParserReturn},
    span::SourceType,
};
​
pub fn contains_jsx_inner(source_text: &str) -> Result<bool, String> {
    let allocator = Allocator::default();
    let source_type = SourceType::jsx();
    let mut errors = Vec::new();
​
    let ParserReturn {
        program,
        errors: parser_errors,
        panicked,
        ..
    } = Parser::new(&allocator, source_text, source_type).parse();
    errors.extend(parser_errors);
​
    if panicked {
        for error in &errors {
            // println!("{}", source_text);
            return Err(format!("解析失败 {}", error));
        }
    }
​
    let mut ast_pass = HasJSXNodes::default();
    ast_pass.visit_program(&program);
​
    Ok(ast_pass.has_jsx)
}
​
#[derive(Debug, Default)]
struct HasJSXNodes {
    has_jsx: bool,
}
​
impl<'a> Visit<'a> for HasJSXNodes {
    fn visit_jsx_element(&mut self, element: &JSXElement) {
        self.has_jsx = true;
        walk::walk_jsx_element(self, element);
    }
}

8. 处理 TypeScript 错误

如果遇到下面的报错,是由于 swc 与 babel 处理 TypeScript 的差异导致的类型错误。

这个是应为 Ref 是纯 ts 类型,在 js 中不存在,swc 在解析注解时没法解析,所以需要换成 import { type Ref, ref } from 'lit/directives/ref.js',指定 swc 注解的行为。

javascript 复制代码
import { Ref, ref } from 'lit/directives/ref.js'
​
× ESModulesLinkingError: export 'Ref' (imported as 'Ref') was not found in 'lit/directives/ref.js' (possible exports: createRef, ref)
      ╭─[1880:39]
 1878 │         attribute: false
 1879 │     }),
 1880 │     _ts_metadata("design:type", typeof Ref === "undefined" ? Object : Ref)
      ·                                        ───
 1881 │ ], DocToolbar.prototype, "hostRef", void 0);
 1882 │ _ts_decorate([
      ╰────

另外有可能有循环依赖的情况,需要将最深层的依赖中依赖外层的地方改为 import type。

相关推荐
打野赵怀真5 分钟前
前端资源发布路径怎么实现非覆盖式发布(平滑升级)?
前端·javascript
顾林海14 分钟前
Flutter Dart 流程控制语句详解
android·前端·flutter
tech_zjf15 分钟前
装饰器:给你的代码穿上品如的衣服
前端·typescript·代码规范
xiejianxin52017 分钟前
如何封装axios和取消重复请求
前端·javascript
parade岁月17 分钟前
从学习ts的三斜线指令到项目中声明类型的最佳实践
前端·javascript
狼性书生19 分钟前
electron + vue3 + vite 渲染进程与渲染进程之间的消息端口通信
前端·javascript·electron
阿里巴巴P8资深技术专家20 分钟前
使用vue3.0+electron搭建桌面应用并打包exe
前端·javascript·vue.js
coder_leon23 分钟前
Vite打包优化实践:从分包到性能提升
前端
shmily_yyA24 分钟前
【2025】Electron 基础一 (目录及主进程解析)
前端·javascript·electron
吞吞071126 分钟前
浅谈前端性能指标、性能监控、以及搭建性能优化体系
前端