此次实现的功能是输入网址后生成全屏截图,可用于生成图片版文章,也可以用做图片二维码海报的生成器,可以支持各种尺寸定义,下面讲解实现的一些技术细节。
实现截图 主要使用的是 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' }) await page.screenshot({ path : '{保存图片的路径}' , fullPage : true }) await browser.close()
让网页接管截图动作 当我们使用浏览器截图方案作为内部业务方法使用的时候,为了保证生成图片的完整性,就需要等待异步操作执行完毕再开始截图,比如图片资源是否加载完成以及接口数据请求结果等判断,对于浏览器来说无能为力,而我们并不希望使用等待的方式来解决问题,而是让程序本身决定何时开始截图,这时我们可以通过 puppeteer 向页面注入一个全局方法,然后在目标页面中处理好资源的准备判断后,调用该方法,则可以实现页面对截图操作的控制。
1 2 3 4 5 6 7 8 await page.exposeFunction('loadFinishToInject' , async () => { 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' })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 function sleep (timeout = 10 ) { return new Promise ((resolve ) => { setTimeout (() => { resolve() }, timeout) }) }
使用调用: await sleep(1000)
模拟设备 当目标页面是移动端网页时,有时可能需要对浏览器ua进行模拟才能访问真实的页面(有些H5网页可能是通过判断ua来进入不同项目,而不是自适应或响应式)
模拟UA方法:
1 2 ua && page.setUserAgent(ua)
还有一种方法是模拟设备,该模式下会自动设置ua以及屏幕宽高等参数,可以创建自定义的设备,但是没必要,Puppeteer已经为我们做了很多预设,代码如下:
1 2 3 4 5 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 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 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++ 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() console .log('--> 加载完成,可以开始截图' )try { window .loadFinishToInject('done' ) } catch (err) {}
懒加载页面处理方法 有时我们会遇到截取页面资源是懒加载的情况,像生成第三方网页文章时会非常不稳定,而且也不能通过单纯的sleep
等待函数来解决问题。
所以我们需要加一个自动滚动的方法,来模拟真实的页面浏览触发页面的资源懒加载。
实现的核心是利用 Puppeteer
的 evaluate
函数,改方法可以在目标页面上下文中执行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) { clearInterval (interval) resolve() } else { lastScroll = scrollTop } }, 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 毫秒后开始截图(这里滚动只是触发了资源加载,如果不等待一下资源有可能没加载完)
最终结果如下,大部分页面的情况应该都差不多:
正常截图
加入自动滚动
页面打开处理方式 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标签打开页面
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()
这种方式可以利用浏览器缓存,让引用相同资源的页面在加载时更快,一个标签页占用的内存肯定比一个浏览器实例要少,也就提高了并行任务上限。
缺点是有常驻内存,时间长了免不了会出现内存泄漏等问题,所以定时重启实例释放内存还是有必要的。需要控制一个浏览器实例最大打开的标签页数量,避免开太多标签页导致卡顿。
「隐身」独立浏览器会话
除了使用 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 const quality = 90 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' ) 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 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 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) async printscreen(req: any , res: any ) {if (queueList.length > 100 ) { res.json({ code: 200 , msg: '任务繁忙,请稍候再试!' }) return } 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 module .exports = async (req: any, res : any, next : any) => { const { queueList } = require ('../utils/node-queue.ts' ) const time = 30000 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) .then(() => { if (!res.headersSent) { } }) .catch((e: any ) => { res.json({ code: 500 , e }) }) }
并发任务阈值设为1手动请求几次看看效果: 最终效果超时任务不会再执行,接口的返回也正常。
koa的超时处理方式与之类似,注意koa是洋葱模型,需要把异常捕获的中间件放在最前面
下面介绍一下在服务端部署的问题以及本地调试的方法。
本地调试 先说说本地如何调试,通常情况下使用 npm install
或 yarn
就会自动下载好浏览器的依赖了,不需要自己配置,在nodemodules里已经配好,本地调试的时候关闭掉无头浏览器 模式,运行puppeteer
的时候会看到程序调起一个蓝色logo的谷歌开发版浏览器并开始进行自动操作,代码如下:
1 2 3 4 5 const isDev = process.env.NODE_ENV === 'development' const browser = await puppeteer.launch({ headless: !isDev, })
服务器配置 在Linux的环境下,浏览器没有视图界面,此时就需要使用无头 的模式才能截图,linux浏览器正常来说npm会帮忙下载并引用好依赖,但是我实际情况下并未成功,索性自己安装浏览器并配置路径,等下会讲到,参考配置如下:
创建一份配置文件例如:config.js
1 2 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, executablePath: isDev ? null : executablePath, ignoreHTTPSErrors: true , 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"
第二种方式比较适合在公司部署服务,把镜像丢给运维一键启动就行,每次重新部署也就是重启整个容器,比较方便。