整页截图为什么老是不对:懒加载、fixed 重复和另外四个坑

0 阅读6分钟

用无头浏览器截个整页图,第一版代码通常长这样:

await page.goto(url)
await page.screenshot({ path: 'out.png', fullPage: true })

跑起来能出图,然后你会发现出来的图不太对——中间几张图是空白占位、导航栏在页面里出现了五次、中文全变成了系统默认字体、某个卡片正处在展开动画的一半。

这些不是随机故障,是六类可预期的问题。挨个说。

一、懒加载:视口外的图根本没加载

现在的站点基本都上了懒加载,图片要滚到视口附近才开始请求。整页截图虽然把 viewport 拉到了整页高度,但很多懒加载实现监听的是滚动事件或者 IntersectionObserver 的实际触发,单纯改 viewport 尺寸不一定会触发。

最笨也最可靠的办法是先滚一遍:

await page.evaluate(async () => {
  const step = window.innerHeight
  for (let y = 0; y < document.body.scrollHeight; y += step) {
    window.scrollTo(0, y)
    await new Promise(r => setTimeout(r, 120))
  }
  window.scrollTo(0, 0)
})

滚完还得等一下图片真正解码完成,不然截到的是「已请求但还没画出来」的状态:

await page.evaluate(() => Promise.all(
  [...document.images]
    .filter(img => !img.complete)
    .map(img => new Promise(res => { img.onload = img.onerror = res }))
))

另一条路是直接把 loading="lazy" 全改掉,让浏览器一次性加载。简单粗暴,但对用 IntersectionObserver 手写懒加载的站点没用。

二、fixed 元素在拼接式截图里会重复 N 次

这个是滚动拼接方案的经典症状:固定定位的顶栏、回到顶部按钮、右下角客服气泡,在最终长图里每隔一屏出现一次。

原因很直白——拼接是「滚一屏、截一张、再滚一屏」,而 fixed 元素相对视口定位,每一屏它都在。

Puppeteer 的 fullPage: true 走的是 CDP 的整页捕获,理论上不该有这个问题,但实际上仍然会遇到:有些页面的「固定顶栏」是用 JS 监听滚动手动改 transform 实现的,整页捕获时它停在了某个中间位置;还有些 position: sticky 的元素在超长视口下的表现和正常滚动时不一致。

处理方式是截图前把这类元素临时改掉。但这里没有通用解——position: fixed 写在 CSS 类里的时候,属性选择器匹配不到,只能靠遍历计算样式:

await page.evaluate(() => {
  document.querySelectorAll('*').forEach(el => {
    if (getComputedStyle(el).position === 'fixed') {
      el.style.position = 'absolute'
    }
  })
})

对元素多的页面这一段本身就有性能代价,几万个节点跑 getComputedStyle 会明显卡一下。

三、字体没加载完

截图时中文变成系统默认字体,通常是 webfont 还没下载完就截了。networkidle 之类的等待条件不总能覆盖——字体可能是 CSS 里按需触发的,也可能走了 font-display: swap,浏览器先用后备字体渲染,认为「已经可以显示了」。

显式等一下:

await page.evaluate(() => document.fonts.ready)

服务器上还有个更基础的问题:容器里压根没装中文字体,webfont 之外的所有回退字体都是豆腐块。这个 document.fonts.ready 救不了,得在镜像里装字体。

四、动画和过渡的中间态

入场动画、骨架屏、轮播图,都可能被截在半路上。最省事的做法是把动画全禁掉:

await page.addStyleTag({
  content: `*, *::before, *::after {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
    transition-duration: 0s !important;
    transition-delay: 0s !important;
  }`
})

这段要在页面加载后、截图前注入。注意它会让「依赖动画结束事件才显示内容」的实现出问题——有些组件在 animationend 里才把元素设成可见,把时长设成 0 后事件仍会触发,通常没事,但确实见过卡住的。

五、100vh 元素被拉伸

fullPage: true 的实现是把视口高度临时撑到整页高度。于是页面里所有 height: 100vh 的东西——常见于首屏 hero 区、全屏弹窗——会跟着变成整页那么高,最终图里就是一个被拉得极长的首屏。

这个没什么优雅解法。要么改用滚动拼接(代价是回到第二个坑),要么截图前把 vh 单位换成固定像素。后者写出来就知道是个 hack,命中率有限:计算样式里拿到的已经是解析后的像素值,分不清它原本是 100vh 还是恰好等于那个数。实践中更多是接受它,或者对已知站点做特例处理。

六、清晰度

默认截出来是 1 倍图,放大看糊。加 deviceScaleFactor

await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 2 })

代价是内存和文件体积都是四倍。一个很长的页面配 deviceScaleFactor: 3,位图能轻松到几百 MB,无头浏览器直接 OOM。Chrome 本身对单张截图的尺寸也有上限,超长页面配高倍率时会截断或者失败。所以长页面和高清晰度这两件事,实践中得二选一或者折中。

两种方案怎么选

整页一次性捕获滚动拼接
fixed 元素重复基本没有需要专门处理
100vh 被拉伸没有
超长页面容易 OOM/触顶可控,分段处理
实现复杂度一行要处理拼接、重叠、滚动条

短页面用前者,超长页面用后者。没有一个方案能同时避开所有坑。

几件做不到的事

说点上面那些技巧解决不了的,免得有人照着做完发现还是不行。

canvas 和 WebGL 内容不一定能截到。 依赖 GPU 的渲染在无头环境里可能是空白,得开软件渲染,慢且不一定和真实浏览器一致。

跨域 iframe 里的内容碰不到。 上面所有 page.evaluate 都进不去跨域 iframe,里面的懒加载、动画、字体都没法处理。

video 截出来常是黑的。 除非显式 seek 到某一帧并等待 seeked,而有些格式在无头环境里根本解不了。

有些站点会拒绝你。 反爬会检测各种自动化特征,截出来是验证码页或者 403。这是产品边界问题,不是技术问题——能不能绕过和该不该绕过是两件事。

登录后的页面截不了。 没有会话就是没有会话,这个得用户自己提供。


这套东西我在做在线网页截图功能的时候基本都撞过一遍,上面写的是收敛之后的通用做法,不涉及具体产品实现。如果只是偶尔要截个整页图、不想自己搭环境,forxi.cn 上有个现成的可以用;真要自己做,建议直接从「先滚一遍 + 禁动画 + 等字体」这三件事开始,能解决大概七成的问题。

剩下三成,得看你截的是什么页面。