从 76 分到满分:实现 Lighthouse 完美评分
摘要: 一个个人作品集网站从移动端 Lighthouse 性能评分 76 分、CLS 0.493 提升到了四项指标均达到 100/100/100/100 的完美分数。这段优化之旅揭示了一些隐蔽的 CSS 加载问题、正则表达式 bug,以及一个特别狡猾的 CSS 变量覆盖问题——正是它导致了移动端的布局偏移。
起点
该网站是一个使用 FastAPI + Jinja2 构建的作品集,采用 HTMX 和 Alpine.js 实现交互功能。初始的 Lighthouse 移动端审计结果:
| 指标 | 分数 |
|---|---|
| 性能 | 76 |
| 无障碍 | 91 |
| 最佳实践 | 100 |
| SEO | 100 |
| CLS | 0.493 |
这个 CLS 数值相当糟糕。Google 认为任何超过 0.1 的数值都属于”差”。0.493 意味着页面在加载过程中会明显跳动。
第一阶段:无障碍快速优化
在解决性能问题之前,我先清理了无障碍问题以建立基准:
表单标签
将装饰性的 span 改为正确的 <label> 元素:
<!-- Before: span with aria-describedby -->
<span class="contact-form__label">Email</span>
<input id="email" aria-describedby="...">
<!-- After: proper label association -->
<label for="email" class="contact-form__label">Email</label>
<input id="email">
对比度
页脚标题和表单标签使用的是 --color-text-tertiary(40% 不透明度的白色)。将其提升到 --color-text-secondary(65% 不透明度)以满足 WCAG AA 4.5:1 的对比度要求。
冗余的 Alt 文本
社交媒体图标有类似”LinkedIn icon”的 alt 文本,但它们本身就在已经有 aria-label 的链接内部。改为 alt="" 并添加 aria-hidden="true" 以防止屏幕阅读器重复朗读。
相同的链接文本
多个”View Case Study”链接对屏幕阅读器来说无法区分。添加了带有项目名称的 aria-label 属性:
<a href="/work/introl" aria-label="View Introl Branding case study">
View Case Study
</a>
结果: 无障碍分数从 91 跃升至 100。
第二阶段:渲染阻塞 CSS 问题
Lighthouse 显示 LCP 分解中有 2,460ms 的”元素渲染延迟”。罪魁祸首:一个同步加载的 CSS 文件阻塞了首次绘制。
<!-- The problem: render-blocking stylesheet -->
<link rel="stylesheet" href="/static/css/styles.min.css">
浏览器必须下载并解析完整个 25KB 的样式表才能绘制任何内容。
解决方案:关键 CSS + 异步加载
第一步: 将关键(首屏)CSS 提取到单独的文件中,内联到 <head> 里。
第二步: 使用 media="print" 技巧异步加载完整样式表:
<!-- Critical CSS inlined for instant first paint -->
<style>{% include "components/_critical.css" %}</style>
<!-- Full CSS loaded async (doesn't block render) -->
<link rel="stylesheet" href="/static/css/styles.min.css"
media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="/static/css/styles.min.css">
</noscript>
media="print" 属性告诉浏览器”这个样式表不是屏幕渲染所需的”,因此不会阻塞渲染。加载完成后,onload 处理程序将其切换为 media="all"。
关键 CSS 提取脚本
我编写了一个 Python 脚本来自动提取首屏组件的关键 CSS:
CRITICAL_PREFIXES = [
".container",
".header",
".nav",
".hero",
".personal-photos",
]
def extract_critical_css(css_content: str) -> str:
# Strip comments to prevent regex pollution
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
# Extract :root variables
# Extract base reset rules
# Extract component rules by prefix
# Extract media queries containing critical selectors
...
结果: LCP 元素渲染延迟从 2,460ms 降至约 300ms。
第三阶段:CLS 噩梦开始
实现异步 CSS 后,CLS 实际上变得更糟——跳升至 0.119。Lighthouse 显示 <main> 是发生偏移的元素。
Bug #1:CSS 注释污染正则表达式
提取脚本使用正则表达式匹配选择器:
rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"
问题:CSS 注释在提取前没有被清除。像这样的注释:
/* Hero Section - Editorial */
.hero { ... }
会导致正则表达式从”Hero Section”匹配到左大括号,创建格式错误的选择器,无法通过前缀检查。关键的 .hero 样式被悄悄丢弃了。
修复: 在任何正则操作之前清除注释:
css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)
Bug #2:媒体查询内的规则被提取为独立规则
正则表达式匹配到媒体查询内部的规则,并将它们作为独立规则提取出来:
/* Full CSS structure */
.hero__title { font-size: 5rem; } /* Desktop */
@media (max-width: 768px) {
.hero__title { font-size: 1.875rem; } /* Mobile */
}
脚本将两条规则都提取到顶层:
/* Broken critical CSS */
.hero__title { font-size: 5rem; }
.hero__title { font-size: 1.875rem; } /* Overrides desktop! */
在移动端,标题从关键 CSS 以移动端尺寸渲染,然后……保持不变,因为完整 CSS 有相同的规则。但在桌面端,移动端的覆盖规则被错误地应用了。
修复: 跟踪媒体查询的位置,跳过其中的规则:
# Find all media query ranges
media_ranges = []
for match in re.finditer(media_pattern, css_no_comments):
media_ranges.append((match.start(), match.end()))
def is_inside_media_query(pos: int) -> bool:
return any(start <= pos < end for start, end in media_ranges)
# Skip rules inside media queries during extraction
for match in re.finditer(rule_pattern, css_no_comments):
if is_inside_media_query(match.start()):
continue # Will be included with full media query block
# ... extract rule
第四阶段:100vh 假设
CLS 仍然是 0.116。理论:如果首屏以下的内容在初始绘制时不可见,就不会影响 CLS。
将 hero 区域从 85vh 改为 100vh:
.hero {
/* 100vh ensures nothing below fold is visible on initial paint */
min-height: 100vh;
}
结果: 没有变化。CLS 仍然是 0.116。偏移发生在视口内部。
第五阶段:找到元凶
Lighthouse 的帧序列图显示 hero 文本在帧之间明显偏移。不是渐变——是水平移动。
深入研究影响水平定位的样式:
- .hero__content 使用 padding: 0 var(--gutter)
- --gutter 在 :root 中定义为 48px
然后我发现了问题:
/* In full CSS, but NOT in critical CSS */
@media (max-width: 768px) {
:root {
--gutter: var(--spacing-md); /* 24px */
}
}
移动端的执行顺序:
- 关键 CSS 加载:
--gutter: 48px - Hero 以 48px 的侧边内边距渲染
- 完整 CSS 异步加载
- 媒体查询将
--gutter设置为24px - Hero 内边距从 48px 缩小到 24px
- 文本重排并偏移 = CLS 0.116
修复方案
将 :root 媒体查询作为关键 CSS 的一部分提取:
# Extract :root media queries (variable overrides are critical)
root_media_pattern = r"@media[^{]+\{\s*:root\s*\{[^}]+\}\s*\}"
for match in re.finditer(root_media_pattern, css_no_comments):
critical_rules.append(match.group())
现在关键 CSS 包含:
:root { --gutter: 48px; /* ... */ }
@media (max-width: 768px) {
:root { --gutter: var(--spacing-md); }
}
移动端从一开始就使用正确的 24px 间距渲染。当完整 CSS 加载时,--gutter 已经是 24px——没有变化,没有偏移。
第六阶段:CSP 和 Alpine.js
还有一个问题:Alpine.js 抛出了关于内容安全策略违规的控制台错误。Alpine 内部使用动态表达式求值。
# In security headers middleware
CSP_DIRECTIVES = {
"script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
# ...
}
Alpine.js 的表达式解析需要 'unsafe-eval' 指令。替代方案是使用 Alpine 的 CSP 兼容版本,但它有一些限制。
最终结果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 性能 | 76 | 100 |
| 无障碍 | 91 | 100 |
| 最佳实践 | 100 | 100 |
| SEO | 100 | 100 |
| CLS | 0.493 | 0.033 |
| LCP | 2.6s | 0.8s |
证据
桌面端审计显示四项指标全部满分:

移动端审计——最重要的一项——同样达到完美分数:

核心经验
1. CSS 变量可能导致 CLS
CSS 自定义属性的媒体查询覆盖在 DevTools 的计算样式面板中是不可见的——你只能看到最终值。如果你的关键 CSS 不包含变量覆盖,布局会在完整样式表加载时发生偏移。
2. 关键 CSS 提取很棘手
简单的正则表达式方法在以下情况会失败:
- CSS 注释(可能污染选择器匹配)
- 媒体查询内的规则(必须保持在其块内)
- :root 媒体查询(变量更改影响所有内容)
3. media="print" 技巧有效
使用 media="print" onload="this.media='all'" 加载非关键 CSS 是一种合理的延迟样式表加载的方式,无需复杂的 JavaScript。
4. CLS 调试需要帧序列图
Lighthouse 的帧序列视图可以准确显示偏移发生的时间。没有它,你只能猜测。被标记为”偏移”的元素可能只是症状——要找出导致它偏移的原因。
5. 100vh Hero 区域的好处不仅仅是美观
如果你的 hero 填满视口,首屏以下的内容就不会影响 CLS,因为在用户滚动之前它不会被测量。
技术栈
- 后端: FastAPI + Jinja2
- 前端: HTMX + Alpine.js(自托管,无 CDN)
- CSS: 纯 CSS 配合自定义属性,无预处理器
- 优化: 自定义 Python 脚本用于关键 CSS 提取
- 托管: Railway,启用 GZip 压缩和不可变缓存
使用的工具
- Lighthouse(Chrome DevTools)
- Lighthouse 帧序列视图用于 CLS 调试
grep和regex101.com用于 CSS 分析- Python 用于关键 CSS 提取脚本
- iTerm 2
- Claude Code CLI 配合 Opus 4.5 以及大量的 ultrathink