一次 React 项目的性能优化实践:从包体积治理到请求时序踩坑

这段时间,我对一个运行了几年的 React 项目做了一轮性能优化。

一开始,我以为这次优化主要是拆包:找出主包里的大依赖,把页面和 SDK 拆出去,再配一下缓存策略,包体积自然就会降下来。

真正做完以后才发现,包体积只是其中一部分。路由加载方式、接口请求时机、App Bridge 初始化、Token 写入顺序,最后全部串在了一起。尤其是在灰度环境中,提前请求数据还暴露了一个之前已经存在、但没有稳定出现的 Token 竞态。

这篇文章记录这次优化做了哪些调整、哪些方案后来回退了,以及拆包如何一步步影响请求时序。

背景

这是一个运行在 App WebView 中的 React H5 项目。

它和普通网站有一点不一样:用户不一定先进入首页,再从首页跳转到其他页面。App 可以直接打开某一个账单页、交易页、活动页或者详情页。

也就是说,项目里的很多业务页面都可能成为用户看到的第一个页面。

同时,这些页面大多依赖接口数据。页面本身的静态结构并不多,通常需要经历下面这条链路:

  1. App 打开 WebView
  2. 下载并执行 JavaScript
  3. 初始化公共 SDK
  4. 通过 App Bridge 获取 Token
  5. 调用页面接口
  6. 接口返回后展示主要内容

只要其中一个步骤比较慢,用户看到的就是空白、Loading,或者一个还没有真实内容的页面。

随着项目不断迭代,构建产物里也逐渐积累了不少历史内容:

  • 已经下线但仍然存在的路由页面
  • 多套功能相同的 Toast 和弹窗依赖
  • 从入口同步加载的大型 SDK
  • 没有按功能导入的 Lodash、CryptoJS
  • 重复图片和未使用字体
  • 生产依赖和开发依赖混在一起
  • 为了一个常量而被带入主包的业务组件
  • 页面挂载后才开始调用的接口

所以,这次优化的目标不是简单地让 main.js 变小,而是尽量缩短用户从打开页面到看到有效内容的完整时间。

优化思路

我没有一开始就修改 Webpack 配置,而是先分析构建产物,再删除无效代码、重复依赖和静态资源。在确认剩余内容确实需要保留后,才继续做按需导入、路由拆分、公共依赖分包和请求前移,并在灰度环境验证完整链路。

这次优化遵循的顺序很简单:

先分析,再删除,然后才是拆分和重构。

能直接删掉的东西,不应该通过复杂的打包配置去解决。

实现方案

1. 用 Bundle Analyzer 找出真正的大头

使用 Bundle Analyzer 分析构建产物

优化开始时,我临时接入了 webpack-bundle-analyzer,用它查看构建产物中的模块组成。

这一步主要想确认几个问题:

  • 主包里最大的依赖是什么
  • 是否存在多个功能相同的 UI 库
  • 哪些 SDK 被入口同步加载
  • 是否存在整包导入
  • PDF、字体和图片占了多少空间
  • 公共工具文件是否意外引入了重依赖
  • 页面拆分后,依赖是否真的离开了首屏资源

分析以后发现,项目的问题并不是某一个包特别大,而是很多小问题叠加在一起。

例如,项目中同时存在多种 Toast 实现;有的页面从 Lodash 根入口导入一个函数;DOMPurify 放在通用工具文件中;一些 SDK 虽然只有部分页面使用,却在应用启动时同步加载。

Bundle Analyzer 在这一阶段的价值主要是帮助定位。完成分析后,我删除了 analyzer 依赖和相关插件配置,不让分析工具长期留在正式构建链路里。

2. 统一功能重复的 UI 依赖

项目迭代时间比较长,Toast 和弹窗能力来自多个地方:

  • antd 的 message
  • rc-notification
  • 基于 rc-notification 封装的业务 Toast
  • react-hot-toast
  • antd-mobile 的 Toast
  • react-modal
  • 项目自己封装的 Popup 和 Modal

这些依赖解决的问题很接近,但调用方式、样式和销毁逻辑都不一样。

如果只是为了显示一条提示信息,同时保留三四套实现,不仅会增加依赖体积,也会增加维护成本。

这次优化中,我优先复用项目本来就大量使用的 antd-mobile:

  • 将 antd message 替换为 antd-mobile Toast
  • 将自定义 Toast 的底层从 rc-notification 改为 antd-mobile Toast
  • 删除 rc-notification
  • 将 react-modal 替换为 antd-mobile Popup
  • 删除不再需要的 antd 和 react-modal

为了避免大范围修改业务调用方,可以在原来的接口外面保留一层适配:

import { Toast as MobileToast } from "antd-mobile";

type ToastOptions = {
  message: string;
  type?: "success" | "error";
};

export function showToast(options: ToastOptions | string) {
  const value = typeof options === "string" ? { message: options } : options;

  return MobileToast.show({
    duration: 2000,
    position: "center",
    content: value.message,
  });
}

这样业务代码不需要立即全部改成新的调用方式,但底层能力已经统一。

这里我没有一次性删除所有 react-hot-toast。它仍然被部分历史页面使用,如果为了“依赖绝对统一”同时改动所有页面,测试范围会明显扩大。

所以这次采用的是渐进式处理:先删除明确重复、替换成本较低的依赖,剩余页面后续再逐步收敛。

3. 删除无效代码和静态资源

删除无效代码和静态资源

这一部分看起来没有拆包那么“高级”,但实际收益最直接。

项目中有 7 个已经下线的路由页面,虽然用户不会再进入,但对应代码和资源仍然存在。删除这些页面后,一次清理了六千多行代码。

除此之外,还处理了这些内容:

  • 删除空文件和无效配置
  • 清理未使用的变量和导入
  • 删除重复 Logo 和弹窗图片
  • 让多个页面复用同一份图片
  • 使用 SVGO 优化 SVG
  • 位图优先使用 WebP 等压缩格式
  • 删除不再使用的字体字重
  • 将 TTF 字体替换为 WOFF2

字体压缩的效果比较明显。

例如,项目中一个约 53 kB 的 TTF 字体,转换为 WOFF2 后约为 18 kB;另一个约 72 kB 的字体,转换后约为 30 kB。

单看一个字体不算特别大,但当页面包含多个字重、多个字体文件时,累计体积就很可观。

这也让我重新确认了一点:

性能优化中,删除资源通常比拆分资源更有效。

拆包只是决定资源什么时候加载,删除才是真正让资源不再加载。

4. 清理依赖并区分运行依赖和开发依赖

项目的 dependencies 中原来混入了很多只在开发和构建阶段使用的依赖,例如:

  • Babel
  • Webpack loader
  • Jest 和测试工具
  • TypeScript 类型声明
  • ESLint
  • 格式化工具
  • 本地开发服务器相关依赖

这次把它们重新移动到了 devDependencies,并删除了一些已经不再使用的包,例如 Tailwind、Pug、Sass loader、过渡组件和旧构建工具。

需要说明的是,把依赖从 dependencies 移到 devDependencies,不一定会直接让浏览器里的 JavaScript 变小。

浏览器包体积最终取决于代码有没有被构建进去。这一步更主要的作用是:

  • 让依赖职责更清楚
  • 减少生产安装时不必要的依赖
  • 降低部署环境的安装内容
  • 避免后续开发误用构建依赖

PDF 相关依赖也做了一次整理。

项目同时存在 react-pdf 和单独安装的 pdfjs-dist,但实际能力存在重复,因此删除了单独声明的 pdfjs-dist。

中间也尝试升级过 react-pdf,但依赖升级会同时影响 Worker、构建配置和运行兼容性。为了避免把性能优化和版本兼容问题混在一起,验证后最终还是恢复到了项目原来稳定使用的版本。

版本更高不一定就更适合当前项目。性能、兼容性和改造成本需要一起评估。

5. 避免从根入口导入整个依赖

资源优化与按需导入

分析构建产物时,还发现了一些典型的整包导入。

例如,项目只使用 Lodash 的 debounce 或 throttle,却从 Lodash 根入口导入;CryptoJS 只使用 SHA256,也从整个包导入。

后来统一改成了按功能路径加载:

import debounce from "lodash/debounce";
import throttle from "lodash/throttle";
import SHA256 from "crypto-js/sha256";

const handleSearch = debounce(search, 300);
const encrypted = SHA256(value).toString();

虽然现代构建工具支持 Tree Shaking,但并不是所有包都能稳定地被完整裁剪,尤其是仍然包含 CommonJS 的依赖。

按功能路径导入更明确,也更容易从构建结果中确认实际引入内容。

Lodash 还有两种可选的处理方式。一种是改用 lodash-es,通过 ESM 导出配合构建工具做 Tree Shaking;另一种是迁移到 es-toolkit,它同样提供 debounce、throttle 等常用方法,并针对 ESM 和 Tree Shaking 做了优化。

这两种方案都需要重新核对 API 行为和构建结果。尤其是从 Lodash 迁移到 es-toolkit 时,部分参数和边界行为并不完全相同。为了控制本轮优化的改动范围,这个项目最终只采用了按功能路径导入,没有切换到 lodash-es 或 es-toolkit。

DOMPurify 也有一个类似的问题。

它原来放在通用的 utils.ts 中,而这个文件被很多页面引用。即使某个页面只使用一个普通工具函数,也可能因为依赖关系把 DOMPurify 带入公共链路。

后来我把 HTML 安全处理方法拆到了单独的 html.ts,只有真正需要渲染富文本的页面才导入它。

同时,一些纯常量也从大型组件入口中拆了出来。否则 service 文件为了读取一个版本号或枚举,可能会顺着依赖关系把整个 UI 组件带进来。

优化 import 时,不能只看当前这一行代码,还要顺着依赖链继续往下看。

6. 页面按路由拆分

路由拆分与分包缓存

项目中的业务页面比较多,而用户一次通常只访问其中一个页面。

因此,我将页面组件统一改成通过 React.lazy 按路由加载:

const TransactionPage = React.lazy(() => import("./views/Transaction"));
const BillDetailPage = React.lazy(() => import("./views/BillDetail"));

const routes = [
  {
    path: "/transaction",
    element: (
      <Suspense fallback={<PageLoading />}>
        <TransactionPage />
      </Suspense>
    ),
  },
];

对于普通网站,首页通常是最明确的首屏。

但这个项目运行在 App WebView 中,App 可以直接打开任意业务路由,所以每个路由都需要被当作可能的首屏处理。

路由级拆分以后,用户进入交易页面时,不需要同时下载税务、活动、个人资料等其他页面代码。

不过,拆分也不是越细越好。

我最初还尝试把每个路由的 loader 也改成动态加载。这样确实能继续减少入口代码,但用户进入页面时会多一次 loader chunk 请求。

在 App WebView 和弱网环境中,额外请求的延迟有时会比减少的那部分 JavaScript 更明显。

最后保留了页面组件的懒加载,但将 loader 恢复为同步引入,在包体积和请求数量之间做了一个平衡。

7. 将非关键 SDK 移出主执行链路

项目入口中原来会同步初始化多个 SDK,包括国际化工具、安全签名和其他业务 SDK。

其中一些能力并不影响页面最初的结构展示,没有必要全部阻塞主执行链路。

因此,部分 SDK 改成了动态导入:

const HomePage = React.lazy(() => import("./views/Home"));

import("security-sdk")
  .then(({ init }) => init())
  .catch((error) => {
    reportError("security-sdk.init", error);
  });

中间还尝试过切换部分 SDK 的 ESM 入口,希望减少 CommonJS 版本中被一起打包的兼容代码和本地配置。

但动态 import() 并不代表这个依赖一定会离开首屏。

如果某个同步 loader、公共工具或者其他入口仍然直接引用相同 SDK,Webpack 仍可能把它合并到首屏公共包中。

所以每次调整后,都需要重新查看构建产物,而不是看到代码里出现了 import() 就认为拆分已经成功。

8. 公共依赖分包和静态缓存

页面拆分解决的是“当前页面需要加载哪些业务代码”,公共依赖拆分则为稳定资源单独缓存创造条件。

项目最终按照依赖类型配置了几个公共包:

optimization: {
  runtimeChunk: "single",
  splitChunks: {
    chunks: "all",
    cacheGroups: {
      framework: {
        test: /node_modules[\\/](react|react-dom|react-router-dom|antd-mobile)/,
        name: "framework",
        priority: 40,
        enforce: true,
      },
      sdk: {
        test: /node_modules[\\/]@company/,
        name: "sdk",
        priority: 30,
      },
      pdf: {
        test: /node_modules[\\/](react-pdf|pdfjs-dist)/,
        name: "pdf",
        priority: 20,
      },
      vendor: {
        test: /node_modules/,
        name: "vendor",
        priority: 10,
      },
    },
  },
}

拆分后的主要收益不是让所有依赖消失,而是让不同类型的代码可以独立失效:

  • 业务代码修改时,React 等框架依赖的 hash 尽量不变
  • PDF 只在需要预览的页面加载
  • SDK 和普通第三方依赖分开缓存
  • Webpack runtime 独立,减少业务变更对其他 chunk 的影响

配合内容哈希、缓存响应头和 WebView 的实际缓存策略,这种拆分可以减少业务代码变更对稳定依赖缓存的影响。对于经常通过 App 打开页面的用户,这部分收益可能比单次冷启动的包体积数字更有价值。

当然,代价是文件数量增加。

如果拆得太细,弱网下的请求排队和连接开销可能抵消体积收益。所以最终还是要结合真实运行环境,而不是只追求更小的单个文件。

9. 提前请求页面数据

将请求迁移到路由 loader

前面的优化主要解决资源加载问题,但页面最终还要等待接口数据。

旧实现通常是在组件挂载以后,通过 useEffect 调用接口:

下载页面代码 → 执行组件 → 挂载页面 → 调用接口 → 展示内容

为了让接口更早发起,我将路由升级为 createBrowserRouter,并把部分页面初始化接口移动到 loader 中。

loader 执行时,可以立即发起多个请求,再通过 defer 把尚未完成的 Promise 交给路由管理:

import { defer } from "react-router-dom";

export function pageLoader() {
  const detailPromise = requestDetail();
  const optionsPromise = requestOptions();

  return defer({
    detail: detailPromise,
    options: optionsPromise,
  });
}

两个请求之所以能够并发等待,是因为 requestDetail() 和 requestOptions() 在 loader 中立即执行;defer 负责让路由跟踪这些未完成的 Promise,并配合 Suspense 和 <Await> 渐进展示数据。

这里使用的 react-router-dom 版本是 6.4.5,所以示例保留了 defer。React Router 7.0.0 的官方 Changelog中,defer 已被移除,loader 可以直接返回包含 Promise 的普通对象:

export function pageLoader() {
  return {
    detail: requestDetail(),
    options: requestOptions(),
  };
}

组件侧仍然可以通过 Suspense 和 <Await> 处理尚未完成的数据。相比 6.x,7.x 省掉了 defer() 这一层,写法更直接。

进入路由后,接口不再等组件挂载完成才发起。在当前路由配置下,请求过程通常可以和页面 chunk 的加载发生重叠,但具体效果仍要以浏览器网络瀑布图为准。

从执行链路上看,它变成了:

进入路由 → 页面代码加载
        ↘ 接口同时请求 → 数据返回后展示

这个改造对接口型页面比较有效,但它也改变了整个应用的请求时序。

后面的 Token 问题,就是在这里暴露出来的。

灰度环境踩坑记录

1. 首个接口没有携带 Token

优化进入灰度环境后,部分页面出现了首个接口没有携带 Token 的问题。

项目的 Token 不是直接从 URL 或 Cookie 中读取,而是通过 App Bridge 异步获取:

WebView 启动 → 调用 Bridge → App 返回 Token → 写入公共参数

旧页面通常在组件挂载以后才调用接口。虽然代码里没有明确等待 Token,但组件渲染、状态初始化和副作用执行,无意中给了 App Bridge 一些时间。

大多数情况下,Token 会在接口请求之前写入,所以问题一直不明显。

loader 改造以后,请求被提前到了路由初始化阶段:

WebView 启动
├─ 调用 Bridge 获取 Token
└─ loader 立即请求接口

这两个异步任务之间没有顺序保证。

如果 loader 更快,接口就会在没有 Token 的情况下发出。

所以,这次优化并没有制造新的 Token 问题。它只是改变了请求时机,把一个之前就存在的竞态问题暴露了出来。

2. 先回退,再定位

灰度出现问题后,我没有继续叠加修改,而是先回退了一部分比较激进的方案:

  • loader 从动态加载恢复为同步引入
  • 减少进入路由时额外产生的请求
  • 页面组件的 React.lazy 继续保留
  • PDF 等存在兼容变量的依赖恢复到稳定版本

回退的目的不是放弃优化,而是减少变量,确认问题究竟来自拆包、loader 加载,还是接口本身。

最终确认,真正的问题是 Token 初始化和 loader 执行之间缺少同步机制。

3. 用共享 Promise 管理 Token 状态

Token 竞态与共享 Promise

最后的解决思路是:把 Token 初始化从一个没有状态的 Bridge 回调,变成一个全局共享的 Promise。

App 启动和所有 loader 复用同一个初始化任务。Token 获取成功时,Promise 会在公共参数写入后完成;Bridge 抛错、不回调或返回空值时,则在超时和重试后降级放行,避免页面永久等待。

下面是简化后的核心实现:

let tokenReadyPromise: Promise<string> | null = null;

function requestTokenOnce(timeout = 1500) {
  return new Promise<string>((resolve) => {
    let settled = false;

    const finish = (token = "") => {
      if (settled) return;
      settled = true;
      window.clearTimeout(timer);
      resolve(token);
    };

    const timer = window.setTimeout(() => finish(""), timeout);

    try {
      AppBridge.getToken((token) => finish(token || ""));
    } catch {
      finish("");
    }
  });
}

function delay(ms: number) {
  return new Promise<void>((resolve) => window.setTimeout(resolve, ms));
}

export function initializeToken() {
  if (tokenReadyPromise) return tokenReadyPromise;

  tokenReadyPromise = (async () => {
    for (let attempt = 0; attempt < 2; attempt += 1) {
      const token = await requestTokenOnce();

      if (token) {
        setCommonParam("token", token);
        return token;
      }

      if (attempt === 0) await delay(100);
    }

    // 本轮初始化失败,允许后续导航重新尝试。
    tokenReadyPromise = null;
    return "";
  })();

  return tokenReadyPromise;
}

function withTokenReady(routes: RouteObject[]): RouteObject[] {
  return routes.map((route) => ({
    ...route,
    loader: route.loader
      ? async (args) => {
          await initializeToken();
          return route.loader!(args);
        }
      : undefined,
    children: route.children ? withTokenReady(route.children) : undefined,
  }));
}

这个实现处理了几个边界:

  1. 同一轮初始化中,App 和 loader 不会重复调用 Bridge
  2. Token 获取成功时,loader 会等公共参数写入后再请求
  3. Bridge 首次失败后会重试一次
  4. 单次调用只会完成一次,超时后的迟到回调会被忽略
  5. Bridge 不回调时会通过超时继续执行
  6. 最终失败会降级放行,并允许后续导航重新初始化

这里也有取舍。

如果 Bridge 真的异常,loader 最长可能需要等待两次超时和一次重试间隔。这会增加异常场景下的尾部耗时,但相比立刻发送一个没有 Token 的请求,等待后再按明确策略降级更可控。

整体收益与取舍

这次优化同时观察了构建产物体积和页面完全加载完成时间。下面的数据是当时留下的大致记录,适合用来判断前后变化,不作为精确的基准测试结果。

指标优化前优化后
全量构建产物,未压缩约 7.2 MB约 4.3 MB
全量构建产物,gzip 后约 1 MB600 多 kB
页面完全加载完成时间约 4.6 秒约 2.x 秒

未压缩的全量构建产物从约 7.2 MB 降到约 4.3 MB,大约减少了四成。gzip 后则从约 1 MB 降到 600 多 kB。这部分变化主要来自删除废弃代码和重复依赖、压缩字体与图片,以及调整资源格式和导入方式。

优化后的 4.3 MB 中,公司 SDK 和 PDF.js 相关代码分别占了 1.x MB。它们被拆成独立 chunk 后,仍然会计入全量构建产物,但不代表每个页面都要在首屏加载全部 4.3 MB。PDF 相关代码只在预览页面使用,稳定的 SDK 也可以借助内容哈希和 CDN 缓存,减少业务版本更新时的重复传输。

SplitChunks 本身不会直接减少全量代码,甚至会增加一点(相比全部放一起,压缩策略),它改变的是资源如何拆分、什么时候加载,以及哪些文件可以独立缓存。CDN 也不会让构建产物变小,主要影响传输和缓存命中。最终的体积下降,还是来自代码和资源本身的清理与压缩。

方案上线后,页面完全加载完成时间从约 4.6 秒降到了 2.x 秒。这个结果来自整条页面加载链路,不能单独归因于 loader 或某一次拆包,但说明资源治理、按路由加载、CDN 缓存和请求前移组合起来产生了实际收益。

这次优化也有明确代价:

  • 拆分后的资源文件数量增加
  • 弱网下多请求可能抵消部分体积收益
  • loader 提前请求增加了初始化时序复杂度
  • Token 异常时需要等待重试和降级
  • 部分重复依赖为了控制风险仍然保留

最终方案追求的不是最小的单个文件,而是包体积、请求数量、缓存效果和稳定性之间的平衡。

总结

这次性能优化从分析包体积开始,最后却落到了请求时序和 App 初始化上。

能删除的页面、资源和重复依赖先删除,剩余内容再拆分。动态导入之后还要回到构建产物确认结果,因为依赖链中的任意同步引用,都可能让目标模块重新进入首屏公共资源。评价效果时也不能只看 main.js,首屏总体积、请求数量和缓存行为更接近用户真正承担的成本。

这次更重要的教训来自请求时序。把接口从组件挂载后提前到路由 loader,缩短了串行链路,也要求 Token、语言、城市和 SDK 等初始化状态拥有明确的同步机制。灰度中出现的 Token 问题不是优化制造的新问题,而是原有竞态在新的执行顺序下更容易发生。

出现问题后,先回退一部分方案、减少变量,再确认根因,比继续叠加修复更容易控制风险。最终保留下来的版本没有追求最小的单文件体积,但它更符合这个 App WebView 项目的运行环境。

以上是这次性能优化的过程记录,也包括我在拆包、缓存和请求时序上的一些思考。项目后续还会继续迭代,这篇先记录到这里。

评论