用无头浏览器截个整页图,第一版代码通常长这样:
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 上有个现成的可以用;真要自己做,建议直接从「先滚一遍 + 禁动画 + 等字体」这三件事开始,能解决大概七成的问题。
剩下三成,得看你截的是什么页面。