← 所有文章

从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

相关文章

Midjourney V8 Killed Your V7 Workflow: What Actually Changed

V8 isn't a better V7. It's a different creative loop: personalization over prompting, native 2K, conversation mode, and …

15 分钟阅读

Every Iteration Makes Your Code Less Secure

43.7% of LLM iteration chains introduce more vulnerabilities than baseline. Adding SAST scanners makes it worse. SCAFFOL…

11 分钟阅读

Your Agent Sandbox Is a Suggestion

An attacker opened a GitHub issue and shipped malware in Cline's next release. Agent sandboxes fail at three levels. Her…

18 分钟阅读