基于Lighthouse参数对WordPress网站优化提速

一句话结论:折腾了一整天,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 分的落差,不是网站有问题,是模拟条件本身造成的。

不要跨浏览器比分数:同为 Chromium 内核,Chrome 和 Edge 的 Lighthouse 在 CPU 降速校准值上并不一致,Edge 还默认开启效率模式会主动限频。同一个浏览器的前后对比才有意义,跨浏览器比出来的差值没有参考价值。

更关键的是:同一页面连测三次,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 每次测试都会清空缓存跑。它模拟的永远是"第一次访问",所以你为"第二次访问"做的一切优化,它一概看不见。

正确的心态:Lighthouse 分数衡量的是冷缓存最坏情况。你的真实访客大部分带着浏览器缓存、走着 CDN 节点。别为了让一个"最坏情况"的数字好看,去牺牲真实体验。

二、动手前的 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 分去冒险。

一个判断技巧:如果 LCP breakdown 里 TTFB + Element render delay 的和,明显小于报告里的 LCP 数值(比如 150+460=610ms,但 LCP 报 2.4s),那中间那段没人认领的时间,几乎一定是在等 CSS。这是不用看任何代码就能得出的结论。

三、六项改动,按真实收益排序

改动 1:缓存头(真实收益最大,但 0 分)

在站点根目录的 .htaccess 里加上这段。第一组是重点,别漏:

.htaccess —— 静态资源长效缓存
<code># ===== 静态资源长效缓存 =====
&lt;IfModule mod_expires.c&gt;
    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"
&lt;/IfModule&gt;</code>
ExpiresDefault 的危险:它会无差别命中所有没被 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 这种时间戳。以后改样式,数字自动变,老访客立刻拿到新版。

注意子目录的 .htaccess 优先级更高:如果你的图片放在某个子目录(比如 /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 保证,不会乱。

为什么这不是 defer:很多主题注释里写着"不要给脚本加 defer,会报 Bootstrap's JavaScript requires jQuery"。那条警告说得对,但它针对的是 defer,不是移到页脚。defer 会让执行顺序变得不确定;而声明依赖 + 页脚,顺序是确定不变的,反而比原来更安全。别被注释吓退。

第二步:如果 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-&gt;registered[ $handle ] ) ) {
			$scripts-&gt;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-&gt;registered['jquery'] ) ) {
		return;
	}
	$ver = $scripts-&gt;registered['jquery']-&gt;ver;
	$scripts-&gt;remove( 'jquery' );
	$scripts-&gt;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,这个文件一次都不会执行。

如何确认是死代码:不要靠推理,用两个硬证据。一是查看页面源码,搜组件的关键 class 名(如 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 变成有值。

建议的收手时机:当你的 TTFB 已经进两位数毫秒、CLS 和 TBT 都是满分、缓存策略完备时,继续优化的边际收益已经低于测量噪声了。这时更应该去看 Google Search Console 的核心网页指标(CrUX),那里是真实访客的数据,比本地跑一百次 Lighthouse 都更有说服力。

最后:一份可复用的检查顺序

  1. 看 LCP breakdown,确定 LCP 元素到底是图片还是文字
  2. 看 Render-blocking requests,分清是 CSS 还是 JS 在阻塞
  3. TBT / CLS 已是 0,就不要碰脚本顺序和布局
  4. 先做缓存头(真实收益最大),但必须同时改 filemtime 版本号
  5. 再把阻塞脚本移出 head(唯一真涨分的)
  6. 清理死代码(零风险,纯收益)
  7. WebP 交给服务器重写,别改模板
  8. 连测 3 次取中位数,别为 ±3 分的波动焦虑

本文所有代码与数据均来自真实站点的优化过程。环境为 Windows + Apache + WordPress + Cache Enabler,Nginx 用户需将 .htaccess 规则转换为对应的 server 块配置。

分享到:

用微信扫一扫,分享给朋友


发布日期:

所属分类: Wordpress 综合 标签: