最近看到一篇关于降低 Chromatic 费用的实践分享,作者给出的核心数据是:把视觉测试账单砍到了原来的十分之一。这个数字听起来很夸张,但仔细梳理它的实现路径,会发现它并不是靠压缩测试范围、少跑几趟 CI 换来的,而是把视觉测试的消费逻辑彻底想清楚了。
Chromatic 这类视觉测试工具的计费单位是“快照量”,你每跑一次构建、每提交一组 Story 变体截图,都会产生新的快照。组件库越大、Story 越多、CI 触发越频繁,账单就涨得越快。很多团队在使用早期根本感知不到成本,等到组件库扩展到几百个 Story、每个 PR 都触发全量视觉回归时,费用已经在后台悄悄失控。
这篇文章会把这套优化思路完整拆开:从 Chromatic 的计费机制,到条件式 Story、CI 增量触发、TurboSnap 构建复用,再到如何把同样的方法论迁移到 Percy、Argos、BackstopJS 等其他视觉测试工具上。如果你正在被视觉测试账单困扰,或者准备引入视觉测试但担心成本失控,这篇文章值得收藏。
1. 视觉测试账单为什么涨得这么快
先说一个很多人忽略的事实:Chromatic 不是按“构建次数”收费,而是按“生成的快照数量”收费。一次构建里有多少个 Story 变体,就会产生多少张截图,这些截图逐张累加,最终进入你的订阅额度。
举个例子。一个中等规模的组件库,假设有 100 个组件、每个组件平均 8 个 Story,那么全量构建一次就会产生 800 张快照。如果团队每天有 20 个 PR,每个 PR 都触发一次 Chromatic 构建,一天就是 16000 张快照。一个月下来,即便按工作日 22 天计算,也是 35 万级别。这个数字还没算上 viewport 适配、主题变体、状态变体这些额外维度。
更隐蔽的是 Story 里那些“看起来必要,实际上没有增量价值”的变体。比如同一个按钮,只改变一个内边距参数就生成一个新 Story;同一个表单,为了跑通交互测试把每个输入状态都写成独立 Story。这些 Story 在开发调试时有意义,但作为视觉回归快照,它们大多是重复的、低价值的。
Chromatic 的账单高速增长的底层原因有三个:
第一,Story 数量和快照数量呈正相关,但很多人写 Story 时没想过“这张截图要不要留给视觉回归”。第二,CI 触发策略太粗放,所有分支、所有提交都跑全量构建,根本没有区分“是否真的影响了视觉”。第三,团队缺少清理机制,Story 只增不减,废弃组件和重复变体长期占用快照额度。
理解了这三条,才能理解后面所有优化手段的出发点:降低费用的核心不是“少花钱不办事”,而是“让每一次快照都产生必要的验证价值”。
2. 降低费用的核心思路:三个消费杠杆
阅读那篇经验分享时,我注意到它的优化路径可以抽象成三个消费杠杆:控制快照产生量、减少无效构建、复用已有构建结果。
第一个杠杆是“控制快照产生量”。这件事发生在 Story 编写阶段,目的是让最终生成快照的每个 Story 都有独立视觉意义。按钮的普通态、悬停态、禁用态是三种视觉上完全不同的状态,值得保留;但为了测试某个回调函数而写的 Story,就没必要进入 Chromatic。这里的核心操作是条件式 Story 和禁用标记,后面会详细展开。
第二个杠杆是“减少无效构建”。它发生在 CI 流程中。不是每次代码提交都需要跑视觉回归,也不是每个 PR 都要全量跑。通过 git diff 判断本次变更是否触及组件源码、Story 文件或样式文件,只有真正影响视觉的变更才触发 Chromatic。更进一步,把全量回归只放在 main 分支或发布前,PR 阶段只做增量验证。
第三个杠杆是“复用已有构建结果”。这是 Chromatic TurboSnap 的典型用法。它通过 git 历史和文件指纹分析出哪些组件没有被这次的代码变更影响,只对受影响组件相关的 Story 生成快照,未受影响的直接复用上一次构建结果。这个机制可以把单次构建的快照量压缩 50% 到 90%,具体效果取决于项目的文件耦合度。
三个杠杆是递进关系:先控制 Story 源头,再优化 CI 策略,最后用增量复用兜底。三者叠加,才是账单从“失控”走向“可控”的完整路径。单独只做其中一项,效果都会打折扣。
3. 实践一:条件式 Story 控制快照范围
第一个可以立刻动手的优化,是在 Story 层面控制哪些变体会产生快照。
Chromatic 默认会对每个 Story 生成快照,但有些 Story 并不适合做视觉回归。比如带有随机数据的图表、包含动画的状态、依赖用户登录态的业务页面。这些 Story 要么视觉结果不稳定,要么对环境有强依赖,放在 Chromatic 里只会制造噪声快照。
Chromatic 官方提供了 disable 配置,可以在 Story 级别关闭快照生成。下面的代码展示了一个典型的配置方式:
// 文件路径:src/components/Button/Button.stories.js export default { title: 'Components/Button', parameters: { chromatic: { disable: false, }, }, }; export const Primary = { args: { variant: 'primary', children: 'Primary Button', }, }; // 这个 Story 用于交互调试,不需要快照 export const WithRandomChildren = { args: { children: ['Text ', <strong key="1">Bold</strong>, ' End'], }, parameters: { chromatic: { disable: true }, }, };这里的关键判断标准是:视觉回归验证的是“渲染结果有没有意外变化”。如果某个 Story 每次渲染结果都不同,或者它的变化不受代码变更影响,它就不应该消耗快照额度。
更精细的做法是条件启用,只在 Chromatic 环境下生成快照,日常开发时完全透明。Chromatic 官方提供了一个isChromatic工具函数,可以在运行时判断当前是否运行在 Chromatic 环境中:
// 文件路径:src/components/Chart/Chart.stories.js import { isChromatic } from 'chromatic/isChromatic'; export default { title: 'Components/Chart', parameters: { chromatic: { // 动画在视觉测试中是不稳定因素,通常在 Chromatic 中关闭动画 pauseAnimation: true, }, }, }; export const MonthlyTrend = { args: { data: mockData, animated: !isChromatic(), }, }; export const LargeDataset = { args: { data: mockLargeData, animated: false, }, parameters: { chromatic: { // 数据量大、截图像素高,如果视觉价值有限,可以直接禁用 disable: process.env.NODE_ENV === 'development', }, }, };在这个例子里,MonthlyTrend这个 Story 在普通 Storybook 开发环境里会播放动画,方便开发者观察交互效果;但一旦运行在 Chromatic 的截图环境里,动画会被关闭,避免截图时捕捉到动画中间帧导致的伪差异。
条件式 Story 的核心原则是:把“开发用途的 Story”和“视觉回归用途的 Story”区分开。一个 Story 可以同时服务两种场景,但在 Chromatic 中是否需要生成快照,应该由视觉价值决定,而不是由抽象价值决定。
4. 实践二:CI 驱动下的增量测试与构建复用
控制住 Story 源头后,下一步是优化 CI 中的触发策略。这一步直接决定“你有多少次构建在花钱”。
很多团队的 Chromatic 集成方式是“每个 PR 都跑一次全量”,这看起来最安全,实际上是很大的资源浪费。一个改动 Table 组件内边距的 PR,把 Button、Input、Select 等几十个无关组件的快照全部重跑一遍,这些快照大多不会有 diff,但仍然被计费。
优化方案是引入增量构建机制。Chromatic 的--only-changed参数就是 TurboSnap 的开关。启用后,Chromatic 会分析 git 提交历史,找出当前构建相对于 baseline 变更过的文件,再反向定位到受影响的 Story,只对这些 Story 生成快照。
在 GitHub Actions 中,一个带增量策略的配置看起来像这样:
# 文件路径:.github/workflows/chromatic.yml name: Chromatic on: push: branches: [main] pull_request: # 只有涉及这些路径时才触发视觉回归 paths: - 'src/**' - '.storybook/**' - 'package.json' - 'package-lock.json' jobs: chromatic: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: # TurboSnap 需要完整的 git 历史来做基线判断 fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 18 - name: Install dependencies run: npm ci - name: Publish to Chromatic uses: chromaui/action@v1 with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} # 只对受影响组件生成快照 onlyChanged: true # 自动接受唯一的快照,减少人工确认量 autoAcceptChanges: 'main'这段配置里有两个关键点。
第一个是paths过滤。它让 Chromatic 工作流只在src目录、Storybook 配置、依赖清单发生变化时才触发。如果你只是改了 README 或者文档目录,视觉回归根本不需要跑。
第二个是fetch-depth: 0。TurboSnap 依赖 git 历史来确定 baseline 和变更范围,如果 CI 环境默认只拉取浅历史(shallow clone),TurboSnap 会无法工作,最终退回全量快照模式。这是很多团队开启 TurboSnap 后效果不佳的直接原因。
团队可以在本地模拟一次增量构建,验证 TurboSnap 是否生效:
# 本地模拟仅提交一次组件变更 git add src/components/Button/Button.tsx git commit -m "chore: update button padding" # 触发 Chromatic build npx chromatic --project-token=${CHROMATIC_PROJECT_TOKEN} --only-changed如果配置正确,运行结果里会明确提示本次构建生成了多少快照,其中会显示大量的快照被跳过并复用。如果输出里显示的快照数和全量构建一致,说明 TurboSnap 没有生效,优先检查 git 历史深度和文件依赖关系。
5. 实践三:用 Storybook 配置拆分控制加载范围
第三个实践在大多数优化文章里很少被提到,但作用非常直接:通过拆分配置文件,让日常开发和视觉测试使用不同的 Story 加载列表。
Storybook 支持从配置文件里声明 stories 的加载范围。默认情况下,我们会在.storybook/main.js里写../src/**/*.stories.@(js|jsx|ts|tsx),意思是把项目里所有 Story 文件都加载进来。Chromatic 构建时也会加载这些 Story,然后对每个 Story 生成快照。
问题在于:一个项目里 Story 文件很多,但真正需要进入视觉回归的往往只占一部分。比如一些只用于内部调试的临时 Story、一些数据展示型 Story、一些与后端接口强耦合的业务 Story,它们在普通 Storybook 里可以存在,却不应该进入 Chromatic 快照。
一个实用的做法是给视觉测试指定独立的 Story 文件后缀,然后在 CI 构建时覆盖加载路径:
// 文件路径:.storybook/main.js const allStories = ['../src/**/*.stories.@(js|jsx|ts|tsx)']; const visualStories = ['../src/**/*.visual.@(js|jsx|ts|tsx)']; module.exports = { stories: process.env.VISUAL_TESTING === 'true' ? visualStories : allStories, addons: ['@storybook/addon-essentials'], };这样设计之后,团队约定:需要进入视觉回归的 Story 使用.visual.jsx或.visual.tsx文件名,普通调试 Story 沿用.stories.jsx。
然后在 Chromatic 的构建命令里设置环境变量:
VISUAL_TESTING=true npx chromatic --project-token=${CHROMATIC_PROJECT_TOKEN}如果视觉测试文件命名后缀不同,Storybook 的加载规则也要相应调整。比如:
// 文件路径:.storybook/main.js const path = require('path'); module.exports = { stories: (process.env.VISUAL_TESTING === 'true' ? ['../src/**/*.chromatic.@(js|jsx|ts|tsx)'] : ['../src/**/*.stories.@(js|jsx|ts|tsx)', '../src/**/*.chromatic.@(js|jsx|ts|tsx)'] ), };这个方案有两个优点。第一,日常开发体验不受影响,所有 Story 仍然可以正常浏览。第二,Chromatic 构建时只加载目标明确的视觉测试 Story,快照量直接从源头上压缩。
需要注意的坑是:如果你的组件库本身依赖 Story 之间的组合关系,比如一个组件在另一个组件的 Story 里被子组件嵌套引用,那么拆分后要确保依赖链完整。Chromatic 构建只加载视觉测试文件,如果这些文件引用了其他未加载的 Story 资源,有可能出现解析失败或者缺失样式的问题。遇到这种情况,把共同依赖拆成公共模块,或者在视觉测试文件中显式引入,而不是依赖 Storybook 的隐式全局注册。
6. 实践四:把断言粒度控制在“需要验证的那一行”
除了控制快照数量,还有一个常常被忽视的成本点:一次视觉回归测试失败的排查成本。
Chromatic 的定价虽然不直接按“diff 数量”计费,但一个快照量庞大的项目,reviewer 需要人工处理的 diff 也会更多。每次 PR 触发视觉测试,如果出现几十个无关紧要的 pixel-level diff,团队要么花时间逐个确认,要么干脆养成“看到 diff 就点接受”的坏习惯。后者会直接削弱视觉测试的保护价值,甚至让真正的回归问题混在噪声里被带过去。
从成本角度讲,视觉断言应该追求“精确打击”。如果一个组件的视觉测试需要验证三个关键区域——布局、颜色、文案——那么三种维度可以拆成不同的断言方式,而不是全部依赖一张整图截图。
以 Storybook 中的交互测试结合视觉测试为例,可以在 Story 的 play function 里精确设置截图前的状态:
// 文件路径:src/components/Modal/Modal.visual.jsx export const ModalOpenState = { args: { open: true, title: 'Delete project', description: 'This action cannot be undone.', }, play: async ({ canvasElement }) => { // 等待动画稳定 await new Promise((resolve) => setTimeout(resolve, 500)); }, parameters: { chromatic: { // 只截取弹窗内容区域 viewport: 'mobile', diffThreshold: 0.2, }, }, };把视觉变化控制在最小范围内,有几个常见手段。
第一,针对需要验证的状态单独写 Story 变体,而不是在一个 Story 里通过交互去切换多个状态。这样每张截图只表达一个视觉状态,diff 出现时定位更快。
第二,使用 Chromatic 的diffThreshold参数控制像素差异容忍度。动画、阴影、字体渲染在不同平台上的细微差异可以通过合理阈值过滤,降低无意义的 diff 量。
第三,把大页面组件拆成多个小组件分别做视觉测试。一个复杂的 Dashboard 页面级 Story 会产生大图、慢截图、高 diff 概率;拆成 Header、Sidebar、DataTable 三个组件级 Story,每张截图都更精准,失败时定位成本也更低。
这条策略的价值在于:它不直接减少快照量,但减少的是“维护快照的人工成本”。一个视觉测试体系如果产生大量需要人工确认的 diff,团队迟早会为了降低维护负担而关掉测试。用精准断言控制噪声,是让视觉测试长期可持续的前提。
7. 这套方法论如何迁移到其他视觉测试工具
前面几节都在讲 Chromatic,但这套降费逻辑完全可以平移给其他视觉测试工具。无论你用的是 Percy、Argos、BackstopJS,还是自研的 Puppeteer 截图对比平台,核心都是三个问题:测什么、什么时候测、怎么复用结果。
Percy 的计费逻辑和 Chromatic 非常相似,也是按快照量计费。Percy 提供了only参数和 DOM 快照机制,团队可以把前面提到的条件式 Story 思路迁移过去,在快照命名上做统一规划,避免同一组件在不同状态下重复截图。
Argos 这类基于 Storybook 的工具,可以直接复用上一节的.visual.jsx文件后缀策略。它支持通过环境变量控制快照生成范围,配置方式与 Chromatic 基本一致。
如果你的团队使用 BackstopJS 这类开源工具,成本逻辑就变成了“CI 执行时间和存储成本”。虽然工具本身免费,但快照量的增长会拖慢 CI 和占用存储空间。同样的优化思路依然成立:通过scenarios配置精确到具体页面路径,而不是对每个 route 都截图;通过git diff检测只运行受影响的场景。
如果团队已经完全自研视觉测试平台,下面这个脚本可以作为一个通用的成本控制模板:
#!/usr/bin/env bash # 脚本路径:scripts/run-visual-test.sh # 用法:在 CI 中调用,根据 diff 决定是否执行视觉测试 CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD) echo "Changed files:" echo "$CHANGED_FILES" # 如果变更不涉及视觉相关文件,直接跳过 if ! echo "$CHANGED_FILES" | grep -E '(src/.*\.(tsx|jsx|ts|js|css|scss)$|\.storybook/)' ; then echo "No visual-related changes. Skip visual testing." exit 0 fi # 如果变更是 .md 文件或文档,也跳过 if echo "$CHANGED_FILES" | grep -qE '\.(md|mdx)$' ; then echo "Only documentation changes. Skip visual testing." exit 0 fi npm run visual:test这里的关键是:成本优化的本质是定义“什么值得测”。任何视觉测试工具,只要你能把快照生成逻辑改成“只对视觉相关变更、在视觉相关路径内、生成有独立视觉价值的快照”,费用都会自然下降。
8. 常见问题与排查思路
在实际优化过程中,团队大概率会遇到下面几个问题。我整理成表格,方便对照排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 开启 TurboSnap 后快照量没有下降 | CI 拉取的是浅历史,git 信息不足 | 检查 Actions 日志中的 turboSnap 提示 | 将fetch-depth设置为0,确保完整 git 历史 |
| 某些 Story 被 disable 后,组件变更没有被视觉测试覆盖 | disable 范围过大,关键视觉变体被误关 | 逐一检查被 disable 的 Story 清单 | 为被禁用的 Story 补充更精确的替代变体,而不是全部禁用 |
按.visual.jsx拆分后,Chromatic 构建失败 | 视觉测试文件引用了未加载的公共依赖 | 查看构建日志中的模块解析错误 | 将公共依赖提取为独立模块,在视觉 Story 中显式导入 |
| 同一份代码,本地跑很慢,CI 跑很快但消耗大量快照 | 本地未配置VISUAL_TESTING环境变量,加载了全部 Story | 对比本地与 CI 的 Story 加载数量 | 统一脚本命令,使用VISUAL_TESTING=true执行 Chromatic 构建 |
| PR 里出现大量像素级噪声 diff | 动画、阴影、字体渲染差异被当成回归 | 查看 diff 图片的差异区域分布 | 使用diffThreshold提高容忍度,或关闭动画截图 |
| 账单还是增长,虽然单次构建快照少了 | 构建频率过高,每个 commit 都触发 | 查看 CI 执行历史 | 限制触发路径,增加paths过滤,只在 PR 和 main 分支跑 |
一个容易被忽略的排查点是:Chromatic 的 TurboSnap 对文件引用关系很敏感。如果一个子组件的样式文件被多个组件引用,而这个样式文件的变更会触发全量重新截图,TurboSnap 的收益就会明显下降。遇到这种情况,可以观察 Chromatic 构建日志中“skipped snapshots”的数量,如果大量构建都没有 skip,说明文件依赖关系过深,需要梳理组件之间的样式引用。
9. 从“降费”到“建设可持续的视觉测试体系”
真正让账单降下来的,不是某一个配置,也不是某一个 CI 脚本,而是团队对视觉测试的定位发生了变化:从“有多少 Story 就截多少图”变成“这个变更值不值得截一张图”。
在此基础上,有几点工程建设层面的建议值得落实。
第一,把“快照数量”纳入代码评审范围。在 PR 描述中显示新增快照量、本次构建的快照总数和上一版对比值。如果一次 PR 大幅增加了快照量,评审人要确认每一个新增变体是否都有独立视觉价值。这个习惯一旦养成,Story 数量会自然收敛。
第二,建立 Story 清理机制。每季度定期检查视觉测试列表,剔除废弃组件的 Story、合并重复度高的变体、删除长期无人确认的禁用项。视觉测试和单元测试一样,需要维护,而不是写了就永远保留。
第三,让视觉测试的“保护价值”可感知。将 Chromatic 的 UI Review 流程嵌入到 PR 合并门槛里,让每个 diff 都有明确的确认人和确认记录。这不仅能防止回归漏网,也能让团队意识到每一个快照背后的“人工确认成本”,从而更谨慎地添加新 Story。
第四,在工具链上保持灵活性。Chromatic 的方案可以继续使用,但方法论要沉淀为团队文档,而不是绑定在某一个 SaaS 工具上。如果未来项目切换视觉测试平台,团队内部通行的“快照准入标准”和“CI 触发策略”仍然可以复用。
10. 总结
回到开头那篇经验分享,作者用 10 倍账单差距说明的核心事实是:视觉测试的成本不是由工具决定的,而是由使用方式决定的。同样的 Chromatic,有人跑全量构建产生上万张快照,有人用条件式 Story 加 TurboSnap 只生成几百张必要快照,两者获得的回归保护可能相差无几,账单却天差地别。
本文逐条拆解了四个核心手段:用条件式 Story 控制快照源头、用 CI 增量策略减少无效构建、用配置拆分让只有视觉价值的 Story 进入测试、用精确断言降低人工维护成本。这套方法论在 Chromatic 上可以直接落地,在其他视觉测试工具上也有同样的迁移价值。
下一步建议你从两个动作开始:一是检查当前的 CI 工作流是否启用了增量构建,二是统计项目里被 disable 的 Story 数量,思考它们是不是真的不需要快照。这两件事做完,账单会给你最直接的反馈。