本文目录
前端性能优化是个很容易做无用功的领域。很多人一上来就装插件、开「一键压缩」、把能 defer 的都 defer,折腾一晚上,Lighthouse 分数涨了 3 分,用户实际感受没变。
问题出在没有先定义目标。这篇文章讲的是我第一次系统优化一个资讯站时踩出来的顺序:先测量、再分层、按影响排序动手。所有建议都在一台 2 核 4G 的 VPS 和一份普通的资讯类内容页上验证过,不是理论推演。
一、先定一个基线:你要快多少,才算「有改善」
在动手之前,必须记录三个数字:当前 LCP、INP、CLS。它们现在的采集方式有两条路:CrUX(真实用户数据,需要有足够流量)和 Lighthouse(实验室数据)。个人站的流量通常进不了 CrUX,所以用 Lighthouse 加自建监控更现实。
这里有个关键认知:Lighthouse 分数不是目标,指标数字才是。分数会随着工具版本变化,同一份页面在 v10 和 v11 下可能差 10 分。真正稳定的目标是:LCP 在移动端 4G 仿真下低于 2.5 秒,CLS 低于 0.1,INP 低于 200ms。
测量时要注意三个变量,否则你得到的数字没有可比性:
1. 网络条件。默认的移动端仿真包含 1.6Mbps 带宽和 150ms RTT,这个条件比真实的国内 4G 差一些,但作为统一标准是合理的。别用「无节流」测出来的数字自我安慰。
2. 是否命中缓存。第一次访问和第二次访问完全是两个页面。Lighthouse 默认是冷启动,这才是用户第一次来的体验。
3. 服务器位置。你在本地跑 Lighthouse,请求是从本地机房发出去的;真实用户可能在两千里之外。测国内站点建议至少用两个来源,比如一台国内 VPS 上一个 curl 计时,加上一个第三方测速平台做交叉验证。
记录基线的方式很简单,养成习惯:
# 服务器侧看后端真实耗时(绕开 CDN 与网络)
curl -o /dev/null -s -w 'dns=%{time_namelookup} conn=%{time_connect} ssl=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
-H "Host: www.example.com" "http://127.0.0.1/article/123.html?_=$(date +%s)%s"
这条命令的六个数字是你后面所有优化的对照表。ttfb 里已经包含了 PHP 执行时间,如果 ttfb 就超过 800ms,前端再怎么优化都是在优化尾巴。
二、测量层:不装工具也能测准的四个方法
方法一:Chrome DevTools 的 Performance 面板录制。录一次首屏加载,看主线程的长任务(Long Task)。如果主线程被某个脚本占满了 600ms,那首屏再怎么调 CSS 也不会好看,答案在 JS 那边。
方法二:网络面板按大小排序。这一步最容易被跳过,但它能立刻暴露「哪个文件是意外的」。常见意外是:一张没压缩的 PNG 有 1.2MB、一个统计脚本加载了第三方 iframe、某个字体文件被加载了三个字重。
方法三:查看 TTFB 的组成。在 DevTools 里看 Timing 面板,把 DNS、Initial Connection、SSL、Request Sent、Waiting (TTFB) 分开。Waiting 时间长是服务端问题,Connection 时间长是 CDN 或者网络线路问题,两者应对方式完全不同。
方法四:真实用户采样。在页面里埋一段 PerformanceObserver,只采 LCP 和 CLS,每天汇总一次推到自己的日志里。代码不长,但对个人站来说性价比很高,因为 Lighthouse 测不到「某个地区的用户特别慢」这种情况。
new PerformanceObserver(function (list) {
var e = list.getEntries().pop();
navigator.sendBeacon('/track/lcp', JSON.stringify({ v: e.startTime, t: location.pathname }));
}).observe({ type: 'largest-contentful-paint', buffered: true });
注意 sendBeacon 不会阻塞页面卸载,比在 unload 里用 ajax 稳得多。
三、DNS 与连接建立:最小化 TTFB 的第一步
很多人不知道,DNS 解析失败或者慢,会直接体现为「页面白屏很久然后突然全部出现」。在国内环境下,DNS 的问题尤其普遍,因为它同时受解析商、递归服务器和运营商缓存的影响。
三个动作能显著缩短这一段:
用支持 EDNS Client Subnet 的解析商。国内用户走就近节点,海外用户走海外节点,这一点对 CDN 生效非常重要。如果解析商不支持 ECS,CDN 只能拿到递归服务器的位置,可能把北京用户调度到广州节点。
减少域名数量。每多一个独立域名(css.example.com、img.example.com、static.example.com),就多一次 DNS 查询和一次 TCP 握手。在 HTTP/2 下,同域多路复用已经足够,拆域名的收益远小于成本。我见过为了「突破浏览器并发限制」拆了四个域名,那套逻辑在 HTTP/1.1 时代成立,现在是负优化。
开 TCP Fast Open 与 TLS 1.3。TLS 1.3 把握手从两个 RTT 降到 1-RTT,会话恢复还能做到 0-RTT。Nginx 只需要两行:
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
TLS 1.3 要求 OpenSSL 1.1.1 以上,Debian 11 以后都满足。注意如果你还留着 TLS 1.0/1.1 为了兼容某些老客户端,应当权衡——不兼容的客户端占比在今天已经非常低。
四、静态资源:让浏览器第一次就不下载
最有效的静态资源优化,其实不是压缩,而是让第二次访问不下载任何东西。这一点在内容站上价值极大,因为导航栏、CSS、JS、字体、logo 在每次翻页时都是重复的。
缓存头的正确配置:
location ~* \.(css|js|woff2|png|jpg|jpeg|webp|svg|ico)$ {
expires 365d;
add_header Cache-Control "public, immutable";
access_log off;
}
immutable 这个关键字值得单独说:它告诉浏览器「这个 URL 的内容永远不会变,不要在后台发条件请求验证」。没有它,浏览器每次加载页面仍会发一个 If-None-Match,虽然只返回 304,但在弱网下仍然是几十毫秒。加了 immutable,连这个请求都省了。
代价是你必须做文件名带哈希的版本化,否则更新文件后用户拿不到新的。这一点在纯静态站上用构建工具很容易,在动态站点上就要靠模板里自动拼版本号,比如 style.css?v=20261019。注意查询串版本的缓存在某些 CDN 上默认不区分,需要确认 CDN 的缓存键包含查询串。
另外一个小技巧:把不改动的小图标合并成 SVG sprite 或者直接内联。一个 20 个图标的图标库,如果每个都是独立请求,即使走 HTTP/2 也相当浪费;内联成 symbol 之后是零请求。
五、CSS 与首屏:关键样式内联的取舍
外链 CSS 是阻塞渲染的:浏览器在拿到并解析完 CSS 之前不会渲染任何内容。这就是为什么有些页面在弱网下「白屏很久,然后一下子全出来」。
标准解法是把首屏用到的样式内联到 head 里,其余样式异步加载。实现方式:
<style>/* 首屏关键 CSS,通常 8-15KB */</style>
<link rel="preload" href="/css/main.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/main.css"></noscript>
这个 onload 切换 rel 的技巧很实用,但要注意两点:一是如果 JavaScript 被禁用,必须用 noscript 兜底;二是内联的 CSS 和外链的 CSS 如果有冲突,后加载的会覆盖,所以内联的部分应该只包含「首屏必要的布局和字体颜色」,不是整站样式。
更根本的做法是减少 CSS 总量。我见过一个资讯站模板带 400KB 未压缩 CSS,里面 60% 是图标字体、动画和后台样式。用 Chrome 的 Coverage 面板能直接看到「这段 CSS 有多少百分比被用到了」,通常首屏只用到 20%-30%。把用不到的拆出去,比任何加载技巧都管用。
如果一定要上「未使用 CSS 移除」的工具,务必在真实页面里逐页验证。这类工具靠静态分析选择器,遇到动态类名(JS 拼出来的 active、collapsed 之类)会误删,结果是小概率的样式错乱——最典型的是下拉菜单在移动端展开后没有背景色。
六、JS:defer、async 与「别让第三方脚本卡住首屏」
三个属性要分清:
无属性:下载并执行完 JS 才继续解析 HTML,阻塞最严重。
async:异步下载,下载完立即执行,执行时仍然阻塞解析。适合完全独立的脚本,比如统计代码。
defer:异步下载,等 HTML 解析完、按顺序执行。适合依赖 DOM 或者彼此有依赖的业务脚本。
业务脚本几乎都应该用 defer。但真正的性能杀手不是自己的 JS,而是第三方脚本:广告、统计、客服、评论、分享按钮。它们有三个特点:域名不在你的控制下(DNS + 握手成本)、体积不可控、经常同步加载。
处理办法:把所有这些脚本集中到一个「延迟加载器」,等用户有交互或者页面空闲再加载。最简单的实现:
window.addEventListener('load', function () {
setTimeout(function () {
var s = document.createElement('script');
s.src = 'https://third-party.example.com/widget.js';
s.async = true;
document.body.appendChild(s);
}, 1200);
});
延迟 1.2 秒是个经验值:既不会让用户觉得功能缺失,又保证了 LCP 窗口内主线程不被占用。如果你的站点有广告位(AdSense 之类),注意广告脚本一旦延迟,广告位置会出现高度塌陷,进而引发 CLS 抖动——所以给广告容器预留固定高度是必须的。
另外提醒一句:Google AdSense 的自动广告会自己插入脚本,这种你控制不了。能做的只有把广告位容器的高度定死,并在 CSS 里给 min-height。我在 article 内嵌广告上就是这么处理的,容器 min-height:280px,实测 CLS 从 0.18 降到 0.04。
七、图片:真正影响 LCP 的三件事
内容站上 LCP 元素通常是头图或者文章首图。影响它的因素按重要性排序:
第一,图片本身的大小。一张 1600px 宽的 JPEG 如果没有压缩,通常是 300KB 以上,而非付费图床的 WebP 版本可能只有 80KB。压缩比任何加载技巧都重要。工具上,cwebp -q 80、jpegoptim --strip-all、pngquant 三件套足够,别引入复杂的构建链。
第二,是否被正确预加载。LCP 图片如果是通过 CSS 背景或者 JS 插入的,浏览器发现它很晚。正确做法是 <link rel="preload" as="image" href="..." fetchpriority="high">,配合 img 标签的 fetchpriority="high"。
第三,尺寸是否匹配。桌面显示 800px 宽的图,如果上传 2000px,浏览器要额外做一次缩放,这在低端手机上有真实成本。用 srcset + sizes 让浏览器自己挑:
<img src="/img/a-800.webp"
srcset="/img/a-400.webp 400w, /img/a-800.webp 800w, /img/a-1600.webp 1600w"
sizes="(max-width: 600px) 100vw, 800px"
width="800" height="450" alt="..." fetchpriority="high">
width 和 height 属性必须写,这不是为了展示,而是让浏览器在图片下载前就预留出空间,直接消除这部分 CLS。很多人只写 CSS 的 aspect-ratio,在旧浏览器上不生效。
还有两个小点:非首屏图片一律 loading="lazy",但**首屏图片绝对不要 lazy**——这会让 LCP 变差,因为浏览器要等布局完成才发现它。这个错误非常普遍,很多「一键优化」插件默认给所有图片加 lazy。
八、字体:为什么它总是拖累 CLS
自定义字体带来两个问题:加载慢(阻塞文字显示)和替换时抖动(CLS)。
font-display 是最关键的属性,四个值要分清楚:
block:最多等 3 秒,期间文字不可见。最差,等于白屏。
swap:立即用系统字体显示,字体到了再换。文字可见最快,但会有一次跳动。
fallback:短暂等待(约 100ms),超出用系统字体,字体到了也不换。
optional:极短等待,超时就放弃本次使用自定义字体。CLS 最低。
内容站我推荐 swap,因为文字内容的可读性优先于视觉一致性。但必须配两件事:一是 preload 主字体文件,二是用 size-adjust 或者手动调 line-height 让系统字体和自定义字体的占位尺寸接近,减小跳动。
@font-face {
font-family: 'Main';
src: url('/fonts/main.woff2') format('woff2');
font-display: swap;
unicode-range: U+4E00-9FFF; /* 只加载中文字符集 */
}
中文字体是另一个大坑:一套完整的中文字体动辄 3-8MB。解决办法是子集化——只保留页面实际用到的字。工具上 fonttools pyftsubset 或者 glyphhanger 都能做。一个纯中文内容站的首屏标题通常只用到 200-300 个字,子集化之后字体文件能压到 80KB 以内。
但要小心子集化的副作用:如果页面有用户输入内容(评论、搜索框结果),冷僻字会缺字显示成方框。稳妥方案是准备两份字体,一份常用字子集用于首屏,一份完整字体异步加载兜底。
九、服务端:小 VPS 上最有效的五个参数
前端优化到一定程度,瓶颈就回到服务器。小内存 VPS(1-2G)上,以下几个调整收益最明显:
1. PHP-FPM 的 pm 模式。默认的 pm = dynamic 在低并发下没问题,但如果每天有几次突发流量,建议改成 pm = ondemand 并设置 pm.process_idle_timeout = 10s,让步空闲进程真正退出,节省内存给 MySQL。反过来,如果你的站点 QPS 稳定,pm = static 加固定数量的进程反而更快(省了 fork 开销)。
2. OPcache。opcache.memory_consumption=128、opcache.max_accelerated_files=20000、opcache.validate_timestamps=0(生产环境,改代码后手动 reload)。这一项在 PHP 站点上通常能砍掉 30%-50% 的 PHP 执行时间,但前提是你的代码文件数不要超过 max_accelerated_files,超过就会频繁失效。
3. Nginx 的 gzip 与 brotli。gzip 按 MIME 类型开,只开 text/html、css、js、json、svg。不要对图片和视频开 gzip,纯属浪费 CPU。brotli 在现代浏览器上比 gzip 小 15%-20%,但需要额外模块,且压缩级别不要超过 5,否则 CPU 会成为瓶颈。
4. open_file_cache。对静态资源多的站点,这一项能显著降低磁盘 stat 次数:
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
5. sendfile 与 tcp_nopush。默认大多数发行版都开了,但如果你接手的是别人配的机器,务必确认 sendfile on; 在 http 块里,tcp_nopush on; 在 http 或 server 块里。这一对组合能让大文件传输的吞吐明显提升。
顺带说一个反直觉的点:不要急着加内存缓存(Redis/Memcached)来解决 TTFB。先看慢查询日志和 PHP 执行时间,很多站点的 TTFB 慢是因为一条没有索引的 SQL,而不是缓存不够。加了缓存只是把问题藏起来,等到缓存失效或者写操作频繁时,问题会以更糟的形式出现。
十、一个可落地的改造顺序与验收清单
如果你现在就要动手,按这个顺序做,每一步做完记录一次数据:
第 1 天:测量。记录 LCP/CLS/INP、TTFB、页面总字节数、请求数。用 DevTools 导出一次网络面板的 HAR 存档,作为对照。
第 2 天:清掉明显的浪费。压缩所有图片、给静态资源加长缓存和 immutable、去掉不用的字体字重、把首屏图片改为 fetchpriority high 并去掉 lazy。这一步通常能带来最大的单次收益。
第 3 天:处理阻塞资源。CSS 内联首屏关键样式、JS 加 defer、第三方脚本延迟加载、广告容器预留高度。验证 CLS 是否下降。
第 4 天:服务端。开 OPcache、检查 PHP-FPM 配置、开 open_file_cache、确认 gzip/brotli。用 curl 的 TTFB 数字前后对比。
第 5 天:复测并固化。用同一条件重测三项指标,把「改了什么」和「数字变化」写成一张表。以后每次改模板,先跑一遍这张表。
验收清单,逐项打勾:
- 移动端 LCP < 2.5s,CLS < 0.1,INP < 200ms
- 服务器本机 TTFB < 300ms(动态页面)
- 静态资源响应头包含
Cache-Control: public, immutable - 没有任何图片是 lazy 且同时是 LCP 元素
- 所有 img 标签都写了 width 和 height
- 第三方脚本没有一个在 head 里同步加载
- 广告位容器有 min-height 且实测无抖动
- 字体文件 < 150KB(中文)或已子集化
最后说一句实话:前端性能优化没有终点,也不需要。做到 LCP 2.5 秒以内、CLS 0.1 以下,用户的体感就已经是「这个站挺快」了。剩下的精力放在内容上,比继续抠 200 毫秒划算得多。真正的性能问题,往往是你以为优化完了、再也没测过的那半年里悄悄长出来的——所以,把上面的测量方法固化成一条每周跑一次的脚本,比任何一次性的优化都重要。