别再「看起来对」:用真浏览器验证前端
站点是 Typecho 1.3 加 sagiri 主题,跑在一台 2 核 2G 的云服务器(Ubuntu 22.04)上,同一台机器还兼着 QQ 机器人。站里 18 篇文章、6 个分类、26 个标签。前端这一摊基本都是我一个人折腾,没有测试同学、没有设计走查,所以“谁来判断它对不对”这件事,最后只能落在我和一张截图上。
下面记的,就是我被打脸之后改出来的验证方式。
一、我以前的验证方式:肉眼 + 一张截图
以前我“验证前端”的完整流程是这样的:改完 CSS,浏览器里打开页面,往下拉两屏,看着顺眼,截一张图存进笔记,收工。要是有人问“你怎么确定修好了”,我就把图发过去。
这套流程的问题不在于“看图”本身,而在于它验证的是我的眼睛,不是页面。眼睛只覆盖了我恰好滚到的那一屏、恰好触发的那几个状态、恰好没走缓存的那一次加载。剩下的部分——首屏之外的、hover 才出现的、手机上才有的、第二次访问才发生的——全在证据之外。
更要命的是它不好失败。截图永远是“生成成功”的,脚本也永远是“退出码 0”,只要我不盯着看,它什么都不会说。下面这几个坑,全都是“我的截图说好,别人的屏幕说不好”。
二、截图骗过我一次:滚动显现的动画
主题给列表页的文章卡片加了滚动显现:进入视口时 opacity 从 0 过渡到 1。我在无头浏览器里截了一张列表页,整块内容区是空白的。
第一反应是样式没生效,翻 CSS 翻了半天。后来在 page.evaluate 里读了一下才知道:
await page.evaluate(() => getComputedStyle(document.querySelector('#posts article')).opacity);
// "0"页面从加载到截图,一次都没有滚动过。显现动画的 IntersectionObserver 压根没被触发,卡片老老实实停在 opacity: 0。截图没有骗我,是我用一个不会滚动的采样,去验证一个依赖滚动的效果。
修法很土:截图前先滚到底,再滚回顶,等过渡结束。
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(600);
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(600);
await page.screenshot({ path: 'list.png' });教训是:截图只能证明“这一屏、这一刻、这个滚动位置,有这些东西”。它证明不了“元素可见”,也证明不了“效果生效”。拿它当可见性判据,测的是采样时机。
三、page.mouse 不是手指
看板娘手机端有个“点击换人”的交互。我写完以后用 Playwright 测:移动视口配好、isMobile: true、hasTouch: true,然后鼠标在 canvas 上点一下,换人成功,控制台里 [live2d] 就绪 也打出来了。我认为它过了,写了句“移动端已验证”。
真机上点了没反应。
根因很直白:page.mouse.click() 派发的是鼠标事件流(mousedown / mouseup / click),移动端浏览器在真实触摸下合成的是 touch 事件流。我那套换人逻辑绑在 touchstart 上,page.mouse 这条路径上它一句都没跑到——但脚本“点了”,于是“通过了”。
正确的写法是把触屏能力真的打开,用触屏 API 去点:
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true, hasTouch: true, deviceScaleFactor: 2
});
const page = await ctx.newPage();
const box = await page.locator('.l2d-box canvas').boundingBox();
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);顺带一条:坐标别拍脑袋。看板娘是绝对定位加 canvas 叠层,坐标拿错就是从别的元素身上点过去,同样不报错。稳妥的做法是先 elementFromPoint 确认这个点上最上层是谁,再派发事件(这套方法后面还会用到)。
四、CDP 也有物理极限
真触屏也不是万能的。有个交互要求“320ms 内连点两下算双击”,我一开始这么写:
await page.touchscreen.tap(x, y);
await page.waitForTimeout(150);
await page.touchscreen.tap(x, y);结果永远不算双击。原因不在页面上:page.touchscreen.tap 走的是 CDP 的 Input.dispatchTouchEvent,一次 touch 序列的往返,在这台 2 核 2G 的机器上实测 300–600ms。也就是说我 waitForTimeout(150) 之后,两次点击的真实间隔早就过 320ms 了,双击窗口根本进不去。这种测试不只是测不出东西,还可能给出反的结论——“用户点两下没打开”,未必是代码错,可能只是往返太慢。
结论:UI 层的触屏 API 适合验证“点了有没有反应”;要验证“时间窗内的反应”,就得把事件派发下沉到页内,自己构造真实 TouchEvent:
await page.evaluate(() => {
const el = document.querySelector('.l2d-box canvas');
const r = el.getBoundingClientRect();
const x = r.x + r.width / 2, y = r.y + r.height / 2;
const mk = (type) => {
const t = new Touch({ identifier: 1, target: el, clientX: x, clientY: y, pageX: x, pageY: y });
const touches = type === 'touchend' ? [] : [t];
return new TouchEvent(type, {
bubbles: true, cancelable: true,
touches, targetTouches: touches, changedTouches: [t]
});
};
el.dispatchEvent(mk('touchstart'));
el.dispatchEvent(mk('touchend'));
el.dispatchEvent(mk('touchstart'));
el.dispatchEvent(mk('touchend'));
});这样两次点击的间隔由页内时钟决定,是毫秒级,320ms 的窗口才是真的。当然它验证的是我的监听逻辑,不是浏览器的手势语义——合成事件不触发默认行为,这一点自己心里要有数。
五、position: sticky 在 fullPage 截图里整块消失
侧栏 280px,用了 position: sticky。截图里侧栏整块不见了,一像素都没有。
不是 CSS 坏了,是截图方式和 position 语义在打架。fullPage: true 的做法是把视口撑到整页高度再拍一张;页面高度一口气变成几千像素的那一刻,sticky 元素按它的包含块重新定位,那块本该“贴在视口里”的内容就哪儿都不在了。
替代做法是先量页高,再撑视口,最后做一张普通截图:
const h = await page.evaluate(() => document.documentElement.scrollHeight);
await page.setViewportSize({ width: 1240, height: h });
await page.screenshot({ path: 'full.png' }); // 注意:不要传 fullPage视口够高、位置仍然是“视口内”,sticky 元素就会正常画进图里。这两种截图的差别,光看图片文件名是看不出来的,我第一次踩就是踩在这——两张图长得不一样,我还以为是主题的锅。
六、移动端那次“看起来对”:侧栏空、哨兵卡住、分号吞脚本
移动端改造我动了四处,每一处都通过了“看起来对”。
侧栏是空的。 isPc() 是按普通 User-Agent 正则判断的,手机上连侧栏 HTML 都不输出——这本身没错。但我改完以后,手机上的侧栏是空的:模板默认激活的是「文章目录」,而主题 JS 在移动端根本不构建 TOC。DOM 在、盒子在、内容没有,桌面上一切正常。
无限滚动的哨兵卡住了。 IntersectionObserver 的哨兵如果加载完还留在视口里,就不会再次触发。桌面大屏第一页短,哨兵一直可见,于是“滑到底自动加载”永远只加载一次。
注入的脚本被前一句吞掉。 我用 (function(){ ... })() 往页尾塞代码,前面没加分号,紧贴在上一段表达式后面,浏览器把它当成了调用,报 setInterval(...) is not a function。加了前导分号才活过来。
新插入的图片没人管。 后插入的卡片里的图片,主题的懒加载不接管——扫描只在初始时跑过一次,新图永远停在 lazy-src 上不加载。所以取数据时还得手动补一句 img.src = img.getAttribute('lazy-src')。
这四个坑的共同点是:DOM 结构对、控制台不报错、截图也看不出问题。它们只在“真的滚到底、真的等一次异步加载、真的数一次请求、真的在手机断点上看一眼”的时候才暴露。
七、我现在的取证四件套
不再拿“看起来对”当结论了,落四个硬信号:
1. computed style。 getComputedStyle(el).opacity / display / position —— 断言的是浏览器算完之后的值,不是我以为写在文件里的那行文本。样式被后面的规则覆盖、被媒体查询改掉、被动画停在中间,都在这里现形。
2. 几何矩形。 el.getBoundingClientRect() 给出位置、尺寸、有没有被挤出视口、有没有压在别人身上。看板娘那一笔账就是这么算出来的:原来桌面 300px,实测压住了 150×79 的数据卡和 150×152 的词云区;改成 240px 并锚定内容区右边缘,手机 200px。这种“压住”只有矩形相交能证明,截图里它俩都只是“存在”。
3. elementFromPoint(x, y)。 直接问浏览器“这个坐标上最上层是谁”。所有点击类问题,先用它确认命中对象,再派发事件。第三节、第四节的坐标都是它兜住的。
4. 控制台日志。 page.on('console') 和 page.on('pageerror') 全量收下来,别只看有没有红字。我给关键路径打了点([live2d] 就绪),这些字符串就是断言锚点——“等到了这行日志”和“等到了 1.5 秒”是两回事。
布局那轮我也是量着改的:容器 1110px → 1240px,内容区 772px → 937px,卡片三列每列 299px、内边距 40px 收到 20px,侧栏 280px 加 sticky;词云 22 个词,字号 12px → 26px 按文章数线性映射、8 色循环,分类 6 个、标签 16 个胶囊。这些数字不是审美,是断言的基线——改完再量一遍,对不上就是错。
八、缓存也是验证的一部分
静态资源上了 gzip 加 Cache-Control: max-age=2592000(30 天),首屏传输量从 703KB 掉到 48.8KB,代价是“我修好了,你说没变”变成了日常。浏览器拿着 30 天前的 CSS 给你看,你看到的确实是我上周的代码。
所以我给自己立了三条:
- 引用带版本。
style.css?v=20261018,改一次换一次。靠人记着刷新是记不住的。 - 留自测钩子。
?testswitch=1强制走测试分支,?l2dfast=1跳过空闲等待。646KB 的l2d-widget.min.js改成页面load之后空闲注入,正常路径要等 idle,验证时等不起——钩子让“等条件”变成“改条件”,而不是把超时时间调大。 - 验证用干净上下文。 每次
browser.newContext(),冷缓存跑。不拿“我这边刚清过缓存”当证据。
自测钩子还有一个副作用挺值钱:它把“我心里知道要等什么”变成了代码里的一个开关,三个月后的我也能复现同一套验证。
九、能证伪的验证才算验证
回头看,我所有“看起来对”的误判都是同一个形状:我验证了一个更弱的命题。
截图证明“这一屏此刻有像素”,不证明“元素可见”;page.mouse 证明“坐标上发生了鼠标点击”,不证明“手指点了”;fullPage 证明“页面总高度有这么长”,不证明“整页都画出来了”。每一次我都在用弱命题冒充强命题,还都觉得挺踏实。
好的验证应该能失败。写下一条断言之前,先问自己它在什么情况下会变红:如果无论代码对错它都是绿的,那它不是验证,是装饰。
现在的顺序固定了:先想清楚“什么信号能证伪这次改动”(computed style / 矩形 / 命中对象 / 控制台日志 / 请求数),再开真浏览器跑,最后才截图。截图留着交付,不当证据。
你好