nuxt3搭建中间层服务html生成PDF方案: 基于nuxt3 + puppeteer
简介
后端根据HTML生成PDF的常见的方案主要有wkhtmltopdf, Headless Chrome, and LibreOffice 三种。
本项目使用 nuxt3 + puppeteer 在服务器搭建一个服务, 作为客户端和后端之间的中间层, 负责渲染生成PDF的页面,启动一个Headless Chrome用于生成PDF。
之所以叫中间层,可以看以下流程对比:
在这里,nuxt3是一个在客户端和后端之间,专门用于生成PDF的服务,因此我称为中间层服务
代码和安装
代码仓库: nuxt3_puppeteer_pdf_app: nuxt3 + puppeteer 作为中间层用于生成PDF文件 (gitee.com)
运行:
# pnpm
pnpm install --shamefully-hoist
npm run dev
运行效果:
生成效果:
测试中,生成90+页的PDF大概需要7-12S不等, 不过我的电脑比较老旧了。以实际为准。
代码目录结构和详解
核心代码展示:
report.js
let handlingCount = 0; // 正在处理的请求数
let global = {}; // 全局数据
import puppeteer from "puppeteer";
import fs from 'fs';
import dayjs from 'dayjs';
//import axios from "axios";
async function launchBrowser() {
try {
const browser = await puppeteer.launch({
/* args: ['--no-sandbox', '--disable-setuid-sandbox', '--enable-accelerated-2d-canvas', '--enable-aggressive-domstorage-flushing'], */
args: [
'–disable-gpu',
'–disable-dev-shm-usage',
'–disable-setuid-sandbox',
'–no-first-run',
'–no-sandbox',
'–no-zygote',
'–single-process'
],
ignoreHTTPSErrors: true,
headless: true,
timeout: 60000,
});
const wsAddress = browser.wsEndpoint();
const w_data = Buffer.from(wsAddress);
global.wsa = w_data;
global.browser = browser;
console.log('chrome内核启动成功')
} catch (e) {
console.log(e)
}
}
launchBrowser();
async function getNewPage() {
const browser = await puppeteer.connect({
browserWSEndpoint: global.wsa
});
console.log('open Page')
return browser.newPage();
}
export default eventHandler(async(req, res) => {
// 连接数 + 1
++handlingCount;
console.log(`正在处理的连接数${handlingCount}`)
// 未超过最大连接数,进行生成
if (handlingCount <= 12) {
const startTime = dayjs(); // 打印开始时间
const page = await getNewPage(); // 开启一个新的标签页
await page.goto('http://localhost:3000/pdf', { waitUntil: 'networkidle0' }) // 等待响应加载
// 页头模板, 详情查看 http://puppeteerjs.com/#?product=Puppeteer&version=v19.2.2&show=api-pagepdfoptions
const header = `
<div style="width:calc(100% - 28px);margin-top: -20px; font-size:8px; padding:15px 14px;display: flex; justify-content: space-between; border-bottom:1px solid #333; font-weight: bold;">
<div style="width: 100%; display: flex; align-items:center;">
<span style="color: #333; font-size: 12px;"> 我是页头 </span>
<span style="margin-left: 32px;font-size: 12px;">
作者: <a style=" font-size: 12px;" href="https://juejin.cn/user/4332493267283560" type="primary"> Damon </a>
</span>
</div>
</div>`
// 页尾模板, 详情查看 http://puppeteerjs.com/#?product=Puppeteer&version=v19.2.2&show=api-pagepdfoptions
const footer = `
<div style="width:calc(100% - 28px);margin-bottom: -20px; font-size:8px; padding:12px 14px;border-top:1px solid #333; font-weight: bold; text-align: left;">
<div style="width: 100%; display: flex; justify-content: space-between;">
<span style="color: #333; font-size: 12px;width: 45%;">
<span> 我是左页脚 </span>
</span>
<span style="color: #333; font-size: 12px;width: 45%;">
<span> 我是右页脚 </span>
</span>
</div>
<div style="width: 100%; text-align:center; margin-top: 8px; font-size: 12px;">
第 <span style="color: #333; font-size: 12px;" class="pageNumber"></span> 页
</div>
</div>`
// PDF尺寸设置
const pdfConfig = {
//纸张尺寸
format: 'A4',
//打印背景,默认为false
//printBackground: true,
/* width: '1300px',
height: '1848px', */
//不展示页眉
margin: {
top: '110px',
bottom: '150px',
left: '20px',
right: '30px',
},
/* format: {
width: 550
}, */
// scale: 0.99,
displayHeaderFooter: true, // 是否展示页头页脚
preferCSSPageSize: true, // 页面优先级声明CSS
printBackground: true, // 是否打印背景,CSS
footerTemplate: footer, // 页尾模板
headerTemplate: header // 页眉模板 /* (await axios.get('http://localhost:3000/header')).data */
}
await page.addStyleTag({
content: '@page { size: 1588px 2246px; }', // 设置页面高度和宽度
})
const result = await page.pdf(pdfConfig); // 生成 PDF
const endTime = dayjs(); // 获取结束时间
console.log(`生成时间: ${ endTime.diff(startTime, 'second', true) }s`); // 生成耗时
fs.writeFileSync(`./${dayjs().format('YYYY_MM_DD_HH_mm_ss')}.pdf`, result) // 写入文件
await page.close(); // 关闭标签页
--handlingCount;
return {
code: 200,
message: '返回成功',
blob: result
}
}
// 超过最大处理任务则返回提示
else {
--handlingCount;
console.log('当前人数过多,请稍后再尝试')
return {
code: 202,
message: '当前人数过多,请稍后再尝试'
}
}
})
启动了一个Headless Chrome, 每次请求该接口,判断是否超过最大的窗口数,否则新建一个标签页page
,再去请求 /pdf 页面,/pdf 渲染响应前先去请求 /api/data的接口(模拟后端数据接口),获取数据渲染返回页面,page
再通过page.pdf
生成PDF文件流返回。
/api/data 接口 这个接口模拟了后端接口返回的数据,
export default eventHandler(async (req, res) => {
return {
tableCount: 10, // table组件个数
hasCover: true, // 是否有封面
htmlCount: 10, //富文本组件个数
waterMark: '我是水印'
}
})
关于代码的目录详解查看nuxt3的详细文档: Nuxt 3 - 中文文档 (nuxtjs.org.cn)
关于生成pdf的详细参数可以查看 puppeteer 的详细文档: Page (puppeteerjs.com)
在这一接口中定义了一个全局变量,用于控制Headless Chrome可以同时打开的标签页(即同时生成PDF的请求数, 毕竟是比较消耗内存的操作)的数量,超出时提示请稍等。
在pdf.vue中渲染的页面,主要通过CSS属性page-break-after
,page-break-before
,page-break-inside
。来控制属性。
在puppeteer抓取页面的时候,通过设置@page { size: 1588px 2246px; }
来规定了渲染页面的基础宽高。
对于水印组件waterMark.vue
:
则是通过fixed
布局来实现水印的效果。
<template>
<div v-if="content" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(-45deg); font-size: 84px; color: #666; font-weight: bold; opacity: 0.6;">
{{ content }}
</div>
</template>
<script setup>
const props = defineProps(['content']);
</script>
总体来说是个挺简易的demo,模拟了客户端请求和请求后端, 渲染页面, 生成PDF这一套流程。如果有更多的问题,欢迎评论区交流。
对于HTML生成PDF的前后端方案的分析
纯前端方案:
纯前端的方案,存在着浏览器环境依赖或一定的局限性, 一定程度上难以做到多端导出统一(也许可以,但是过于繁复)。 以下是尝试过的方案
- printjs/window.print()
通过调取浏览器原生的打印功能进行打印,缺点: 对于自定义页眉页脚的自定义不友好。 需要用户手动打印/进行微调,对用户不够友好。APP端可能无法打印。
后端方案:
使用后端生成PDF再传输到客户端,可以保证生成的PDF与客户端的环境无关,可以实现多端统一。 我了解到比较常用的是以下三种: wkhtmltopdf, Headless Chrome, LibreOffice 。
-
wkhtmltopdf:
这个是后端生成HTML的经典解决方案, 可以HTML文本/HTML链接生成PDF文件, 页眉页脚也同理。 网上各种语言的二次开发也有,实现详情可以搜索自行了解。
主要讲一下缺点:
由于过于经典, wkhtmltopdf 对于一些比较 “新” 的CSS3属性并不支持, 例如flex布局就不支持, HTML内要使用float布局替代。
-
puppeteerjs: (Headless Chrome)
Puppeteer v19.2.0 (puppeteerjs.com)
puppeteerjs 在服务端启动了一个无头(headless)的chrome内核,可以进行页面操作和生成PDF。 通过启动一个标签页,数据加载选然后通过pdf接口进行生成。
比起wkhtmlpdf优点是,对于CSS样式的支持与你启用的chrome内核相关联,可以对页面进行操作。等
缺点是可能存在很大的内存消耗 (实际生产环境中没有测试过)。 测试中,生成100页pdf所需大概3s,内存占用最大飙升至200M,如果并发数过大,不知道会不会存在问题。
-
LibreOffice
还没有用过,具体不太了解, 有用过的朋友可以分享一下。
创作不易,喜欢请点赞、收藏、评论
转载自:https://juejin.cn/post/7165902819701522445