电商篇:性能——首页迁到 SSR,PageSpeed 移动端从 61 做到 100
本文是全栈电商微服务从 0 到 1 再到 10系列的电商篇。上一篇:电商篇:Spec Coding——让 AI 按仓库规范干活,而不是靠猜。
商城首页在 PageSpeed 上的移动端性能是 61,桌面 88。同一份代码,差 20 多分。这篇文章记录从「差在哪」到「线上 100」的完整过程:怎么读分数、怎么定位、两条修法怎么选、迁移怎么做,以及发布链路里踩的七个坑。数字全部来自实测,命令可以照抄。
先读分数:TBT 和 CLS 满分,说明不是 JS 的问题
Lighthouse 性能分不是一个整体印象,是五个指标各自得分乘权重再相加。把改造前的移动端结果按这个公式摊开:
| 指标 | 回答的问题 | 值 | 得分 | 权重 | 贡献 |
|---|---|---|---|---|---|
| FCP | 第一次画出任何内容要多久 | 4.9 s | 0.10 | 10% | 1 |
| LCP | 视口里最大那块内容画完要多久 | 6.7 s | 0.08 | 25% | 2 |
| Speed Index | 视口内容视觉上填满的平均速度 | 7.5 s | 0.27 | 10% | 2.7 |
| TBT | 主线程被 >50 ms 长任务卡住的总时长 | 140 ms | 0.95 | 30% | 28.5 |
| CLS | 加载过程中元素跳动的累计幅度 | 0.001 | 1.00 | 25% | 25 |
加起来 59,和 PageSpeed 的 61 吻合。TBT 和 CLS 合计 55 分已经拿满,剩下 45 分只拿了 5.7,全在「多久能看到东西」这一类。
这个结论直接决定了后面不该做什么:memo、虚拟列表、减少 re-render,这些都改善 TBT,而 TBT 已经满了,做了一分不加。要找的是网络传输链——首屏前必须下载多少字节、分几波。移动端和桌面端跑的是同一份代码,差距来自模拟条件:移动端 1.6 Mbps / 150 ms RTT / CPU 慢 4 倍,桌面 10 Mbps。字节量的问题在桌面被带宽掩盖了。
定位:一份 162 KB 的 CSS,里面全是字体声明
不要只看 PageSpeed 的网页报告,本机跑一次 Lighthouse 拿 JSON,网络时间线和每个资源的字节都在里面。Chrome 用 Playwright 缓存里的那份就行,不用另装:
CHROME_PATH="$HOME/Library/Caches/ms-playwright/chromium-1243/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" \npx lighthouse https://shop.apikv.com/ --only-categories=performance \ --form-factor=mobile --screenEmulation.mobile --throttling-method=simulate \ --output=json --output-path=/tmp/lh.json --chrome-flags="--headless=new"本机复现 59 分。从 audits.network-requests 按类型汇总:
| 类型 | 请求数 | 字节 |
|---|---|---|
| Font | 19 | 674 KB |
| Script | 48 | 466 KB |
| Stylesheet | 3 | 181 KB |
| Document | 1 | 3 KB |
四个问题按影响排序:
一、渲染阻塞的 CSS 162 KB(gzip 后),内容全是字体声明。 index-*.css 解压 363 KB,用 Python 把 @font-face 块全部抠掉,剩下 1 个字节。303 条声明来自入口文件的三行 import:
import "@fontsource/noto-serif-sc/400.css";import "@fontsource/noto-serif-sc/700.css";import "@fontsource/noto-serif-sc/900.css";Noto Serif SC 按 unicode-range 切成 101 片,3 个字重就是 303 条,每条带 woff2 和 woff 两个 URL。更糟的是 Vite 默认把小于 4 KB 的资源内联成 base64,小切片全部进了 CSS,占 63 KB。应用自身的样式走 emotion 在 JS 里,这份 CSS 对首屏零贡献,却挡在渲染前 1.2 秒。
二、启动是三段瀑布。 HTML 里的 #app 是空的,画面完全靠 JS:第一波是 index.js 加 7 个 modulepreload 加那份 CSS;然后入口里 await initTransport()、await initI18n() 之后 await import("./bootstrap")——这个动态 import 让 Vite 无法在 HTML 里预加载第二波;第二波是 bootstrap 130 KB 加 40 多个 0–2 KB 的 MUI 小 chunk,共 45 个请求,全拿到才 render。
三、114 KB gzip 的一个 chunk 是整套图标库。 解压 453 KB,2700 多条 SVG path。图标包里写的是:
import * as icons from "lucide";function icon(name: IconName, props: IconProps) { return <MorphIcon icon={icons[name]} {...props} />;}命名空间 import 再按名字动态取,tree-shaking 彻底失效。首页只用四五个图标。
四、字体本体 674 KB,19 个文件。 font-display: swap 保住了 FCP——文字先用后备字体画出来——但 1.6 Mbps 上这些字节要下三四秒,宋体一片片换上去,Speed Index 被拖到 7.5 s。另外源码里 fontWeight: 400 只用了一处,400 字重的 101 条声明基本白带。
顺带发现静态资源没有 Cache-Control:/assets/* 带 hash 却没有 immutable,Lighthouse 报「1,321 KiB 缓存策略低效」。这项不影响实验室分数,影响的是真实回访。
两条路线:预渲染 SPA,还是把首页迁到 Next.js
这些问题有一个共同上限:#app 为空,FCP 永远不早于全部 JS 下载加执行。改字体和分包能逼近这个上限,突破它只有让 HTML 自带内容。仓库里恰好有两条路可走,因为 shop.apikv.com 已经跑着两套前端:/zh/*、/en/*、/_next/* 由 consumer-next(Next.js App Router,商品详情 SSR + ISR)承接,其余路径由 Caddy 托管的 Vite SPA 承接。
| 路线 A:SPA 构建期预渲染 | 路线 B:首页迁到 consumer-next | |
|---|---|---|
| 改动面 | 只改 apps/consumer,Caddy 与部署拓扑不动 | HTTPRoute 分流、组件共享、SPA 跨应用跳转 |
| 新增渲染模式 | 第三种:SPA、Next SSR、SPA 预渲染 | 无,归入既有 SSR 轨道 |
| emotion SSR 与 hydration 边界 | 要在 Vite 侧从零踩:renderToString 时 @emotion/server 抽 critical CSS,否则首帧无样式闪一下 | 商品页已踩过 |
| 运行期与构建期差异 | 语言、isTauri()、登录态、IntersectionObserver 初始态全部要挪进 effect,任何一处不一致 React 19 直接丢弃预渲染内容重新 client render | 首页只有三个小岛 |
| 运行时依赖 | Caddy 吐静态文件 | Node 进程 SSR,ISR 缓存兜底 |
选 B。决定性的理由不是工作量——B 的改动面更大——而是仓库早有决策「公开可收录页归 consumer-next」,商品详情已经在那条轨道上,首页本来就该在。A 会让仓库出现第三种渲染模式,坑要重踩一遍。
实现
代码在 frontend/apps/consumer-next/app/[lang]/page.tsx 和 src/home/。要点按「为什么这样做」组织。
共享包,不是复制
灯市的设计 token、演示商品数据、墨线 SVG 插画从 SPA 抽成 @ecommerce/lantern,不依赖 MUI,两个应用都从它 import。SPA 的 styles/tokens.ts 改成再导出,三十处 import 路径不用动。抽包的理由不只是首页:两个应用各维护一份 AppBar 和 token 已经是漂移风险。
整页静态,/ 用 rewrite 不用 redirect
演示数据是构建期常量,页面没有任何请求期数据,所以走 generateStaticParams 在构建期预渲染 /zh 和 /en。根路径 / 在 next.config.ts 里改写到 /zh:
async rewrites() { return [{ source: "/", destination: "/zh" }];}用 rewrite 而不是 redirect,因为 redirect 多一跳 RTT,在移动端模拟下直接吃掉 LCP 的预算。
客户端 JS 只有三个岛
首页需要交互的地方只有三处:灯阵进入视口才次第点亮、顶栏搜索、登录态。分别做成三个 "use client" 组件,其余全是服务端渲染的 HTML。搜索岛的 RPC 客户端在用户第一次提交时才 import(),首屏 JS 里不带 connect-web 和 protobuf 运行时。
async function onSubmit(e: FormEvent) { e.preventDefault(); const { searchProducts } = await import("./search-client"); const items = await searchProducts(value.trim(), ac.signal); setState({ kind: "results", items });}原来 layout 里包着 TransportProvider 和 QueryClientProvider,商品页需要它们,首页不需要。挪到商品页自己包,否则 react-query 和 connect 运行时会跟着 layout 进首页首屏。
字体:按渲染槽位枚举字形,19 KB
这是收益最大也返工过一次的部分。
首页文案是构建期常量,宋体真正出现的位置可以数出来:品牌名、大标、朱砂印按钮、主灯徽记、主灯商品名、价格数字、类目名、页脚栏目标题。用 pyftsubset 从 @fontsource 的完整字体文件切出 700 和 900 两个子集,经 next/font/local 自托管——它会自动输出 preload 并生成 size-adjust 的回退字体。缺字由字体栈落到系统宋体。
第一版子集的采集方式是「扫描文案文件里出现过的所有字符」,两种语言全部文案都进了 700 字重,单个子集 65 KB,两份 130 KB。线上实测移动端 89,LCP 3.5 s,elementRenderDelay 2.1 s——LCP 元素是主灯卡,它的文字等字体。改成一个脚本按槽位列出字符串:
for (const copy of Object.values(homeCopy)) { w700.push(copy.brand, copy.nav.signIn, copy.home.enterMarket, copy.home.heroToday, copy.home.viewLamp, copy.home.priceFrom, /* … */); w900.push(copy.brand, copy.home.slogan, copy.home.gridTitle);}w700.push(heroProduct.name, ...demoCategories.map((c) => c.name), "0123456789¥.");700 字重 15 KB,900 字重 4 KB。线上 LCP 从 3.5 s 到 1.4 s。教训是:字体子集的输入应该是「哪些字会以这个字体渲染」,不是「文件里有哪些字」。
路由分流:Exact 优先于 PathPrefix
HTTPRoute 给 consumer-next 加一条 path: {type: Exact, value: "/"}。Gateway API 规定 Exact 优先于 PathPrefix,所以它能从 frontend 那条 PathPrefix / 里抢走根路径,/cart、/assets/* 照旧归 Caddy。仓库里 helm chart 和裸 manifest 是两份真相源,受 verify-deploy-parity.sh 约束,改一边必改另一边。
SPA 侧:回首页要整页跳
SPA 里所有 navigate({ to: "/" }) 收敛到一个 goHome():生产 web 构建 window.location.assign("/"),本地 dev 和 Tauri 桌面端仍走 SPA 自己的首页路由。不改这一处,从购物车点品牌 logo 会客户端路由到 SPA 那份旧首页。
结果
| 环境 | 性能 | FCP | LCP | Speed Index | 请求 | 字节 |
|---|---|---|---|---|---|---|
| 改造前,线上 | 59 | 4.9 s | 6.7 s | 7.5 s | 77 | ~1.3 MB |
| 迁移后,本地 | 97 | 0.8 s | 2.6 s | 1.9 s | 14 | 279 KB |
| 迁移后,线上(字体 130 KB) | 89 | 1.1 s | 3.5 s | 3.9 s | 14 | 278 KB |
| 字体瘦身后,线上 | 100 | 1.0 s | 1.4 s | 1.0 s | 14 | 169 KB |
PageSpeed 自己那次测的是 98。之后又补了两项不在性能类目里的:canonical 和 hreflang 改成绝对 URL(SEO 83 到 100),llms.txt 和图标链接的 aria-label(无障碍 96 到 100,「智能体浏览」1/3 到 3/3)。
发布链路里的七个坑
代码改完只是一半。这个仓库的发布是「打 tag 触发 CI 构建 12 个多架构镜像,晋级脚本校验后写清单,再 apply 到集群」,一轮四十分钟。以下每一条都真实花掉了至少一轮。
一、workspace 包的 node_modules 要单独 COPY。 consumer-next 的 Dockerfile 从依赖层拷了根 node_modules 和 apps/consumer-next/node_modules,新加的 packages/lantern/node_modules(react 符号链接)没拷,CI 里 tsc 报 Cannot find module 'react'。本地能过是因为目录本来就在。修一行,但 tag 不可移动,1.7.0 作废切 1.7.1。
二、静态页里手写的 <link rel="preload"> 会被丢掉。 在服务端组件里写 <link>,React 19 会把 canonical 提升到 <head>,preload 却不见了;按 Next 文档在客户端组件里调 ReactDOM.preload(),最终 HTML 里同样没有。next/font/local 走的是另一条路,preload 正常输出。没有深究原因,因为 next/font 本来就是更对的做法。
三、字体子集按文件采集而不是按槽位。 上面已经说过,线上 89 的那一版。
四、首页不能设 revalidate。 线上 Pod 的 .next/server/app 是只读的,只有 app/zh 和 app/en 两个子目录挂了可写卷,那是给商品页 ISR 用的。首页产物是 app/zh.html,不在任何可写目录里,revalidate 写文件会失败。目前首页整页静态没有这个问题,商品目录服务接通后改 ISR 时要同时改卷挂载。
五、调度死锁。 consumer-next 滚动时新 Pod 一直 Pending:node3 带 workload 污点,剩下两个节点里 node4 被旧 Pod 占着(反亲和),node5 被拓扑偏差挡住(maxSkew: 1,node5 已经比 node4 多两个业务 Pod)。三个约束单看都合理,叠在一起就是无节点可去。当时的解法是删掉旧 Pod 让位,/ 中断约十秒。只要 node3 再被污点,这个服务就只能跑一个副本。
六、CI 红不一定是代码红。 四次发布三次撞到 GitHub release 下载 504 或 proxy.golang.org 流错误。处理方式是 gh run rerun --failed,不是切新 tag;只有 Dockerfile 那次才是真的要改代码。
七、本机 Lighthouse 测线上有抖动。 修完之后连续几次测线上,性能在 91 到 100 之间跳。看 observedFirstContentfulPaint:有时 285 ms,有时 2.3 s。用 Playwright 起真实 Chromium 连测五次,首绘 236 到 332 ms,完全稳定。抖动来自 Lighthouse 自带的 Chrome for Testing 在这条网络路径上的偶发行为,不是站点问题。判断有没有退化,以真实浏览器读 performance 条目为准,不以单次 Lighthouse 为准。
没做的事
首页迁走之后,前面定位到的四个问题对 /cart、/checkout 这些 SPA 页仍然成立。按收益排序:图标包改具名 import 加静态映射表(省约 110 KB gzip);字体 import 移到首次 render 之后动态加载;await import("./bootstrap") 改成静态 import 链让 Vite 把第二波写进 modulepreload;Caddy 给 /assets/* 加 immutable 缓存头。这些都是半天以内的改动,留给下一轮。
自查清单
- 五个指标里哪几个丢分?TBT 和 CLS 满分就不要碰 JS 执行。
- 阻塞 CSS 解压后有多大?里面有多少是
@font-face?入口里 import 的@fontsourceCSS 一定是阻塞的。 - HTML 的 modulepreload 之外,首屏前还有几波请求?每一个
await import()都是一波。 - 有没有
import * as再动态取成员的写法?那是 tree-shaking 的盲区。 - 字体子集的输入是「哪些字会以这个字体渲染」,还是「文件里有哪些字」?
- 静态页需要的 preload,是不是交给了
next/font? - 拿到分数之前,先用真实浏览器测一次首绘,再决定信不信 Lighthouse。
相关
- 电商篇:Spec Coding——让 AI 按仓库规范干活,而不是靠猜 —— 本文提到的两份部署真相源、tag 不可移动、发布前检查,都是那篇讲的规范落成的门禁。
- 前端隐私资产保护 —— 同样涉及字体的自托管,关注点是授权而不是体积。