跳到主内容
笔记 技术分享

技术分享(10)同样的代码在本地正常,部署到线上就 50

同样的代码在本地正常,部署到线上就 500,差异只在一处看起来毫无关系的环境变量。

39 分钟阅读 0 次浏览

背景

同样的代码在本地正常,部署到线上就 500,差异只在一处看起来毫无关系的环境变量。

这篇文章记录完整的排查过程与最终方案,包含可直接复用的代码。全文约 2000 字,代码块较长,建议在桌面端阅读。

排查现场截图

问题现象

线上表现有三个特征:

  1. 首次访问正常,刷新后样式丢失;
  2. 只在部署后 10 分钟内出现,过了这段时间自动恢复;
  3. 深色主题下问题更明显,浅色主题偶尔看不出来。

这三个特征放在一起,基本可以断定是静态资源缓存与主题变量注入时机这两件事叠加造成的。下面逐个验证。

超长代码块压力测试

下面这段代码有 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.02.2.0已提测P111h10h2026-02-112026-02-21133773%
子系统-02老周2.2.02.3.0已完成P214h14h2026-03-122026-03-22147474%
子系统-03Kai2.3.02.4.0待排期P317h18h2026-04-132026-04-23161175%
子系统-04Mori2.4.02.5.0已阻塞P020h22h2026-05-142026-05-24174876%
子系统-05阿岚2.5.02.6.0进行中P123h26h2026-06-152026-06-25188577%
子系统-06阿熊2.6.02.7.0已提测P226h30h2026-07-162026-07-26202278%
子系统-07小林2.7.02.8.0已完成P329h34h2026-08-172026-08-27215979%
子系统-08老周2.8.02.9.0待排期P032h38h2026-09-182026-09-28229680%
子系统-09Kai2.9.02.10.0已阻塞P135h42h2026-01-102026-01-20243381%
子系统-10Mori2.10.02.11.0进行中P238h46h2026-02-112026-02-21257082%
子系统-11阿岚2.11.02.12.0已提测P341h50h2026-03-122026-03-22270783%
子系统-12阿熊2.12.02.13.0已完成P044h54h2026-04-132026-04-23284484%
子系统-13小林2.13.02.14.0待排期P147h58h2026-05-142026-05-24298185%
子系统-14老周2.14.02.15.0已阻塞P250h62h2026-06-152026-06-25311886%
子系统-15Kai2.15.02.16.0进行中P353h66h2026-07-162026-07-26325587%
子系统-16Mori2.16.02.17.0已提测P056h70h2026-08-172026-08-27339288%
子系统-17阿岚2.17.02.18.0已完成P159h74h2026-09-182026-09-28352989%
子系统-18阿熊2.18.02.19.0待排期P262h78h2026-01-102026-01-20366690%

图文混排

第 1 组截图

截图 1

上图对应第 1 步操作,注意看地址栏与网络面板的对比。

第 2 组截图

截图 2

上图对应第 2 步操作,注意看地址栏与网络面板的对比。

第 3 组截图

截图 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 两个主题下各跑一遍。
雄

雄哥

独立开发者 · 长期写作者

关于