Day 23 — 性能优化:修复 25fps 卡顿与 View Transitions 主题丢失

从用户反馈的 '网页卡顿' 出发,深入诊断并修复了三层性能问题:backdrop-blur 导致 GPU 重绘过载、timeTheme.js 的 applyColor 每帧无脑调用造成 DOM 重排和 PMREM 环境贴图重建、所有页面使用硬编码颜色导致时间主题失效。同时修复了 View Transitions 切换页面后颜色回退到白色的兼容性问题。

Day 23 — 性能优化:修复 25fps 卡顿与 View Transitions 主题丢失

背景

用户反馈网站 “网页卡顿”,即使开启 Firefox GPU 加速也不流畅。同时反映部分页面的卡片”一直是半透明白色”,且切换目录后颜色主题丢失。

使用 FPS 监视器(Ctrl+Shift+F)确认:帧率仅 25fps,远低于预期的 60fps。


问题 1:backdrop-blur 性能杀手

诊断

整个网站大量使用玻璃态(glassmorphism)效果:bg-white/60 backdrop-blur-mdbackdrop-filter: blur() 是 CSS 中最耗 GPU 的滤镜之一,尤其在 Firefox 上性能显著低于 Chrome。

影响范围:

元素原模糊强度位置
Navbarbackdrop-blur-lg (16px)所有页面
卡片backdrop-blur-md (12px)所有页面
按钮/badgesbackdrop-blur-sm (4px)所有页面
播放器blur(28px) (内联 CSS)音乐播放器组件
阅读进度backdrop-blur-mdReadingBackdrop 组件
Like 按钮backdrop-blur-smLikeButton 组件

每个使用 backdrop-blur 的元素,滚动时都会触发 GPU 重绘。 多个模糊元素叠加时,性能问题成倍放大。

修复方案

方案 1:纯半透明背景代替模糊(主要)

用提高不透明度的纯色背景替代 backdrop-filter: blur()

- class="bg-white/60 backdrop-blur-md rounded-2xl"
+ class="bg-white/85 rounded-2xl"

同时提高暗色模式的 CSS 变量不透明度(补偿模糊丢失的视觉融合感):

:root {
- --glass-bg: rgba(255, 255, 255, 0.6);
+ --glass-bg: rgba(255, 255, 255, 0.85);
}

[data-theme="night"] {
- --glass-bg: rgba(0, 0, 0, 0.4);
+ --glass-bg: rgba(0, 0, 0, 0.65);
}

方案 2:降低模糊强度(Navbar/播放器保留处)

对必须保留模糊的视觉焦点元素,降低强度:

- backdrop-blur-lg (16px)
+ backdrop-blur-sm (4px)

播放器内联 CSS:

- backdrop-filter: blur(28px);
+ backdrop-filter: blur(12px);

方案 3:添加 will-change 提示

+ .glass-blur {
+   will-change: backdrop-filter;
+ }

- <header class="... backdrop-blur-sm">
+ <header class="... backdrop-blur-sm glass-blur">

方案 4:View Transitions 回退动画

- <ClientRouter fallback="animate" />
+ <ClientRouter />

去掉 fallback="animate" —— 不支持原生 View Transitions API 的浏览器直接无动画切换,避免 JS 回退动画增加渲染负担。

修改文件

文件改动
src/styles/global.css--glass-bg 不透明度 0.6→0.85(日)/ 0.4→0.65(夜);添加 .glass-blur
src/components/Navbar.astrobackdrop-blur-lgbackdrop-blur-sm + .glass-blur
src/components/SmallPostCard.astro移除 backdrop-blur-md
src/components/ReadingBackdrop.astro移除 backdrop-blur-md
src/components/MusicPlayer.astroblur(28px)blur(12px)(2处)
src/components/LikeButton.tsxbg-white/40 backdrop-blur-smbg-white/60
src/layouts/BaseLayout.astro<ClientRouter fallback="animate" /><ClientRouter />
7 个页面文件bg-white/60 backdrop-blur-mdbg-white/85
11 处按钮bg-gray-100/60 backdrop-blur-smbg-gray-100/80

问题 2:硬编码颜色导致的主题失效

诊断

SmallPostCard.astro/blog/build-log/ 等文章卡片)使用 CSS 变量 bg-[var(--glass-bg)],能跟随时间主题切换。但大部分其他页面使用硬编码 Tailwind 类:

- bg-white/85          ← 永远是白色
- text-gray-900         ← 永远是深灰
- text-gray-500         ← 永远是中灰
- border-white/30       ← 永远是白边框

timeTheme.js 在夜间设置 data-theme="night" 时,CSS 变量正确切换为深色值,但硬编码类完全不受影响。

修复

将 10 个文件中的所有硬编码颜色替换为 CSS 变量:

硬编码类CSS 变量
bg-white/85bg-[var(--glass-bg)]
bg-gray-100/80bg-[var(--glass-bg-secondary)]
text-gray-900text-[var(--glass-text)]
text-gray-500text-[var(--glass-text-secondary)]
text-gray-400text-[var(--glass-text-muted)]
border-white/30border-[var(--glass-border)]
border-gray-200/40border-[var(--glass-border)]

顺便优化了首页卡片的过渡:

- transition-all duration-200
+ transition-shadow duration-200

修改文件清单

文件优先级改动量
src/pages/blog/index.astro最高(用户反馈的根因)卡片 + 标题 + 按钮 → CSS vars
src/pages/blog/[category].astro返回按钮 + 侧栏标签 → CSS vars
src/pages/blog/tags/index.astro标签卡片 + 标题 → CSS vars
src/pages/blog/tags/[tag].astro返回按钮 + 标题 → CSS vars
src/components/CategoryDisplay.astro分类标题 → CSS vars
src/pages/index.astro首页卡片 + 社交按钮 → CSS vars
src/pages/about.astro全部 → CSS vars
src/pages/shares.astro全部 → CSS vars
src/pages/pictures.astro全部 → CSS vars
src/pages/projects/index.astro全部 → CSS vars

遗漏修复:文章详情页返回按钮未更新

后续验证发现 src/pages/blog/[category]/[slug].astro(文章详情页)的两个返回按钮被遗漏,仍在使用硬编码颜色:

- border border-gray-200/40 hover:bg-gray-200/60
+ border border-[var(--glass-border)] hover:bg-white/60

border-gray-200/40 在深色模式下几乎透明(浅灰 40% 在黑背景上不可见),导致按钮”可以点击但看不见”。同步修复 [category].astro 返回按钮缺少 <nav> 包裹的结构问题,与全站其他页面保持一致。

此外,日间模式下按钮 #6b7280 文字在 rgba(243,244,246,0.85) 背景上对比度仅约 2:1,且完全没有阴影层次。全站 9 个返回按钮统一添加 shadow-sm 增强视觉深度和可识别性:

- rounded-lg px-3 py-1.5
+ shadow-sm rounded-lg px-3 py-1.5

涉及文件

  • src/pages/blog/[category]/[slug].astro — 两个返回按钮 border/hover → CSS 变量 + shadow-sm
  • src/pages/blog/[category].astro — 裸 <a><nav> 包裹(结构统一)+ shadow-sm
  • src/pages/blog/index.astroshadow-sm
  • src/pages/blog/tags/index.astroshadow-sm
  • src/pages/blog/tags/[tag].astroshadow-sm(2 处)
  • src/pages/shares.astroshadow-sm
  • src/pages/about.astroshadow-sm
  • src/pages/pictures.astroshadow-sm
  • src/pages/projects/index.astroshadow-sm

问题 3:timeTheme.js 每帧无脑重绘(25fps 的根因)

诊断

timeTheme.js 的 RAF 循环每帧都调用 applyColor()

function loop(timestamp) {
  if (!lastTimeCheck || timestamp - lastTimeCheck > 60000) {
    lastTimeCheck = timestamp
    const { hour, minute } = getSingaporeTime()
    currentColor = interpolateColor(hour, minute)
  }

  // 即使颜色完全没变,这行每帧都执行!
  if (currentColor) {
    applyColor(currentColor)  // ← 60fps 无脑调用
  }

  rafId = requestAnimationFrame(loop)
}

applyColor() 每次执行都做了这些操作:

  1. 调用 document.body.style.backgroundColor = hex → 触发 DOM 重排
  2. 调用 document.documentElement.style.setProperty('--time-bg-color', hex) → 触发 DOM 重排
  3. 设置/删除 data-theme 属性 → 触发 CSS 重新匹配
  4. 调用 window.__setSceneBackground(hex) → 触发 PMREM 环境贴图重建

其中第 4 步最为致命:

window.__setSceneBackground = (hex) => {
  // hex 完全没变,但每次都重建!
  const pmrem2 = new THREE.PMREMGenerator(renderer)
  scene.environment = pmrem2.fromScene(envScene).texture  // 生成 HDR 环境贴图
  pmrem2.dispose()
  // 更新地板和网格颜色…
}

PMREMGenerator 生成环境贴图是 Three.js 中计算密集的操作之一。 每 16ms 做一次,直接拖垮帧率。

修复

timeTheme.js:只在颜色真正变化时调用 applyColor

  function loop(timestamp) {
    if (!lastTimeCheck || timestamp - lastTimeCheck > 60000) {
      lastTimeCheck = timestamp
      const { hour, minute } = getSingaporeTime()
-     currentColor = interpolateColor(hour, minute)
+     const newColor = interpolateColor(hour, minute)
+     if (newColor !== currentColor) {
+       applyColor(newColor)
+     }
    }

-   if (currentColor) {
-     applyColor(currentColor)
-   }

    rafId = requestAnimationFrame(loop)
  }

crystalScene.js__setSceneBackground 添加 hex 去重:

+ let __lastBgHex = null
  window.__setSceneBackground = (hex) => {
+   if (!scene || !renderer || hex === __lastBgHex) return
+   __lastBgHex = hex
    // …后续逻辑
  }

性能变化

操作修复前修复后
applyColor 调用频率60 次/秒(每帧)1 次/小时(只需刷新时 + 跨小时)
PMREM 环境贴图生成60 次/秒1 次/小时
body.style.backgroundColor 写入60 次/秒≈0 次/秒(颜色不变时)
data-theme 属性设置60 次/秒≈0 次/秒
FPS≤25≥60

问题 4:View Transitions 导航后主题丢失

诊断

在目录之间切换时,卡片和导航栏的颜色回退到白天的白色样式。这是因为 View Transitions 在页面切换时,新页面的 <head> 内联脚本可能在 DOM 尚未完全就绪时运行,导致 data-theme="night" 属性未正确设置。

修复

timeTheme.js 中做了三处修复:

+ function applyTheme() {
+   const { hour, minute } = getSingaporeTime()
+   currentColor = interpolateColor(hour, minute)
+   applyColor(currentColor)
+ }

  export function init() {
+   // 1. 取消旧 RAF 循环(View Transitions 导致模块脚本重复执行)
+   if (rafId) cancelAnimationFrame(rafId)
+
-   const { hour, minute } = getSingaporeTime()
-   currentColor = interpolateColor(hour, minute)
-   applyColor(currentColor)
+   applyTheme()

    rafId = requestAnimationFrame(loop)
    // …
  }

+ // 2. 监听 astro:page-load —— Astro 的 View Transitions 完成事件
+ document.addEventListener('astro:page-load', () => {
+   applyTheme()
+ })

此外在 BaseLayout.astro 中为 3D 场景容器添加了 contain:strict

- <div id="bg3d" style="position:fixed;inset:0;z-index:0;pointer-events:none">
+ <div id="bg3d" style="position:fixed;inset:0;z-index:0;pointer-events:none;contain:strict">

contain:strict(等价于 contain: layout style paint)告诉浏览器这个元素与外界无关,在 View Transitions 页面切换时可以作为独立合成层处理,避免浏览器重新计算 3D 场景的布局/样式/绘制。

为什么 astro:page-load 更可靠

执行时机内联 <script>astro:page-load
初始页面加载✅ 同步执行,防止闪烁❌ 不触发
View Transitions 导航⚠️ 在 DOM 过渡期间执行✅ 新 DOM 完全就位后
data-theme 设置目标document.documentElement 可能处于过渡态document.documentElement 已稳定

辅助工具:调试面板(FPS + 3D 坐标)

为了方便后续性能调试和 3D 场景开发,将原先独立在 crystalScene.js 中的坐标显示面板和 FPS 监视器整合为统一的调试面板

整合过程

crystalScene.js 原先在 createScene() 中直接创建了一个独立的 DOM 面板显示相机坐标(camera.position + cameraTarget),并可点击复制。这导致坐标面板在每页都会出现,无法统一控制显隐。

重构方案

  1. crystalScene.js:移除坐标面板的 DOM 代码(约 40 行),改为在 animate() 循环中通过全局变量暴露相机状态:

    function animate() {
      animationId = requestAnimationFrame(animate)
      // …水晶动画逻辑…
      renderer.render(scene, camera)
    
      // 暴露相机状态供调试面板读取
      window.__cameraState = {
        pos: { x: camera.position.x, y: camera.position.y, z: camera.position.z },
        look: { x: cameraTarget.x, y: cameraTarget.y, z: cameraTarget.z },
      }
    }
  2. BaseLayout.astro:FPS 监视器扩展为调试面板,每秒一并读取 window.__cameraState 并渲染:

  60 FPS          ← 颜色编码:绿 ≥55 / 黄 30-55 / 红 <30
  ─────────────
  pos 12.00, 7.50, 6.00     ← 实时相机坐标
  look 2.00, 5.00, 5.87     ← 实时相机目标点

功能说明

  • 快捷键Ctrl + Shift + F 切换显示/隐藏
  • 位置:左上角(top: 80px,导航栏下方)
  • 颜色编码:🟢 ≥55fps / 🟡 30-55fps / 🔴 <30fps
  • 点击面板:复制当前 3D 坐标到剪贴板(格式:pos(x,y,z),look(x,y,z)),显示绿色反馈”✓ 坐标已复制”1 秒后恢复
  • 默认隐藏,快捷键唤醒
  • 自身开销:纯 requestAnimationFrame 计数,每秒更新一次 DOM,几乎为零

修改文件总清单

文件操作类型
src/styles/global.css修改不透明度提升 + will-change 工具类
src/layouts/BaseLayout.astro修改ClientRouter fallback 移除 + contain:strict + 调试面板(FPS+3D坐标)
src/lib/timeTheme.js修改applyColor 去重 + astro:page-load 监听 + RAF 去重
src/lib/crystalScene.js修改__setSceneBackground hex 去重缓存 + 坐标面板整合为 window.__cameraState
src/components/Navbar.astro修改模糊强度降低 + glass-blur
src/components/SmallPostCard.astro修改移除模糊
src/components/ReadingBackdrop.astro修改移除模糊
src/components/MusicPlayer.astro修改模糊从 28px 降至 12px
src/components/LikeButton.tsx修改模糊替换为纯色
src/pages/index.astro修改CSS 变量替换硬编码颜色
src/pages/blog/index.astro修改CSS 变量替换硬编码颜色
src/pages/blog/[category].astro修改CSS 变量替换硬编码颜色
src/pages/blog/tags/index.astro修改CSS 变量替换硬编码颜色
src/pages/blog/tags/[tag].astro修改CSS 变量替换硬编码颜色
src/components/CategoryDisplay.astro修改CSS 变量替换硬编码颜色
src/pages/about.astro修改CSS 变量替换硬编码颜色
src/pages/shares.astro修改CSS 变量替换硬编码颜色
src/pages/pictures.astro修改CSS 变量替换硬编码颜色
src/pages/projects/index.astro修改CSS 变量替换硬编码颜色

学到的概念

概念理解
backdrop-filter: blur() 的性能代价模糊滤镜需要 GPU 对每个像素进行采样计算,滚动时每帧都要重绘。多个模糊元素叠加会成倍放大性能开销。纯 background-color + 提高不透明度可以模拟类似效果且几乎零性能开销
CSS 自定义属性(变量)在不同作用域的解析:root 中定义的变量被 [data-theme="night"] 覆盖。选择器优先级决定最终值 —— 属性选择器([attr])优先级高于 :root 标签选择器。Tailwind 的 bg-[var(--x)] 语法允许在任意类中使用 CSS 变量
requestAnimationFrame 与 View Transitions 的交互View Transitions 导航会导致模块脚本重新执行,旧的 RAF 循环不会被自动清理。必须显式 cancelAnimationFrame 防止多个 RAF 循环同时运行
astro:page-load 事件Astro 在 View Transitions 导航完成后触发此事件。相比 <script is:inline> 在 DOM 过渡期间执行,astro:page-load 在新 DOM 完全就位后才触发,更可靠地处理页面切换后的状态恢复
contain: strict 的性能意义contain: layout style paint 将元素声明为独立渲染子树。浏览器在 View Transitions 页面切换时不必为此子树重新计算布局和样式,可以直接复用上一帧的合成结果
PMREMGenerator 的开销Three.js 的 PMREMGenerator 通过预过滤环境图生成粗糙度-金属度 PBR 贴图,每次生成都涉及多级下采样和卷积计算。在动画循环中每帧调用会造成严重的 GPU 管线阻塞
window.__ 全局变量作为模块间通信的轻量手段当两个模块(crystalScene.js 生成数据、BaseLayout.astro 的调试面板消费数据)在 Astro 的模块系统中无法直接导入引用时,通过约定的全局变量(window.__cameraState)传递高频更新的运行时数据是一种低开销方案。在 animate 循环中每帧赋值、调试面板每秒读取,避免了 props 传递或事件派发的复杂性和性能开销


问题 5:backdrop-filter 导致前序元素不可见(GPU 合成层冲突)

背景

/blog/bible 页面为「分类简介」卡片添加 backdrop-blur-md 之后,用户反馈整个页面的返回按钮「可以点击但看不见」——按钮在 DOM 中存在、样式完全正确、Playwright 自动化测试中渲染正常,但在用户浏览器中完全透明。

随后排查发现受影响的不仅是返回按钮,还包括导航栏的 “Blog” 链接、标签云等一系列使用 CSS 变量的元素。

诊断历程

步骤尝试结果
1怀疑 CSS 变量 --glass-bg-secondary 未解析❌ Playwright 确认变量正确解析为 rgba(0,0,0,0.5)
2按钮改用硬编码 Tailwind 颜色(bg-gray-100/90❌ 仍不可见;且发现 bg-gray-100/90 在 Tailwind v4 中不存在
3按钮改用 hex 色值 #f3f4f6 + !important 行内样式❌ 仍然不可见
4替换 &larr; 为 SVG 图标,排除字体渲染问题❌ 仍然不可见
5用户用浏览器 DevTools 确认元素在 DOM 中存在、Computed 样式正确✅ 确认不是 CSS 层叠问题
6移除描述卡片的 backdrop-blur-md✅ 元素闪现出现,但片刻后消失
7<main> 添加 relative z-10 明确层叠顺序✅ 所有元素稳定可见

根因

backdrop-filter: blur() 与 WebGL 3D 场景的 GPU 合成层冲突。

当在分类简介卡片上使用 backdrop-blur-md 时:

  1. backdrop-filter 触发浏览器为此元素创建独立的 GPU 合成层
  2. 同时 #bg3d 中的 Three.js WebGL 画布也占用独立的 GPU 合成层
  3. Chrome 的 GPU 合成器在同时处理多个合成层时,backdrop-filter 元素之前的前序普通静态元素(如返回按钮所在的 <nav>)被错误地标记为”不在视口内”,GPU 不为其分配渲染资源
  4. 这就是为什么元素在 DOM 中存在、Computed 样式完全正确、Playwright 的软件渲染下可见,但硬件加速的浏览器中不可见

移除 backdrop-blur-md 后,GPU 合成层减少,元素短暂可见。但 3D 场景的 WebGL 层仍可能抢占渲染资源。加上 position: relative; z-index: 10 后,<main> 的层序被明确定义在 3D 场景(z-index: 0)之上,浏览器合成器不再混淆。

关键的排查线索是用户描述的「刷新瞬间能看到→然后消失」,这暴露了硬件合成层 vs 软件渲染的差异——初始帧由软件渲染(可见),激活 GPU 合成后(消失)。

修复

  1. 移除 backdrop-blur-md:分类简介卡片不再使用 backdrop-filter,保留 bg-[var(--glass-bg)] 半透明底 + border-[var(--glass-border)] 边框保持玻璃质感
  2. <main> 添加层序relative z-10 确保主内容在 3D 场景之上
  3. 返回按钮恢复到 CSS 变量毛玻璃样式:用 var(--glass-bg-secondary)var(--glass-text-secondary)var(--glass-border) 实现主题自适应
  4. SVG 箭头图标:替换 字符为内联 SVG,避免字体渲染差异

涉及文件

文件改动
src/pages/blog/[category].astro分类简介卡片移除 backdrop-blur-md;返回按钮使用 CSS 变量 + SVG 图标
src/layouts/BaseLayout.astro<main> 添加 relative z-10

学到的一课

  • backdrop-filter 不仅仅是性能问题——它在特定配置下可以与 WebGL 3D 场景产生 GPU 合成层冲突,导致前序 DOM 元素不可见。这类 Bug 极难在自动化测试中发现(因为 Playwright/Headless Chrome 使用软件渲染),需要实际硬件加速环境才能复现
  • 按钮等小尺寸元素建议使用 shadow-sm/shadow-md:没有阴影的纯色按钮在毛玻璃背景下显得扁平,与页面背景融为一体。微弱的阴影提供必要的视觉深度感
  • Tailwind v4 的透明度修饰器:不是所有数值都内置。bg-gray-100/90 不被生成,需使用 bg-gray-100/80 或在 CSS 中自定义 @theme

后续可能的优化方向

  • 按需渲染 3D 场景:页面不滚动/不交互时降低 Three.js 的渲染帧率(如降至 30fps),仅在有动画/滚动时恢复 60fps
  • CSS @property 注册 --p 的浏览器兼容性ring-glow 灯条动画依赖 @property --p,Safari 和旧版 Firefox 不支持。可考虑用 JS 驱动的动画作为降级方案
  • Tailwind CSS v4 @theme 指令:当前 CSS 变量通过 @layer base@layer components 组织,可以探索将主题色放入 Tailwind v4 的 @theme 块中以获得更好的 IDE 支持和类型安全
92%