Skip to content

如何将旧项目升级到 Rsbuild

书接上文,之前的文章中,我从升级到 Rsbuild 说起,讲解了前端工程化的重要性,本文将着重讲解如何将旧项目升级到 Rsbuild。

包括以下几个方面:

  • 问题背景和升级思路;

  • 具体迁移工作:

    • 基础准备
    • 常规配置;
    • 重难点配置;
    • CICD 打包配置;
  • 技术升级执行思路

问题背景和升级思路

问题背景

现在有一个后台项目,使用的是基于 Webpack4 的 Create React App 脚手架,同时使用的 Node 版本为 14,存在以下严重影响开发效率的问题:

  • 开发服务器启动慢
  • 没有模块热替换,修改代码后需要手动刷新页面;
  • 没有 CICD 打包,需要本地打包并上传到服务器;
  • 本地打包时间过长、占用内存过高

升级思路

首先是方案选择,改动最小的毫无疑问是基于当前的 Webpack 配置进行优化,但问题在于原项目中的 Webpack 配置过于复杂,不好找到可优化点,同时由于使用的是 Webpack4,本身就存在性能瓶颈,优化效果不一定好。

所以,经过评估,决定直接升级构建工具,将之前的推倒重来

那么就是选择升级的构建工具,当前可选择的构建工具主要有以下几个选择:

  • Webpack5:基于 Webpack4 的升级,改动最小,但还是存在性能瓶颈;
  • Vite:能带来巨大的性能提升,甚至 HMR 速度比 Rsbuild 更快,但当时 Vite 8 尚未正式发布,当前尚且存在开发环境和生产环境产物不完全一致的风险;
  • Rsbuild:同时兼顾性能和安全性,同时字节跳动内部也在大量使用,算是当前最优选择;

决定了构建工具后,接下来就是升级方案。

这里遵从的基本原则是让收益最大化,考虑到整个项目升级的复杂度,预期升级过程会非常久,所以决定将整个升级流程拆解为3个子步骤,进行分批完成和上线

  1. 添加 Rsbuild 作为构建工具,并完成开发服务器、生产打包的配置;
  2. 清除包括 Webpack 在内的相关无用依赖和其他无用配置,精简项目;
  3. 生产打包迁移到 CICD,并完成 CICD 配置;

相比于一次性完成,这样分批推进带来的开发效率提升如下:

upgrade-process-line-chart

除了能够提前带来收益,这样分批推进还便于进行项目管理,降低开发难度

具体迁移工作

确定了总体计划,接下来就是具体进行迁移升级的开发工作了。

整个迁移工作以 Rsbuild 的从 CRA 迁移的官方文档为标准,官方文档中已经有明确说明的,本文就不再赘述,而主要集中在本项目的特殊配置。

基础准备

Node.js 版本更新

由于本项目原先使用的 Nodejs 版本较低,根据 Rsbuild 官方文档说明,需要升级到 Node.js 18.12.0 或更高版本。

具体可选的 Node.js 版本由 CICD 系统提供的选项决定,同时根据 Node.js 官方的发布计划,决定选择版本最高的、且是维护 LTS 状态的 Node.js 版本——20.9.0。

确定后,更新项目 README.md 中的 Node.js 版本说明,并且更新package.json中的 Volta 的 Node.js 版本配置(其他 Node.js 版本管理工具则参考对应的文档进行配置):

json
{
  "volta": {
    "node": "20.9.0"
  }
}

初步添加 Rsbuild

这一步也主要参考官方的迁移文档,不过不同的是,不移除旧的 cra 依赖、命令和文件

安装 Rsbuild 依赖:

bash
yarn add @rsbuild/core @rsbuild/plugin-react -D

添加 Rsbuild 相关命令:

json
{
  "scripts": {
    "rs:start": "rsbuild dev",
    "rs:start:proxy": "rsbuild dev --env-mode proxy",
    "rs:build": "rsbuild build",
    "rs:build:watch": "rsbuild build --watch",
    "rs:build:mobile": "rsbuild build --env-mode mobile.production",
  }
}

为了和原命令进行区分,所以所有命令均添加了rs:前缀,在后续清除旧命令后,这些命令也会再去除前缀。

其中的rs:start:proxyrs:build:mobile命令是用来进行代理模式启动开发服务器和打包移动端页面的,相比常规的命令,添加了--env-mode参数用来加载不同的环境变量,在下文中会详细说明。

最后就是官方文档中说明的一样,在根目录下添加 Rsbuild 的配置文件rsbuild.config.ts

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

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

常规配置

大部分常规配置请参考官方文档说明,此处只列举不是很常见的配置。

添加 Node 模块 polyfill

image-20251010162544380

原项目中使用了 Node 模块,导致在运行时出现报错,需要根据官方文档添加 Node 模块的 polyfill 插件,从而防止报错。

注意,如果 TS 配置不正确,那么在打包时不会检测并提示该报错,必须是等到在浏览器中运行时,才会能看到报错

参考插件文档,按照以下方法添加该插件:

ts
/** rsbuild.config.ts */
import { pluginNodePolyfill } from "@rsbuild/plugin-node-polyfill";

export default {
  plugins: [pluginNodePolyfill()],
};

配置 Less 全局变量

原项目中存在注册全局 Less 变量,参考 Less 插件文档,可以通过配置 Less 插件的lessLoaderOptions.lessOptions.modifyVars参数进行设置。

rsbuild.config.ts

ts
export default defineConfig({
  plugins: [
    pluginLess({
      lessLoaderOptions: {
        lessOptions: {
          // less全局变量
          modifyVars: {
            '@font-family': '"SimSun"',
          },
        },
      },
    })
  ]
})

开启生产环境 Source map

因为开启 source map 后对打包时间影响不大,同时本项目为内部使用,不用担心源代码泄露风险,所以设置正式打包时也产生 source map 以便于出现线上报错时快速定位源代码:

rsbuild.config.ts

ts
export default defineConfig({
  output: {
    // 开启生产环境的 source map 功能
    // 因为该系统只有内部使用,不用担心源码泄露,同时有 source map 方便排查问题
    sourceMap: {
      js: 'source-map',
      css: true,
    },
  },
})

强制清理产物目录

因为打包目标目录不在前端项目目录下,所以 Rsbuild 默认会不清理目录,虽然改为线上打包后清不清理都不影响(打包产物都被 git 忽略了),但这里还是改为强制清理,以减少打包时的日志输出。

rsbuild.config.ts

ts
export default defineConfig({
  output: {
    // 强制清理产物目录,默认值是 'auto',在生产打包时不会清理产物目录(因为产物目录不在当前工作目录下)
    // 虽然因为现在产物文件都被 git 忽略了,无论清不清理都不影响,但为了减少打包时的日志输出,还是强制清理了
    cleanDistPath: true,
  },
})

动态引入模块名称冲突

根据官方文档,Rsbuild 支持和 webpack 一样,通过/* webpackChunkName: XXX */注释来设置动态引入模块的名称,但是不同点在于模块名称限制不能使用index

该问题会在编译时出现提示,根据提示修改模块名称即可。

路由配置文件:

ts
const route = {
  // ...
  key: 'root',
  path: '/',
  childRoutes: [
        {
          path: 'index',
          getComponent: (_, callback) =>
            // 原来设置的分包名称是 index ,但是 Rsbuild 不支持 index 作为分包名称,所以改成了 index2
            import(/* webpackChunkName: "index2" */ '../views/index').then((component) => {
              callback(null, component.default || component);
            }),
        },
    ]
}

装饰器版本配置

项目中使用了旧版的装饰器(由第三方依赖提供的),需要配置 tsconfig.jsoncompilerOptions.experimentalDecoratorstrue,以开启 TS 对旧版本装饰器的支持,以保证类型推断正常。

但是 Rsbuild 不会读取 TS 的配置项来编译旧版本的装饰器官方文档说明),而需要单独在 rsbuild.config.ts 添加以下配置:

ts
/** rsbuild.config.ts */
export default defineConfig({
  // 源码配置
  source: {
    decorators: {
      version: "legacy", // 使用旧版装饰器
    },
  }
})

如果不进行该配置,可能导致无法正常运行的代码出现在打包产物中,例如本项目中使用了第三方库提供的装饰器,编译时没有任何报错,但运行时会出错。

重难点配置

除了以上这些常规配置,这个项目还具有一些特殊情况需要进行特别配置:

  1. 入口文件是 FTL,而不是常规 HTML 文件。需要在开发阶段返回渲染后的 HTML 文件,而生产环境正常保留原 FTL 模板代码;
  2. 项目打包完成后,需要将入口 HTML 文件移动到特定目录下
  3. 项目同时适配多个打包模式,该项目中有两种打包模式(PC 端和移动端)两者使用的模板文件和最终打包出来的入口文件不一样,而且移动端需要将打包产物移动到另一个仓库中进行提交;
  4. 项目的前端逻辑会同时请求 a.vip.com 和 b.vip.com 两个域的接口,配置开发服务器代理时需要分别配置。
  5. 项目有接口模拟数据,需要在不改动原接口模拟数据的情况下实现对应的接口模拟数据功能;

入口 FTL 文件模板代码动态注入

首先,因为 Rsbuild 默认情况下会识别模板文件中的模板语法,并且尝试编译为 HTML 文件(该部分编译错误会直接中断整个 Rsbuild 进程),所以不能保留模板文件中的原模板语句,而改为通过模板语法注入需要保留到生产环境的模板语句(见模板参数官方文档)。

desktop-template.html 文件修改前后:

image-20251124111814429

通过以下 Rsbuild 配置,即可在打包时将这段模板语句原封不动注入回入口 HTML 文件中:

ts
/** rsbuild.config.ts */
export default defineConfig({
  html: {
    templateParameters: {
      // 引用其他 ftl 文件的语句。开发模式下无须引用
      include: isDev ? `` : `<#include "global.ftl" encoding="utf-8" parse="true">`,
    }
  }
})

除了这种引用其他 FTL 的模板语句需要恢复,还有一类会根据访问者动态渲染的模板语句,这种模板语句在开发环境中就需要渲染好再返回,以防止开发环境和生产环境有差异。

desktop-template.html 文件修改前后:

image-20251124112222013

这里可以看到,生产环境中,后端服务器会根据访问者的不同,动态设置这三个<input/>元素的值,而开发服务器,则需要返回已经渲染好的<input/>元素,并设置需要的值,这里就可以这样进行配置:

ts
/** rsbuild.config.ts */
export default defineConfig({
  html: {
    templateParameters: {
      // input 元素语句
      // 开发时,从环境变量中取值进行渲染,确保开发环境也能正常获取这些 input 元素的值
      inputElement: isDev
        ? `
        <input type="hidden" id="globalName" value="${process.env.TEMPLATE_NAME || '开发者'}">
        <input type="hidden" id="globalUserId" value="${process.env.TEMPLATE_USER_ID || '1234567890'}">
        `
        // 生产环境时,则直接返回模板语句
        : `
        <input type="hidden" id="globalName" value="<%= '\${name}' %>">
        <input type="hidden" id="globalUserId" value="<%= '\${userId}' %>">
        `,
    }
  }
})

打包完成后移动入口 HTML 文件

Rsbuild 默认打包后会将所有打包产物(包括入口 HTML 文件)放在 output.distPath.root 目录下,而本项目则需要单独将入口 HTML 文件移动到特定目录下。

首先,Rsbuild 有output.copy 这个配置项,但是其不支持在打包后复制文件,所以我这里单独开发了插件,以实现该功能。

plugins/plugin-copy-template.ts插件实现:

ts
import { logger, type RsbuildPlugin } from '@rsbuild/core';
import fs from 'fs';
import path from 'path';

type PluginCopyTemplateOptions = {
  /** 源文件路径,默认为源文件目录下的 index.html */
  sourcePath?: string;
  /** 目标文件路径,默认为当前仓库下的 /ftl/index.ftl */
  targetPath?: string;
};

/**
 * 移动模板文件插件
 * 默认情况下,会在打包完成后将输出目录下的 HTML 入口文件移动并重命名为 /ftl/index.ftl
 */
export const pluginMoveTemplate = (pluginOptions?: PluginCopyTemplateOptions): RsbuildPlugin => ({
  name: 'copy-template-plugin',

  setup(api) {
    api.onAfterBuild(async () => {
      /** 归一化后的 Rsbuild 配置 */
      const normalizedConfig = api.getNormalizedConfig();

      const {
        distPath: {
          /** 输出目录 */
          root,
        },
        filename: {
          /**
           * 输出的 HTML 入口文件名称,默认为 index.html
           * 如果是多入口的项目,可能会有多个 HTML 入口文件,需要根据实际情况进行调整
           */
          html = 'index.html',
        },
      } = normalizedConfig.output;

      /** 源文件路径 */
      const sourcePath = pluginOptions.sourcePath || path.resolve(root, html);

      /** 目标文件路径 */
      const targetPath = pluginOptions.targetPath || path.resolve(__dirname, '/ftl/index.ftl');

      try {
        // 检查源文件是否存在
        if (!fs.existsSync(sourcePath)) {
          logger.warn(`[copy-template-plugin] 源文件不存在: ${sourcePath}`);
          return;
        }

        // 确保目标目录存在
        const targetDir = path.dirname(targetPath);
        if (!fs.existsSync(targetDir)) {
          fs.mkdirSync(targetDir, { recursive: true });
        }

        // 移动文件
        fs.renameSync(sourcePath, targetPath);

        logger.success('[move-template-plugin] 文件移动成功');
        logger.success(`  源文件: ${sourcePath}`);
        logger.success(`  目标文件: ${targetPath}`);
      } catch (error) {
        logger.error(`[move-template-plugin] 文件移动失败`);
        logger.error(error);
      }
    });
  },
});

该插件的逻辑和功能其实很简单,就是使用 Rsbuild 的api.onAfterBuild注册打包完成后事件,然后使用 Node.js 的 api 进行文件操作

不过,以下几个点可以重点关注:

  1. 使用 Rsbuild 提供的 RsbuildPlugin 类型获取代码提示
  2. 使用 api.getNormalizedConfig() 方法获取归一化后的配置信息,避免写死源文件路径;
  3. 使用 Rsbuild 提供的 logger 这个 api 打印友好的日志信息
  4. 通过入参适配多种打包情况(不要直接在插件中读取环境变量来作为参数,不然不利于维护)。

适配移动端打包

首先新增一个移动端打包的专属命令rs:build:mobile,并且该命令通过--env-mode参数加载对应的环境变量:

json
// package.json
{
  "scripts": {
    "rs:build:mobile": "rsbuild build --env-mode mobile.production",
  }
}

对应的环境变量配置文件.env.mobile.production的内容为:

cmd
# OA 打包生产环境变量,指定 --env-mode 为 mobile.production 时生效
# 如需修改环境变量,请不要直接修改该文件,而应该复制该文件为 .env.mobile.production.local,然后修改 .env.mobile.production.local 中的环境变量来进行覆盖

# 打包目标相对路径
DIST_PATH_ROOT="../../mobile-webapp/resources"

# 入口 HTML 文件移动的目标路径
INDEX_TARGET_PATH="../../mobile-webapp/ftl/index.ftl"

# 模板文件相对路径
TEMPLATE_PATH="./public/mobile-template.html"

可以看到,移动端打包相比默认状态的打包,区别就在于这三个环境变量,而将这三个环境变量添加到rsbuild.config.ts就是:

ts
/** 输出产物的根目录 */
const distPathRoot = path.resolve(__dirname, process.env.DIST_PATH_ROOT || '../vfc-admin-webapp/src/main/webapp/frontend/mis/');

export default defineConfig({
  plugins: [
    pluginMoveTemplate({
      targetPath: process.env.DIST_INDEX_TARGET_PATH, // 使用环境变量控制入口 HTML 文件的目标移动路径
    }),
  ],
  html: {
    template: process.env.TEMPLATE_PATH || './public/desktop-template.html', // 使用环境变量控制 HTML 模板地址
  },
  output: {
    distPath: {
      root: distPathRoot, // 使用环境变量控制打包产物输出目录
    }
  }
});

同理,当后续需要新增不同的打包配置时,也可以通过控制环境变量来进行适配

接口代理配置

项目需要配置开发服务器代理,以便于接口相关功能的快速开发。

该项目中,除了会使用相对路径请求 a.vip.com 的接口,还会使用绝对路径直接请求 b.vip.com 的接口。

而这其中,b.vip.com 的接口尚且可以根据 /api 前缀来进行筛选,而 a.vip.com 的接口则完全没有特征,在不修改源代码的前提下,只能使用排除法判断是否代理至线上(如果修改源代码,可以给开发环境的请求地址添加前缀,以进行区分)。

plugins/plugin-proxy-and-mock/index.ts 插件内容:

ts
import { type RsbuildPlugin } from '@rsbuild/core';
import { bypass } from './bypass';

/**
 * 接口代理插件
 * 代理接口到目标地址
 */
export const pluginProxy = (): RsbuildPlugin => {
  return {
    name: 'plugin-proxy',
    setup(api) {
      api.modifyRsbuildConfig((config) => {
        config.server ??= {};

        config.server.proxy = [
          // 如果是 /api 开头的请求,则代理到 PROXY_TARGET_B 地址
          {
            context: ['/api'],
            target: process.env.PROXY_TARGET_B || '',
            changeOrigin: true,
            headers: {
              cookie: process.env.COOKIE || '',
            },
          },
          {
            context: ['/'],
            target: process.env.PROXY_TARGET || '',
            changeOrigin: true,
            headers: {
              cookie: process.env.COOKIE || '',
            },
            bypass,  // 绕过代理规则
          },
        ];

        return config;
      });
    },
  };
};

bypass.ts导出的bypass方法内容为:

ts
import { IncomingMessage } from 'http';
import path from 'path';
import fs from 'fs';

/**
 * 接口代理绕过逻辑,只有后端接口请求才会返回 undefined
 *
 * 返回字符串代表重定向路径
 * 返回 true 表示绕过
 * 返回 undefined 表示不绕过
 */
export const bypass = (req: IncomingMessage) => {
  /** 获取当前请求的完整url,单纯用来去除请求参数的,使用的 http://a 无实际意义 */
  const fullUrl = new URL(req.url, 'http://a');

  /** 去除请求参数、hash和末尾的 / 后的请求路径 */
  const urlWithoutParamsAndHash = fullUrl.pathname.endsWith('/') && fullUrl.pathname !== '/' ? fullUrl.pathname.slice(0, -1) : fullUrl.pathname;

  /** 返回 index.html 的请求路径 */
  const indexHtmlPathList = ['/', '/index', '/index.html', void 0];

  // 当访问以上路径时,直接返回 index.html
  if (indexHtmlPathList.includes(urlWithoutParamsAndHash)) {
    return '/index.html';
  }

  /** 映射到public目录下时的路径 */
  const publicFilePath = path.join(process.cwd(), 'public', urlWithoutParamsAndHash || '');

  // 如果public下有对应的文件,则直接返回
  if (fs.existsSync(publicFilePath)) {
    return true;
  }

  /** 专门指定的不进行代理的请求前缀 */
  const noProxyPrefixList = [
    // 打包产生的静态资源,详见https://rsbuild.rs/zh/config/output/dist-path#outputdistpath
    '/static',
    // rsbuild的开发服务器静态资源情况页面
    '/rsbuild-dev-server',
  ];

  if (req.url && noProxyPrefixList.some((prefix) => req.url?.startsWith(prefix))) {
    return true;
  }
};

在该插件中,最复杂的逻辑即为判断是否将接口代理至a.vip.com`,其具体判断流程如下:

mermaid
flowchart TD
    A[开始处理请求] --> B[解析请求URL]
    B --> C[去除请求参数、hash和末尾的/]
    C --> D{是否为index.html路径?\n(包括空路由、/、/index 等)}

    D -->|是| E[返回 '/index.html']
    D -->|否| F[检查public目录下是否存在文件]

    F --> G{文件存在?}
    G -->|是| H[返回 true - 绕过代理]
    G -->|否| I{检查是否为不代理的前缀}

    I -->|是| K[返回 true - 绕过代理]
    I -->|否| L[返回 undefined - 不绕过,走代理]

    E --> M[结束]
    H --> M
    K --> M
    L --> M

    style A fill:#e1f5fe
    style E fill:#c8e6c9
    style H fill:#c8e6c9
    style K fill:#c8e6c9
    style L fill:#ffcdd2
    style M fill:#f3e5f5

最后再在 rsbuild.config.ts 添加以下配置,将该插件动态挂载即可:

ts
/** 是否启用代理模式 */
const isProxy = process.env.IS_PROXY === 'true';

export default defineConfig({
  plugins: [
    // 根据环境变量动态挂载插件
    isProxy ? pluginProxy() : pluginMock(),
  ],
})

原接口模拟数据接入

该项目的接口模拟数据目录为 mock-data,模拟数据的具体逻辑进行存放在对应路径的 js 文件中。

以下是插件内容:

ts
/**
 * 模拟数据插件
 * 从 mock-data 目录下获取模拟数据
 */
export const pluginMock = (): RsbuildPlugin => {
  return {
    name: 'plugin-mock',
    setup(api) {
      api.modifyRsbuildConfig((config) => {
        config.dev.setupMiddlewares = (middlewares) => {
          middlewares.unshift(bodyParser.json(), async (req, res, next) => {
            const bypassResult = bypass(req); // 使用和代理配置一样的 bypass() 方法,检测是否需要使用模拟数据进行响应
            // 当重定向到其他路径时,直接修改请求路径
            if (typeof bypassResult === 'string') {
              req.url = bypassResult;
              next();
              return;
            }

            // 当绕过时,直接让下一个中间件处理
            if (bypassResult === true) {
              next();
              return;
            }

            // 当不绕过时,即为后端接口请求,则获取模拟数据并返回
            res.setHeader('Content-Type', 'application/json');
            try {
              const getMockDataResult = await getMockData(req.url, req, res, next);

              if (getMockDataResult.type === 'Err') {
                logger.error('[plugin-proxy-or-mock]', getMockDataResult.error);
                next();
                return;
              }

              const { mockFilePath, mockData } = getMockDataResult.value;

              logger.log(`[plugin-proxy-or-mock] 代理请求至本地模拟数据:${req.url} ➡ ${mockFilePath}`);

              // 如果 mockData 为 undefined,则代表模拟数据方法中调用了 next 方法,直接返回
              if (mockData === void 0) {
                return;
              }

              res.end(JSON.stringify(mockData));
            } catch (error) {
              logger.error('[plugin-proxy-or-mock] 获取模拟数据失败,已跳过', req.url, error);
              next();
            }
          });
          return middlewares;
        };

        return config;
      });
    },
  };
};

其中,bypass()方法内容和前文中接口代理配置插件一样,用来判断当前收到的请求是否要代理,如果需要代理,就使用本地模拟数据进行响应

那么本地模拟数据比较关键的就是getMockData()方法,以下是该方法内容:

ts
/** get-mock-data.ts */
/**
 * 获取模拟数据方法
 **/
import { IncomingMessage, ServerResponse } from 'http';
import path from 'path';
import fs from 'fs';
import module from 'module';
import { B_PREFIX, A_PREFIX } from '../../src/constants/others';
/**
 * 重新创建的 require 方法
 *
 * 因为 rsbuild 默认的 require 是 jiti 的 require,没有提供清除缓存的方法,
 * 所以这里单独创建一个 nodejs 原生的 require 方法
 */
const require = module.createRequire(import.meta.url);

/** 需要去除的请求前缀 */
// 当是以移动端模式启动时,接口请求就会带有这些前缀
const removePrefixList = [B_PREFIX, A_PREFIX];

interface GetMockData {
  (
    /** 接口请求地址 */
    url: string,
    /** 请求对象 */
    req: IncomingMessage,
    /** 响应对象 */
    res: ServerResponse,
    /** 调用下一个中间件方法 */
    next: () => void
  ): Promise<
    // 成功
    | {
        type: 'Ok';
        value: {
          /** 模拟数据文件路径 */
          mockFilePath: string;
          /** 模拟数据 */
          mockData: any;
        };
      }
    // 失败
    | {
        type: 'Err';
        /** 错误 */
        error: string;
      }
  >;
}

/**
 * 获取模拟数据方法
 *
 * @param url 接口请求地址
 * @param req 请求对象 当 mock-data 文件中存在 switchData 方法时,会传入该参数
 * @param res 响应对象 当 mock-data 文件中存在 switchData 方法时,会传入该参数
 * @param next 调用下一个中间件方法 当 mock-data 文件中存在 switchData 方法时,会传入该参数
 *
 * @return 模拟数据,当返回的 value.mockData 为 undefined 时,则代表模拟数据中调用了 next 方法
 */
export const getMockData: GetMockData = async (url, req, res, next) => {
  /** 干净的接口请求地址 */
  const clearUrl = (() => {
    /** 构建请求路径对象,用来处理请求路径,http://a 无实际意义 */
    const fullUrl = new URL(url, 'http://a');

    /** 去除请求参数、hash和末尾的 / 后的请求路径 */
    const urlWithoutParamsAndHash = fullUrl.pathname.endsWith('/') && fullUrl.pathname !== '/' ? fullUrl.pathname.slice(0, -1) : fullUrl.pathname;

    // 如果请求路径中包含 removePrefixList 中的前缀,则去除前缀
    let clearUrl = urlWithoutParamsAndHash;

    removePrefixList.forEach((prefix) => {
      if (clearUrl.startsWith(prefix)) {
        clearUrl = clearUrl.replace(prefix, '');
      }
    });

    return clearUrl;
  })();

  // XXX 未来使用 jiti 等模块加载器兼容 ts 等多种类型的 mock-data 文件,以便在模拟数据文件中使用接口方法中定义的 ts 类型
  /** 模拟数据文件路径 */
  const mockFilePath = path.join(__dirname, '../../mock-data', clearUrl) + '.js';

  if (!fs.existsSync(mockFilePath)) {
    return {
      type: 'Err',
      error: `${url} 的模拟数据文件不存在: ${mockFilePath}`,
    };
  }

  /** 模拟数据文件路径 */
  const modulePath = require.resolve(mockFilePath);
  // 删除缓存,防止缓存导致数据不更新
  if (require.cache[modulePath]) {
    delete require.cache[modulePath];
  }

  /** 模拟数据文件 */
  const mockDataModule = require(mockFilePath);

  // 如果存在 switchData 方法,则调用该方法获取数据
  if (typeof mockDataModule.switchData === 'function') {
    /** 模拟数据 */
    const mockData = await mockDataModule.switchData(req, res, next);
    return {
      type: 'Ok',
      value: {
        mockFilePath,
        mockData,
      },
    };
  }

  return {
    type: 'Ok',
    value: {
      mockFilePath,
      mockData: mockDataModule,
    },
  };
};

该方法的实现有以下几点需要特别注意:

  1. 使用了 Node.js 的module.createRequire() api 重新创建了一个 require 方法,以实现读取模拟数据前清除缓存。因为 Rsbuild 的插件中默认使用的 require方法是jitirequire方法,没有提供清除缓存的方法,需要自行创建一个;
  2. 因为该项目还会部署到另一个域名下,在另一个域名下部署时,接口请求会带上特定前缀,这时就需要预先去除,以防止影响查找接口模拟数据文件;
  3. 返回值是一个对象,当对象的 type 字段的值为 Err 时,pluginMock 插件中使用 logger.error() 打印错误信息。当错误是找不到接口模拟数据 js 文件时,错误信息中会带有期望的模拟数据文件路径,如果是在 IDE 中,就可以点击该路径,快速创建 js 文件;
  4. 插件会将中间件接收的 reqresnext 通过 getMockData 方法透传给模拟数据模块switchData 的方法(如果存在的话),从而实现模拟数据模块可以完全接管响应过程,对于需要根据请求参数动态响应、传输图片、模拟 sse 流式传输的情况,均可以在模拟数据模块内部自行实现。

CICD 打包配置

本次升级除了将构建工具升级为 Rsbuild,同时还给当前项目添加了 CICD 打包。

具体进行的修改包括:

  1. 给 CICD 打包烘焙阶段环境添加 Node.js 支持和执行打包脚本;
  2. 添加前端项目打包脚本。

CICD 配置修改

CICD 变更包括设置 Node.js 版本为 20.9.0,并且在执行脚本配置项中添加执行 js 脚本的语句

sh
# 直接将打包烘焙脚本放在项目中,便于进行版本管理
node ${WORKSPACE}/webapps/pack-bake.js

前端项目打包脚本

根据添加的执行脚本,在前端项目根目录下添加 pack-bake.js 文件,其内容如下:

js
/**
 * 前端项目 CICD 打包脚本
 **/
const { execSync } = require('child_process');
const path = require('path');
const {
  volta: { yarn: yarnVersion },
} = require('./package.json');

const main = async () => {
  console.log('Running pack-bake.js...');

  /** yarn 所处的目录 */
  const yarnPath = path.resolve(process.env.WORKSPACE, 'yarn');
  console.log('yarn 安装目录', yarnPath);

  /** yarn 的可执行文件地址 */
  const yarnExecutablePath = path.resolve(yarnPath, 'bin', 'yarn');
  console.log('yarn 可执行文件地址', yarnExecutablePath);

  /** 前端项目目录 */
  const frontendWorkspace = path.resolve(process.env.WORKSPACE, 'webapps');
  console.log('前端项目目录', frontendWorkspace);

  /** 安装 yarn 的命令 */
  const installYarnCommand = `npm install -g yarn@${yarnVersion} --prefix ${yarnPath}`;
  console.log(`执行 yarn 安装命令: ${installYarnCommand}`);
  // 安装 yarn
  execSync(installYarnCommand, { stdio: 'inherit' });

  console.log('执行 yarn --version');
  execSync(`${yarnExecutablePath} --version`, { stdio: 'inherit' });

  console.log(`在 ${frontendWorkspace} 目录下执行安装依赖命令: ${yarnExecutablePath} install`);
  execSync(`${yarnExecutablePath} install`, { cwd: frontendWorkspace, stdio: 'inherit' });

  console.log(`在 ${frontendWorkspace} 目录下执行打包命令: ${yarnExecutablePath} rs:build`);
  execSync(`${yarnExecutablePath} rs:build`, { cwd: frontendWorkspace, stdio: 'inherit' });
};

main();

该脚本的内容非常简单,就是依次执行安装yarn使用yarn安装依赖执行打包命令这几件事。

需要注意的有两点:

  1. 不能直接使用 npm install -g yarn 把 yarn 安装到全局,CICD 的机器有限制,会安装不成功,而应该搭配 --prefix 参数,将其安装到指定目录,然后执行后续命令时,直接调用这个目录下的可执行文件;
  2. 多输出日志以方便调试。因为该脚本的运行环境是在 CICD 的烘焙镜像的机器中,和本地环境差异较大,所以会遇到一些只有实际运行才会发生的问题,这就要求打印尽可能多的日志,以方便来回调试;
  3. 使用 execSync() 方法,以及配合 {stdio: 'inherit'} 参数执行命令。这样可以以同步的形式执行各条命令,同时将执行命令过程中的所有输出都同步输出到当前进程中,以便在日志中看到各个命令的执行效果。

技术升级执行思路

接下来以这次构建工具升级为例,聊聊这类技术升级的执行思路。

首先,一个旧的前端项目进行构建工具的升级,通常包含大量变更,根据是否影响最终打包产物,以及是否是必要的变更,可以进行以下分类:

upgrade-process

通过将整个升级流程进行拆分解,可以发现,其实有很多变更并不是必要的,在升级流程中完全可以先不进行这些变更,从而提升升级效率

其次,可以发现,其中有部分变更是不影响打包产物的,也就是说,这些变更的上线可以不用进行页面功能测试,只要变更前后打包产物没有发生变化,即视为测试通过。那么就可以视情况来灵活决定是否进行这些变更

可能导致打包产物发生变化的变更

因为 Rsbuild 宣称的是几乎完全兼容 Webpack 的 API,所以在最开始进行升级时,计划的是在不变更源码的情况下进行改动,从而保证升级前后的打包产物完全一致,这样就可以不用进行测试了。

但实际情况是由于使用的语法转换器不同、内部的分包逻辑不同等内部设计的差异,打包产物一定会存在差异

通过创建 demo 测试,可确定以下变更会引起打包产物的不同:

  1. 构建工具变更。比如从 CRA 升级到 Rsbuild,内部打包相关逻辑有区别,最终产物也会有区别;
  2. 同一构建工具版本升级。比如从 Rsbuild 1.4 升级到 Rsbuild 1.5,可能进行了打包相关逻辑的修改,导致产物有区别;
  3. 同一构建工具、同一版本,但是模块文件移动了位置。无论是 Webpack 还是 Rsbuild,对各个模块都会使用模块的实际路径生成一个 id,所以即便模块的内容不变,但只要文件路径发生了变化,这个 id 也会发生变化,从而导致打包产物发生变化。

具体 demo 内容可查看:https://codesandbox.io/p/github/RJiazhen/cra-rsbuild-diff-demo/master

测试是验证迁移是否成功的唯一标准

通过上面的描述,相信你也明白了,升级构建工具的过程必然会出现着打包产物的变化,所以如果要验证迁移后的页面没问题,只能通过测试来验证。

在本次升级中,因为原项目没有配套的页面测试用例,所以采取了以下手动测试方案:

  1. 开发模式代理到回归环境,打开所有页面,同时打开回归环境相同页面,检查控制台是否出现新的报错,如果有,则直接修复;

  2. 开发模式代理到回归环境,打开所有页面,同时打开回归环境相同页面,检查页面样式是否有问题,如果有,则直接修复;

  3. 开发模式测试通过了,部署至回归环境,打开所有页面,尽可能执行到所有模块的挂载逻辑,确保不出现模块未正常挂载引起的问题;

  4. 理论上,因为未改动到源代码,所以只要模块正常挂载了,那内部逻辑是必定保持原状能正常执行的。但也可能存在以下问题无法在编译时检出,而到运行时才发现:

    1. 使用了 Node.js 的 API,而没有配置对应的 polyfill;
    2. 未配置正确的装饰器版本,导致装饰器逻辑无法正常执行;
    3. 其他类似的,同一语法,但新旧打包方案处理不同导致的问题

    最好的情况应该是项目全面使用 TS,并且进行了规范配置,那么这些问题在编译时就可以发现。

    如果没有,那就只能在回归环境进行功能测试了,但是因为测试量过大,本次升级则是对重点页面的重点功能进行了验证测试

在我看来,内部使用的后台类项目进行构建工具升级时,各测试方案优先度如下:

  1. 最次方案:手动测试。就像上面说的,手动测试的工作量非常大,同时容易出现遗漏,并非最优选;
  2. 较优方案:多构建方案并行。即使用多个构建方案进行打包,同时,使用新构建方案打包的页面中,保留旧版页面的快捷入口,当用户出现使用问题时能够快速切回旧版打包产物的页面。搭配手动测试重点功能,相当于提供了一个兜底方案,让一些边缘测试场景在实际使用中进行测试。不过该方案尚未进行具体实现,可能需要涉及到 CICD 流程修改和服务器配置,后续有机会再尝试实现。
  3. 最优方案:自动化测试。最好的方案必然是所有组件都有测试用例,直接跑一遍测试即可。但是也需要注意,测试用例中需要涉及样式检查,因为可能出现样式文件未正常打包的情况。

总结

虽然本文大部分的内容都是具体的配置迁移和修改方案,但不要去记各种配置如何迁移、修改,而应该对以下几个方面建立起大致的认知:

  1. 构建工具一般有哪些配置
  2. 哪些问题会在编译阶段被暴露出来,哪些又会到运行时才暴露
  3. 对于复杂的定制化,应该如何进行封装,才便于维护

这些思想才是真正通用的,而不只局限于当前的构建工具,在未来接触到其他构建工具时,也依然能够带来帮助。

除了建立起关于构建工具的相关概念,还需要建立起关于技术升级的整体思路,这也是我认为本文最重要的东西。

进行技术升级时,应当把握的最重要的核心思想是**“让收益最大化”**。一个大型前端项目的完整技术升级往往需要花费大量时间,而如果将整个升级过程进行拆解,在不影响正常开发流程的前提下,把收益最大的部分拆解出来,优先完成、尽快上线,就可以让项目更早地享受到技术升级带来的收益,而在这之后再慢慢将其余的部分进行补完,完成整个技术升级。

同时,将整个升级过程进行拆解和分步推进,也利于项目管理和跟进。还有比较重要的一点是,这样可以显著降低回退造成的影响。如果出现回退的情况,可以保证只下线有问题的变更,而已经上线的、无问题的变更则不会受到影响。

image-20251028182822899