← 所有文章

从76到100:实现Lighthouse满分

摘要: 一个个人作品集网站从移动端Lighthouse性能评分76分、CLS高达0.493,提升到所有类别均达到100/100/100/100的满分。这段优化历程揭示了隐蔽的CSS加载问题、正则表达式缺陷,以及一个特别棘手的CSS变量覆盖导致移动端布局偏移的问题。


起点

该网站基于FastAPI + Jinja2构建,使用HTMX和Alpine.js实现交互功能。初始的Lighthouse移动端审计结果如下:

指标 得分
性能 76
无障碍 91
最佳实践 100
SEO 100
CLS 0.493

这个CLS数值非常糟糕。Google将任何超过0.1的值定义为”差”。CLS达到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的对比度要求。

冗余的替代文本

社交媒体图标使用了类似”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分析中存在2460毫秒的”元素渲染延迟”。罪魁祸首是一个阻塞首次绘制的同步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元素渲染延迟从2460毫秒降至约300毫秒。


第三阶段:CLS噩梦的开始

实现异步CSS加载后,CLS实际上反而恶化了——飙升至0.119。Lighthouse显示<main>是发生偏移的元素。

缺陷一:CSS注释污染正则表达式

提取脚本使用正则表达式来匹配选择器:

rule_pattern = r"([.#\w][^{]+)\{([^}]+)\}"

问题在于:提取前没有先剔除CSS注释。类似这样的注释:

/* Hero Section - Editorial */
.hero { ... }

会导致正则表达式从”Hero Section”匹配到左花括号,产生格式错误的选择器,从而无法通过前缀检查。关键的.hero样式被静默丢弃了。

修复方案: 在执行任何正则操作之前先剔除注释:

css_no_comments = re.sub(r'/\*[\s\S]*?\*/', '', css_content)

缺陷二:媒体查询内的规则被提取为独立规则

正则表达式匹配到了媒体查询内部的规则,并将其作为独立规则提取出来:

/* 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.js内部使用了动态表达式求值。

# In security headers middleware
CSP_DIRECTIVES = {
    "script-src": "'self' 'unsafe-inline' 'unsafe-eval'",
    # ...
}

Alpine.js的表达式解析需要'unsafe-eval'指令。替代方案是使用Alpine.js的CSP兼容构建版本,但该版本有一些功能限制。


最终结果

指标 优化前 优化后
性能 76 100
无障碍 91 100
最佳实践 100 100
SEO 100 100
CLS 0.493 0.033
LCP 2.6s 0.8s

证据

桌面端审计显示所有四个类别均获得满分:

Lighthouse桌面端审计显示100/100/100/100得分

移动端审计——这才是最重要的——同样达到了满分:

Lighthouse移动端审计显示100/100/100/100得分


关键收获

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

相关文章

The Design Engineer's Agent Stack

Design engineers need agent infrastructure that enforces visual consistency, typography discipline, color compliance, an…

14 分钟阅读

App Intents 2.0 In iOS 26: Visual Intelligence, Interactive Snippets, And Deferred Properties

iOS 26 expanded App Intents with IntentValueQuery for Visual Intelligence, @DeferredProperty for async values, interacti…

11 分钟阅读

Genmoji And NSAdaptiveImageGlyph: How Apps Display User-Generated Inline Emoji

Genmoji ships as NSAdaptiveImageGlyph in attributed text. Apps using UITextView with TextKit 2 enable supportsAdaptiveIm…

10 分钟阅读