上线第二天,产品群里甩来两张截图:暗色模式下,主按钮的文字和背景几乎融成一片,看不清;换到某款手机上,首页卡片布局错位、文字被截断。这两类缺陷,我们的视觉回归一条都没报——因为基线只在桌面亮色下存过。
等把三档断点、两套主题都补齐,麻烦立刻反噬:基线图数量瞬间 ×6,CI 跑一次视觉回归二十几分钟,截图产物把存储吃得死死的。团队嫌烦,很快又退回「只跑桌面亮色」,兜了一圈回到原点。本篇讲的就是怎么把「断点×主题」这个二维矩阵管得住又不爆炸:既不漏暗色和移动端,也不让用例数和耗时失控。
只跑桌面亮色的视觉回归,本质上是拿六分之一的场景,去替全部场景背书。
一、先给结论:矩阵要组合,但不要全笛卡尔积
先说立场:断点和主题这两个维度必须都进矩阵,缺一个就会漏一整类缺陷;但绝不该无脑做「每个页面 × 每档断点 × 每套主题」的全笛卡尔积,那样用例数会以乘法爆炸。
正确做法是分层:矩阵的骨架用 Playwright 的 projects 机制搭出来(把断点与主题组合成一组可复用的运行配置),但每个页面按它的业务价值决定跑哪些格子——核心转化页跑全矩阵,次要页只跑代表组合,纯装饰页直接跳过。骨架统一、覆盖分层,既不失控也不漏关键。
三种策略摊开对照,差别一眼可见:
| 维度 | 只跑桌面亮色 | 无脑全矩阵 | 按价值分层的矩阵 |
|---|---|---|---|
| 缺陷覆盖 | 漏掉暗色与移动端整类问题 | 全覆盖,但大量重复 | 核心页全覆盖,长尾按需 |
| 用例数 | 最少(1×) | 最多(页数×断点×主题) | 中等,随价值收敛 |
| CI 耗时 | 最快 | 最慢,动辄几十分钟 | 可控,核心页优先跑 |
| 存储成本 | 最低 | 最高,基线图爆炸 | 中等,组件级快照再降一档 |
| 可维护性 | 差,出了事才发现没覆盖 | 差,误报淹没、没人愿意跑 | 好,一张图属哪层清清楚楚 |
结论很直白:只跑桌面是欠债,无脑全矩阵是浪费,分层才是能长期跑下去的中间解。
二、用 projects 把「断点×主题」组合成矩阵
Playwright 的 projects 机制天生就是干这个的:每个 project 是一份独立的运行配置,你把三档断点的 viewport 和两套主题的 colorScheme 排列组合,就得到 3×2=6 个 project,一次 playwright test 就能让同一批用例在六种组合下各跑一遍。
下面这份 playwright.config.ts 定义了矩阵骨架:
// playwright.config.ts —— 用 projects 定义 3 断点 × 2 主题矩阵
import {
defineConfig, devices } from '@playwright/test';
const viewports = {
desktop: {
width: 1440, height: 900 },
tablet: {
width: 834, height: 1112 },
mobile: {
width: 390, height: 844 },
} as const;
const schemes = ['light', 'dark'] as const;
// 用两层循环生成矩阵,避免手写 6 份重复配置
const matrix = Object.entries(viewports).flatMap(([bp, viewport]) =>
schemes.map((scheme) => ({
name: `${
bp}-${
scheme}`, // 例:mobile-dark,一眼可查
use: {
viewport,
colorScheme: scheme, // 'light' | 'dark' 切换主题
deviceScaleFactor: bp === 'mobile' ? 3 : 2,
},
}))
);
export default defineConfig({
snapshotDir: './visual-baselines',
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01, // 差异像素占比阈值
threshold: 0.2, // 单像素色差灵敏度
animations: 'disabled', // 截图前禁用动画,防误报
},
},
projects: matrix,
});
为什么这么写。第一,用 flatMap 两层循环生成矩阵,而不是手写六份 project,是为了以后加一档断点或一套主题时只改一个数组,配置不会跟着膨胀、也不会漏改。第二,project 名字统一成 断点-主题(如 mobile-dark),这个名字会直接体现在基线图路径和 CI 报告里,一张图属于哪档断点、哪套主题不用猜。第三,animations:'disabled' 和 threshold、maxDiffPixelRatio 放在全局 expect 里,是让所有截图断言共用一套防误报基线,而不是每条用例各写各的——动画不禁用,光是淡入淡出就能让你天天收假警报。踩过的坑是 deviceScaleFactor 一定要按断点区分,移动端用高倍屏,否则基线在不同分辨率设备上对不上,全是无意义的 diff。
三、按页面价值分层:核心跑全,次要跑代表
矩阵骨架搭好,接下来是不让它爆炸的关键——不是每个页面都值得跑满六个格子。给页面分三层:
核心转化页(首页、商详、下单、支付)跑全矩阵,六种组合一个不落,因为这些页面在暗色或移动端出问题直接掉转化。次要页(帮助中心、列表页)只跑代表组合,比如桌面亮色 + 移动暗色各一张,抓住「最大屏」和「最小屏+最难主题」两个极端即可。纯装饰页(关于我们、活动落地页的静态区)可以直接跳过视觉回归,或只在 nightly 跑。
用一段过滤逻辑把分层落到代码里,让用例自己声明该跑哪些 project:
// tests/checkout.spec.ts
import {
test, expect } from '@playwright/test';
// 页面价值分层:核心页跑全矩阵,次要页只跑代表组合
const TIER: Record<string, string[]> = {
all: ['desktop-light','desktop-dark','tablet-light','tablet-dark','mobile-light','mobile-dark'],
rep: ['desktop-light','mobile-dark'], // 代表组合:最大屏 + 最小屏最难主题
none: [],
};
function projectsFor(tier: keyof typeof TIER) {
return TIER[tier];
}
// 核心转化页:全矩阵
for (const p of projectsFor('all')) {
test(`下单页视觉回归 @${
p}`, async ({
page }) => {
test.skip(!process.env.PW_PROJECT?.includes(p.split('-')[0]!) , '按分层跳过');
await page.goto('/checkout');
// 组件级快照,而非整页:数量更少、定位更准
await expect(page.getByTestId('pay-button')).toHaveScreenshot(
`checkout-pay-button.png`,
{
mask: [page.getByTestId('order-countdown')] } // 遮掉倒计时等动态区
);
});
}
// 次要页:只跑代表组合
for (const p of projectsFor('rep')) {
test(`帮助中心视觉回归 @${
p}`, async ({
page }) => {
await page.goto('/help');
await expect(page.locator('main')).toHaveScreenshot(`help-main.png`);
});
}
为什么这么写。第一,把分层写成一张 TIER 映射表而不是散在各用例里的 if,是为了让「哪个页面跑哪些组合」变成一处可查、可 review 的策略,新人一看就知道核心页和次要页的差别。第二,坚持组件级快照(getByTestId('pay-button'))而不是整页截图,是降数量和降误报的关键——整页快照任何一个角落的无关变化都会触发 diff,组件快照只盯你真正在意的那块,基线图也更小。第三,mask 一定要遮住倒计时、时间、头像、广告位这类动态区,它们是视觉回归误报的头号来源,不遮的话你会天天在假 diff 上耗时间。
分层之外,还有三个降耗时、降存储的手段可以叠加。其一,让矩阵并行跑:Playwright 的 worker 天然支持并行,六档组合分散到多个 worker,墙钟时间不会真的翻六倍,前提是 CI runner 的核数跟得上。其二,把「不变的组件」从矩阵里摘出来:像页脚、logo 这类在所有断点、所有主题下长得一样的静态组件,只在桌面亮色存一张基线即可,没必要六档各存一份,这一步能砍掉相当一部分冗余图。其三,主题差异集中的组件才需要双份基线——按钮、卡片、表单这些真正会随亮暗变化的元素,才值得在两套主题下各存一张。判断标准很简单:这张图在不同组合下到底会不会不一样,会不一样才配进矩阵,不会就是纯浪费存储。

四、暗色主题的两个特有坑
把暗色补进矩阵后,会遇到亮色下从不出现的两类问题。
第一个是对比度不足在亮色下看不出。同一套按钮,亮色下深字浅底对比够、暗色下如果直接反色没调好,就可能变成浅字浅底,文字几乎隐形。这类缺陷像素 diff 未必大(颜色都变了但整体轮廓没动),纯靠 maxDiffPixelRatio 可能压不住,所以暗色基线必须单独存、单独看,不能拿亮色基线去推。视觉回归能在暗色 project 下把它截出来,这正是补暗色维度的价值。
第二个是主题切换的过渡态误报。很多产品切主题时有个 0.3 秒的过渡动画,如果截图正好卡在过渡中途,抓到的是半亮半暗的中间态,每次都不一样,于是天天误报。解法就是前面配置里的 animations:'disabled',加上进入页面后显式等待主题 class 落定再截图,别在过渡态上抓帧。
五、基线目录怎么命名,一张图属哪层一眼可查
矩阵一多,基线目录就会变成一团乱麻,谁都不敢删。命名约定要能让「这张图属于哪个断点/主题/组件」一眼可查。Playwright 会按 project 名自动分目录,你再在文件名里带上页面和组件:
visual-baselines/
mobile-dark/
checkout-pay-button.png # 下单页-支付按钮-移动暗色
help-main.png # 帮助中心-主区-移动暗色
desktop-light/
checkout-pay-button.png
...
目录名就是 project 名(断点-主题),文件名是 页面-组件。这样任意一张基线,路径本身就说明了它的三维归属,删改之前先看清楚它守的是哪一层。基线随代码一起进版本库,改动走 PR,diff 出新增或删除的基线图时 reviewer 能立刻判断是不是预期内。
更新基线这件事,最容易埋雷。图变了不代表就该无脑接受——很多人习惯性地 --update-snapshots 一把梭,把所有 diff 覆盖成新基线,等于把视觉回归的报警键直接按掉。正确的做法是:PR 里必须能看到旧图、新图、diff 三张并排,reviewer 逐张确认这次变化是设计改版的预期结果,还是不小心引入的布局错乱。如果一个 PR 一次性改动了几十张基线,这本身就是个危险信号,得有人显式批准并说明原因,而不是默默合进去。基线是视觉回归的记忆,随便覆盖一次,之前攒下的防护就清零了。
六、矩阵不是越大越好
最后提醒一句:分层策略不是定完就一劳永逸。哪个页面从次要升级成核心、哪档断点的用户占比涨了、暗色用户多了,都该回来调 TIER 表。矩阵的目标从来不是「覆盖所有组合」,而是「把有限的 CI 时间和存储,压在出问题代价最高的那些格子上」。
设计对了,六倍场景不再是六倍负担;设计错了,再多的基线图也只是给团队添堵、最后被悄悄关掉。
视觉回归的价值不在于存了多少张基线,而在于每一张都守在一个出问题会疼的格子上。
你们的视觉回归现在覆盖到暗色和移动端了吗,还是也停在只跑桌面亮色这一步?