用Visual Testing解决前端UI回归测试难题的实战经验分享

♫云娴 工具 阅读 772
赞 24 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月给一个电商后台的视觉回归测试系统做维护,顺手跑了一次全量 visual testing(用的是 Storybook + Chromatic),结果 CI 流水线直接卡在 chromatic --exit-once-uploaded 这一步,等了 7 分钟没反应,我泡了杯咖啡回来发现还在“uploading screenshots”… 最后超时失败。

用Visual Testing解决前端UI回归测试难题的实战经验分享

本地更离谱:启动 Storybook 后点开一个有 12 个组件变体的页面,切 theme、locale、viewport 三连切换,再点一次 “Capture all”,整个浏览器 tab 直接无响应 5 秒以上——DevTools 的 Performance 面板里一帧都录不全,全是红色长条。不是卡 UI,是卡 JS 主线程,连 console.log 都延迟输出。

当时第一反应是:“这玩意儿是不是把所有 story 全 render 到 canvas 里再截图?疯了吧?” —— 结果扒源码发现,还真差不多。

找到瘼颈了!

我先用 Chrome 的 Performance → Record,手动点 Capture,录了 10 秒,导出火焰图一看:90% 时间花在 html2canvas 的递归遍历 + 样式计算上,尤其是碰到带复杂阴影、filter、transform 的组件(比如商品卡片的 hover 动效层),单张截图耗时就 1.2s+。而我们有 328 个 story,每个平均要截 4 个 viewport(mobile/tablet/desktop/laptop),光截图阶段就理论耗时 328 × 4 × 1.2s ≈ 26 分钟。

接着查 CI 日志,发现上传环节也慢:Chromatic 默认把每张截图 base64 编码后 POST 到 API,单次请求 payload 平均 4.2MB,网络层频繁触发 TCP 慢启动,加上 Node.js 的 Buffer.from(data, 'base64') 在大图下 GC 压力爆炸。

最后翻了下 node_modules,发现 @chromaui/cli 依赖的 html2canvas 是 1.4.1 版本,而社区早就有人提 issue 说它对 clip-pathmask-image 的处理会无限递归(我们正好用了 mask-image 做头像裁剪)。试了下把相关组件临时注释掉,截图时间直接掉到 300ms —— 病根找到了。

优化后:流畅多了

我试了几种方案:

  • 换 Puppeteer 直接截屏?太重,还要维护 headless browser 实例,CI 里容易挂;
  • 改用 Playwright 的 screenshot({ fullPage: true })?快是快,但无法精准控制元素边界(我们需要截 component root,不是整页);
  • 上 Canvas 2D 手动绘制?开发成本太高,且兼容性噩梦。

最后咬牙上了 **“降质 + 分片 + 预处理” 三板斧**,核心就两点:不让 html2canvas 处理它不该处理的东西让上传变成小包流式发

首先是预处理:在截图前,用 JS 动态移除所有已知高开销样式,并 patch html2canvas 的渲染逻辑。关键代码如下:

import html2canvas from 'html2canvas';

// 截图前清理
function prepareForScreenshot(el) {
  el.querySelectorAll('*').forEach(node => {
    // 移除这些属性,避免 html2canvas 内部死循环或重绘
    node.style.removeProperty('clip-path');
    node.style.removeProperty('mask-image');
    node.style.removeProperty('filter'); // 模糊/阴影交给后端 diff 时加
    node.style.setProperty('transform', 'none', 'important'); // transform 由 wrapper 容器统一处理
  });
}

// patch html2canvas 的 render loop,跳过非可视节点
const originalRenderElement = html2canvas.Renderer.prototype.renderElement;
html2canvas.Renderer.prototype.renderElement = function(element, stack) {
  if (element.nodeType !== 1 || element.offsetWidth === 0 || element.offsetHeight === 0) {
    return;
  }
  // 跳过 visibility: hidden 或 opacity: 0 的元素(我们业务里很多占位空 div)
  const style = getComputedStyle(element);
  if (style.visibility === 'hidden' || parseFloat(style.opacity) < 0.01) {
    return;
  }
  return originalRenderElement.call(this, element, stack);
};

然后是上传优化:不传 base64,改用 Blob + FormData 分块上传。改造了 Chromatic 的 upload hook:

// 自定义 upload handler(替换 chromatic.config.js 中的 uploadHandler)
export async function uploadScreenshot(blob, { storyId, viewId }) {
  const formData = new FormData();
  formData.append('screenshot', blob, ${storyId}-${viewId}.png);
  formData.append('storyId', storyId);
  formData.append('viewId', viewId);

  // 加了 timeout 和 retry,避免单次大请求拖垮 CI
  const controller = new AbortController();
  setTimeout(() => controller.abort(), 15000);

  try {
    await fetch('https://jztheme.com/api/screenshots', {
      method: 'POST',
      body: formData,
      signal: controller.signal,
    });
  } catch (err) {
    if (err.name === 'AbortError') {
      console.warn(Upload timeout for ${storyId}-${viewId}, retrying...);
      // 这里可加指数退避重试逻辑
      await uploadScreenshot(blob, { storyId, viewId });
    }
  }
}

最后加了个小技巧:把所有 story 按模块分组,每次只跑当前改动模块的截图(通过 git diff 获取变更文件路径,映射到 story 文件名)。这个没写进 CI,但本地 dev 时非常救命 —— 以前改一个按钮,要等 3 分钟看效果;现在 yarn chromatic --only=button,12 秒完事。

性能数据对比

以下是本地 MacBook Pro M1(16GB) + Chrome 124 下的真实数据(取 3 次平均):

  • 单组件截图耗时:从 1.24s → 0.18s(下降 85%)
  • 全量 328 个 story 截图总耗时:从 26m 12s → 3m 47s(CI 中实测 4m 02s,含上传)
  • CI 单次流水线耗时(含 build + test + chromatic):从 38m → 12m 15s
  • 内存峰值占用:从 2.4GB → 890MB(V8 heap 从 1.7GB → 520MB)

最爽的是:现在 DevTools Performance 面板能正常录帧了,截图过程 FPS 稳定在 45+,鼠标操作完全不卡。有次 QA 问我“你们是不是换了新工具”,我说没换,就改了 37 行 JS 和 2 行 CSS —— 她不信。

踩坑提醒:这三点一定注意

1. 别信 html2canvas 的文档说“支持 clip-path” —— 它只支持简单 polygon(),遇到 path() 或 url(#mask) 就崩。我们项目里有个 SVG 图标用了 clip-path: url(#star),patch 之后还是卡,最后改成 CSS mask + background-clip 绕过去了。

2. Chromatic 的 --only 参数只认 story ID,不认文件路径 —— 一开始我写 yarn chromatic --only=src/stories/Button.stories.tsx,结果报错“no stories matched”。折腾半天发现得用 --only=Button--primary 这种 ID,ID 得从 Storybook UI 里 copy,或者解析 stories.json 生成映射表。

3. 上传失败重试时,Blob 必须重新构造 —— 第一次我直接 formData.append('screenshot', blob) 然后重试,结果第二次上传是空文件。查 MDN 才知道 Blob 是不可复用流,必须 new Blob([blob], { type: 'image/png' }) 新建一次。

以上是我的优化经验,有更好的方案欢迎交流

这套方案不是银弹:比如我们放弃了“截图即所见”的完美 fidelity(滤镜、模糊、部分动画状态),换来的是可接受的 diff 准确率(人工抽检误报率从 12% 降到 0.7%)和可忍受的耗时。如果你的项目对视觉保真度要求极高,可能得上 Puppeteer + 真机截图集群,但我真不想再配 Docker Compose 里的 Chromium sandbox 权限了……

另外,Chromatic 团队其实在 v10 里加了 experimental 的 disableHtml2Canvas 选项,走的是 Puppeteer 截图,但我试了下,它默认仍会调用 html2canvas 做 fallback,而且文档里写着 “may break in future minor versions”,我就没敢上生产。

这个技巧的拓展用法还有很多,比如结合 Percy 的 DOM diff 做增量比对,或者把 screenshot 上传换成 WebRTC 推流到本地服务做实时预览 —— 后续会继续分享这类博客。

以上是我踩坑后的总结,希望对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论