此次实现的功能是输入网址后生成全屏截图,可用于生成图片版文章,也可以用做图片二维码海报的生成器,可以支持各种尺寸定义,下面讲解实现的一些技术细节。

实现截图

主要使用的是 puppeteer 这个库,最简单地实现一个网页截图,只需要几行代码即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const puppeteer = require('puppeteer') // 引入

// 启动浏览器
const browser = await puppeteer.launch({
headless: false, // 本地测试先关掉无头模式
})
// 创建一个页面
const page = await browser.newPage()
// 设置浏览器视窗
page.setViewport({
width: 300,
height: 600,
})
// 输入网页地址
await page.goto(url, { waitUntil: 'domcontentloaded' })
// 开始截图,全屏截图的关键参数就是这个fullPage,页面会一直滚动到底
await page.screenshot({ path: '{保存图片的路径}', fullPage: true })
// 关闭浏览器
await browser.close()

让网页接管截图动作

当我们使用浏览器截图方案作为内部业务方法使用的时候,为了保证生成图片的完整性,就需要等待异步操作执行完毕再开始截图,比如图片资源是否加载完成以及接口数据请求结果等判断,对于浏览器来说无能为力,而我们并不希望使用等待的方式来解决问题,而是让程序本身决定何时开始截图,这时我们可以通过 puppeteer 向页面注入一个全局方法,然后在目标页面中处理好资源的准备判断后,调用该方法,则可以实现页面对截图操作的控制。

c3d71e17-e9bf-49ae-9402-ccbd174bf5ce.png

1
2
3
4
5
6
7
8
//  puppeteer   注入全局方法

await page.exposeFunction('loadFinishToInject', async () => {
// console.log('-> 开始截图')
await page.screenshot({ path, fullPage: true })
// 关闭浏览器
await browser.close()
})
1
2
3
4
5
6
7
8
9
// 目标页面/业务页面/截图页面:

..... Some Preload Code Function ....

console.log('--> 可以开始截图')

try {
window.loadFinishToInject() // 触发截图方法
} catch (err) {}

立即开始截图

通常情况下,我们可能只需要等待网页加载完成后就立即生成截图:

1
2
3
4
5
// 方法一:利用自带事件
await page.goto(url, { waitUntil: 'domcontentloaded' })
// console.log('-> 开始截图')
await page.screenshot({ path })
await browser.close()

waitUntil 说明:

  • load: window.onload 事件被触发时继续。
  • domcontentloaded: Domcontentloaded 事件触发时继续。
  • networkidle0: 在 500ms 内没有网络连接时(全部的request结束)则继续。
  • networkidle2: 500ms 内有不超过 2 个网络连接时就算成功(还有两个以下的request)则继续。
1
2
3
4
5
6
// 方法二:利用回调函数
page.on('load', async () => {
// 开始截图
await page.screenshot({ path, fullPage: true })
await browser.close()
})

页面等待

有时我们可能希望让页面等待一段时间再执行截图,当使用await page.waitFor(1000)来让页面等待时会提示该方法将被弃用:

waitFor is deprecated and will be removed in a future release. See https://github.com/puppeteer/puppeteer/issues/6214 for details and how to migrate your code.

所以我们自己简单实现一个

1
2
3
4
5
6
7
8
// Puppeteer基于node环境,对js新语法支持度非常好,可以用promise实现
function sleep(timeout = 10) {
return new Promise((resolve) => {
setTimeout(() => {
resolve()
}, timeout)
})
}

使用调用: await sleep(1000)

模拟设备

当目标页面是移动端网页时,有时可能需要对浏览器ua进行模拟才能访问真实的页面(有些H5网页可能是通过判断ua来进入不同项目,而不是自适应或响应式)

模拟UA方法:

1
2
// const ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1'
ua && page.setUserAgent(ua)

还有一种方法是模拟设备,该模式下会自动设置ua以及屏幕宽高等参数,可以创建自定义的设备,但是没必要,Puppeteer已经为我们做了很多预设,代码如下:

1
2
3
4
5
6
// ua && page.setUserAgent(ua)
// const devices = 'iPhone 6'
if (devices) {
devices = puppeteer.devices[devices]
devices && (await page.emulate(devices))
}

预设列表我也整理出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
const DevicesNames = [
'Blackberry PlayBook',
'Blackberry PlayBook landscape',
'BlackBerry Z30',
'BlackBerry Z30 landscape',
'Galaxy Note 3',
'Galaxy Note 3 landscape',
'Galaxy Note II',
'Galaxy Note II landscape',
'Galaxy S III',
'Galaxy S III landscape',
'Galaxy S5',
'Galaxy S5 landscape',
'iPad',
'iPad landscape',
'iPad Mini',
'iPad Mini landscape',
'iPad Pro',
'iPad Pro landscape',
'iPhone 4',
'iPhone 4 landscape',
'iPhone 5',
'iPhone 5 landscape',
'iPhone 6',
'iPhone 6 landscape',
'iPhone 6 Plus',
'iPhone 6 Plus landscape',
'iPhone 7',
'iPhone 7 landscape',
'iPhone 7 Plus',
'iPhone 7 Plus landscape',
'iPhone 8',
'iPhone 8 landscape',
'iPhone 8 Plus',
'iPhone 8 Plus landscape',
'iPhone SE',
'iPhone SE landscape',
'iPhone X',
'iPhone X landscape',
'Kindle Fire HDX',
'Kindle Fire HDX landscape',
'LG Optimus L70',
'LG Optimus L70 landscape',
'Microsoft Lumia 550',
'Microsoft Lumia 950',
'Microsoft Lumia 950 landscape',
'Nexus 10',
'Nexus 10 landscape',
'Nexus 4',
'Nexus 4 landscape',
'Nexus 5',
'Nexus 5 landscape',
'Nexus 5X',
'Nexus 5X landscape',
'Nexus 6',
'Nexus 6 landscape',
'Nexus 6P',
'Nexus 6P landscape',
'Nexus 7',
'Nexus 7 landscape',
'Nokia Lumia 520',
'Nokia Lumia 520 landscape',
'Nokia N9',
'Nokia N9 landscape',
'Pixel 2',
'Pixel 2 landscape',
'Pixel 2 XL',
'Pixel 2 XL landscape'
]

设置像素比

针对移动端的页面,截图效果可能会比较模糊,我们可以通过提高像素比来增加分辨率,获得更好的图片效果(类似设备DPR),不过参数越高生成速度与性能消耗也会越大,建议加个阈值。

1
2
3
4
5
6
// 设置浏览器视窗
page.setViewport({
width: Number(width),
height: Number(height),
deviceScaleFactor: !isNaN(scale) ? (+scale > 4 ? 4 : +scale) : 1, // 默认为1,阈值为4
})

对页面某个元素截图

实际项目中没有使用到的需求场景,简单做下记录

1
2
let [element] = await page.$x('/html/body/section[4]/div/div[2]')
await element.screenshot({ path: 'xxx.png' })

如何在页面中判断图片已加载完成

主动调用注入函数进行截图的场景,通常都是我们自己的业务页面,这时我们可以在页面中对资源加载情况进行判断,来决定截图的时机。

起初我的想法是把图片url链接传进一个处理函数,函数中用Image对象加载src,当实例对象触发onload时就接着下一个,直到全部处理完毕那么也就算资源加载完成,但是很快我发现这样处理并不对,因为这个函数实际是异步的,很可能先加载完了资源但是该资源在页面中却并未加载完成,所以正确的方式应该是获取到页面当中真实的资源DOM节点,传入这个函数中处理,而且onload回调也不一定正确,经测试最稳定的方式是轮询complete属性来确定是否真的加载完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Preload.ts 参考代码
export default class PreLoad {
private i: number
private arr: any[]
constructor(arr: string[]) {
this.i = 0
this.arr = arr
}
public doms() {
return new Promise((resolve: Function) => {
const work = () => {
if (this.i < this.arr.length) {
this.arr[this.i].complete && this.i++ // 核心是轮询img节点的complete属性
setTimeout(() => {
work()
}, 100)
} else {
resolve()
}
}
work()
})
}
}

假设业务页面当中,内容是通过接口请求到前端渲染,为每个图片的div容器我都加上了img__box这个class样式,即<div class="img__box"> <img /> </div>这种形式,那么我可以这么处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
const imgsData = []
const cNodes = document.querySelectorAll('.img__box')
for (const el of cNodes) {
imgsData.push(el.firstChild)
}

const preload = new Preload(imgsData)
await preload.doms() // 实例化上面的Preload函数,开始轮询资源

console.log('--> 加载完成,可以开始截图')
try {
window.loadFinishToInject('done') // 触发`Puppeteer`的注入方法
} catch (err) {}

懒加载页面处理方法

有时我们会遇到截取页面资源是懒加载的情况,像生成第三方网页文章时会非常不稳定,而且也不能通过单纯的sleep等待函数来解决问题。

所以我们需要加一个自动滚动的方法,来模拟真实的页面浏览触发页面的资源懒加载。

实现的核心是利用 Puppeteerevaluate 函数,改方法可以在目标页面上下文中执行JS代码,简单的触底判断:比较两次滚动后的scrollTop是否一致,如果一致就是页面不再往下滚了,即判断为触底。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 创建自动滚动函数
async function autoScroll() {
await page.evaluate(async () => {
await new Promise((resolve, reject) => {
try {
const maxScroll = Number.MAX_SAFE_INTEGER
let lastScroll = 0
const interval = setInterval(() => {
window.scrollBy(0, 100)
const scrollTop = document.documentElement.scrollTop || window.scrollY
if (scrollTop === maxScroll || scrollTop === lastScroll) { // 判断触底,或超出js最大安全长度
clearInterval(interval)
resolve()
} else {
lastScroll = scrollTop
}
}, 100) // 100毫秒执行间隔
} catch (err) {
console.log(err)
reject(err)
}
})
})
}

在截图前加入该函数:

1
2
3
4
5
6
7
8
page.on('load', async () => {
await autoScroll() // 自动截图时先模拟滚动
await sleep(wait) // 前面实现的等待方法
// 开始截图
await page.screenshot({ path, fullPage: true })
// 关闭浏览器
await browser.close()
})

这样当页面加载完成后,就会触发自动滚动,每100毫秒向下滚100像素,直到触底为止,跳出Promise,配合前面我们实现的wait参数,等待 x 毫秒后开始截图(这里滚动只是触发了资源加载,如果不等待一下资源有可能没加载完)

最终结果如下,大部分页面的情况应该都差不多:

正常截图 加入自动滚动
image.png image.png

页面打开处理方式

1. 每个页面由单独的浏览器实例打开

前面都是使用该方式,所以每次执行完毕之后都会手动关闭浏览器以释放内存,可以设置一个超时处理模块来销毁浏览器实例:

1
2
3
4
5
6
7
8
9
10
const forceTimeOut = 60000 // 超时时间限制

const regulators = setTimeout(() => {
browser && browser.close()
console.log('强制释放浏览器')
}, forceTimeOut)

// 关闭浏览器
await browser.close()
clearTimeout(regulators)

这种方式的好处是能保证单次任务执行的稳定性,每次执行完毕都销毁浏览器,没有常驻内存。

缺点是无法充分利用到浏览器缓存。存在多个并行任务时内存消耗会更大,需要用队列控制一下,降低了任务并行上限。

2. 在Tab标签打开页面

2022-06-13 10.53.14.gif

1
2
3
4
5
6
7
8
9
10
11
// 启动浏览器,启动后不再销毁该实例
const browser = await puppeteer.launch({
........
})
// 创建复数个新页面
const pageA = await browser.newPage()
const pageB = await browser.newPage()
.....
await pageB.goto(url, { waitUntil: 'domcontentloaded' })
// 关闭标签页
await pageA.close()

这种方式可以利用浏览器缓存,让引用相同资源的页面在加载时更快,一个标签页占用的内存肯定比一个浏览器实例要少,也就提高了并行任务上限。

缺点是有常驻内存,时间长了免不了会出现内存泄漏等问题,所以定时重启实例释放内存还是有必要的。需要控制一个浏览器实例最大打开的标签页数量,避免开太多标签页导致卡顿。

「隐身」独立浏览器会话

2022-06-13 10.57.04.gif

除了使用 puppeteer.launch 创建浏览器实例以外,还可以用另一种方式,在同个浏览器实例下创建多个「隐身」独立浏览器会话,效果其实和创建多个实例差不多,不能共享缓存等信息(有点像打开新的窗口),但是销毁 browser 的时候会把所有上下文都一并关闭,这种方式官方的说法是: 「隐身」浏览器上下文不会将任何浏览数据写入磁盘,感觉在业务上没啥作用,不过还是记录一下:

1
2
3
4
5
6
7
8
9
10
11
12
// 启动浏览器,启动后不再销毁该实例
const browser = await puppeteer.launch({
........
})
// 创建浏览器上下文
const newContextA = await browser.createIncognitoBrowserContext()
const newContextB = await browser.createIncognitoBrowserContext()
// 在不同的窗口中打开新标签页
const pageA = await newContextA.newPage()
const pageB = await newContextA.newPage()
const page1 = await newContextB.newPage()
const page2 = await newContextB.newPage()

生成缩略图

截图生成的图片是未经处理的,在原始分辨率下可能会比较大,在实际业务中不适合直接展示,如果图片上传至OSS服务中可以方便地获得各种尺寸缩略图,否则可以使用 images 这个库在每次截图后顺便生成一下缩略图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const images = require('images')
const size = 300 // 等比缩放到300像素宽
const quality = 90 // 压缩质量:1-100,质量越小图片占用空间越小

const filePath = process.env.NODE_ENV === 'development' ? process.cwd() + `/static/` : '/cache/'
// 保存截图的路径及文件名
const path = filePath + `screenshot_${new Date().getTime()}.png`
// 保存缩略图的文件名,路径及名称和截图一样,只是把后缀改了
const thumbPath = path.replace('.png', '.jpg')

// 创建浏览器
// ...........
// console.log('-> 开始截图')
await page.screenshot({ path, fullPage: true })

// 生成缩略图
compress()

function compress() {
// 压缩图片
thumbPath &&
images(path).size(+size || 300).save(thumbPath, { quality: +quality || 70 })
} catch (err) {
console.log(err)
}
}

JS实现异步任务队列

由于截图服务每次执行任务都需要占用不少时间和性能,且服务器资源有限,如果截图服务并发请求数量过高,必然会引起问题,此时就需要一个异步任务队列来对并发的浏览器数量进行控制。

1
2
// 在前面创建的配置文件 config.ts 中加入配置
exports.maxNum = 1 // 截图队列并发数(阈值)

队列方法参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// node-queue.ts
interface Queue {
Fn: Function
sign?: string | number
}

const { maxNum } = require('../configs.ts')
const queueList: any = [] // 任务队列
let curNum = 0 // 当前执行的任务数
// 队列处理器,将超过允许并行数的任务抛进队列中等待执行
function queueRun(business: Function, ...arg: any) {
return new Promise(async (resolve) => {
const Fn = async () => resolve(await business(...arg)) // 核心是将业务函数封装起来
const sign = { ...arg }[2] // 标记任务的参数,后面会用到
if (curNum >= maxNum) {
queueList.push({ sign, Fn })
} else {
await run(Fn)
}
})
}
// 任务执行器,将队列中等待的任务取出执行
function run(Fn: Function) {
curNum++
Fn().then((res: any) => {
curNum--
if (queueList.length > 0) { // 如果存在任务,继续取出执行
const Task: Queue = queueList.shift()
run(Task.Fn)
}
return res
})
}

module.exports = { queueRun, queueList }

请求服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { queueRun, queueList } = require('../utils/node-queue.ts')
const screenshotFn = require(xxxxxx) // 这里引入你的截图方法
// ...... 这里使用的是一个express框架的服务,只贴出部分关键代码 .....
async printscreen(req: any, res: any) {
if (queueList.length > 100) { // 限制在达到某个最大队列数量时直接暂停服务
res.json({ code: 200, msg: '任务繁忙,请稍候再试!' })
return
}
// 进入队列处理,最终执行的是:screenshotFn(url, { width, height ...等参数.. })
queueRun(screenshotFn, url, { width, height ...等参数.. })
.then(() => {
res.setHeader('Content-Type', 'image/jpg') // 请求结果将直接返回图片
type === 'file' ? res.sendFile(path) : res.sendFile(thumbPath)
})
.catch((e: any) => {
res.json({ code: 500, e })
})
}

express超时任务销毁

有了前面的队列处理,在任务跑满的情况下基本可以将性能稳定下来,但是服务的健壮性还有所欠缺。假设现在有100个任务同时并发,每个任务的平均处理时间约为10秒,并发任务阈值为1,那么处理到第30个任务的时候已经过了约5分钟的时间了,浏览器默认超时时间大概也就4到5分钟(大部分的项目可能会手动设置一个30~60秒的请求超时)在http请求中如果浏览器超时了,服务端是不知道的,而我们的任务还在不断执行着,于是后面这几十条任务等于没办法把结果返回给前端了,因为链接早已断开。

此时我们需要约定一个超时时间,这里的超时时间最好是服务端<客户端,这样超时的时候请求还在,可以将错误信息返回给前端。

在服务端还需要做一个超时任务销毁的动作,因为超时的任务实际上已经没有存在队列里的必要了,如果不断叠加请求,队列中的无效任务一直在耗时处理,那么后面的所有任务都将会无法正常执行。

中间件 timeout 代码参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// timeout.ts
module.exports = async (req: any, res: any, next: any) => {
const { queueList } = require('../utils/node-queue.ts')
const time = 30000 // 设置所有HTTP请求的服务器响应超时时间
res.setTimeout(time, () => {
const statusCode = 408
const index = queueList.findIndex((x: any) => x.sign === req._queueSign)
if (index !== -1) { // 如果超时任务存在队列中,则移除该任务
queueList.splice(index, 1)
if (!res.headersSent) { // 如果链接还存在,则返回错误信息
res.status(statusCode).json({
statusCode,
message: '响应超时,任务已取消,请重试',
})
}
}
})
next()
}

这里的 sign 在前面队列方法中也有传入,主要是为了给任务一个标记,在超时处理中才好找出对应的任务,只要是不重复的字符即可,这里简单使用时间戳来标记。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 请求服务的部分关键代码
async printscreen(req: any, res: any) {
const sign = new Date().getTime() + '' // 时间戳标记
req._queueSign = sign // 在此次请求中记录标记值
queueRun(screenshotFn, url, { width, height ...等参数.. }, sign) // 增加了第三个参数,对应前面的{ ...arg }[2]
.then(() => {
if (!res.headersSent) {
// TODO 正常请求的返回动作
}
})
.catch((e: any) => {
res.json({ code: 500, e })
})
}
1
2
// ........
app.use(handleTimeout) // 别忘了注册中间件

并发任务阈值设为1手动请求几次看看效果:
image.png
最终效果超时任务不会再执行,接口的返回也正常。

koa的超时处理方式与之类似,注意koa是洋葱模型,需要把异常捕获的中间件放在最前面

下面介绍一下在服务端部署的问题以及本地调试的方法。

本地调试

先说说本地如何调试,通常情况下使用 npm installyarn 就会自动下载好浏览器的依赖了,不需要自己配置,在nodemodules里已经配好,本地调试的时候关闭掉无头浏览器模式,运行puppeteer的时候会看到程序调起一个蓝色logo的谷歌开发版浏览器并开始进行自动操作,代码如下:

1
2
3
4
5
const isDev = process.env.NODE_ENV === 'development' // node下判断是生产环境还是dev环境
// 启动浏览器
const browser = await puppeteer.launch({
headless: !isDev, // 本地调试时关闭无头
})

服务器配置

在Linux的环境下,浏览器没有视图界面,此时就需要使用无头的模式才能截图,linux浏览器正常来说npm会帮忙下载并引用好依赖,但是我实际情况下并未成功,索性自己安装浏览器并配置路径,等下会讲到,参考配置如下:

创建一份配置文件例如:config.js

1
2
// 服务器上的浏览器路径,按你的实际路径来,默认安装路径都是在 /opt/google 下面
exports.executablePath = '/opt/google/chrome-unstable/chrome'
1
2
3
4
5
6
7
8
9
10
11
12
const isDev = process.env.NODE_ENV === 'development' // 环境判断
const { executablePath } = require('../configs') // 引入上面的配置

// 启动浏览器
const browser = await puppeteer.launch({
headless: !isDev, // 生产环境时为true,即为打开无头模式
executablePath: isDev ? null : executablePath, // 服务器上指定调用linux浏览器
ignoreHTTPSErrors: true, // 忽略https安全提示阻塞页面
// 下面一些优化的选项,涉及关闭沙箱模式、禁用gpu、禁用共享内存等,照着配就行
args: ['–no-first-run', '–single-process', '–disable-gpu', '–no-zygote', '–disable-dev-shm-usage', '--no-sandbox', '--disable-setuid-sandbox'],
defaultViewport: null,
})

一些可能用到的linux命令参考:

1
2
3
4
google-chrome --version # 查看浏览器版本号

apt-get update
apt-get install -y google-chrome-stable // 安装最新稳定版谷歌浏览器

Docker容器

可以通过docker运行一个带linux浏览器的容器,然后暴露一个截图服务以供使用,我使用的基础镜像为:

1
docker pull howard86/puppeteer_node:12

运行容器参考(其中映射/cache为临时目录,放生成图片用):

1
docker run -itd -v /data/docker-home:/home -v /data/cache:/cache -p 7001:7001 --name screenshot howard86/puppeteer_node:12

运行后可以手动进入容器中查看谷歌浏览器版本,看需不需要升级,安装pm2作为服务启动工具,服务启动/重部署相关脚本命令参考:

1
2
3
4
docker exec screenshot /bin/bash -c 'pm2 delete screenshot-service'
docker exec screenshot /bin/bash -c 'cd /home/ && yarn'
docker exec screenshot /bin/bash -c 'pm2 start /home/screenshot-service.js'
docker exec screenshot /bin/bash -c 'pm2 flush'

另一种方式是在本地/服务器先运行镜像,进入容器中配置好pm2,然后把做好的容器导出为新的镜像,例如:new-design/screenshot,命令运行参考:

1
docker run -itd -u root -v ~/data/tmp/screenshot:/cache -p 9001:9001 --name screenshot2 new-design/screenshot /bin/sh  -c "/usr/local/bin/pm2 start /home/dist/server.js && /usr/local/bin/pm2 flush"

第二种方式比较适合在公司部署服务,把镜像丢给运维一键启动就行,每次重新部署也就是重启整个容器,比较方便。