静态站"看起来对不对"怎么变成证据:Edge headless 截图 + iframe 移动端视口 + 溢出判定

2026-10-08 · 前端工程 / 站点运维 · 约 9 分钟阅读

做静态站最容易被忽略的一步,是真正看它一眼。我们的流水线里,一个页面"完成"的标准长期是:标签配平检查通过、所有本地资源返回 200、部署后 URL 200。这三项全绿,我们就会认为页面没问题——直到某天有人截图回来,发现移动端上半屏是空的。

问题的本质是:标签配平和资源 200 只证明了文件的存在性,不证明渲染结果的正确性。CSS 特异性打架、媒体查询没生效、入场动画永远停在半透明、某个绝对定位元素把页面撑宽 40px——这些都不会让任何一条 HTTP 状态码变红。

本文记录我们在这台没有 Playwright、没有 puppeteer、agent-browser 还偶尔卡死的 Windows 机器上,把"看起来对不对"变成一个可判定结果的全过程。所有参数、报错和数值都来自本机实测。

一、先起本地服务,不要用 file://

静态站大多用 /assets/css/style.css 这类根绝对路径引用资源。直接 file:// 打开本机 HTML,所有绝对路径都会失效,页面会退化成无样式的白板——这时候截图看到的一切都是假的。

cd <站点目录>
<python> -m http.server 8900 --bind 127.0.0.1
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8900/   # 必须 200

服务起来之后再截图,看到的人才真的是"用户会看到的东西"。这一步不能省。

二、Edge headless 的三个必踩坑

本机唯一可用的浏览器是 Edge:C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe。基础命令长这样:

"$E" --headless=new --disable-gpu --hide-scrollbars \
  --no-first-run --no-default-browser-check \
  --force-prefers-reduced-motion \
  --user-data-dir="C:\Users\Administrator\AppData\Local\Temp\es_验证名" \
  --window-size=1440,900 --virtual-time-budget=9000 \
  --screenshot="C:/绝对路径/shots/out.png" "http://127.0.0.1:8900/"

看起来很正常,但它有三个坑,每一个都会让你白等半天。

坑 1:Edge 是异步写文件的,命令返回 ≠ 图片已存在

命令执行完立刻检查文件,会发现文件根本还没写出来——而且是随机性的,有时有有时没有,非常像"玄学"。必须轮询等待:

for i in $(seq 1 30); do [ -f shots/out.png ] && break; sleep 2; done

坑 2:--screenshot" 的路径要用正斜杠绝对路径

写成 "...\shots\\$n.png" 这种反斜杠拼接变量的形式,会出现一个名为 shots$n.png 的垃圾文件——变量没被展开,反斜杠被当成转义。稳定写法是全部用正斜杠,从盘符开始写绝对路径。

坑 3:一条命令里连续跑多个 Edge 实例,只有第一个成功

实测两个实例共用同一个 --user-data-dir 时会互相锁住,结果只有先启动的那个能出图。规则很简单:一次调用只截一张;要多张就分多次调用,或者每张之间 sleep 12+ 并给每个实例不同的 --user-data-dir。

还有一个容易忽略的坑:不要写 命令A && 命令B & 命令C。& 会把 A && B 整段丢进子 shell,前台 shell 的当前目录并不改变,于是后面用相对路径的 cp / rm 会操作到错误目录。我们曾因此在"临时改样式、截完还原"的流程里留下了一个未还原的样式表,还报了一个 No such file or directory,排查了很久。凡是涉及文件写入的命令,一律用绝对路径。

三、Windows 窗口有最小宽度:为什么"移动端截图"是假的

这是本文最值钱的一条。想验证移动端,第一反应是 --window-size=390,9000。但 Windows 的窗口有最小宽度限制,这个参数实际会被强制到约 500px 的视口宽度。于是:

我们就据此误报过一个根本不存在的 bug——查了半天 CSS,最后发现是截图工具的限制。

四、正确姿势:iframe 固定宽度 + PIL 裁剪

要让浏览器真的按 390px 渲染,得让内容待在一个 390px 宽的容器里。用一个临时页面加载同源 iframe 即可:

<!-- _mview.html,放在站点目录,验证完删除 -->
<!DOCTYPE html><html><head><meta charset="utf-8">
<style>html,body{margin:0;background:#000}
iframe{width:390px;height:8000px;border:0;display:block}</style>
</head><body><iframe src="/index.html"></iframe></body></html>

然后用 --window-size=500,8000 去截这个页面,再用 PIL 裁 (0,0,390,H),就得到一份真实 390px 视口的渲染。关键点在于 iframe 内部的媒体查询按 iframe 宽度生效,100vh 也按 iframe 高度计算——所以窄屏断点、字号、栈式布局全部是真的。

PIL 用我们环境里已装好的那个解释器:~/.workbuddy/binaries/python/envs/ar_env/Scripts/python.exe。

五、整页长图:先临时解除 100vh

首页的英雄区常写 min-height:100vh。用一个 7000px 高的窗口去截整页,英雄区会直接占满整张图,下面所有内容都被推到很远的地方,看长图的人会以为"页面就这一屏"。

办法是临时把 100vh 换成固定值,截完立刻还原并校验:

S="C:/绝对/站点"; cp "$S/assets/css/style.css" /tmp/sb.css
sed -i 's/min-height:100vh/min-height:720px/' "$S/assets/css/style.css"
# ... 截图 ...
cp /tmp/sb.css "$S/assets/css/style.css"
grep -c "min-height:100vh" "$S/assets/css/style.css"   # 应为 1(还原成功)

注意这里全部用绝对路径。清理与还原是这套流程里最容易被忽略、后果又最麻烦的一步:临时改动的样式如果没还原,就会被部署上线。所以收尾一定要有一条可核验的命令(比如 grep -c 返回预期值),不要靠"我记得还原了"。

六、截图是半透明的?多半是入场动画

滚动显现效果一般这么写:元素初始 opacity:0,进入视口后由 IntersectionObserver 加类名,CSS transition 负责过渡。无头环境里虚拟时间几乎不推进 transition,截图就停在半透明甚至完全透明的中间态。

两步解决:

/* 1) 命令行加 --force-prefers-reduced-motion
   2) CSS 里写好对应媒体查询,注意特异性要够 */
@media (prefers-reduced-motion: reduce){
  *{animation:none!important;transition-duration:.01ms!important}
  html.js .reveal{opacity:1;transform:none}
}

第二条括号里的"特异性要够"是踩出来的:如果页面里写的是 html.js .reveal{opacity:0},而你的覆盖规则只写 .reveal{opacity:1},那么 html.js .reveal 的特异性更高,照样把你压回去,截图还是空的。覆盖规则必须用同级或更高的选择器。

同理,数字滚动动画也会骗截图:如果 HTML 里初始值是 0、由 JS 递增到真实值,那么截图永远停在中间态。我们曾想截一个"27"的统计数字,截出来的是"4"。正确做法是把真实值直接写在 HTML 里,JS 只负责递增的视觉效果。

七、横向溢出:不要猜,建一个诊断页

"页面被撑宽了"最大的麻烦是不知道是谁撑的。靠肉眼看 CSS 猜效率极低,正确做法是让浏览器自己报出越界元素:

<!-- _diag.html,验证完删除 -->
<div id="out">pending…</div>
<iframe id="f" src="/index.html"
        style="width:390px;height:150px;border:2px solid red"></iframe>
<script>
document.getElementById('f').addEventListener('load', function () {
  var d = this.contentDocument, vw = 390, bad = [];
  d.querySelectorAll('body *').forEach(function (el) {
    var r = el.getBoundingClientRect();
    if (r.width > 0 && (r.right > vw + 1 || r.left < -1))
      bad.push(el.tagName + '[' + el.className + ']' +
               ' R' + Math.round(r.right) + ' W' + Math.round(r.width));
  });
  document.getElementById('out').textContent =
    'SCROLLW=' + d.documentElement.scrollWidth +
    ' CLIENTW=' + d.documentElement.clientWidth +
    ' BADCOUNT=' + bad.length + '\n' + bad.slice(0, 14).join('\n');
});
</script>

把结果用大字号渲染出来截图读取即可。判据只有一行:

SCROLLW == CLIENTW 且 BADCOUNT == 0 → 无横向溢出。反之,输出里会直接列出越界元素的标签名、类名、右边界和宽度,顺着去查那个类就行。

顺带一个环境事实:本机 Edge 的 --dump-dom 在新旧两种无头模式下都没有输出,别指望用它把 DOM 打出来分析,诊断结果只能靠截图看。

八、长图直接读会缩得看不清,用 PIL 分段裁

from PIL import Image
im = Image.open(r'C:/.../full.png').convert('RGB')
im.crop((0,900,1400,2500)).save(r'C:/.../p1.png')      # 按段裁
im.crop((0,0,390,1000)).resize((624,1600)).save(...)    # 移动端放大看细节
# 找内容真实底部(避开纯黑背景)
px = im.load(); W,H = im.size; last = 0
for y in range(H-1,0,-3):
    if any(px[x,y][2] > 26 or px[x,y][0] > 14 for x in range(0,W,10)):
        last = y; break

最后那个"找内容底部"的小循环很实用:长图底部往往是一大片背景色,肉眼分不清"页面就到这"还是"下面被裁了",用一个亮度阈值扫一遍就能确认页面真实高度。

九、静态检查仍要做,但注意一个细节

截图之外,标签配平和"HTML 里出现但 CSS 未定义的类名"仍然值得跑一遍——拼写错的类名不会报错,只会静默失效,这类问题截图也很难看出来。

但校验脚本里做"资源存在性"检查时要先剥掉 #锚点,否则 /products.html#service 这种会被误报成文件缺失,白跑一轮排查。这是纯字符串处理问题,一句话就能修。

十、收尾清单(每次都要做)

动作为什么必须做
删除 _mview.html / _diag.html / 截图目录临时文件会被一起部署上线,污染站点
校验临时改过的 CSS 已还原grep -c "min-height:100vh" 回到原值,防"临时改动"上线
只杀自己起的无头实例按命令行含 --headless 过滤,不要误杀正在使用的浏览器
关掉本地的 http.server不留后台尾巴,占用端口、干扰后续验证

这四条看起来琐碎,但前两条是"会污染生产"的级别,后两条是"会干扰下一次工作"的级别。我们把它们固化成了收尾脚本,每轮验证结束自动跑。

一句话流程

起本地服务 → 轮询式 Edge 截图(正斜杠绝对路径、一次一张)→ 移动端用 iframe 固定 390px 模拟视口 → 入场动画用 reduced-motion 绕过 → 溢出用同源 iframe 诊断页判定 → PIL 分段裁剪细看 → 清理临时文件并还原 CSS。

这套方法的通用价值在于:它把"我觉得看起来还行"换成了三条可复现的判据——截图存在且非半透明、SCROLLW 等于 CLIENTW、越界元素数为 0。只要判断标准是数字,验证就可以自动化,也可以交给别人复核。对内容站、工具站这类上线频繁的静态项目,这是防止"肉眼漏看"最便宜的一道闸门。

我们站内在售的方言语料资产(苏州话标准词库 3550 条)与数字人素材,也都是走同一套"先验证再交付"的流程做出来的——附字段说明与示例代码,交付即可用。

需要有人帮你做这份体检?看技术服务 → 看看站内已上架的数字资产 →

常见问题

为什么用 --window-size=390 截出来的移动端图不能用?

因为 Windows 的窗口有最小宽度限制,--window-size=390 实际会被强制到约 500px 的视口宽度。于是截出来的图右侧被裁掉,你会把"截图被裁"误判成"页面横向溢出",我们据此误报过一个根本不存在的 bug。正确做法是用一个固定 390px 宽的同源 iframe 加载目标页面,再用 PIL 把图裁成 (0,0,390,H),得到真实 390px 视口的渲染。

Edge 无头截图命令返回了,为什么没有生成图片?

两个常见原因。一是用了旧的 --headless(本机实测不出文件),必须用 --headless=new。二是 Edge 异步写文件,命令返回不等于文件已落盘,必须轮询等待。另外 --screenshot 的路径要用正斜杠绝对路径,用反斜杠拼接变量会生成名为 shots$n.png 的垃圾文件。

截图里页面是半透明的、内容没显示,是什么问题?

多半是入场动画。滚动显现常用 opacity:0 + IntersectionObserver + transition,无头环境里虚拟时间几乎不推进 transition,截图就停在半透明态。解决办法是加 --force-prefers-reduced-motion,并写好 prefers-reduced-motion 的 CSS 覆盖规则;注意覆盖选择器的特异性要够(用 html.js .reveal 而不是 .reveal),否则会被页面原有的 html.js .reveal{opacity:0} 压住。

怎么判断页面到底有没有横向溢出?

不要靠猜。建一个诊断页,用同源 iframe 加载目标页,遍历 body 下所有元素,用 getBoundingClientRect 找出 right 超过视口宽或 left 小于 0 的元素并列出。判据是 documentElement.scrollWidth == clientWidth 且越界元素数为 0。本机 Edge 的 --dump-dom 新旧无头模式都没有输出,结果要靠截图读取。

一条命令里连续截多张图为什么只有第一张成功?

多个 Edge 实例共用同一个用户数据目录会互相阻塞,实测只有第一个能成功出图。要么一次只截一张,要么每张之间间隔 12 秒以上并给每个实例指定互不相同的 --user-data-dir。另外不要把 cd 和 & 混在同一条命令链里:& 会把前半段放进子 shell,前台目录不变,导致后续相对路径的 cp/rm 操作错目录。