一次 WordPress 性能诊断的完整复盘:哪些改动真有用,哪些只是在讨好分数
实战环境:Windows Server + XAMPP(Apache + PHP) · WordPress + Cache Enabler · Bootstrap 3 主题 · 2026-10-25
一句话结论:折腾了一整天,Lighthouse 从 96 分变成 95 分,看起来毫无进展。但真实访客的首屏体验改善幅度,远超这个数字能表达的。
这篇文章复盘的不是"如何把 Lighthouse 刷到 100 分",而是如何分辨哪些指标值得追、哪些改动只对分数有效、哪些改动只对真人有效。所有代码都来自真实站点,可直接复用。
96 → 95
Lighthouse 移动分
99
桌面端得分
120→50ms
TTFB 首字节
0 / 0
CLS / TBT
一、先别急着改代码:三个反直觉的认知
1. 实验室分数和真实体验,测的不是同一件事
Lighthouse 移动模式的默认设定是CPU 降速 4 倍 + 模拟慢速 4G(1.6 Mbps)。这意味着它测的是"一个人在信号很差的地铁上用千元机打开你的网站"这个最坏情况。
同一个站点,桌面模式跑 99 分(FCP 0.6s / LCP 0.8s),移动模式只有 92~95 分。这中间 7 分的落差,不是网站有问题,是模拟条件本身造成的。
更关键的是:同一页面连测三次,Speed Index 能在 3.7s 到 4.8s 之间跳动,波动幅度接近 30%。所以在你的站点上,96、95、93 本质上都是同一个分数。任何"优化后降了 3 分"的恐慌,多半是被噪声吓到了。
2. LCP 元素可能根本不是图片
几乎所有优化教程都会教你"预加载 LCP 图片"。但在这次案例中,Lighthouse 的 LCP breakdown 面板明确告诉我们,LCP 元素是:
p.home-intro.lead.alert.alert-info
一段为了 SEO 写的纯文字介绍。文字不需要下载,它是被 CSS 堵住的。浏览器的硬性规则是:外部样式表没全部到齐,一个像素都不画。因为浏览器不知道该画成什么样,万一 CSS 里写着 display:none 呢。
真实的时间线是这样的:
HTML 到达 → 文字内容其实已在内存 → 干等 8 个 CSS 下载(840ms) → CSS 齐了才能画 → LCP 记在这里
<link rel="preload" as="image">。结果完全无效,因为它根本不是 LCP 元素,反而和关键 CSS 抢了带宽。不查 LCP breakdown 就加 preload,等于闭着眼睛开枪。
3. 你最有价值的优化,Lighthouse 一个都不给分
这是本文最想强调的一点。看这张对照表:
| 优化项 | 对 Lighthouse 分数 | 对真实访客 |
|---|---|---|
| 静态资源缓存 1 年 | 0 分 | 回访用户 CSS/JS/图片全部本地读取,等于零请求 |
| 版本号改 filemtime | 0 分 | 让你敢开长缓存又不锁死自己 |
| jQuery 移出 head | +1~3 分 | 每次访问都少一个阻塞脚本 |
| 整页静态缓存 | 间接 | TTFB 从几百毫秒降到 50ms |
为什么缓存头不给分?因为 Lighthouse 每次测试都会清空缓存跑。它模拟的永远是"第一次访问",所以你为"第二次访问"做的一切优化,它一概看不见。
二、动手前的 30 秒诊断
在做任何改动之前,先花半分钟搞清楚三件事。这三步能避免绝大部分无用功。
① LCP 到底是谁
跑一次 Lighthouse,展开 LCP breakdown 面板,看 LCP element 那一行。是 img 还是某个 div/p?这决定了你后面 80% 的力气往哪使。
② 阻塞清单里都有什么
展开 Render-blocking requests。分清哪些是 CSS、哪些是 JS。如果 JS 在里面,说明它被输出到了 <head>;如果全是 CSS,那优化方向就是压缩体积和减少数量,跟 JS 一点关系都没有。
③ 分数的组成
如果 TBT 已经接近 0、CLS 已经是 0,那就别再碰 JS 执行顺序和布局了。你已经满分了,剩下的所有动作都是在拿 0 分去冒险。
TTFB + Element render delay 的和,明显小于报告里的 LCP 数值(比如 150+460=610ms,但 LCP 报 2.4s),那中间那段没人认领的时间,几乎一定是在等 CSS。这是不用看任何代码就能得出的结论。
三、六项改动,按真实收益排序
改动 1:缓存头(真实收益最大,但 0 分)
在站点根目录的 .htaccess 里加上这段。第一组是重点,别漏:
.htaccess —— 静态资源长效缓存
<code># ===== 静态资源长效缓存 ===== <IfModule mod_expires.c> ExpiresActive On # 关键:页面 HTML 本身绝不缓存 # 否则发新文章访客看不到,登录状态还会串号 ExpiresByType text/html "access plus 0 seconds" ExpiresByType text/plain "access plus 0 seconds" ExpiresByType application/json "access plus 0 seconds" # 静态资源缓存 1 年 ExpiresByType text/css "access plus 1 year" ExpiresByType application/javascript "access plus 1 year" ExpiresByType text/javascript "access plus 1 year" ExpiresByType image/jpeg "access plus 1 year" ExpiresByType image/png "access plus 1 year" ExpiresByType image/gif "access plus 1 year" ExpiresByType image/webp "access plus 1 year" ExpiresByType image/svg+xml "access plus 1 year" ExpiresByType font/woff2 "access plus 1 year" # 兜底:其余一律 1 个月(HTML 已在上面排除) ExpiresDefault "access plus 1 month" </IfModule></code>
ExpiresByType 匹配到的响应,包括 WordPress 页面本身。如果不先用 ExpiresByType text/html "access plus 0 seconds" 把 HTML 排除掉,你的文章页会被浏览器缓存一个月,发新文章访客看不到,这是灾难级的。
改完记得确认 Apache 已启用 mod_expires(XAMPP 面板里勾选,或在 httpd.conf 里确认 LoadModule expires_module 前面没有 #),改完重启 Apache。
改动 2:版本号改 filemtime(改动 1 的强制配套)
设了 1 年缓存后,你改 CSS 老访客永远看不到新版。清 Cache Enabler、清 Redis 都没用,因为那份 CSS 存在访客自己的浏览器里,服务器端的清理根本够不着。
唯一的解法是让 URL 自己变。加一个助手函数:
functions.php —— 版本号自动跟随文件修改时间
<code>/** * 取文件最后修改时间当版本号 * 文件一改,版本号就变,浏览器认为是新地址,立刻重新下载。 * 这样才敢设 1 年缓存,且永远不用手动清任何缓存。 */ function mytheme_ver( $rel_path ) { $file = get_template_directory() . $rel_path; return file_exists( $file ) ? filemtime( $file ) : '20120206'; }</code>
然后把所有 enqueue 里硬编码的版本号换成它:
<code>wp_enqueue_style( 'mytheme-main', get_template_directory_uri() . '/assets/css/main.css', array(), mytheme_ver( '/assets/css/main.css' ) ); wp_enqueue_script( 'mytheme-custom', get_template_directory_uri() . '/js/custom.js', array( 'jquery' ), mytheme_ver( '/js/custom.js' ), true );</code>
改完看源码,链接会变成 main.css?ver=1791971958 这种时间戳。以后改样式,数字自动变,老访客立刻拿到新版。
/pagephoto/)且那里有自己的 .htaccess,那么根目录的配置管不到它,需要单独去那个子目录里改缓存时长。而这类 FTP 手动维护的图片目录没有版本号机制,换图只能改文件名。
改动 3:把 jQuery 移出 head(唯一真涨分的)
这是本次唯一拿到 Lighthouse 那个"省 290ms"的改动。分两步走。
第一步:给你的脚本声明依赖
很多主题里有这么一行,看着人畜无害:
<code>// 旧写法:不声明依赖,默认输出到 head wp_enqueue_script( 'mytheme-custom', get_template_directory_uri() . '/js/custom.js' );</code>
问题在于:这个脚本在 head 里要用 jQuery,WordPress 只能把 jQuery 也提到 head,于是 jQuery 就成了渲染阻塞资源。改成:
<code>// 新写法:声明依赖 + 第 5 个参数 true(移到页脚) wp_enqueue_script( 'mytheme-custom', get_template_directory_uri() . '/js/custom.js', array( 'jquery' ), mytheme_ver( '/js/custom.js' ), true );</code>
加上 array( 'jquery' ) 和 true 之后,custom.js 排到页脚,jQuery 跟着一起下去,而且执行顺序由 WordPress 保证,不会乱。
第二步:如果 jQuery 还在 head
有时候改完上面的,jQuery 依然在 head,因为某个插件在 head 里排队了 jQuery。先做个判断:打开源码看 head 里除 jQuery 外还有没有别的脚本。如果没有,说明没人真的需要在 head 用它,可以强制挪走:
functions.php —— 强制 jQuery 输出到页脚
<code>add_action( 'wp_enqueue_scripts', function () { $scripts = wp_scripts(); foreach ( array( 'jquery', 'jquery-core' ) as $handle ) { if ( isset( $scripts->registered[ $handle ] ) ) { $scripts->add_data( $handle, 'group', 1 ); } } }, 999 );</code>
改完务必实测:菜单能否展开、轮播能否切换、搜索能否点开。有异常就删掉这段,30 秒回退。
改动 4:移除 jquery-migrate
jquery-migrate 是给用了废弃 API 的老代码兜底的垫片。现代主题基本用不到它,白白多一个请求。
<code>add_action( 'wp_default_scripts', function ( $scripts ) { if ( is_admin() || empty( $scripts->registered['jquery'] ) ) { return; } $ver = $scripts->registered['jquery']->ver; $scripts->remove( 'jquery' ); $scripts->add( 'jquery', false, array( 'jquery-core' ), $ver ); // 只依赖核心,不带 migrate } );</code>
风险较低,但同样需要实测一遍交互功能。
改动 5:清理主题遗留的死代码
这是性价比最高、也最容易被忽略的一项。爆改过的主题、换过轮播插件的站点,几乎都有这类残留。
本次案例中,主题原生的 Nivo Slider 被换成了 Bootstrap carousel,但 functions.php 里加载这两个 CSS 的代码留着没删:
<code>wp_enqueue_style( 'mytheme-nivo-style', ... . '/assets/css/nivo-slider.css' ); wp_enqueue_style( 'mytheme-nivo-skin-style', ... . '/assets/css/nivo-default/default.css' );</code>
更隐蔽的是,负责输出幻灯片的 slider-nivo.php 还被 header.php 调用着,但它内部写着 if ( ... && is_home() )——而首页是静态页,is_home() 永远为 false,这个文件一次都不会执行。
nivoSlider),搜不到就是没输出;二是看它依赖的运行时数据,比如从主题自定义器读取的配置项是否从来没填过。两条都成立,就可以放心删。
<code>add_action( 'wp_enqueue_scripts', function () { wp_dequeue_style( 'mytheme-nivo-style' ); wp_dequeue_style( 'mytheme-nivo-skin-style' ); }, 100 ); // 优先级 100 必须,确保在 enqueue(默认 10)之后执行</code>
那个优先级 100 不是随便写的,必须排在主题的 wp_enqueue_scripts 回调之后,否则样式还没注册,注销不掉。
改动 6:WebP 用服务器重写,不要用插件改写 HTML
两种方案,差别在失败时的表现:
✕ 插件改写 HTML
缓存插件在 HTML 层把 .jpg 字符串替换成 .webp。它不检查文件是否存在,转换失败的那批图会永久 404,用户看到裂图。
✓ Apache 重写
靠 RewriteCond %{REQUEST_FILENAME}.webp -f,文件存在才替换,不存在就自动返回原图,永远不可能裂图。
图片目录的 .htaccess —— WebP 自动改写
<code>AddType image/webp .webp RewriteEngine On RewriteCond %{HTTP_ACCEPT} image/webp RewriteCond %{REQUEST_FILENAME}.webp -f RewriteRule ^(.+\.(jpe?g|png))$ $1.webp [NC,T=image/webp,L] # 必须:同一 URL 可能返回 jpg 或 webp Header append Vary Accept</code>
%{REQUEST_FILENAME}.webp,也就是 dmyj.jpg 对应 dmyj.jpg.webp(追加后缀,不是替换)。如果你的转换脚本生成的是 dmyj.webp,两边对不上,规则永远不生效。用 cwebp 批量转换时应该写成 -o "%%f.webp" 而不是替换扩展名。
另一个容易踩的坑:缓存插件的 WebP 选项也是追加命名,会和 Apache 规则抢同一批文件。两者只能留一个,建议只留 Apache 这套,在插件后台把 WebP 选项取消勾选。
四、验证工具箱:三条命令定性
① WebP 到底生效没有
注意必须手动带 Accept 头,curl 默认发 */*,不带 image/webp,会让你误判成没生效:
<code>curl -s -D - -o nul -H "Referer: https://example.com/" -H "Accept: image/webp,*/*" ^ https://example.com/pagephoto/banner/hero.jpg | findstr /i "content-type"</code>
返回 image/webp 就是生效了。再跑一次把 Accept 换成 image/jpeg,*/*,这次必须返回 image/jpeg,确认老浏览器的降级正常。
② 缓存头有没有配上
<code>curl -s -D - -o nul https://example.com/wp-content/themes/your-theme/assets/css/main.css ^ | findstr /i "cache-control expires"</code>
看到 max-age=31536000 说明生效。同时要测一次首页 HTML,确认它是 max-age=0,确保没被误伤。
③ 压缩是否已开启
<code>curl -s -D - -o nul -H "Accept-Encoding: gzip, deflate" ^ https://example.com/wp-includes/css/dashicons.min.css | findstr /i "content-encoding content-length"</code>
本次案例中,Lighthouse 报这个文件 35 KiB,但 curl 实测只有 5725 字节,说明压缩早就开着,只是响应头在回程路上被安全网关抹掉了。文件小到这个程度,再去纠结它就没有意义了。
五、我踩过的 6 个坑
| 坑 | 真相 |
|---|---|
| 凭感觉加 preload | 轮播图不一定是 LCP 元素。本次 LCP 是一段 SEO 文字,加的 preload 完全无效还抢带宽。先看 LCP breakdown 再动手。 |
| 删 dashicons | Lighthouse 说它"未使用 19 KiB",这是误判,覆盖率统计识别不了伪元素 ::before 里的图标规则。菜单图标全靠它,删了立刻变空白方块。 |
| 模板硬写 .webp | 老浏览器不支持 WebP 会直接 404。应该保持 .jpg,交给服务器重写,让 -f 检查兜底。 |
| 盲目加 defer | 只给 head 里的脚本加 defer,会让它们先于 jQuery 执行,报 "requires jQuery"。移到页脚 ≠ defer,前者顺序确定,后者不确定。 |
| 拿 TBT/CLS 开刀 | 已经 0ms / 0 的时候,任何改动都是在拿满分冒险。为了 200ms 把 CLS 从 0 变成有值,是纯亏本买卖。 |
| 单次分数当真 | 同页面 Speed Index 能在 3.7~4.8s 之间跳。连测 3 次取中位数,别看单次。 |
还有一条:别动已经到位的布局约束
首页右侧文章列表和左侧轮播图并排,列表条数必须刚好和轮播图等高。为了"减少 DOM 节点"把 13 条砍成 10 条,下方立刻空出一截。而且多个 tab 之间条数必须一致,否则切换时高度会跳,CLS 立刻从 0 变成有值。DOM 节点数量从来不是 Lighthouse 标红的项目,为它牺牲布局得不偿失。
六、什么时候该停手
做完上面六项,这个站点的状态是:
- TTFB 稳定在 50~150ms(整页静态缓存直出)
- CLS = 0、TBT = 0~10ms(满分区间)
- WebP 全站生效,且具备自动降级
- 静态资源缓存 1 年,版本号自动更新
- 首屏无阻塞脚本,死代码清理完毕
剩下的阻塞项全是 CSS:bootstrap、font-awesome、dashicons、slicknav……想继续压,只能做关键 CSS 内联 + 全量异步。但没有构建工具的话手工抽取极其痛苦,容易漏规则导致样式崩,而且 CSS 晚到会让文字先以无样式状态画出来,CLS 极可能从 0 变成有值。
最后:一份可复用的检查顺序
- 看 LCP breakdown,确定 LCP 元素到底是图片还是文字
- 看 Render-blocking requests,分清是 CSS 还是 JS 在阻塞
- TBT / CLS 已是 0,就不要碰脚本顺序和布局
- 先做缓存头(真实收益最大),但必须同时改 filemtime 版本号
- 再把阻塞脚本移出 head(唯一真涨分的)
- 清理死代码(零风险,纯收益)
- WebP 交给服务器重写,别改模板
- 连测 3 次取中位数,别为 ±3 分的波动焦虑
本文所有代码与数据均来自真实站点的优化过程。环境为 Windows + Apache + WordPress + Cache Enabler,Nginx 用户需将 .htaccess 规则转换为对应的 server 块配置。