产品操作手册这类文档,写一版不难,难的是它永远赶不上产品迭代的速度。按钮挪个位置、弹窗改个文案、流程加一步确认,手册里对应的截图和步骤就全成了"历史文物"。我最近把手上一个后台产品的操作手册整条链路搬到了 Playwright 上:同一套脚本,跑起来既当冒烟用例,又顺手把每一步的截图和文案产出来,最终拼成 Markdown 和 PDF。这篇就把这套"用 Playwright 写产品操作手册"的做法完整拆开讲,包含目录结构、步骤模型、截图增强、文档生成、踩过的坑,以及测试提效到底提在了哪儿。适合测试工程师、技术文档岗、交付实施同学,以及任何需要反复产出图文教程的人参考,前端基础一般也能跟上,代码都是能直接抄的。
1. 为什么我把操作手册的活儿交给了 Playwright
1.1 手工写手册的三笔隐性成本
先说清楚我为什么动了这个念头。以前写手册的流程是:打开测试环境,一边点一边截,截完丢进文档工具里排版,再补文字说明。表面上看一个章节两小时能搞定,实际上成本藏在三个地方。
第一笔是重复劳动成本。产品每个版本都会动 UI,动一处就要重新截一批图、重新对一遍文字。一个二十章的手册,改版一次就是两三天的人力,而且这活儿没人愿意干,最后往往是"先这样吧,下个版本再更新",然后手册就烂在那儿了。
第二笔是一致性成本。截图是张三截的,文字是李四写的,两个人对同一个按钮的叫法可能都不一样——截图里叫"提交",正文里写"确定",用户对着找半天。手工流程里这种偏差没法自动校验,只能靠人肉 review。
第三笔是可信度成本。手册和真实产品对不上,用户第一反应不是"文档旧了",而是"这产品是不是有问题"。我做交付支持那阵子,至少三成的工单是用户照着过期手册操作失败的,排查半天发现是文档问题,沟通成本极高。
把这三笔账算清楚之后,结论就很明显了:手册的问题不是"写得不够好",而是"产出方式不对"。只要产出方式还是人肉截图 + 人肉对文字,它就一定会随版本漂移。
1.2 Playwright 相比其他方案的取舍
既然是产出方式的问题,那就要找一个能"自动跑流程 + 自动截图"的工具。我对比过几条路子,最后选 Playwright,理由不是它最流行,而是它在几个关键点上刚好合适。
| 方案 | 截图能力 | 稳定性 | 文档产出链路 | 我的判断 |
|---|---|---|---|---|
| 手工截图 + 文档工具 | 灵活但不可复用 | 依赖人 | 断开的 | 成本随版本线性增长 |
| 录屏转 GIF | 直观 | 差,文件巨大 | 断开的 | 适合演示动效,不适合步骤说明 |
| Selenium + 自写截图 | 能做 | 需要大量显式等待 | 需要自己拼 | 等待逻辑写起来累,flaky 多 |
| Puppeteer | 能做 | 较好 | 需要自己拼 | 只覆盖 Chromium,PDF 能力和 Playwright 接近 |
| Playwright | 元素级截图、遮罩、动画禁用、PDF 输出 | 自动等待机制成熟 | 可以一路打通 | 最终选它 |
具体打动我的有三点。一是自动等待,Playwright 的 locator 在点击、截图前会自动等待元素可交互,省掉大量sleep,这对"脚本要能长期跑"至关重要。二是元素级截图和 mask 遮罩,locator.screenshot()能直接截某个元素,mask参数能把用户头像、手机号这类敏感区域涂掉,这在产出对外文档时是刚需。三是能直接输出 PDF,page.pdf()基于 Chromium 的打印能力,意味着我从截图到成册可以在一个进程里完成,不用再引一套文档工具链。
顺带说一句,选择器我全程用getByRole、getByLabel、getByText这类语义选择器,而不是 CSS 类名。原因很实际:类名会随前端重构变化,但"这个按钮的可访问名称叫保存"这件事相对稳定。手册脚本要活过好几个版本,选择器的稳定性比写起来快不快重要得多。
1.3 这套方案能做什么、不适合做什么
把边界说清楚,避免有人照着做完发现不是自己想要的。
适合的场景:后台管理系统的操作流程、表单填写指引、多步骤配置向导、需要定期随版本更新的标准化文档、需要多语言版本的手册(Playwright 的 locale 和 context 隔离很方便做多语言截图)。
不太适合的场景:需要展示交互动效的(比如拖拽排序的手感、加载动画),这类还是录屏更直观;强依赖真实业务数据的(比如报表页要真实数据才好看),脚本里得造数据,成本会上去;一次性的、不会再更新的文档,投入产出比不划算。
还有一个隐性的适用条件:你的产品得能被自动化驱动。如果登录有强人机校验、关键操作有短信验证码,那就得准备测试账号或者留后门接口,这部分要和后端同学提前对齐,不要等到写脚本时才发现走不通。
2. 整体架构:把操作步骤抽成一份可执行的数据
2.1 目录结构与职责划分
这套东西最怕写成一坨"能跑就行"的脚本,因为手册是要长期维护的,结构乱了第二个接手的人根本改不动。我用的是下面这个结构,核心思路是把"做什么"和"怎么记录"彻底分开。
manual-kit/ ├── steps/ # 手册正文,按章节一个文件 │ ├── 01-login.ts │ ├── 02-create-project.ts │ └── 03-export-report.ts ├── lib/ │ ├── runner.ts # 步骤执行器 │ ├── capture.ts # 截图封装 │ ├── highlight.ts # 高亮与遮罩 │ ├── mask.ts # 敏感信息规则 │ └── markdown.ts # 文档生成器 ├── fixtures/ │ └── auth.json # 登录态缓存 ├── output/ │ ├── images/ │ │ ├── 01-login/ │ │ └── 02-create-project/ │ ├── manual.md │ └── manual.html ├── playwright.config.ts └── package.jsonsteps/目录里每个文件就是一个章节,文件里只描述"点哪儿、填什么、说什么话",一行截图代码都不写。lib/里全是通用能力,改一次所有章节受益。output/是产物目录,加进.gitignore,需要归档时再单独提交。
这个划分带来的直接好处是:产品改版时,我只需要改steps/里对应的几行选择器和文案,截图重跑一遍就更新了,lib/完全不用动。反过来,如果我要调整截图风格(比如把高亮框从红色改成蓝色),只改lib/highlight.ts一个文件,八十张截图全部生效。
2.2 步骤描述的三层模型:动作、断言、文案
每一"步"我定义成三层,缺一不可,这是整套方案能同时服务"测试"和"文档"两个目标的关键。
第一层是动作(action):这一 step 要在页面上做什么,点击、输入、选择、上传。第二层是断言(assert):做完之后页面应该出现什么,这一步是它作为测试用例的价值所在。第三层是文案(title + desc):这一步在手册里怎么描述,写给用户看的话。
三层合一的数据结构大概长这样:
// lib/types.ts import type { Page, Locator } from '@playwright/test'; export interface ManualStep { id: string; // 步骤唯一标识,用于图片命名 title: string; // 手册里的小标题,如"填写项目名称" desc: string; // 正文说明,2-3 句 action: (page: Page) => Promise<void>; assert?: (page: Page) => Promise<void>; highlight?: string | Locator; // 截图时要高亮的元素 mask?: Array<string | Locator>; // 截图时要打码的元素 shot?: boolean | 'element'; // false 表示这一步不截图 clip?: { x: number; y: number; width: number; height: number }; }shot: 'element'是个很实用的开关。有些步骤整个页面截图看不清楚重点(比如一个只有三个输入框的小弹窗),这时候只截弹窗本身,图片小、看得清,排版也好看。我统计过,一个典型章节里大概六成用整页截图、四成用元素截图,混着用阅读体验最好。
为什么把 assert 和文案绑在同一步上?因为它们描述的是同一件事的两个视角。用户视角是"点完保存,页面提示创建成功",测试视角是"点完保存,应该出现成功提示"。写手册的时候顺手把断言写了,等于文档和用例天然同步,不会出现"文档说点了会成功、实际点了报错"这种尴尬。
2.3 截图与文案如何绑定:靠 id,不靠顺序
一个容易被忽略但很要命的细节:图片文件名和步骤的对应关系,一定要靠 id,不能靠序号。
我最早图省事,用01.png、02.png这种递增序号命名,结果中间插了一步,后面所有图全部错位,文档里的图注和截图对不上,排查了半天才发现是命名问题。后来改成{章节号}-{步骤id}.png,比如02-create-project--fill-name.png,id 是我手写的英文短名,插入新步骤不影响任何已有图片。
// lib/capture.ts 里的命名逻辑 function buildFileName(chapterNo: string, stepId: string) { const safe = stepId.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase(); return `${chapterNo}-${safe}.png`; }命名里带上章节号是为了在文件管理器里排序时天然按章节聚在一起,方便人工抽查。步骤 id 用英文而不是中文,因为不同系统对中文文件名的处理不一致,某些静态站点生成工具会出乱码,这个坑我踩过不止一次。
3. 环境与基础配置:先把截图这件事做稳
3.1 视口、缩放与图片尺寸的计算
大部分"截图糊"的问题,根子都在deviceScaleFactor上。浏览器默认是 1,意味着 CSS 里一个像素就是物理一个像素,在 2 倍屏上看就是糊的。
我的配置是视口 1440×900,deviceScaleFactor: 2。这样截出来的 PNG 实际是 2880×1800 像素。这个数字不是随手定的,算一下就知道:
- 文档正文容器宽度我设为 760px,截图在正文里以
max-width: 100%显示,实际显示宽度大约 700px 左右; - 2 倍图对应 1400px 的物理宽度,正好能在高分屏上做到 1:1 像素映射,放大看按钮文字边缘依然锐利;
- 如果按 1440 的视口截,等比缩到 700px 显示是 0.49 倍,2 倍图实际呈现为 0.97 倍,几乎无损。
再算体积。1440×900、2 倍、PNG、以 UI 为主的画面(大片纯色),单张大概 180KB 到 500KB 之间。一个一百步的手册,原图约 30MB。这个体积放进代码仓库不合适,所以output/一律 gitignore,只在发版时打压缩包归档,同时用 sharp 把文档里引用的图转成 WebP,质量 80,实测体积能降到原来的 35% 左右,肉眼看不出差别。
// playwright.config.ts 里的关键片段 import { defineConfig } from '@playwright/test'; export default defineConfig({ use: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', colorScheme: 'light', reducedMotion: 'reduce', screenshot: 'off', // 手动控制截图,关掉自动截图 trace: 'retain-on-failure', // 失败时留 trace,排查用 }, expect: { timeout: 8000 }, });locale和timezoneId必须固定,否则日期控件、货币格式、时区显示的截图在不同机器上会不一样,扎眼得很。colorScheme: 'light'也是同理,避免跟随系统切到暗色主题导致截图风格突变。
3.2 让页面真正"停下来":动画、字体、图片三件事
截图不稳定,九成是"页面还没静下来就截了"。要治这个毛病,得从三个源头下手。
动画。Playwright 的截图参数有animations: 'disabled',它会快进 CSS 动画和过渡,把元素定格在最终态。但注意它管不到 JS 驱动的动画(比如 canvas 绘制的图表、用 requestAnimationFrame 跑的进度条),这类要单独处理,后面第五节会讲。
字体。自定义字体是异步加载的,截图时字体没加载完就会用 fallback 字体渲染,同一个页面两次截图字形不一样。解决办法是截图前显式等字体就绪:
await page.evaluate(() => document.fonts.ready);这一行必须加,尤其是中文字体文件动辄几 MB,首次加载慢的时候很容易撞上。
图片。img标签的src加载完不代表浏览器已经解码绘制完成。我用的判断是等所有图片complete且naturalWidth > 0:
await page.waitForFunction(() => { const imgs = Array.from(document.images); return imgs.every(img => img.complete && img.naturalWidth > 0); }, null, { timeout: 10000 });超时设 10 秒,超了就继续走不要卡死——有些页面的埋点像素图本来就会一直 pending,死等会让整条链路挂住。我一开始没设超时,结果某个页面的第三方统计图加载失败,整个截图流程卡在那儿,排查了快一个小时才发现是这种低级原因。
3.3 登录态复用与数据准备
手册脚本最浪费时间的环节就是登录。一个章节跑十次,就登录十次,加上验证码、二次确认,一次两分钟起步。
Playwright 的storageState就是为这个设计的:先跑一次登录,把 cookie 和 localStorage 存成 JSON,后续所有 context 直接加载。
// scripts/auth.setup.ts import { test as setup, expect } from '@playwright/test'; setup('登录并保存状态', async ({ page }) => { await page.goto('/login'); await page.getByLabel('账号').fill(process.env.MANUAL_USER!); await page.getByLabel('密码').fill(process.env.MANUAL_PASS!); await page.getByRole('button', { name: '登录' }).click(); await expect(page.getByRole('heading', { name: '工作台' })).toBeVisible(); await page.context().storageState({ path: 'fixtures/auth.json' }); });然后在手册脚本里建立 context 时直接挂上:
const context = await browser.newContext({ storageState: 'fixtures/auth.json', viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, });注意:
storageState里的 token 是有有效期的,我设的过期时间是 7 天,超过就得重新跑 setup。CI 上我把它做成"先跑 setup 再跑手册"的串行流程,避免拿到过期态导致的批量失败。另外fixtures/auth.json绝对不能提交到公开仓库,里面是活生生的登录凭证,加到.gitignore是底线。
数据准备这块,我的做法是每个章节开头写一小段"数据前置"逻辑,用接口直接造所需要的数据,而不是靠 UI 一层层点出来。理由是 UI 造数据又慢又脆,一个弹窗改版就废了。接口造数据哪怕接口参数变了,改起来也就一行。这部分我一般放在steps/文件顶部的prepare()里,跑章节前先执行。
4. 核心实现:从步骤执行器到文档生成器
4.1 步骤执行器:一个不到 80 行的循环
执行器的逻辑其实很朴素:遍历步骤,执行动作,跑断言,截图,记录结果。真正需要琢磨的是"记录什么"和"出错了怎么办"。
// lib/runner.ts import { Page } from '@playwright/test'; import { ManualStep } from './types'; import { capture } from './capture'; export interface StepRecord { id: string; index: number; title: string; desc: string; image?: string; } export async function runChapter( page: Page, chapterNo: string, steps: ManualStep[], ): Promise<StepRecord[]> { const records: StepRecord[] = []; let index = 0; for (const step of steps) { index += 1; // 1. 执行动作 await step.action(page); // 2. 断言:既是测试,也是"页面已到达预期状态"的确认 if (step.assert) { await step.assert(page); } // 3. 截图 let image: string | undefined; if (step.shot !== false) { image = await capture(page, { chapterNo, stepId: step.id, highlight: step.highlight, mask: step.mask, mode: step.shot === 'element' ? 'element' : 'page', clip: step.clip, highlightTarget: step.highlight, }); } records.push({ id: step.id, index, title: step.title, desc: step.desc, image, }); // 4. 失败现场留档,方便定位是哪一步崩的 console.log(`[${chapterNo}] 第 ${index} 步 ${step.title} 完成`); } return records; }里面有两处设计值得说一下。
一是断言的复用。手册脚本里我要求每一步都写 assert,哪怕只是await expect(page.getByText('保存成功')).toBeVisible()。这条断言在手写教程时是"验证操作成功",在跑测试时就是"用例步骤",一份代码两个用途。这也是我说的"测试提效"真正落地的地方——不是省了写用例的时间,而是文档和用例根本就是同一份东西。
二是失败即中断。一旦某步断言失败,整个章节直接抛错退出,不继续往下跑。早期我试过"失败了也继续跑,把能截的都截下来",结果产出半本错位的手册,比没有更糟——用户看到的是"点完这一步就跳到第五步"的断裂流程。所以现在的策略是:要么完整产出一章,要么这一章一张图都不出。失败信息里带上章节号、步骤序号、步骤 id,配合 trace 文件,定位起来基本五分钟内能锁定。
4.2 截图增强:高亮、遮罩与裁剪三件套
裸截图的问题是"用户不知道该看哪儿"。一屏二十个按钮,你说"点击右上角的设置",用户得自己找。所以我在截图前会做三件事:高亮目标元素、遮罩敏感信息、必要时裁剪。
高亮的做法是往页面注入一段样式,用box-shadow铺一张超大扩散的半透明遮罩,同时给元素本身加描边。这样效果是"页面整体压暗,只有目标元素亮着",比单纯画个红框引导性强得多。
// lib/highlight.ts import { Page } from '@playwright/test'; export async function applyHighlight( page: Page, selector: string, color = '#ff4d4f', ) { await page.evaluate(({ selector, color }) => { const el = document.querySelector(selector) as HTMLElement | null; if (!el) return; // 先滚动到视口中间,避免被固定头部遮挡 el.scrollIntoView({ block: 'center', inline: 'center' }); const style = document.createElement('style'); style.id = '__manual_highlight_style__'; style.textContent = ` [data-manual-highlight] { position: relative !important; z-index: 2147483000 !important; outline: 3px solid ${color} !important; outline-offset: 2px !important; border-radius: 6px !important; box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45) !important; pointer-events: none !important; } `; document.head.appendChild(style); el.setAttribute('data-manual-highlight', '1'); }, { selector, color }); } export async function clearHighlight(page: Page) { await page.evaluate(() => { document.getElementById('__manual_highlight_style__')?.remove(); document.querySelectorAll('[data-manual-highlight]').forEach(el => { el.removeAttribute('data-manual-highlight'); }); }); }这里有几个坑必须提前说。
第一,遮罩会不会盖住固定导航栏。box-shadow的扩散层属于元素自身的绘制内容,会跟随元素层级。如果页面顶部有个position: fixed; z-index: 9999的导航栏,它的层级可能高于你设的2147483000,结果就是导航栏没被压暗,视觉上很割裂。我的处理是在截图前把这类固定元素临时隐藏或降低层级,规则写在lib/mask.ts里统一维护。
第二,父级transform会让 outline 和 box-shadow 的渲染变形。如果目标元素的某个祖先有transform: scale(0.9)这类样式,描边宽度会被一起缩放,看起来粗细不一。我的处理是加一句transform: translateZ(0)强制提升为独立图层,实际测试下来渲染就正常了。
第三,document.querySelector在 Shadow DOM 里找不到元素。现在不少组件库用 Shadow DOM 封装,这时候得穿透查询,Playwright 的选择器引擎本身支持穿透,但注入的querySelector不支持,所以要换成locator版本的高亮实现。这块我留了个兼容分支,检测到元素在 shadow root 里就改用locator.evaluate处理。
遮罩敏感信息这块,Playwright 自带mask参数,比我自己写模糊滤镜靠谱:
await page.screenshot({ path: filePath, animations: 'disabled', caret: 'hide', scale: 'css', mask: [ page.locator('.user-avatar'), page.locator('[data-field="phone"]'), ], maskColor: '#e5e7eb', });maskColor我定的是浅灰#e5e7eb,为什么不用纯黑?因为纯黑块在浅色界面里太突兀,读者会以为页面出问题了。浅灰接近输入框占位符的颜色,视觉上更自然。这个细节是从用户反馈里学来的——最早用黑色打码,有人专门来问"这个地方是加载失败了吗"。
遮罩规则我建议成体系维护,别散落在各个章节里。我的做法是在lib/mask.ts里定义一份"全局敏感选择器清单",包括头像、手机号、邮箱、地址、金额、身份证等,在capture里默认全部挂上,章节内再按需追加。这样哪怕某一步忘了写,也不会漏掉。
裁剪则是在"整页截图太散"时用的。比如只想突出页面右下角的操作区,就传一个clip区域。裁剪区域我一般用元素的 boundingBox 稍微外扩 40px 得到:
const box = await page.locator('.action-panel').boundingBox(); if (box) { const pad = 40; await page.screenshot({ path: filePath, clip: { x: Math.max(0, box.x - pad), y: Math.max(0, box.y - pad), width: box.width + pad * 2, height: box.height + pad * 2, }, }); }外扩 40px 是为了留白,图片边缘贴着内容会显得很挤。这个数字可以按视觉喜好调,我试过 20、60,40 是比较舒服的中间值。
4.3 手册正文生成:Markdown 模板与图片路径
截图有了,接下来拼文档。我选 Markdown 作中间格式,理由是它能进 Git、能做 diff、能被各种静态站点工具消费,需要 PDF 时再转一次就行。
// lib/markdown.ts import { StepRecord } from './runner'; export function renderChapter( chapterNo: string, chapterTitle: string, records: StepRecord[], ): string { const lines: string[] = []; lines.push(`## ${chapterNo} ${chapterTitle}`, ''); for (const r of records) { lines.push(`### ${chapterNo}.${r.index} ${r.title}`, ''); if (r.desc) { lines.push(r.desc, ''); } if (r.image) { lines.push(``, ''); } } return lines.join('\n'); }生成的产物大概长这样:
## 2. 创建项目 ### 2.1 填写项目名称 在页面顶部点击"新建项目",弹出的表单里填写项目名称。名称支持中英文、数字和短横线,长度 2 到 40 个字符。  ### 2.2 选择项目模板 模板决定了项目初始的字段结构。已有项目可以直接复制结构,也可以从空白开始。 这里有个排版细节值得单独提:图片 alt 文本用步骤标题,不要用"截图1""图2"。一是无障碍阅读器会念出来,二是图片加载失败时能显示文字兜底,三是搜索引擎能识别。这是个很小的动作,但对文档质量影响挺大。
另外,图片路径我用相对路径./images/...,不用绝对路径也不用图床。原因很实在:相对路径让整个手册目录可以整体打包发给客户,对方解压就能看,不需要联网。图床的方案我试过,一旦链接失效整本手册全是裂图,得不偿失。
4.4 输出 HTML 与 PDF:一次编译三端可用
Markdown 有了,转 HTML 和 PDF 就很顺了。HTML 我用一个极简模板,重点在样式:正文宽度限制、图片自适应、代码块样式、章节锚点。
// lib/export.ts import { chromium } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; export async function exportPdf(htmlPath: string, pdfPath: string) { const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto('file://' + path.resolve(htmlPath), { waitUntil: 'load', }); // 等图片全部加载完再打印,否则 PDF 里是空白框 await page.waitForFunction(() => { const imgs = Array.from(document.images); return imgs.every(img => img.complete && img.naturalWidth > 0); }, null, { timeout: 30000 }); await page.pdf({ path: pdfPath, format: 'A4', printBackground: true, margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' }, displayHeaderFooter: true, headerTemplate: '<div></div>', footerTemplate: '<div style="font-size:9px;width:100%;text-align:center;color:#999;">' + '<span class="pageNumber"></span> / <span class="totalPages"></span></div>', }); await browser.close(); }这段代码里最容易被忽略的是那句waitForFunction等图片加载完。用file://协议打开本地 HTML 时,图片是异步读取的,waitUntil: 'load'只保证 HTML 文档本身加载完成,图片可能还在读。我第一次导出的 PDF 里,前面几张图正常,后面几张全是空白框,就是这个原因。加上显式等待之后问题消失。
页边距的参数也是调过的:上下 18mm、左右 16mm,A4 纸宽 210mm,减去左右边距剩 178mm 的正文宽度。这个宽度下,1440px 宽的截图等比缩放后大约占 178mm,文字大小看着也舒服。左 16 右 16 是对称的,上下留 18mm 是为了给页脚页码留空间——最早设 15mm,页码和正文最后一行贴在一起,很难看。
5. 常见问题与排查实录
5.1 截图不稳定:一份按现象查根的速查表
这套东西跑起来之后,我大概有两周时间都在处理"截图不稳"的问题。整理成一张表,遇到类似现象可以直接对号入座。
| 现象 | 根因 | 处理办法 |
|---|---|---|
| 文字发虚、边缘模糊 | deviceScaleFactor为 1 | 设为 2,视口保持 1440×900 |
| 两次截图字形不一样 | 自定义字体未加载完 | 截图前await document.fonts.ready |
| 偶发整屏白 | 页面还在首屏渲染 | 等关键元素toBeVisible后再截 |
| 图表截到动画中间态 | JS 驱动动画未受animations控制 | 关闭图表动画配置或等待特定帧 |
| 固定导航栏没被压暗 | 导航栏 z-index 高于高亮层级 | 截图前临时降低其层级 |
| 高亮框位置偏移 | 祖先元素有transform: scale | 加translateZ(0)或按缩放比换算坐标 |
| 高亮框在 Shadow DOM 里不生效 | querySelector无法穿透 | 改用 locator 版本注入 |
| 图片有时是空白框 | 图片未解码完成 | 等img.complete && naturalWidth > 0 |
| 表格列宽每次不一样 | 内容异步渲染导致重排 | 等数据请求完成再截图 |
| 截图里出现真实用户信息 | 未配置 mask | 建立全局敏感选择器清单 |
表里最值得展开的是"图表截到动画中间态"。这个问题我折腾了很久。Playwright 的animations: 'disabled'只会处理 CSS 动画和 Web Animations API 的一部分,canvas 绘制的图表用的是requestAnimationFrame,完全不受管。我的解决办法是两条:一是在产品侧给图表组件加一个"关闭入场动画"的配置项,测试环境默认关掉;二是脚本里用page.waitForTimeout显式等一个足够长的稳定时间,虽然不优雅,但比截到半截动画强。
还有"表格列宽每次不一样"这个,本质是数据请求回来的时机不同,表格自动布局会先按占位内容渲染一次,数据到了再重排。处理方式是等表格行数稳定之后再截——判断方法很简单,连续两次查询tbody tr的数量相同,就认为渲染稳定了。
5.2 图片体积与渲染性能:别让手册打开要转圈
一百张 2 倍图的手册,如果直接用 PNG 嵌进 HTML,打开时会明显卡顿,网络慢的时候还会出现"图一张一张蹦出来"的观感。
我做了三件事控制体积。一是转 WebP,用 sharp 批量处理,质量 80,实测 500KB 的 PNG 转完大约 170KB,文字边缘依然清晰。二是按需加loading="lazy",HTML 模板里的图片统一加懒加载属性,首屏只加载可视区域。三是限制单张图高度,CSS 里给图片设max-height: 720px; object-fit: contain;,避免某些超长页面截图把整篇文档拉得老长。
// scripts/optimize-images.ts import sharp from 'sharp'; import fs from 'node:fs'; import path from 'node:path'; const dir = 'output/images'; async function walk(d: string) { for (const name of fs.readdirSync(d)) { const full = path.join(d, name); if (fs.statSync(full).isDirectory()) { await walk(full); continue; } if (!name.endsWith('.png')) continue; const out = full.replace(/\.png$/, '.webp'); await sharp(full).webp({ quality: 80 }).toFile(out); console.log(`优化 ${out}`); } } walk(dir);这里有个小取舍:WebP 兼容性现在没问题了,但如果手册要发给一些老旧环境阅读,PNG 更保险。我的做法是两套都产出,HTML 版引用 WebP,PDF 版引用 PNG(因为 PDF 生成时对 WebP 的支持要看 Chromium 版本,PNG 最稳)。
5.3 手册更新与版本管理:把文档当代码管
最后一个大坑是"手册和产品版本对不上"。解决方法说穿了就是一句:把手册当代码管。
具体做法:手册脚本和产品代码放在同一个仓库的不同目录,产品每次发版,CI 里跑一遍手册脚本,产物归档到对应版本号目录下。如果某次发版后手册脚本跑挂了,说明 UI 有变动,这个失败会像测试失败一样触发通知,由对应的人去改脚本和文案。
output/ ├── v1.2.0/ │ ├── manual.md │ ├── manual.pdf │ └── images/ ├── v1.3.0/ │ └── ... └── latest -> v1.3.0latest是个软链接,指向最新版本,对外发布的永远是这个地址。用户拿到的文档永远和当前版本匹配,历史版本也留得住,出了问题能回溯。
这里还有一个很实用的能力:图片 diff。因为每版截图都留着,我可以对同名图片做像素对比,自动找出"哪些页面的视觉发生了变化"。这比人肉 review 高效太多——一次改版可能只动了三个页面的按钮位置,diff 会精准地把这三张图标出来,我只需要确认这三张对应的文案要不要改。
我试过用简单的哈希比对,结果因为截图里有个动态时间戳,每张图的哈希都不一样,全被标成"变化"。后来改成忽略固定区域(把右上角时间区域裁掉再比),效果就正常了。如果时间戳位置不固定,也可以在截图前用 mask 把它涂掉,思路是一样的。
6. 团队落地时我踩过的那些坑
6.1 文案到底谁来写:别让工程师硬编
技术上跑通之后,我以为这事儿就完了,结果第一个版本出来被吐槽得体无完肤——图很清晰,文字全是"点击此处""执行该操作"这种废话,用户看完还是不知道该干嘛。
问题出在文案不该由写脚本的人来定。工程师写文案,天然会写成"操作说明书",关注点在"做了什么";而用户需要的是"为什么做、做完会怎样"。这两者差别很大。
后来我们调整了分工:工程师负责把步骤结构、选择器、截图跑通,文案部分由熟悉业务的同学(交付实施或者产品运营)来填,填的时候只看截图,不看代码。这个流程跑了两轮之后,文档质量肉眼可见地提升,因为写文案的人真的站在用户视角在思考。
技术上配合这个分工,我把steps/里的文案部分抽成了独立的 JSON 文件,文案同学改 JSON 就行,不用碰 TypeScript。这一步降低的协作门槛比想象中高——非技术同学改 JSON 完全没心理负担,改代码就会各种犹豫。
6.2 和现有测试用例的复用:一份代码两个用途
最初我是把手册脚本放在独立仓库的,跑起来发现和功能测试有大量重复:同一个登录流程写两遍,同一套选择器维护两份,改一次前端要动两个仓库。
后来做了合并:手册脚本就是测试用例,只是多了截图和文案。具体做法是在测试用例的 step 上加可选的手册元信息,如果这个用例要出现在手册里,就填上 title、desc、shot 这些字段;不需要出现在手册里的,就只当普通用例跑。
test('创建项目主流程', async ({ page }) => { await runChapter(page, '02', [ { id: 'open-dialog', title: '打开新建项目弹窗', desc: '在工作台右上角点击"新建项目"按钮。', action: async p => { await p.getByRole('button', { name: '新建项目' }).click(); }, assert: async p => { await expect(p.getByRole('dialog')).toBeVisible(); }, highlight: '[data-testid="btn-new-project"]', }, // ... 后续步骤 ]); });这样做的好处是复利式的:用例覆盖率上去了,手册的步骤也跟着变多;用例因为改版挂了,手册脚本同步挂,两边永远同步。坏处是脚本会变重,一份代码承担两个职责,写的时候要注意别让手册的截图逻辑拖慢测试执行。我的处理是加环境变量开关,CI 跑回归时关掉截图,只在发版前的手册任务里打开。
6.3 实际效果:省下来的时间到底在哪儿
最后说说这套东西跑了大半年的实际感受,不讲虚的。
第一次投入大概是五天,其中两天在搭框架、调截图稳定性,两天在写前两个章节,一天在调 PDF 排版。这个成本不算低,如果只是做一份一次性文档,绝对不划算。
收益体现在第二版之后。以前一次改版更新手册要两到三天,现在改脚本加跑一遍大概两小时,而且这两小时里有一半是在等 CI 跑。按三个月一次大改版、每月一次小改版算,半年就能回本。
更值钱的是那份"可信度"。手册和产品严格对应之后,因为"照着文档做不通"而产生的支持工单明显减少,我印象里相关工单从每月的两位数掉到了一位数以内。这部分收益没法精确量化,但对交付团队来说体感非常明显。
还有个意外收获:跑手册脚本的过程本身就是一次全流程回归。有时候 UI 改版只是挪了个按钮位置,功能测试因为用的接口 mock 没发现,手册脚本一跑就挂了,顺手把这类"视觉层回归"给兜住了。
要说遗憾,就是这套方案对"动效演示"依然无能。拖拽排序那种"手感",截图永远替代不了视频。我的处理是在手册里标注一句"此步骤建议配合演示视频观看",然后链到单独录的短视频,两套东西互补,不强行用一套方案解决所有问题。
另外提醒一句,如果你们团队的前端大量使用动态类名(比如 CSS-in-JS 生成的哈希类名),务必在开始之前推动加上>