使用AI从 27 秒到秒开:一次 Web 首屏加载优化实战

全部优化由AI完成

写在前面

某天收到反馈:线上后台(一个基于 React + Webpack、依赖较多 SDK 的管理应用)打开特别慢,首页 Load 要 27 秒 ,Network 面板显示传输了 22.3MB。但同一份代码部署的 dev 环境却快得多。

同一份代码,为什么两个环境差这么多?这篇文章完整复盘排查过程和最终的优化手段,涉及:服务端压缩、首屏依赖拆分、CJS 包的 tree-shaking 陷阱、Webpack splitChunks 调优 ,以及一套用 stats.json 精准定位首屏体积的方法。

最终首屏入口体积从 22.3MiB 降到 3.1MiB ,配合压缩后实际下载 brotli ≈ 2.8MB / gzip ≈ 3.1B ,节省约 86%


一、先定位,别瞎猜:用数据说话

1.1 一个决定性的信号

打开 Chrome DevTools 的 Network 面板,底部有两个数字:

复制代码
22.3 MB transferred    22.3 MB resources

transferred(实际传输)和 resources(解压后大小)完全相等 。这意味着服务器返回的 JS 没有经过任何 HTTP 压缩------浏览器拿到的就是原始的 22MB。

优化前:首屏 22.3MB transferred、Load 约 27s,单个 bundle 高达 3-4MB转存失败

测试环境

1.2 对比两个环境的响应头

直接 curl 抓同一个 JS 文件的响应头:

bash 复制代码
# 线上(自建 nginx)
curl -sI -H "Accept-Encoding: br, gzip" https://prod.example.com/runtime.bundle.js
# → Content-Type: application/javascript
# → Content-Length: 8357
# → (没有 Content-Encoding!)

# dev(挂了 Cloudflare)
curl -sI -H "Accept-Encoding: br, gzip" https://dev.example.com/runtime.bundle.js
# → content-encoding: gzip
# → cache-control: public, max-age=7200
# → server: cloudflare

真相大白:

线上 自建 nginx dev Cloudflare
JS 压缩 gzip 自动
缓存头 max-age=7200
CDN 有(边缘节点 + 缓存)

结论一:dev 快不是因为代码,而是 Cloudflare 在边缘自动做了压缩 + 缓存 + 就近加速;线上裸 nginx 既没压缩 JS 也没缓存。

经验:性能问题先看 Network 面板的 transferred vs resources、再 curl 抓响应头。两分钟就能判断"是不是压缩没开",比盲目改代码高效得多。


二、三个根因

根因 1:服务端没开压缩

nginx 默认的 gzip on 只对 text/html 生效,gzip_types 不含 application/javascript,所以所有 JS 裸传。

根因 2:重型依赖被打进首屏

即便压缩了,首屏依然有十几 MB 的原始体积 。这个应用依赖了一堆区块链 SDK:ethers@mysten/sui@ton/ton@ledgerhq/*@suiet/wallet-kit......动辄几百 KB 到几 MB。

路由层虽然用了 React.lazy 懒加载,但入口文件 App.tsx 在顶层同步引入了整套钱包栈

tsx 复制代码
// App.tsx ------ 这是入口,每次都会加载
import { WalletProvider, useWallet } from "@suiet/wallet-kit";
import { setWalletAdapter } from "@org/wallet-lib";

结果:用户哪怕只是打开登录页,也要下载 sui / ton / ledger 全套 SDK。

根因 3:splitChunks 配置把异步包"拽"进了首屏

js 复制代码
splitChunks: {
  maxInitialRequests: 3,   // 太小!
  maxAsyncRequests: 5,     // 太小!
  // ...
}

请求数上限过低,Webpack 为了不超限,会把本该独立的异步 vendor chunk 合并进首屏的初始 chunk 。于是只被懒加载路由用到的 @tonzod 等也跑到了首屏。


三、对症下药

手段 1:开启压缩(构建预压 + 服务端下发)

构建时预压缩 :用 compression-webpack-plugin 在打包时生成 .gz.br,用最高压缩级别(gzip 9 / brotli 11),运行时零 CPU。

js 复制代码
// webpack.production.js
const zlib = require("zlib");
const CompressionPlugin = require("compression-webpack-plugin");

const test = /\.(js|css|html|svg|json|wasm)$/;

module.exports = merge(common, {
  plugins: [
    new CompressionPlugin({ filename: "[path][base].gz", algorithm: "gzip", test, threshold: 1024, minRatio: 0.8 }),
    new CompressionPlugin({
      filename: "[path][base].br",
      algorithm: "brotliCompress",
      test, threshold: 1024, minRatio: 0.8,
      compressionOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } },
    }),
  ],
});

服务端下发 :光生成不够,nginx 要用 *_static 指令命中预压文件。

nginx 复制代码
gzip_static on;   # 下发 .gz,Ubuntu nginx-core 默认支持,无需重编译
gzip on;          # 动态 API/HTML 实时压缩兜底
gzip_types application/javascript application/json text/css image/svg+xml;

关于 brotli:brotli_static 需要 ngx_brotli 模块,apt 装的 nginx 默认没有,要重编译。不值得 ------gzip 已经拿到 90%+ 收益(首屏 gzip 0.97MB vs brotli 0.67MB,只差 0.3MB)。真想要 brotli,最省事的是挂 CDN(如 Cloudflare),由边缘自动做 ,源站只管 gzip_static

构建时压缩 vs 运行时压缩 :不是二选一,而是配合。静态产物用构建预压 + gzip_static(压缩率高、零 CPU);动态响应(API JSON)只能靠 gzip on 实时压。

手段 2:把重型依赖移出首屏

关键是找到懒加载边界 。登录 / 注册 / 忘记密码这些页面根本不连钱包,于是把 WalletProvider 从全局入口 App.tsx 下放到登录后的布局 PageLayout(它本身就是懒加载的,且跨路由保持挂载):

tsx 复制代码
// App.tsx ------ 去掉钱包栈,只保留必要的 Provider
export const App = () => (
  <ConfigProvider>
    <Suspense fallback={<Loading />}>
      <RouterProvider router={Routers} />
    </Suspense>
  </ConfigProvider>
);

// layout/index.tsx(登录后才加载)------ 钱包栈搬到这里
const PageLayout = () => (
  <WalletProvider>
    <WalletAdapterSync />
    {/* ...布局... */}
  </WalletProvider>
);

效果:ethers / @mysten/sui / @org/wallet-lib 全部移出登录页,登录后进入业务页才异步加载。

手段 3:警惕 CJS 包的 import type 陷阱

排查中发现一个工具文件被全站引用,它顶部有一行:

ts 复制代码
import { WalletType } from "@org/wallet-lib";  // 其实只当类型用

@org/wallet-lib 是个纯 CJS 包package.jsonmain: dist/cjs/index.js,没有 module 字段,没有 sideEffects)。这意味着 Webpack 无法对它 tree-shake ------任何一处普通的 import { X } 都会把整包(含 ethers/ton/ledger)拖进对应 chunk。

修复非常简单,但收益巨大:

ts 复制代码
// 改成 import type,编译后这行被完全擦除,不产生运行时引用
import type { WalletType } from "@org/wallet-lib";
// 完全没用到的,直接删掉

经验:被广泛引用的 utils / types 文件里,凡是只当类型用的导入,一律 import type。尤其当依赖是不可 tree-shake 的 CJS 包时,一行死导入就能把几百 KB 灌进首屏。

手段 4:splitChunks 调优

js 复制代码
optimization: {
  moduleIds: "deterministic",   // 生产环境用确定性 id,缓存更稳
  splitChunks: {
    chunks: "all",
    maxInitialRequests: 30,     // 调高,避免异步包被合并进首屏
    maxAsyncRequests: 30,
    cacheGroups: {
      react: { test: /[\\/]node_modules[\\/](react|react-dom|react-router.*|@reduxjs\/toolkit)[\\/]/, name: "react", priority: 20 },
      // 重型链上 SDK:强制只进异步 chunk,禁止进入首屏
      blockchain: {
        test: /[\\/]node_modules[\\/](@ton|ethers|@mysten|@ledgerhq|...)[\\/]/,
        name: "blockchain",
        chunks: "async",          // 关键:只对异步 chunk 生效
        priority: 25,
      },
      antd: { test: /[\\/]node_modules[\\/](antd|rc-[^\\/]+|@rc-component)[\\/]/, name: "antd", priority: 15 },
      vendors: { test: /[\\/]node_modules[\\/]/, priority: -10 },
    },
  },
}

两个易踩的坑:

  • maxInitialRequests 别设太小,否则异步包被合并进首屏(这正是本次根因 3)。HTTP/2 下多几个请求无所谓,30 完全 OK。
  • 别滥用 maxSize 。一开始我加了 maxSize: 500KB,结果把 antd 拆成了 17 个小块------它们总是一起变更,拆开对缓存毫无收益,反而平添 16 个请求。去掉后 antd 保持单块,从 17 个文件降回 1 个。

番外:怎么知道首屏里到底有什么?

不要靠猜。生成生产构建的 stats,再脚本化分析"哪些包在 initial chunk 里":

bash 复制代码
webpack --config webpack.production.js --json stats.json
js 复制代码
// 解析 stats.json:找出所有 initial chunk,按包聚合体积
const stats = require("./stats.json");
const initial = new Set(stats.chunks.filter(c => c.initial).map(c => c.id));
const pkgSize = new Map();
for (const m of stats.modules) {
  if (!m.chunks?.some(id => initial.has(id))) continue;
  const pkg = /node_modules\/((@[^/]+\/)?[^/]+)/.exec(m.name)?.[1] || "(app)";
  pkgSize.set(pkg, (pkgSize.get(pkg) || 0) + m.size);
}
console.log([...pkgSize].sort((a,b)=>b[1]-a[1]).slice(0,20));

更进一步,用模块的 reasons(谁引用了它)反查"为什么这个包还在首屏"。本次就是靠它定位到:某个第三方组件库在自己的 barrel 入口里静态引用了 @ton,导致这部分始终无法移出首屏------属于库自身的打包问题 ,需要在库侧做代码分割或开 sideEffects: false


四、成果

指标 优化前 优化后
首屏入口(未压缩) 10.3 MiB brotli 2.8MB (↓87.5%)/ gzip 3.1 MiB(↓86%)
重型链上 SDK 全在首屏 移出,按需异步加载
首屏 JS 文件数 少量超大块(3-4MB) 6 个合理大小的块

优化后本地预览(启用压缩)的 Network 面板,首屏 JS 实际传输约 781 KBLoad 仅几百毫秒: 优化前 见 1.1

优化后:首屏 JS 传输约 781KB,单个 bundle 经 brotli 压缩后大幅缩小


gzip

五、方法论总结

一套可复用的首屏优化清单:

  1. 先量后改 :Network 面板看 transferred vs resourcescurlContent-Encoding,两分钟定位是不是压缩问题。
  2. 压缩是性价比最高的一步 :构建预压(gzip 9 / brotli 11)+ 服务端 gzip_static,或直接挂 CDN 让边缘做。静态预压 + 动态实时压配合用。
  3. 守住懒加载边界:重型依赖别在全局入口同步 import;按"未登录 / 已登录""核心页 / 边缘功能"切分懒加载点。
  4. import type + 警惕 CJS :类型导入一律 import type;不可 tree-shake 的 CJS 包,一行死导入就能拖垮首屏。
  5. splitChunks 别想当然maxInitialRequests 给够,重型库用 chunks: "async" 隔离,慎用 maxSize
  6. 用 stats + reasons 精准定位,而不是凭感觉拆包。
  7. 缓存别忘 :带 hash 的产物 immutable 长缓存,index.htmlno-cache

性能优化没有银弹,但有方法论:先定位瓶颈,再按收益排序逐个击破,每一步都用数据验证。

相关推荐
leafyyuki1 小时前
两行 CSS 搞定筛选条行尾对齐,Element Plus 表单布局终极方案
前端
着迷不白1 小时前
六、Bash Shell 与进程管理
前端·chrome
A不落雨滴AI1 小时前
DKERP 客户端重构:30天从零到一的架构演进之路
前端
Xp021911031 小时前
知网研学、万方、WPS、大以论文四大排版工具横评,新用户免费排版等你领!
前端·css·html·生活·wps·论文排版
全栈技术负责人1 小时前
老项目新需求AI前端开发指南
前端·ai编程
周凡1231 小时前
AI 时代的 Web JavaScript 逆向分析实践与思考
前端·javascript·人工智能
jerryinwuhan1 小时前
marker BiBERTo解释
java·前端·人工智能
zhoumeina991 小时前
分段创建产品,tab 页切换又要保留缓存
前端·javascript
SilentSamsara1 小时前
命令行工具开发:Click/Typer + 打包为独立二进制
linux·服务器·开发语言·前端·python·青少年编程·fastapi