09 · 踩坑清单
每一条都是本次实际踩到并修掉的,不是从文档里抄的。 格式:现象 → 原因 → 修法 → 可复用判据。 其中很大一部分只有靠"截图看"或"算一遍"才能发现 —— 看代码是看不出来的。
A. 色彩与混合模式
A1. 纸色字 + multiply = 把字抹掉
现象:巨型标题设了 color: var(--paper); mix-blend-mode: multiply;,压在红色横带上——字完全看不见。
原因:数学上 multiply(纸色, 红) ≈ 红,multiply(纸色, 纸色) = 纸色。等于把字涂成了底色。
修法:巨型字用墨黑,不用混合模式。
判据:multiply 只会让结果变暗。所以浅色元素永远不要用 multiply ——它不可能浮出来。要"提亮"用 screen / lighten。
A2. 小字落在同色系的色块上
现象:海报页脚的说明文字正好压在红色几何上;其中 --red-ink(深红)的小字落在红底上,几乎不可读。
原因:页脚是透明的,背后几何图形的颜色是变化的。文字颜色只保证"在纸底上"合格,没保证"在色块上"合格。
修法:给页脚一块通栏纸色底。这样无论背后是什么,对比度都是确定的。
判据:任何承载文字的区域,必须自己拥有确定的背景色,不能依赖"背后大概是纸色"。浮动元素(页脚、徽标、角标)尤其要注意。
A3. 抬升带用了和背景同色的颜色
现象:给底部抽屉加"悬浮阴影",用 0 -14px 0 0 var(--red) 做抬升带 —— 落在红色海报上时,完全看不出来。
原因:抬升带和背后的海报同色。
修法:改成双层,外层用中性色:
box-shadow: 0 -5px 0 0 var(--red), 0 -17px 0 0 var(--ink);
判据:当背景色不可控时,装饰性色带至少要有一层是对比色。
A4. "看起来够深"的暗色实际不合格
现象:#8E1B14 暗红看起来很深很安全,压在纯黑上其实是 2.79:1 —— 任何字号都不合格。
修法:删掉这枚颜色,改用 #a30000(on 纸 6.76:1)。
判据:暗色 + 暗底是最容易翻车的组合,因为两者亮度都很低。必须算。
A5. background-blend-mode 混不到文字
现象:想让文字"压进"纸张纹理里,用了 background-blend-mode: multiply —— 没用。
原因:background-blend-mode 只混合元素自己的多层背景,不涉及内容、也不涉及背后元素。
修法:要跨元素混合只能用 mix-blend-mode,并且必须配 isolation: isolate 把混合锁在组件内。
判据:background-blend-mode = 自己内部;mix-blend-mode = 与背后。别混。
B. 无障碍
B1. clip-path 会把 outline 一起裁掉 → 键盘焦点不可见
现象:给按钮做了斜切外观(clip-path),键盘 Tab 到它时焦点环完全看不见。
原因:clip-path 裁剪的是元素的全部绘制输出,outline 也在其中。
严重性:直接违反 WCAG 2.4.7(焦点可见)。键盘用户无法操作。
修法:把 clip-path 交给一个 aria-hidden="true" 的装饰子层,交互元素本体不裁剪:
<button class="wedge-btn">
<span class="wedge-btn__decor" aria-hidden="true"></span>
<span class="wedge-btn__label">按此</span>
</button>
.wedge-btn { position: relative; background: none; border: 0; padding: 0; }
.wedge-btn__decor { position: absolute; inset: 0; clip-path: polygon(...); z-index: 0; }
.wedge-btn__label { position: relative; z-index: 1; }
.wedge-btn:focus-visible { outline: 4px solid var(--focus); outline-offset: 3px; }
判据:clip-path 和 outline 不可共存。凡是可交互元素,裁剪一律外移。
B2. 伪元素重复文字会被读屏念两遍
现象:套印错位效果用 ::before { content: attr(data-text); } 复制文本 —— 部分屏幕阅读器会把标题朗读两遍。
修法:改用真实的、带 aria-hidden 的 <span>:
<h1 class="misreg">标题<span class="misreg__ghost" aria-hidden="true">标题</span></h1>
判据:伪元素内容在无障碍树里不是总被忽略的。 任何"复制一份文字做效果"的手法都要用 aria-hidden 的真实节点,别用 content。
附带好处:真实 <span> 还能 user-select: none 防止复制出重复文字;伪元素做不到。
B3. prefers-reduced-motion 下元素卡在屏幕外
现象:入场动画用 @keyframes 的 from 做初始位移(translateY(0.6em)),在 prefers-reduced-motion 下关掉动画后,元素永久停在位移位置(甚至屏幕外)。
修法:
1. 静止态必须是默认样式,动效只做增强(animation 只负责"怎么来",不负责"在哪")
2. 降级媒体查询里显式复位,不能只写 animation: none:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
.stagger > * { opacity: 1; transform: none; } /* ← 两个都要复位 */
}
判据:只复位 opacity 是最常见的漏项。 位移、缩放、旋转都要一起复位。
B4. 用颜色单独传达状态
现象:焦点样式只靠 outline-color 变化;forced-colors(Windows 高对比)下被系统覆盖,状态消失。
修法:加 @media (forced-colors: active) 分支,用系统色 ButtonText / Canvas,并保证形状(粗细、位移、下划线)本身也能区分。
判据:任何状态至少要有两个通道(颜色 + 形状/位置/文字)。
C. 布局与尺寸
C1. vw 不感知容器宽度
现象:font-size: clamp(4rem, 15vw, 16rem) 的组件被放进一个窄侧栏,字号照样巨大,直接溢出。
原因:vw 是视口单位,和元素所在的容器无关。
修法:组件级响应式改用容器查询单位 cqw:
.hero { container-type: inline-size; }
.hero__title { font-size: clamp(2rem, 14cqw, 12rem); }
判据:只要一个组件可能被放进不同宽度的容器,就不要用 vw 定它的尺寸。
C2. 固定高度抽屉 + 内容短 = 一大片死白
现象:文章抽屉设 height: min(74svh, 680px),但内容只有 500px —— 底部留下一大片空白,看起来像"没做完"。
修法:
1. 面板改 max-height,让它跟着内容收缩
2. 想让它"占屏大",就把内容做大(字号、行高、留白),而不是把面板拉高
.drawer__body { max-height: var(--drawer-h); } /* 不是 height */
.drawer__inner { font-size: clamp(1.0625rem, .5vw + .9rem, 1.25rem); line-height: 1.72; }
判据:"面板空"是内容问题,不是尺寸问题。 拉高面板只会把空白放大。
C3. line-height < 1 时字形溢出行盒,被下面的线切过去
现象:巨型标题 line-height: .76,下面的副标题横线从字母底部切过去。
原因:行盒比字形矮,字形向上向下都溢出。
修法:给大标题留出下边距,或按溢出量补偿:
.mega { line-height: .8; margin-bottom: .14em; }
判据:line-height < 1 就一定会溢出,粗略量是 (1 - line-height) / 2 每侧。紧邻元素必须让开。
C4. svg preserveAspectRatio 与画幅比例不符会裁掉主体
现象:viewBox="0 0 1000 1000" + preserveAspectRatio="xMidYMid slice" 放进 1440×900 的区域,圆形的顶部被裁掉。
原因:slice 是"填满并裁切",正方形 viewBox 在宽画幅上会按高度放大,左右裁切 —— 反过来也一样。
修法:让 viewBox 的宽高比和预期的展示画幅一致(例如 viewBox="0 0 1440 900" 配 16:10 区域),再配合 slice,就基本不裁。
判据:slice + 比例不符 = 必然裁掉边缘。构图主体不要放在靠边位置。
C5. 几何形状要对着原典验,别凭印象画
现象:想画一个"楔形",写出来的多边形两条边平行 —— 实际是平行四边形,完全没有楔形的收束感。
修法:真楔形是三角形,顶点必须收成一个尖。
判据:形状类元素(楔、箭头、斜切)要写完后单独截图确认,代码上看坐标很容易看不出问题。
D. 动效与转场
D1. translateX 两段式转场的"第三态"会二次扫过
现象:转场用 translateX(-101%) → 0 → 101%,清掉 is-out 时元素要从 +101% 回到 -101%,又横扫了一遍屏幕。
修法(二选一):
1. 原路退出:只用 is-in,加类 = 进,删类 = 原路退回。两态即可。
2. 零面积态:clip-path 方案下,"收拢在顶边"和"收拢在底边"都是零面积,互相过渡时不可见,所以可以安全地清类。
.overlay { clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); } /* 零面积·顶边 */
.overlay.is-in { clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); } /* 满屏 */
.overlay.is-out { clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0 100%); } /* 零面积·底边 */
判据:加动画之前先想清楚"清掉类之后会过渡到什么状态"。 三个状态以上的转场必须检查"归位路径"是否可见。
D2. 换动画实现时忘了改 transitionend 的判断属性
现象:转场从 clip-path 改成 transform 后,transitionend 监听还在判 'clip-path' —— 永远等不到,只能靠兜底定时器,每次都白等 700ms。
修法:两者一起改。
判据:propertyName 和实际动画属性是强耦合的一对。 换实现时列个清单一起改,或干脆用 el.getAnimations()。
D3. 动画时长照抄规则值,页面转场变成"闪一下"
现象:把设计规范里"微交互只准 75/100ms"直接套到全屏页面转场上,转场快到像闪烁,完全失去了"压过来"的力度。
修法:页面转场单独开令牌,不复用微交互的:
--dur-fast: 75ms; --dur-base: 100ms; /* 微交互 */
--dur-curtain: 280ms; --ease-curtain: cubic-bezier(.85,0,.15,1); /* 页面转场 */
判据:"统一动效时长"是个陷阱。 微交互和页面转场是两种时间尺度,共用令牌必然有一边不合适。
D4. 转场结束后元素没归位,永久盖在页面上
现象(差点发生):clip-path 转场的收场态如果没清干净,幕布会以零面积以外的形态留在屏幕上。
修法/验证:转场测试必须显式断言收场后的计算样式:
const cp = getComputedStyle(document.getElementById('wipe')).clipPath;
// 期望零面积:polygon(0px 0%, 100% 0%, 100% 0%, 0px 0%) ← 不含 100% 100%
const ok = !cp.includes('100% 100%');
判据:任何整屏覆盖层的测试,最后一条必须是"它回到了看不见的状态"。 这条最容易漏,也最容易变成线上事故。
E. 字体
E1. 把 font-black 当成"数值 900"照抄
现象:规范写 font-black,但选用的窄体字最重只有 700。强行写 font-weight: 900 会触发伪粗体(浏览器把字形机械加粗),笔画粘连、变模糊。
修法:要么承认该字体的黑就是 700(窄体怪诞体的 700 视觉重量本来就接近常规体的 900),要么换一款真有 900 的字体。
判据:font-weight 的有效区间取决于字体文件本身,不是想写多少就写多少。选字体前先查它的字重轴。
E2. 凭字体气质猜它是多语言覆盖
现象:Anton / Bebas Neue / Archivo Black / Barlow Condensed 这类"海报体"看起来万能,实际全都没有西里尔字形 —— 页面一出现俄文就掉字体,版式立刻塌。
修法:实测覆盖范围,不要猜。
判据:
- Google Fonts:查官方元数据 https://fonts.google.com/metadata/fonts 的 subsets 字段
- 更硬一级:下载字体二进制解析 cmap 表逐字形判定
- 快速判定:https://fonts.googleapis.com/css2?family=X&text=Привет —— 不含该字形会报错
详见 10-验证工作流.md。
F. 工具链与验证
F1. 测试页放在别的目录 → 相对路径资源全没加载 → 一堆假失败
现象:功能测试报告"点击板块没反应",但代码没问题。排查后发现:测试页生成在 %TEMP%,而它 <script src="press.js"> 是相对路径,压根没加载。
后果:差点让我去"修"一个不存在的 bug。
修法:测试页必须生成在被测页的同目录(或改用绝对路径 / 内联资源)。
判据:"测试失败"的第一反应应该是"测试本身对不对",而不是"代码坏了"。 尤其当失败模式是"什么都没发生"时。
F2. --dump-dom 通过管道捕获不到输出
现象:& $edge --headless --dump-dom $url 拿到空字符串。
修法:重定向到文件再读:
Start-Process -FilePath $edge -ArgumentList @('--headless=new','--dump-dom',$url) `
-Wait -NoNewWindow -RedirectStandardOutput $outFile -RedirectStandardError $errFile
$dom = [IO.File]::ReadAllText($outFile)
判据:拿不到输出时,先换投递通道(文件),再怀疑被测对象。
F3. 无头截图"看起来空白"未必是页面坏了
现象:--virtual-time-budget + --screenshot 偶尔截到一张几乎全空白的图。
原因:外部字体等网络请求会暂停虚拟时钟,截图时机因此不可预测。
修法/判据:
- 判"逻辑对不对"用 --dump-dom 读文本,不要靠截图
- 判"长相对不对"用截图,并且逐个状态用 URL 直接落到该状态再截,不要依赖脚本驱动
- 截图尺寸给足(--window-size=1440,900),长页面会被裁
F4. 测试桩注入在业务脚本之后 = 桩形同虚设
现象:给测试页注入 matchMedia 桩想强制走 reduced-motion 快路径,结果毫无效果,测出来的仍是慢的动画路径。
原因:桩被注入到 </body> 之前,而业务 <script src="app.js"> 在它之前。浏览器是顺序执行的——业务脚本在解析到桩之前就已经跑完,并已经把真实的 matchMedia 结果存进了变量。
// app.js 加载时就捕获了,之后再替换 window.matchMedia 也没用
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
修法:桩必须插在第一个业务 <script> 之前:
$idx = $patched.IndexOf('<script')
$patched = $patched.Substring(0, $idx) + $stubBlock + "`n" + $patched.Substring($idx)
判据:任何"注入桩 / 打补丁"的操作,都要问一句"目标代码是否已经执行过了"。 浏览器的执行是顺序的,补丁必须早于被补的对象。
F5. 异步收尾的断言只读一次 = 假失败
现象:断言"转场层已归位",同一个测试时好时坏。失败时读到的是 polygon(0px 100%, ...)(转场中途/收尾态),而不是已归位的状态。
原因:转场收尾是异步的(transitionend 或兜底定时器),固定 sleep 之后读一次,遇到边界情况就读到了中间态。
修法:轮询到稳定,并给出超时:
let cp = '';
for (let i = 0; i < 40; i++) {
cp = getComputedStyle(wipe).clipPath;
if (cp.indexOf('100% 100%') < 0) break; // 已归位就提前退出
await sleep(100);
}
判据:凡是断言"最终会收敛到什么状态",一律轮询,不要 sleep 一次就断言。 否则你会得到一个随机红的测试,然后开始怀疑代码——而代码是好的。
F6. PowerShell 5.1 读没有 BOM 的 UTF-8 .ps1 会乱码并报语法错误
现象:写好的 .ps1 脚本(含中文注释/字符串)在 Windows PowerShell 5.1 下直接报一堆语法错误,报错信息里全是乱码(鏈棴鍚?)。用 PSParser 一测,错误消失——因为读入方式不同。
原因:PowerShell 5.1 默认按 ANSI 代码页解码 .ps1 文件,只有检测到 UTF-8 BOM 时才按 UTF-8 读。没有 BOM → 中文变乱码 → 乱码里可能夹带引号类字符 → 破坏引号/here-string 配平 → 报语法错误。
修法:.ps1 一律带 UTF-8 BOM 保存。
$utf8Bom = New-Object System.Text.UTF8Encoding($true) # $true = 写 BOM
[IO.File]::WriteAllText($path, $content, $utf8Bom)
⚠️ 很多现代编辑器/工具(包括某些 write 工具)默认写 无 BOM 的 UTF-8。改动
.ps1之后要复查 BOM 是否还在。
判据:
- .md / .js / .html:无 BOM 更安全(Node、浏览器都按 UTF-8 处理)
- .ps1:必须带 BOM(否则 5.1 会乱码);PowerShell 7 无此问题,但为兼容性统一加 BOM
F7. -replace 的参数被解析成多个,报"只允许 2 个元素"
现象:$s -replace 'a', 'b' + "n" + 'c'` 报错 "运算符只允许后面包含 2 个元素,而不是 3 个"。
原因:PowerShell 把逗号后面的整个表达式列表当成参数数组,'b' + ... + 'c' 没被括号包住就被拆开了。
修法:要么加括号,要么改用字面替换 .Replace():
# ❌ 参数解析有坑,而且替换串里的 $ 还要额外转义
$s = $t -replace '</body>', ($inject -replace '\$', '$$' + "`n</body>")
# ✅ 字面替换,不需要任何转义
$s = $t.Replace('</body>', ($inject + "`n</body>"))
判据:注入/拼接场景一律用 .Replace()(字面),不要用 -replace(正则) —— 既能避开 PowerShell 的参数解析坑,又能避开替换串里 $、\ 的转义坑。
一句话总结
能靠"看代码"发现的 bug 是少数。 本次抓到的近 30 个问题里,绝大多数要么靠截图逐张看,要么靠把数值算一遍, 要么靠在真实浏览器里跑一遍。三件事都必须做,缺一项就会漏。
还有一个反直觉的:其中好几个 bug 出在"验证工具"自己身上 (脚手架放错目录、测试桩注入顺序错、断言没轮询、脚本编码丢了 BOM)。 所以当测试报红时,第一反应应该是"测试本身对不对",尤其当失败模式是"什么都没发生"时。