← 所有文章

从 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 */
  }
}

移动端的执行顺序:

  1. 关键 CSS 加载:--gutter: 48px
  2. Hero 以 48px 的侧边内边距渲染
  3. 完整 CSS 异步加载
  4. 媒体查询将 --gutter 设置为 24px
  5. Hero 内边距从 48px 缩小到 24px
  6. 文本重排并偏移 = 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

证据

桌面端审计显示四项指标全部满分:

Lighthouse desktop audit showing 100/100/100/100 scores

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

Lighthouse mobile audit showing 100/100/100/100 scores


核心经验

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 调试
  • grepregex101.com 用于 CSS 分析
  • Python 用于关键 CSS 提取脚本
  • iTerm 2
  • Claude Code CLI 配合 Opus 4.5 以及大量的 ultrathink