前言
业务需要将页面导出为pdf,并尽可能保证模块在分页时不被切割,所以分享一下踩坑的过程
客户端导出PDF
优点:
- 资源节省:不需要服务器处理,减少了服务器负担,用户的浏览器直接生成PDF。
- 即时反馈:用户可以快速看到生成的结果,适合小型文档或简单内容的导出。
- 易于实现:使用库如
html2canvas和jsPDF,可以快速实现导出功能,代码量较少。
缺点:
- 性能限制:受限于用户的浏览器性能,处理复杂文档时可能会出现卡顿。
- 兼容性问题:不同浏览器对CSS和字体的支持不一致,可能导致PDF格式不一致或乱码。
- 功能限制:对于复杂的布局和样式,可能无法完全还原,尤其是涉及动态内容时。
服务器端导出PDF
优点:
- 强大的处理能力:服务器通常有更强的计算能力,可以处理复杂的文档和大量数据。
- 一致性:生成的PDF格式在不同设备和浏览器上保持一致,避免了客户端的兼容性问题。
- 灵活性:可以使用如
Puppeteer等工具,支持更复杂的样式和布局,甚至可以在生成PDF前进行数据处理。
缺点:
- 资源消耗:服务器需要处理PDF生成请求,可能增加服务器负担,尤其是在高并发情况下。
- 延迟:用户需要等待服务器处理完成,可能导致体验不如客户端即时。
- 实现复杂性:需要设置服务器环境和处理请求,开发和维护成本较高
客户端导出
这里引用一篇文章: juejin.cn/post/713837…
服务端导出
核心能力
基于pupeteer,那pupeteer是什么? Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome 浏览器。它默认以无头(headless)模式运行,但也可以配置为在可见("有头")浏览器中运行。使用 Puppeteer,你可以实现几乎所有在浏览器中手动执行的操作,例如:
- 自动提交表单、UI 测试、键盘输入等;
- 创建自动化测试环境,使用最新的 JavaScript 和浏览器功能;
- 捕获网站的 timeline trace 以帮助分析性能问题;
- 测试 Chrome 扩展程序;
- 生成页面的屏幕截图和 PDF;
- 抓取 SPA(单页应用)并生成预渲染内容(即 "SSR"(服务器端渲染))
开发一个简单版接口
步骤1:基于koa搭建http服务
...
const Koa = require('koa')
const Router = require('koa-router')
const http = require('http')
const app = new Koa()
const router = new Router();
router.get('/export/pdf', async (ctx) => {
await createPdf({
hostname: ctx.hostname,
id: ctx.query.id,
type: ctx.query.type,
token: ctx.query.token
});
let filePath = './page.pdf'
let file = fs.createReadStream(filePath)
let fileName = filePath.split('\').pop()
ctx.set('Content-disposition', 'attachment;filename=' + encodeURIComponent(fileName))
ctx.set('Content-type', 'application/pdf')
ctx.body = file
});
app.use(router.routes()).use(router.allowedMethods())
http.createServer(app.callback()).listen(9527);
步骤2:调用pupeteer实现导出
async function createPdf(obj) {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setBypassCSP(false);
await page.setExtraHTTPHeaders({
Authorization:
"Bearer " + obj.token,
});
await page
.mainFrame()
.goto(`http://${obj.hostname}:3002/exportReport?id=${obj.id}&type=${obj.type}`, {
waitUntil: ["networkidle2"],
});
// await page.reload({ timeout: 60000, waitUntil: ['networkidle2'] })
await page.pdf({
path: "page.pdf",
displayHeaderFooter: false,
footerTemplate: "<p>pdf测试</p>",
format: "A4",
preferCSSPageSize: false,
printBackground: true,
margin: {
top: "20",
bottom: "20",
},
});
// page.close()
browser.close();
}
基于以上逻辑,即可完成简单的导出pdf
用于生产环境的服务
- 如果对业务稳定性,性能没有要求:通过以上单文件的方式+pm2的方式即可部署
- 考虑到后期利用pupeteer的其他能力,以及服务稳定性,所以考虑使用成熟的基于koa的框架,最终选型egg
遇到的问题
1.如何解决打开客户端提供的url时,依赖的接口鉴权问题?
解决办法有两种:
- 方式一:将url中依赖的接口设置为白名单(不鉴权)
- 方式二:客户端提供url时,携带接口鉴权的信息,通过pupeteer的api完成设置
...
const browser = await puppeteer.launch({});
const page = await browser.newPage();
await page.setExtraHTTPHeaders({
['token']: 'xxxxxx',
});
2.pupeteer打开客户端提供的url时,如何判断整个页面已经渲染完成?
使用goto方法时,官方提供了如下等待方式
export type PuppeteerLifeCycleEvent =
/**
* Waits for the 'load' event.
*/
| 'load'
/**
* Waits for the 'DOMContentLoaded' event.
*/
| 'domcontentloaded'
/**
* Waits till there are no more than 0 network connections for at least `500`
* ms.
*/
| 'networkidle0'
/**
* Waits till there are no more than 2 network connections for at least `500`
* ms.
*/
| 'networkidle2';
设置"domcontentloaded"时发现导出的pdf模块数据缺失,单页应用中,此事件只会触发一次,不适用于打开url时有路由跳转;
这里改用由客户端业务侧来控制完成标识
- 步骤1:客户端调用依赖的接口数据后,设置完成标识,调用接口时将标识告诉服务端
<template>
<div>....</div>
<div v-if="flag" class="is-rendered-tag"></div>
</template>
flag=false;
async getData() {
await api1();
await api2();
...
nextTick(()=>{
flag=true;
})
}
- 步骤2:服务端接收到完成标识后,等待元素出现则认为页面渲染完成
const browser = await puppeteer.launch({});
const page = await browser.newPage();
await page.setExtraHTTPHeaders({
['token']: 'xxxxxx',
});
// 打开url
await page.mainFrame().goto(fullUrl);
// 等待渲染成功的标识元素出现
await page.waitForSelector(completeTag);
3.导出的pdf中图片或者其他模块被切割的问题?
基于CSS中的属性page-break-after、page-break-before和page-break-inside
-
page-break-before属性:-
定义:指定在元素之前是否应该插入分页符。
-
可能的值:
auto:由浏览器决定是否在元素前进行分页。always:总是在元素前进行分页。avoid:避免在元素前进行分页。left:如果元素在页面的左侧,则总是在元素前进行分页。right:如果元素在页面的右侧,则总是在元素前进行分页。
-
-
page-break-after属性:- 定义:指定在元素之后是否应该插入分页符。
- 可能的值与
page-break-before相同。
-
page-break-inside属性:- 定义:指定元素内部的内容是否允许在分页时被分割到不同的页面上。
- 可能的值:
auto:由浏览器决定元素内部内容是否可以跨页。avoid:避免将元素内部的内容分割到不同的页面上。
在实际应用中,这些属性可以帮助你控制页面的布局,尤其是在打印长文档或创建PDF文件时,确保内容的连续性和格式的正确性。例如,如果你不希望一个图片或表格被分割到两页上,可以在该元素上使用page-break-inside: avoid;来避免这种情况
代码示例:
// 元素child1会在它之前插入分页符
// 元素child2会在它之后插入分页符
// 元素child3内部不允许插入换行符(内容不能超过一页,当前页放不下,自动换到下一页)
<template>
<div>
<div class="child1" style="page-break-before: always"></div>
<div class="child2" style="page-break-after: always"></div>
<div class="child3" style="page-break-after: avoid"></div>
</div>
</template>
// css中可以通过如下方式设置打印样式
@media print {
table
tr {
page-break-inside: avoid !important; // tr不能被分割
}
thead {
display: table-header-group !important; // 分页后每一页都有表头
}
}
}
}
4.服务如何在不同项目中部署复用?
如果项目A,项目B(内网部署的项目)等等都需要该导出pdf的服务,如何复用呢? 当然是将服务docker化,然后集成到CI/CD流程中;
上Dockerfile脚本:
# 使用官方的 Node.js 镜像作为基础镜像
FROM node:20.15.0-slim
# Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others)
# Note: this installs the necessary libs to make the bundled version of Chrome for Testing that Puppeteer
# installs, work.
RUN apt-get update \
&& apt-get install -y wget gnupg \
&& wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# 创建工作目录
RUN mkdir -p /home/app
RUN mkdir -p /home/etahadmin
# 挂在路径
VOLUME /home/etahadmin
# 设置工作目录
WORKDIR /home/app
# 将 package.json 和 package-lock.json 复制到工作目录
COPY package*.json ./
# 安装依赖
RUN npm install --registry=https://registry.npmmirror.com/
# 将所有文件复制到工作目录
COPY . /home/app
# 暴露端口
EXPOSE 7001
# 启动应用
CMD ["npm", "run", "start"]
导出pdf时会生成临时文件,何时删除?
- 如果不清理这些临时文件,则垃圾文件会越来越多
- 清理策略:每日凌晨删除前一天产生的文件
egg的schedule模块: daily_task.js
const Subscription = require('egg').Subscription;
const fileHelper = require('../extend/fileHelper');
const dateHelper = require('../extend/date');
class DailyTask extends Subscription {
// 通过 schedule 属性来设置定时任务的执行间隔等配置
static get schedule() {
return {
// interval: '1m', // 1 分钟间隔
cron: '0 2 * * *', // 凌晨执行
type: 'worker', // 某一个 worker执行
};
}
// subscribe 是真正定时任务执行时被运行的函数
async subscribe() {
// 删除前一天的
const prevDay = dateHelper.addSpecificDate(null, 0);
fileHelper.deleteDirectory(fileHelper.join(this.app.config.tempPathName, prevDay));
}
}
module.exports = DailyTask;
api完整代码实现
- 初始化框架
npm init egg --type=simple
目录结构:
- 定义路由
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
router.get('/api/generatePdf', controller.api.index);
};
- 开发api
const { Controller } = require('egg');
const fileHelper = require('../extend/fileHelper');
class ApiController extends Controller {
async index() {
const { ctx } = this;
const fullUrl = ctx.query.fullUrl;// 完成url,包含id等信息
const friendlyName = ctx.query.friendlyName || '';// 友好名,用于下载
const authKey = ctx.query.authKey || ''; // 需要设置的请求头
const authValue = ctx.query.authValue || ''; // 需要设置的请求头
const completeTag = ctx.query.completeTag || '.is-rendered-tag';
const config = ctx.config || {}; // 其他配置项
let error = '';
// 验证
if (!fullUrl) {
error = 'fullUrl is required';
}
if (!completeTag) {
error = 'completeTag is required';
}
if (error) {
ctx.body = error;
return false;
}
// 根据当前请求生成文件名
const { filePath, uniqueName } = ctx.service.api.generateFileNameAndPath(this.app.config.tempPathName, fullUrl);
// 生成pdf
await ctx.service.api.createPdf({
fullUrl,
filePath,
authKey,
authValue,
completeTag,
config,
});
// 读取文件流
const file = fileHelper.readStream(filePath);
ctx.set('Content-disposition', 'attachment;filename=' +
encodeURIComponent(friendlyName ? friendlyName : uniqueName));
ctx.set('Content-type', 'application/pdf');
ctx.body = file;
}
}
module.exports = ApiController;
service层完整代码实现
const Service = require('egg').Service;
const stringHelper = require('../extend/string');
const fileHelper = require('../extend/fileHelper');
const dateHelper = require('../extend/date');
const puppeteer = require('puppeteer');
class ApiService extends Service {
// 生成名称
generateFileNameAndPath(tempPath, fullUrl) {
const ext = '.pdf';
// 根据当前请求生成文件名
let uniqueName = stringHelper.md5(fullUrl);
const timePath = dateHelper.formatDateTime(dateHelper.getCurrentTimeInt(false), dateHelper.FORMAT_TYPE.TYPE_YMD);
// 当前路径
const currentPath = fileHelper.join(tempPath, timePath);
fileHelper.createDirectory(currentPath);
const filenameWithExt = `${uniqueName}${ext}`;
const filePath = fileHelper.join(currentPath, filenameWithExt);
return { uniqueName: filenameWithExt, filePath };
}
// 生成pdf
async createPdf(pdfParams = {}) {
const { fullUrl, filePath, authKey, authValue, completeTag, config } = pdfParams;
// 是否有footer
const footerTip = config?.footerTip || 'pdf测试';
const margin = config.margin || 20;
const browser = await puppeteer.launch({
args: [
'--ignore-certificate-errors', // 忽略证书错误
'--no-sandbox',
],
});
const page = await browser.newPage();
// await page.setBypassCSP(false);
if (authKey && authValue) {
await page.setExtraHTTPHeaders({
[`${authKey}`]: authValue,
});
}
// 打开url
await page.mainFrame().goto(fullUrl);
// 等待渲染成功的标识元素出现
await page.waitForSelector(completeTag);
// 导出pdf
await page.pdf({
path: filePath,
displayHeaderFooter: false,
footerTemplate: `<p>${footerTip}</p>`,
format: 'A4',
preferCSSPageSize: false,
printBackground: true,
margin: {
top: `${margin}`,
bottom: `${margin}`,
},
});
// page.close()
await browser.close();
}
}
module.exports = ApiService;
优化
1.如何提高接口的响应速度
- 节省launch时间(见如何支持更多并发)
- 加缓存
伪代码实现
...
// 需要导出的url
const fullUrl = ctx.query.fullUrl;// 完成url,包含id等信息
// 根据url生成一个唯一key,作为pdf文件名
const md5Key = md5(fullUrl);
// 检查文件是否存在
const flag = fs.fileExistsSync(`${filePath}/${md5Key}.pdf`);
if(flag) {
// 直接返回缓存的文件
} else {
// 1.调用pupeteer生成pdf文件
// 2.直接返回缓存的文件
}
缓存效果对比:
2.如何支持更多并发
- 写一个链接池的类 genericPool.js
- 在程序启动时,初始化连接池
- 从连接池获取实例
...
// 写一个链接池的类 genericPool.js
// 写一个链接池的类 genericPool.js
function initPuppeteerPool() {
const factory = {
create: () =>
puppeteer.launch(puppeteerArgs).then(instance => {
// 创建一个 puppeteer 实例 ,并且初始化使用次数为 0
instance.useCount = 0;
return instance;
}),
destroy: instance => {
instance.close();
},
validate: instance => {
// 执行一次自定义校验,并且校验校验 实例已使用次数。 当 返回 reject 时 表示实例不可用
return validator(instance).then(valid => Promise.resolve(valid && (maxUses <= 0 || instance.useCount < maxUses)));
},
};
const pool = genericPool.createPool(factory, config);
const genericAcquire = pool.acquire.bind(pool);
pool.acquire = () =>
genericAcquire().then(instance => {
instance.useCount += 1;
return instance;
});
pool.use = fn => {
let resource;
return pool
.acquire()
.then(r => {
resource = r;
return resource;
})
.then(fn)
.then(
result => {
// 不管业务方使用实例成功与后都表示一下实例消费完成
pool.release(resource);
return result;
},
err => {
pool.release(resource);
throw err;
}
);
};
return pool;
}
// 生命周期中启用以及销毁
const initPuppeteerPool = require('./app/extend/genericPool');
class AppBootHook {
constructor(app) {
this.app = app;
}
async didLoad() {
// 所有的配置已经加载完毕
// 可以用来加载应用自定义的文件,启动自定义的服务
this.app.pool = initPuppeteerPool({
puppeteerArgs: {
args: [
'--ignore-certificate-errors', // 忽略证书错误
'--no-sandbox',
],
},
});
}
async beforeClose() {
// 释放资源
if (this.app.pool.drain) {
await this.app.pool.drain().then(() => this.app.pool.clear());
}
}
}
// service层
createPdf() {
// 从链接池中取出实例
await this.app.pool.use(async instance => {
// 取出连接后直接就是newPage,节省了lanuch时间
const page = await instance.newPage();
...
})
}
完整代码见如下git仓库:
yc-lm/document-helper (github.com)