site logo

Marico's space

我们如何实现99分PageSpeed评分:真实网页性能案例研究

前端技术 2026-09-10 11:30:57 6

大多数网站和SaaS产品都在被一个隐形的转化杀手慢慢耗死:移动端性能烂得一塌糊涂

在顶配Mac上用光纤宽带跑Lighthouse,拿个95分简直轻松加愉快。但一旦切换到移动端——也就是Google模拟的那台低端安卓机,限速4G网络,往返延迟150ms——分数直接崩到四五十。

给Klickspell做性能审计的时候,我们遇到了熟悉的困境:桌面端99分,移动端却只有四五十分,绘制慢、渲染阻塞、布局抖动,一个不落。

我们不想凑合一个"差不多就行"的85分。我们要的是:移动端98-99分桌面端99分总阻塞时间0毫秒布局偏移归零

下面是我们一步步执行的完整工程方案。

审计阶段:找到瓶颈

动手改代码之前,先用PageSpeed Insights和Chrome DevTools做了全面审计。主要问题如下:

  1. 图片体积爆炸:30张未压缩的位图,总共49.08 MB
  2. 渲染阻塞CSS和链式请求:外部样式表(style.css)造成了606毫秒的往返延迟。
  3. GitHub Pages十分钟缓存警告:静态文件使用Cache-Control: max-age=600,触发了Lighthouse的"使用高效缓存策略"扣分项。
  4. 强制回流/布局抖动:一个JavaScript断点处理函数在页面解析初期读取了window.innerWidth
  5. LCP元素渲染延迟1450毫秒:首屏入场动画延迟了文字绘制。
  6. 外部字体网络往返:从Google Fonts请求了10种字重的67 KiB字体,都走外部连接。

来看看我们逐个是怎么解决的。

1. 资源优化:49 MB ➔ 3.3 MB(减少93.2%)

最大的体积杀手是项目展示和案例研究中那些高分辨率PNG和JPG。直接在4G网络下给移动端喂原始位图,页面加载速度直接判死刑。

用Google的cwebp编码器批量转换,质量设为82:

for img in public/assets/**/*.png; do cwebp -q 82 "$img" -o "${img%.png}.webp"
done

每张图片都遵循的规则:

  • 格式:30张位图全部转为.webp
  • 尺寸:所有<img>标签都加了明确的widthheight属性,让浏览器知道宽高比,消除累积布局偏移。
  • 懒加载:首页、团队页、案例页所有首屏以下的图片全部加上loading="lazy"decoding="async"

效果:图片总重从49.08 MB降到3.33 MB,省了45.75 MB

2. 内联关键CSS,斩断渲染阻塞链

PageSpeed报告:

“链式请求/网络依赖树:...css/style.css (606 ms)”
“使用高效缓存策略——预计可节省8 KiB(缓存TTL:10分钟)”

问题所在:

通过外部链接加载样式(<link rel="stylesheet" href="/assets/css/style.css" />)会强制浏览器暂停HTML解析,发起新的TCP连接,下载完样式表才开始绘制第一个像素。另外GitHub Pages默认对静态文件设置Cache-Control: max-age=600(10分钟),这直接触发了Lighthouse的缓存警告。

解决方案:

由于全局样式表很小(压缩后约9 KiB),我们配置Astro把CSS直接内联到HTML文档里:

// astro.config.mjs
import { defineConfig } from 'astro/config'; export default defineConfig({ site: 'https://klickspell.com', build: { format: 'file', inlineStylesheets: 'always' // Inlines CSS directly into <style> in <head> }
});

然后在BaseLayout.astro里直接导入CSS:

---
import '../styles/style.css';
---

效果:

  • 0个外部渲染阻塞请求。浏览器在一次HTTP响应里同时收到HTML和样式。
  • 缓存警告消除。因为style.css不再是单独文件了,十分钟缓存生命周期的警报自然消失。

3. 消除强制回流/布局抖动

Lighthouse在诊断报告里特别标注了这条:

“避免强制回流:BaseLayout.astro:101:23”

翻开代码一看,是这个看起来人畜无害的辅助函数:

// ❌ THE PROBLEM: Layout Thrashing in JS
function swapCTA() { const isMobile = window.innerWidth <= 768; // Triggers synchronous layout calculation document.getElementById('hero-cta-d').style.display = isMobile ? 'none' : 'inline-flex'; document.getElementById('hero-cta-m').style.display = isMobile ? 'inline-flex' : 'none';
}
swapCTA();
window.addEventListener('resize', swapCTA);

为什么这会损伤性能:

读取window.innerWidth会强制浏览器引擎停下来同步计算几何信息,而此时DOM甚至还没绘制完。当这个操作和DOM修改(.style.display)一起执行时,就造成了布局抖动

解决方案:纯CSS媒体查询

直接删掉整个JavaScript函数,用纯CSS替代:

/* ✅ THE SOLUTION: Zero-JS Responsive Toggle */
#hero-cta-d, #cta-d { display: inline-flex; }
#hero-cta-m, #cta-m { display: none; } @media (max-width: 768px) { #hero-cta-d, #cta-d { display: none !important; } #hero-cta-m, #cta-m { display: inline-flex !important; }
}

效果:0毫秒JavaScript执行,零次布局重算, viewport变化时响应即时。

4. 干掉1450毫秒的LCP元素渲染延迟

在移动端LCP分解里,Lighthouse报告:

“元素渲染延迟:1,450 ms — <p class="hero-note-mobile">

为什么文字渲染会被延迟?

我们的首屏区域用了精致的入场动画:

/* ❌ Starts at opacity: 0 with sequential delays */
.hero-headline-mobile { animation: fadeUp .7s .1s ease both; }
.hero-sub-mobile { animation: fadeUp .7s .2s ease both; }
.hero-actions { animation: fadeUp .7s .3s ease both; } @keyframes fadeUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); }
}

因为fadeUpopacity: 0开始,还带顺序延迟最长0.4秒,限速的移动端CPU在前1.4秒内一直让文字保持透明或运动状态。Lighthouse测量LCP的时间点是元素完成动画到达最终状态和透明度的时刻。

解决方案:

桌面端平滑动画确实提升体验,但移动端用户更在意内容秒出。我们直接禁用了移动端首屏元素的入场动画:

@media (max-width: 768px) { .hero-headline-mobile, .hero-sub-mobile, .hero-note-mobile, .hero-actions { animation: none !important; opacity: 1 !important; transform: none !important; } .floating-cards { display: none !important; }
}

效果:文字在第零帧就以100%透明度绘制。移动端LCP从2.7秒降到1.4秒

5. 自托管字体:消除第三方延迟

初始方案是从Google Fonts加载字体:

<!-- 3 external requests, 2 DNS lookups, 67 KiB payload -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Manrope:wght@300;400;500;600;700;800&family=DM+Mono:wght@400;500&display=swap" />

在慢速4G移动网络下,和fonts.googleapis.com以及fonts.gstatic.com协商SSL握手、下载10种不同字重,总共耗时约1.8秒

解决方案:

  1. 换用可变字体:下载了一个单独的manrope-variable.woff2(只有24 KB),原生支持200到800的所有字重。
  2. 全站自托管:把字体文件放到自己域名的/assets/fonts/目录下。
  3. 预加载关键字体:
<!-- BaseLayout.astro -->
<link rel="preload" href="/assets/fonts/manrope-variable.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/assets/fonts/instrument-serif-regular.woff2" as="font" type="font/woff2" crossorigin />

CSS部分:

@font-face { font-family: 'Manrope'; font-style: normal; font-weight: 200 800; font-display: swap; src: url('/assets/fonts/manrope-variable.woff2') format('woff2');
}

效果:

  • Lighthouse网络标签页里零个第三方请求。
  • 首次内容绘制(FCP)从2.7秒降到1.1秒

6. 移动端主线程零JavaScript

现代移动浏览器自带硬件加速的120Hz惯性滚动。给移动端加载平滑滚动库(比如Lenis)或自定义鼠标追踪循环,纯属白耗电还抢主线程资源。

我们重构了客户端脚本,只在有精细指针的桌面设备上运行:

// Run custom cursor and Lenis only on desktop mouse devices
if (window.matchMedia('(pointer: fine)').matches) { import('/assets/js/lenis.mjs').then(({ default: Lenis }) => { const lenis = new Lenis({ lerp: 0.08, smoothWheel: true }); // ... });
} else { // Pure native scroll listener for mobile window.addEventListener('scroll', () => { document.getElementById('nav')?.classList.toggle('scrolled', window.scrollY > 40); }, { passive: true });
}

另外,第三方Cal.com预约嵌入我们延迟到用户首次交互(scrolltouchstart或空闲)时才加载:

function loadCal() { if (window._calLoaded) return; window._calLoaded = true; // Initialize embed script...
}
['scroll', 'touchstart', 'mousemove', 'click'].forEach(evt => window.addEventListener(evt, loadCal, { once: true, passive: true })
);

效果:总阻塞时间(TBT)直接归零。

最终数据

指标 优化前 优化后 提升幅度
移动端性能 91 / 100 98 / 100 +7分
桌面端性能 99 / 100 99 / 100 稳如老狗
首次内容绘制(移动端) 2.7 s 1.1 s 快59%
最大内容绘制(移动端) 2.7 s 1.4 s 快48%
总阻塞时间(TBT) 54 ms 0 ms 完全无阻塞
累积布局偏移(CLS) 0.003 0 零偏移
资源总 Payload ~50 MB ~3.4 MB 减少93%
加载时第三方请求数 4 0 完全消除

给前端工程师的关键教训

  1. 先测移动端,而且要相信节流后的数据:桌面分数太宽容了。如果你的站连模拟4G的低端机都跑不快,你的真实移动端用户早跑了。
  2. 小CSS直接内联:如果样式表压缩后小于15-20 KiB,内联能直接砍掉一整条网络瀑布请求。
  3. 警惕入场动画:任何从opacity: 0开始的CSS动画,或者移动首屏元素的布局动画,都会虚高你的LCP时间。
  4. 字体要自托管:Google Fonts原型阶段用用挺好,但自托管可变.woff2文件能干掉外部握手,节省300-800毫秒。
  5. 移动端尽量别用JS:如果某个交互(比如平滑滚动或鼠标光圈)对触屏设备没意义,一字节都不要发给移动端。

性能不是事后补丁,也不是最后随便开个插件就能搞定的事——它是渗透到每个组件、每行样式、每个资源里的工程纪律。