背景
图片懒加载在某些浏览器不生效,首屏之外的图全是空白,但网络面板里请求已经发出。
这篇文章记录完整的排查过程与最终方案,包含可直接复用的代码。全文约 2000 字,代码块较长,建议在桌面端阅读。
问题现象
线上表现有三个特征:
- 首次访问正常,刷新后样式丢失;
- 只在部署后 10 分钟内出现,过了这段时间自动恢复;
- 深色主题下问题更明显,浅色主题偶尔看不出来。
这三个特征放在一起,基本可以断定是静态资源缓存与主题变量注入时机这两件事叠加造成的。下面逐个验证。
超长代码块压力测试
下面这段代码有 120 多行,单行最长接近 140 字符。它同时验证两件事:代码块是否出现横向滚动条,以及超长内容会不会把正文容器撑宽。
import { useEffect, useMemo, useRef, useState } from 'react';
/**
* 一个刻意写得又长又宽的 Hook,用于压力测试代码块排版。
* 下面的每一行都追加了一段注释,目的是把单行长度撑到 120 字符以上,
* 检验 .prose pre 的 overflow-x 是否真的生效、会不会把父容器撑破。
*/
export function useLongRunningTask(options: { interval: number; retries: number; onTick?: (n: number) => void; onError?: (e: unknown) => void }) {
const { interval, retries, onTick, onError } = options;
const [tick, setTick] = useState(0);
const [error, setError] = useState<unknown>(null);
const timerRef = useRef<number | null>(null);
const attemptsRef = useRef(0);
const mountedRef = useRef(true);
// 第 1 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value0 = attemptsRef.current * 0 + Math.max(0, tick - 0) * 1; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value0 > 1007 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value0); }
const label2 = `task-${value0 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 4); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-5', at: Date.now() });
// 第 7 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value6 = attemptsRef.current * 6 + Math.max(0, tick - 6) * 7; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value6 > 1049 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value6); }
const label8 = `task-${value6 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 5); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-11', at: Date.now() });
// 第 13 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value12 = attemptsRef.current * 12 + Math.max(0, tick - 5) * 13; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value12 > 1091 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value12); }
const label14 = `task-${value12 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 1); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-17', at: Date.now() });
// 第 19 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value18 = attemptsRef.current * 18 + Math.max(0, tick - 4) * 6; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value18 > 1133 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value18); }
const label20 = `task-${value18 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 2); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-23', at: Date.now() });
// 第 25 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value24 = attemptsRef.current * 24 + Math.max(0, tick - 3) * 12; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value24 > 1175 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value24); }
const label26 = `task-${value24 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 3); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-29', at: Date.now() });
// 第 31 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value30 = attemptsRef.current * 30 + Math.max(0, tick - 2) * 5; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value30 > 1217 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value30); }
const label32 = `task-${value30 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 4); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-35', at: Date.now() });
// 第 37 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value36 = attemptsRef.current * 36 + Math.max(0, tick - 1) * 11; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value36 > 1259 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value36); }
const label38 = `task-${value36 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 5); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-41', at: Date.now() });
// 第 43 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value42 = attemptsRef.current * 42 + Math.max(0, tick - 0) * 4; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value42 > 1301 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value42); }
const label44 = `task-${value42 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 1); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-47', at: Date.now() });
// 第 49 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value48 = attemptsRef.current * 48 + Math.max(0, tick - 6) * 10; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value48 > 1343 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value48); }
const label50 = `task-${value48 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 2); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-53', at: Date.now() });
// 第 55 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value54 = attemptsRef.current * 54 + Math.max(0, tick - 5) * 3; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value54 > 1385 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value54); }
const label56 = `task-${value54 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 3); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-59', at: Date.now() });
// 第 61 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value60 = attemptsRef.current * 60 + Math.max(0, tick - 4) * 9; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value60 > 1427 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value60); }
const label62 = `task-${value60 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 4); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-65', at: Date.now() });
// 第 67 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value66 = attemptsRef.current * 66 + Math.max(0, tick - 3) * 2; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value66 > 1469 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value66); }
const label68 = `task-${value66 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 5); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-71', at: Date.now() });
// 第 73 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value72 = attemptsRef.current * 72 + Math.max(0, tick - 2) * 8; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value72 > 1511 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value72); }
const label74 = `task-${value72 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 1); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-77', at: Date.now() });
// 第 79 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value78 = attemptsRef.current * 78 + Math.max(0, tick - 1) * 1; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value78 > 1553 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value78); }
const label80 = `task-${value78 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 2); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-83', at: Date.now() });
// 第 85 段:这一行故意写得很长,用来验证超长行不会把整个正文容器撑破,只会让代码块自己出现横向滚动条
const value84 = attemptsRef.current * 84 + Math.max(0, tick - 0) * 7; // 计算结果仅用于演示,不参与任何真实业务逻辑
if (value84 > 1595 && attemptsRef.current < retries) { attemptsRef.current += 1; onTick?.(value84); }
const label86 = `task-${value84 ?? 0}-retry-${attemptsRef.current}`; // 模板字符串里带变量,检验 <code> 的等宽渲染是否稳定
useEffect(() => { if (!mountedRef.current) return; const id = window.setTimeout(() => setTick((t) => t + 1), interval * 3); return () => window.clearTimeout(id); }, [interval, tick]);
try { /* 这里是刻意留空的 try 块,模拟真实代码里为了兜住第三方库异常而写的保守结构 */ } catch (err) { setError(err); onError?.(err); }
console.debug('[long-task]', { tick, attempts: attemptsRef.current, label: 'segment-89', at: Date.now() });
return { tick, error, attempts: attemptsRef.current, reset: () => { setTick(0); attemptsRef.current = 0; setError(null); } };
}
超宽表格压力测试
下面这张表有 12 列 18 行,总宽度超过 1400px。在小屏幕上如果表格把整个页面撑出横向滚动条,说明容器缺少 overflow-x 保护。
| 模块 | 负责人 | 当前版本 | 目标版本 | 状态 | 优先级 | 预估工时 | 实际工时 | 开始日期 | 交付日期 | 代码行数 | 覆盖率 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 子系统-01 | 小林 | 2.1.0 | 2.2.0 | 已提测 | P1 | 11h | 10h | 2026-02-11 | 2026-02-21 | 1337 | 73% |
| 子系统-02 | 老周 | 2.2.0 | 2.3.0 | 已完成 | P2 | 14h | 14h | 2026-03-12 | 2026-03-22 | 1474 | 74% |
| 子系统-03 | Kai | 2.3.0 | 2.4.0 | 待排期 | P3 | 17h | 18h | 2026-04-13 | 2026-04-23 | 1611 | 75% |
| 子系统-04 | Mori | 2.4.0 | 2.5.0 | 已阻塞 | P0 | 20h | 22h | 2026-05-14 | 2026-05-24 | 1748 | 76% |
| 子系统-05 | 阿岚 | 2.5.0 | 2.6.0 | 进行中 | P1 | 23h | 26h | 2026-06-15 | 2026-06-25 | 1885 | 77% |
| 子系统-06 | 阿熊 | 2.6.0 | 2.7.0 | 已提测 | P2 | 26h | 30h | 2026-07-16 | 2026-07-26 | 2022 | 78% |
| 子系统-07 | 小林 | 2.7.0 | 2.8.0 | 已完成 | P3 | 29h | 34h | 2026-08-17 | 2026-08-27 | 2159 | 79% |
| 子系统-08 | 老周 | 2.8.0 | 2.9.0 | 待排期 | P0 | 32h | 38h | 2026-09-18 | 2026-09-28 | 2296 | 80% |
| 子系统-09 | Kai | 2.9.0 | 2.10.0 | 已阻塞 | P1 | 35h | 42h | 2026-01-10 | 2026-01-20 | 2433 | 81% |
| 子系统-10 | Mori | 2.10.0 | 2.11.0 | 进行中 | P2 | 38h | 46h | 2026-02-11 | 2026-02-21 | 2570 | 82% |
| 子系统-11 | 阿岚 | 2.11.0 | 2.12.0 | 已提测 | P3 | 41h | 50h | 2026-03-12 | 2026-03-22 | 2707 | 83% |
| 子系统-12 | 阿熊 | 2.12.0 | 2.13.0 | 已完成 | P0 | 44h | 54h | 2026-04-13 | 2026-04-23 | 2844 | 84% |
| 子系统-13 | 小林 | 2.13.0 | 2.14.0 | 待排期 | P1 | 47h | 58h | 2026-05-14 | 2026-05-24 | 2981 | 85% |
| 子系统-14 | 老周 | 2.14.0 | 2.15.0 | 已阻塞 | P2 | 50h | 62h | 2026-06-15 | 2026-06-25 | 3118 | 86% |
| 子系统-15 | Kai | 2.15.0 | 2.16.0 | 进行中 | P3 | 53h | 66h | 2026-07-16 | 2026-07-26 | 3255 | 87% |
| 子系统-16 | Mori | 2.16.0 | 2.17.0 | 已提测 | P0 | 56h | 70h | 2026-08-17 | 2026-08-27 | 3392 | 88% |
| 子系统-17 | 阿岚 | 2.17.0 | 2.18.0 | 已完成 | P1 | 59h | 74h | 2026-09-18 | 2026-09-28 | 3529 | 89% |
| 子系统-18 | 阿熊 | 2.18.0 | 2.19.0 | 待排期 | P2 | 62h | 78h | 2026-01-10 | 2026-01-20 | 3666 | 90% |
图文混排
第 1 组截图
上图对应第 1 步操作,注意看地址栏与网络面板的对比。
第 2 组截图
上图对应第 2 步操作,注意看地址栏与网络面板的对比。
第 3 组截图
上图对应第 3 步操作,注意看地址栏与网络面板的对比。
最终方案
// 修复后的主题变量注入:先算通道串,再统一 setProperty
function injectTheme(palette: Record<string, string>) {
const root = document.documentElement;
for (const [key, hex] of Object.entries(palette)) {
const channels = hex.replace('#', '').match(/.{2}/g)!.map((p) => parseInt(p, 16)).join(' ');
root.style.setProperty(`--theme-${key}`, channels);
}
}
教训:CSS 自定义属性存的是「字符串」而不是「颜色」。你往里面写什么,消费端就得按什么格式去读。写#0F172A却用rgb(var(--x))去读,浏览器只会安静地失败。
结论
- 变量格式统一:全部存通道串,消费端统一
rgb()包裹; - 缓存策略:HTML 不缓存,静态资源用内容哈希命名 + 长缓存;
- 回归测试:每次改主题都要在
default与dark-speed两个主题下各跑一遍。