使用Node和Puppeteer實(shí)現(xiàn)批量生成PDF
引言
本文檔旨在記錄項(xiàng)目組使用Node.js和Puppeteer庫將網(wǎng)頁內(nèi)容轉(zhuǎn)換為PDF文件的過程。該方案旨在提供一種高效、穩(wěn)定的方法,以實(shí)現(xiàn)自動(dòng)化網(wǎng)頁內(nèi)容轉(zhuǎn)PDF的需求。
方案選型
- html2canvas + jsPDF
- Node Puppeteer + nb-fe-pdf(二次封裝的庫,支持前端渲染過程中對網(wǎng)頁進(jìn)行切割,支持動(dòng)態(tài)渲染多頁pdf)

技術(shù)選定
html2canvas+jsPDF的優(yōu)缺點(diǎn)
優(yōu)點(diǎn)
- 不依賴服務(wù)器,由前端獨(dú)立完成,定制化樣式強(qiáng)
- 性能高
- 支持局部內(nèi)容生成PDF
缺點(diǎn)
- 兼容性不夠好
- 不支持批量生成
- 導(dǎo)出為圖片,會出現(xiàn)模糊的問題
- pdf過大,關(guān)閉頁面會導(dǎo)致生成失敗
Node Puppeteer的優(yōu)缺點(diǎn)
優(yōu)點(diǎn)
- 兼容性好
- 支持批量生成
- PDF是矢量,放大縮小不會模糊,支持粘貼
- 服務(wù)端生成,關(guān)閉頁面不會導(dǎo)致生成PDF失敗
缺點(diǎn)
- 吞吐量限制,需要滿足用戶的批量生成
- 如果PDF過大,生成過程較長,需要通知前端生成階段和進(jìn)度
- 學(xué)習(xí)成本較高,需要更復(fù)雜的編碼
基于兩種方案的優(yōu)缺點(diǎn),怎么選擇呢
- 如果是支持局部頁面導(dǎo)出PDF,想快速通過當(dāng)前頁導(dǎo)出PDF,并且對清晰圖要求不那么高,不要求能直接粘貼,可以采用方案一
- 如果是對PDF要求比較高,要求高清并且支持文字粘貼,或者需要批量后臺生成,建議采用方案二
基于項(xiàng)目需求,需要在后臺批量生成PDF,所以最終選取方案二
項(xiàng)目實(shí)現(xiàn)
實(shí)現(xiàn)思路
- 前端通過頁面渲染成PDF預(yù)覽的樣子
- Node puppeteer通過模擬打開瀏覽器,并且生成PDF
- Node將生成的文件流上傳到CSP,并且返回前端一個(gè)csp的路徑,存儲到后端服務(wù)器
- 用戶點(diǎn)擊下載通過csp的路徑從CSP上直接下載
第一步: 前端頁面渲染,通過nb-fe-pdf第三庫進(jìn)行頁面切割
基于第三方庫nb-fe-pdf二次改造,支持自定寬高,支持文字截?cái)喙δ?,更好的class標(biāo)志,同時(shí)解決底部空白太多等問題,實(shí)現(xiàn)更完善的分頁功能。
參考:GitHub - Reesejia/nb-fe-pdf-1: html page to pdf file

實(shí)現(xiàn)原理
- @irp/fe-nb-pdf算法實(shí)現(xiàn)是在頁面dom渲染完成之后,根據(jù)標(biāo)記,將頁面分成一個(gè)一個(gè)小的模塊,然后通過計(jì)算木塊的高度,將這些小的模塊合理的放到PDF容器中
- 對于一個(gè)print-page-split-flag表示整個(gè)模塊需要放在同一頁中,如果需要將組件一拆分更細(xì),可以單獨(dú)給組件一里面的內(nèi)容各自加上print-page-split-flag
- 對于表格分頁實(shí)現(xiàn),首先給容器添加一個(gè)print-table標(biāo)志,然后再table上面添加print-table-wrapper的標(biāo)志,表格是需要這兩個(gè)標(biāo)志結(jié)合使用;對于表格的行class可以通過配置修改
- 對于文字截?cái)鄬?shí)現(xiàn),首先通過虛擬渲染,計(jì)算出特殊字符,英文字符以及中文字符的長度,然后再講文字遍歷,一行一行計(jì)算,然后再拼接每一行文字,給每一行文字添加print-page-split-flag,既可以實(shí)現(xiàn)文字分頁功能
第二步:Node puppeteer通過模擬打開瀏覽器,并且生成PDF
實(shí)現(xiàn)原理
- puppeteer通過pege.goto訪問指定頁面
- 然后等待頁面加載完成,可以通過監(jiān)聽全部請求是否加載完成或者通過監(jiān)聽頁面加載完成標(biāo)志
- 監(jiān)聽結(jié)束后,通過page.pdf來生成PDF buffer文件流
- 將buffer流返回到前端或者直接上傳到CSP
eg:
const browser = await puppeteer.launch({
executablePath: 'google-chrome-stable',
headless: true,
args: ['--disable-setuid-sandbox', '--no-sandbox']
})
// 打開瀏覽器
const context = await browser.createIncognitoBrowserContext() // 開啟無痕模式
const page = await context.newPage() // 打開一個(gè)空白頁
await page.goto('url', { timeout: 3000 })
await page.waitForSelector('.report-pages.load-finished', { timeout: 60000 })// 等待頁面加載完成
const bufferStr = await page.pdf({
scale: 1,
width: ctx.request.body.width,
height: ctx.request.body.height + 1, // 加1,解決多生成一個(gè)空白頁
// CSS
preferCSSPageSize: true,
// 開啟渲染背景色,因?yàn)?puppeteer 是基于 chrome 瀏覽器的,瀏覽器為了打印節(jié)省油墨,默認(rèn)是不導(dǎo)出背景圖及背景色的
// 坑點(diǎn),必須加
printBackground: true
// margin:{top:'2cm',right:'2cm',bottom:'2cm',left:'2cm'}
})
Puppeteer痛點(diǎn)
什么時(shí)機(jī)開始生成PDF
page.goto是通過網(wǎng)絡(luò)頁面加載,響應(yīng)速度依賴頁面的資源加載和網(wǎng)絡(luò)狀態(tài),或者前端頁面有報(bào)錯(cuò),會導(dǎo)致失敗,那node服務(wù)怎么確定什么時(shí)候開始取生成PDF呢?
答: 頁面在渲染組件的過程,在每個(gè)組件渲染結(jié)束后通知最外層自己渲染結(jié)束,外層頁面在監(jiān)聽到所有組件渲染完畢,就添加一個(gè)‘loaded-finished’ className的標(biāo)志。Puppetter在獲取到該className的再開始生成PDF

怎么實(shí)現(xiàn)批量生成PDF
一般就會想到循環(huán)遍歷就能實(shí)現(xiàn)批量,再深入想一點(diǎn),就是通過類似于隊(duì)列的方式保證隊(duì)列中至少有多少個(gè)程序在同時(shí)生成PDF
答:有兩種方式,一種是通過隊(duì)列的方式實(shí)現(xiàn),另一種方式通過worker的思想實(shí)現(xiàn)
方案一:
const handlePool = (urls, max, handler) => {
let i = 0 const ret = [] // 存儲所有的異步任務(wù)
const executing = [] // 存儲正在執(zhí)行的異步任務(wù)
const enqueue = function () {
if (i === urls.length) {
return Promise.resolve()
}
const item = urls[i++] // 獲取新的任務(wù)項(xiàng)
const p = Promise.resolve().then(() =>
handler(item, urls))
ret.push(p)
let r = Promise.resolve() // 當(dāng)poolLimit值小于或等于總?cè)蝿?wù)個(gè)數(shù)時(shí),進(jìn)行并發(fā)控制
if (max <= urls.length) { // 當(dāng)任務(wù)完成后,從正在執(zhí)行的任務(wù)數(shù)組中移除已完成的任務(wù)
const e = p.then(() => executing.splice(executing.indexOf(e), 1)).catch((error) => { })
executing.push(e)
if (executing.length >= max) {
r = Promise.race(executing)
}
} // 正在執(zhí)行任務(wù)列表 中較快的任務(wù)執(zhí)行完成之后,才會從array數(shù)組中獲取新的待辦任務(wù)
return r.then(() => enqueue())
}
return enqueue().then(() => Promise.all(ret)).catch(error => {
console.error('handlePool', error)
})}
方案二:
寫一個(gè)MainWorker類,控制任務(wù)(job)生成器,然后通過node的EventEmitter事件通知job開始和結(jié)束

class MainWorker extends EventEmitter {
constructor(ctx, jobCount) {
super()
this.ctx = ctx
this.pagePools = [] // 記錄每個(gè)job的信息
this.jobCount = jobCount || 6 this.instance = null // 生成jobCount個(gè)job,用來后面
this.createJobs()
} createJob(){
return new Promise((resolve, reject) => {
this.ctx.pool.use(async instance => { // instance為瀏覽器實(shí)例,寫處理邏輯的handler
const page = await instance.newPage()
const jobId = Util.token() // 隨機(jī)數(shù)ID
this.pagePools.push({
jobId: jobId, instance, page, isIdle: true })
resolve() }) }) }
async createJobs(){
for (let i = 0;i < this.jobCount;i++){
await this.createJob()
}
} // 調(diào)用開始就是調(diào)用job開始工作
async start() {
this.pagePools.forEach(el => {
this.send({ type: 'jobReady', jobId: el.jobId }) }) }}
線上node服務(wù)生成PDF會經(jīng)常失敗,在本地運(yùn)行不會報(bào)錯(cuò)
查了很久的原因,發(fā)現(xiàn)上述流程是啟動(dòng)一個(gè)瀏覽器實(shí)例,多個(gè)tab頁(page)的時(shí)候,在k8s里面會經(jīng)常goto失敗,監(jiān)控內(nèi)存和cpu都顯示正常,但是經(jīng)常會失敗,導(dǎo)致PDF生成失?。?/p>
解決辦法:采取啟動(dòng)多個(gè)瀏覽器,每一個(gè)瀏覽器只對應(yīng)一個(gè)tab,解決了這個(gè)問題。(雖然沒找到為什么,但是這樣解決了這個(gè)問題,如果大家沒遇到這樣的問題,就可以不用這樣處理了)

這里就用generic-pool在node服務(wù)啟動(dòng)的時(shí)候,就生成多個(gè)Puppetter Instance池,等需要用的時(shí)候,就拿一個(gè)空閑的Puppetter Instance去使用
const puppeteer = require('puppeteer')
const genericPool = require('generic-pool')
/** * 初始化一個(gè) Puppeteer 池
* @param {Object} [options={}] 創(chuàng)建池的配置配置
* @param {Number} [options.max=30] 最多產(chǎn)生多少個(gè) puppeteer 實(shí)例 。如果你設(shè)置它,請確保 在引用關(guān)閉時(shí)調(diào)用清理池。 pool.drain().then(()=>pool.clear())
* @param {Number} [options.min=15] 保證池中最少有多少個(gè)實(shí)例存活
* @param {Number} [options.maxUses=2048] 每一個(gè) 實(shí)例 最大可重用次數(shù),超過后將重啟實(shí)例。0表示不檢驗(yàn)
* @param {Number} [options.testOnBorrow=2048] 在將 實(shí)例 提供給用戶之前,池應(yīng)該驗(yàn)證這些實(shí)例。
* @param {Boolean} [options.autostart=false] 是不是需要在 池 初始化時(shí) 初始化 實(shí)例
* @param {Number} [options.idleTimeoutMillis=3600000] 如果一個(gè)實(shí)例 60分鐘 都沒訪問就關(guān)掉他
* @param {Number} [options.evictionRunIntervalMillis=180000] 每 3分鐘 檢查一次 實(shí)例的訪問狀態(tài)
* @param {Object} [options.puppeteerArgs={}] puppeteer.launch 啟動(dòng)的參數(shù)
* @param {Function} [options.validator=(instance)=>Promise.resolve(true))] 用戶自定義校驗(yàn) 參數(shù)是 取到的一個(gè)實(shí)例
* @return {Object} pool */
const initPuppeteerPool = (options = { otherConfig: {} }) => {
const { max = 20, min = 10, maxUses = 2048, testOnBorrow = true, autostart = false, idleTimeoutMillis = 3600000, evictionRunIntervalMillis = 180000,
puppeteerArgs = {
executablePath: 'google-chrome-stable', // executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', // executablePath: 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', // 寶丹me路徑 // executablePath: 'google-chrome-stable',
headless: true,
devtools: false,
defaultViewport: { width: 1920, height: 1080 },
slowMo: 200,
args: [
'--no-sandbox',
'--unlimited-storage',
'--full-memory-crash-report',
'--disable-gpu',
'--disable-gpu-sandbox',
'--disable-gl-drawing-for-tests',
'--ignore-certificate-errors',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--lang=en-US;q=0.9,en;q=0.8',
'--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36'
]
},
validator = () => Promise.resolve(true)
} = options
const factory = {
create: () =>
puppeteer.launch(puppeteerArgs).then(async(browser) => {
browser.on('targetdestroyed', () => {
console.log('1111 page closed')
})
browser.on('disconnected', () => {
console.log('disconnected')
})
// 創(chuàng)建一個(gè) puppeteer 實(shí)例 ,并且初始化使用次數(shù)為 0
const instance = await browser.createIncognitoBrowserContext() // 開啟無痕模式
instance.useCount = 0
return instance
}),
destroy:async (instance) => {
const pages = await instance.pages()
console.log('實(shí)例 close',pages)
for(let i=0,l=pages.length;i<l;i++){
await pages[i].close()
}
await instance.close()
await browser.close()
},
validate: instance => {
// 執(zhí)行一次自定義校驗(yàn),并且校驗(yàn)校驗(yàn) 實(shí)例已使用次數(shù)。 當(dāng) 返回 reject 時(shí) 表示實(shí)例不可用
return validator(instance).then(valid => Promise.resolve(valid && (maxUses <= 0 || instance.useCount < maxUses)))
}
}
const config = { max, min, testOnBorrow, autostart, idleTimeoutMillis, evictionRunIntervalMillis }
const pool = genericPool.createPool(factory, config)
const genericAcquire = pool.acquire.bind(pool)
// pool.drain = pool.acquire.bind(pool)
// 重寫了原有池的消費(fèi)實(shí)例的方法。添加一個(gè)實(shí)例使用次數(shù)的增加
pool.acquire = () => genericAcquire().then(instance => {
instance.useCount += 1
return instance
})
// pool.drain = () =>{}
pool.use = fn => {
let resource
// let page
return pool
.acquire()
.then(r => {
resource = r
// page = resource.newPage()
return resource
})
.then(fn)
.then(
result => {
// 不管業(yè)務(wù)方使用實(shí)例成功與后都表示一下實(shí)例消費(fèi)完成
pool.release(resource)
return result
},
err => {
pool.release(resource)
throw err
}
).catch(err => {
pool.release(resource)
throw err
})
}
return pool
}
module.exports = initPuppeteerPool
以上就是使用Node和Puppeteer實(shí)現(xiàn)批量生成PDF的詳細(xì)內(nèi)容,更多關(guān)于Node Puppeteer生成PDF的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
windows系統(tǒng)下安裝yarn的詳細(xì)教程
yarn是一個(gè)新的JS包管理工具,它的出現(xiàn)是為了彌補(bǔ)npm的一些缺陷,下面這篇文章主要給大家介紹了關(guān)于windows系統(tǒng)下安裝yarn的詳細(xì)教程,文中通過圖文介紹的非常詳細(xì),需要的朋友可以參考下2023-02-02
Node.js Continuation Passing Style( CPS與
這篇文章主要介紹了Node.js Continuation Passing Style,將回調(diào)函數(shù)作為參數(shù)傳遞,這種書寫方式通常被稱為Continuation Passing Style(CPS),它的本質(zhì)仍然是一個(gè)高階函數(shù),CPS最初是各大語言中對排序算法的實(shí)現(xiàn)2022-06-06

