2153 字
11 分钟
27 次提交重构一个在线简历站,像素级验证全过,上线后还是漏了个 bug

审计报告先摆在这:一个 775 行的组件文件,里面塞了 6 个 URL 解析器、4 个自带 fetch 的卡片组件和两套并存的排序逻辑;主题系统一半组件用 CSS 变量,另一半靠 !important 硬编码灰阶覆盖打补丁;外加两个真实的线上 bug——一个 CSP 头拦掉了正在用的两个域名,一个 SEO 组件被渲染了两次。

这是 readme-resume,Cloudflare Workers 托管、push 到 main 就自动部署的静态简历站,正常访客量不小。重构不能停机,也不能改出视觉回归——recruiter 打开页面看到布局炸了,比代码乱更致命。定了个八阶段计划,按风险从低到高排:死代码清理 → bug 修复 → 抽取纯逻辑加测试 → 拆组件 → 生成脚本改造 → 主题收敛。每阶段独立 commit,独立可部署,独立回滚。

验证方法论比重构本身更重要#

代码重构谁都会写,难的是怎么证明”这次改动没有破坏任何东西”。这次用了三种手段,分别对付三类风险。

结构性改动(把函数从组件里搬到 src/lib/,把 775 行文件拆成几个):构建产物 out/index.html 逐标签 diff。用固定的 WORKERS_CI_COMMIT_SHA 环境变量跑两次 build,消掉 chunk hash 的噪音:

Terminal window
WORKERS_CI_COMMIT_SHA=fixed pnpm build
def norm(path):
html = open(path).read()
body = re.search(r'<body.*</body>', html, re.S).group(0)
body = re.sub(r'chunks/[a-f0-9]+\.js', 'chunks/HASH.js', body)
return body.replace('><', '>\n<').splitlines()
diffs = list(difflib.unified_diff(norm(pre), norm(post), lineterm='', n=1))

拆完 775 行的 projects-section.tsx 之后跑一次,diff lines: 12,全是 buildId 和 chunk hash——DOM 本身零差异。

生成脚本改造.mjs.mts,用 tsx 跑):新旧脚本输出直接字节 diff,还得覆盖在线和离线两条路径:

Terminal window
node old-generator.mjs && cp public/llm.{txt,md} public/openapi.json gen-old/
npx tsx scripts/generate-llm-files.mts && cp public/llm.{txt,md} public/openapi.json gen-new/
diff -r gen-old gen-new && echo "IDENTICAL"
# 再逼一遍离线 fallback 路径
NODE_OPTIONS="--import block-fetch.mjs" node old-generator.mjs
NODE_OPTIONS="--import block-fetch.mjs" npx tsx scripts/generate-llm-files.mts
diff -r gen-old-off gen-new-off && echo "IDENTICAL-OFFLINE"

这里还顺带验证了一个关键行为变化:新脚本把 zod schema 当校验闸门用,API 和本地 fallback 都校验失败时直接 exit 1,不再生成垃圾输出:

[generate-llm] public/data.json failed schema validation (fields: title, contact, ...)
[generate-llm] no valid resume data available — aborting build
exit-code: 1

视觉改动(主题从 gray-* + !important 覆盖收敛到 var(--…) token):这是风险最高的一步,globals.css 里 33 条覆盖规则要全删。这类改动没法只看 DOM——颜色对不对得看像素。

截图对比的坑:客户端 fetch 会制造假阳性#

第一次跑截图 diff,同一个 build 连续截两次图,dark mode 对比出 14% 的像素差异。页面有个 useEffect 里的 fetch + EventSource 长连接,会先渲染骨架屏、fetch 失败后弹出一条 “Showing cached snapshot” 的 banner——这个时序每次截图都不一样,纯粹是噪音,不是真实差异。

解法是把外部请求全部屏蔽掉,让页面每次都稳定落在同一个状态:

Terminal window
"$CHROME" --headless=new --disable-gpu \
--host-resolver-rules="MAP * 127.0.0.1, EXCLUDE localhost" \
--screenshot=out.png http://localhost:8899/

--host-resolver-rules 是 Chromium 内置的 DNS 解析规则参数(Chromium 官方文档),MAP * 127.0.0.1 把所有域名解析到本地、EXCLUDE localhost 放行本地服务器本身。页面里所有外部 fetch 直接连接失败,banner 稳定弹出,骨架屏稳定停在加载态。两次截图,0 像素差异——这才是真正可比的基线。

之后每个主题 token 化的子 commit 都按这套流程验证:

dark diff vs 6c: 0 px (0.000%) bbox: None

7 个子 commit,从影响面小的 PANEL_BASE 常量开始,到最后删掉 globals.css 里的覆盖层,每步都是 0 像素差异。连”从暗色模式打印 PDF”这种边角场景都测了——@media print 里的 token 强制会覆盖 data-theme="dark",纸面必须是纯白:

print('corner pixel (dark-mode print):', a.getpixel((5,5)))
# (255, 255, 255)

顺手抓到的真 bug#

重构途中查 CSP 头的时候,发现 public/_headers 里的 connect-src 允许 api.github.comleetcode.com 这些域名——代码里已经没有任何请求打到它们了(GitHub/LeetCode 数据早改成走自建的 api.lishuyu.app 代理)。同时代码里明明在用的 itunes.apple.comapi.microlink.io(外链卡片的 App Store 元数据和网页预览)却根本不在白名单里:

connect-src 'self' https://api.lishuyu.app https://api.github.com
https://api.crossref.org https://huggingface.co
https://github-contributions-api.jogruber.de https://leetcode.com

也就是说,那两张外链预览卡片在生产环境上一直被 CSP 拦截,静默失败——用户根本看不出这是 bug,只会觉得”这两个链接怎么没有预览图”。改完用 wrangler dev 起本地环境验证:

Terminal window
"$CHROME" --headless=new --dump-dom http://localhost:8787/ 2>&1 >/dev/null \
| grep -iE "refused|security policy"
# 空输出 = 无 CSP 拦截

另一个是 StructuredDataOptimization(JSON-LD 结构化数据组件)在 resume-document.tsx 里被渲染了两次——grep -c 'application/ld+json' out/index.html 应该是 1,实际是 2。多余的一份删掉,顺手把 CLAUDE.md 里一条过时的说明也改了:LeetCode 数据早就不是从 leetcode.com/graphql 直接抓的,那条 GraphQL 爬虫在另一个后端仓库里。

验证全绿之后,用户截图甩过来一个 bug#

七个阶段跑完,24 个 commit 全部推送、生产环境验证通过。用户发来一张生产截图:Internet-Wide Ping Mapping 项目卡片下面,多图画廊左边那张横幅图下方空出一大块背景色,右边的 4096×4096 热力图正常顶到卡片底。

先确认这是不是重构引入的回归——对比重构前后的构建产物,grid gap-4 md:grid-cols-2 这段画廊 class 一字未改。也就是说,这个空盒子在重构之前就存在,我的所有 diff 验证都显示”没有变化”,因为它确实没有变化——它一直是错的,只是我之前看不见。

根因是 CSS Grid 的默认对齐行为。网格项(这里是包裹图片的 <figure>,一个普通 block 容器,不是 <img> 本身)默认 align-items: stretchMDN Grid 对齐规范),会被拉伸到整行的高度(由该行最高的兄弟元素决定)。横幅图是 2.33:1 的宽扁比例,热力图接近正方形——两者放进同一个 grid row,figure 容器被拉到热力图的高度,里面的 <img> 只按自己的宽高比撑到该有的高度,中间空出来的部分就露出 figure 自己的背景色。

这块背景色之前是 bg-gray-50(#f9fafb),和卡片背景 bg-[color:var(--paper)](#fbfaf6)几乎同色,肉眼分辨不出。主题收敛把它换成了 bg-[color:var(--background)](#f4f1ec,暖灰)之后,这块背景和卡片白之间有了明显色差——一个潜伏的布局 bug,被换了个底色就显形了

第一次修复只加了 items-start(screen 端不拉伸,print 保留原来的 items-stretch)——空盒子确实消失了,但两张宽高比差异很大的图片并排放,视觉上还是别扭。用户又发来一张截图,指出问题没解决干净。第二次改成竖排:屏幕上两张图各自占满卡片宽度,从上到下堆叠,和单图项目的展示逻辑保持一致;打印布局原样保留(print:grid-cols-2),没碰 TODO.md 里早就记录过的、修过一次没修好的打印雷区。

经验总结#

像素级 diff 为 0,证明的是”相对之前没有变化”,不是”之前是对的”。 潜伏 bug 只要碰巧和背景色重合就能在任何自动化回归检测下隐身,直到某个看似无关的改动(换一个 CSS 变量的值)把它的遮掩条件打破。这类 bug 靠截图 diff 抓不到,只能靠人眼盯着真实渲染结果看,或者靠会用视觉的人(这里是用户)碰巧点开了那个项目卡片。

主题系统重构自带”考古”属性。 从硬编码颜色换成设计 token 的过程,本质是把所有隐式的颜色巧合都摊开重新计算一遍——originally 靠色差不明显掩盖的布局问题,会在这个过程里集中冒出来。做这类重构时预期会挖出几个”重构前就存在,但从没人报告过”的老 bug,这是正常代价,不是重构引入了新问题。

分阶段 + 独立验证的价值在于快速定位归因。 27 个 commit、7 个子阶段,出问题时能立刻确定”这是哪一步改的”,而不用在一个几千行的大 diff 里大海捞针。这次的 gallery bug 反而恰好证明了分阶段验证的边界——它能保证”我这步没有引入新问题”,但保证不了”整个系统本来就是对的”,两者是不同的命题,不能互相替代。

27 次提交重构一个在线简历站,像素级验证全过,上线后还是漏了个 bug
https://blog.lishuyu.app/posts/2026-07-01-readme-resume-zero-regression-refactor/
作者
猫猫魔女
发布于
2026-07-02
许可协议
CC BY-NC-SA 4.0