记一次中大型 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(¤t_line, "::v-deep $1")
.to_string();
current_line = deep_slash_regex
.replace_all(¤t_line, "::v-deep")
.to_string();
}
if line.trim().ends_with("</style>") {
in_style = false;
}
result.push_str(¤t_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。