在数字化转型的浪潮中,PDF 文档生成已成为企业应用不可或缺的功能。本文将分享如何基于 Egg.js 和 Puppeteer 构建一个功能完整、性能优异的 PDF 生成服务,并深入探讨其中的技术难点和解决方案。
🎯 项目背景与需求
为什么选择这个技术栈?
在企业级应用开发中,我们经常遇到需要将 HTML 内容转换为 PDF 文档的需求。传统的解决方案如 wkhtmltopdf 虽然功能强大,但在部署和维护方面存在诸多不便。而基于 Node.js 的解决方案则具有以下优势:
- 部署简单:无需安装额外的系统依赖
- 维护方便:统一的 JavaScript 技术栈
- 扩展性强:丰富的 npm 生态系统
- 性能优异:基于 Chrome 内核的渲染引擎
经过技术选型,我们最终选择了 Egg.js + Puppeteer 的组合:
- Egg.js:阿里巴巴开源的企业级 Node.js 框架,提供完整的开发规范和最佳实践
- Puppeteer:Google 官方维护的 Node.js 库,提供完整的 Chrome DevTools Protocol 接口
核心功能需求
我们的 PDF 生成服务需要满足以下核心需求:
- 多源输入支持:支持 HTML 字符串、HTML 文件、URL 地址等多种输入方式
- 专业 PDF 格式:支持 A4、A3、A0 等多种纸张格式,满足不同场景需求
- 丰富内容支持:封面页、目录、页眉页脚、ECharts 图表等
- 智能书签导航:自动生成 PDF 书签,支持内容跳转
- 响应式设计:支持移动端和桌面端自适应布局
🏗️ 技术架构设计
整体架构
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 客户端应用 │ │ Egg.js服务 │ │ Puppeteer │
│ │ │ │ │ │
│ - Web应用 │───▶│ - 路由控制 │───▶│ - 浏览器控制 │
│ - 移动应用 │ │ - 控制器逻辑 │ │ - 页面渲染 │
│ - 桌面应用 │ │ - 服务层 │ │ - PDF生成 │
│ - API调用 │ │ - 中间件 │ │ - 资源管理 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
技术选型理由
Egg.js 框架优势
Egg.js 作为企业级 Node.js 框架,具有以下特点:
- 约定优于配置:提供统一的目录结构和开发规范
- 插件化架构:支持按需加载功能模块
- 企业级特性:内置日志、监控、安全等企业级功能
- 生态完善:阿里巴巴内部大规模应用验证
Puppeteer 渲染引擎
Puppeteer 作为 Chrome 官方维护的 Node.js 库,具有以下优势:
- 渲染质量高:基于 Chrome 内核,支持最新的 Web 标准
- 功能完整:支持 JavaScript 执行、CSS 渲染、字体加载等
- 性能优异:支持无头模式,资源占用低
- 维护活跃:Google 官方维护,更新及时
🚀 项目实现过程
1. 项目初始化
首先创建 Egg.js 项目并安装必要的依赖:
bash
# 创建项目
npm init egg --type=simple node-pdf
cd node-pdf
# 安装依赖
npm install puppeteer egg-cors egg-static
2. 核心服务实现
PDF 生成服务
在 app/service/pdf.js
中实现核心的 PDF 生成逻辑:
javascript
const Service = require('egg').Service;
const puppeteer = require('puppeteer');
class PdfService extends Service {
async getBrowser() {
if (!this.browser) {
this.browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage'
],
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
});
}
return this.browser;
}
async generateFromUrl(url, options = {}) {
const browser = await this.getBrowser();
const page = await browser.newPage();
try {
// 设置视口大小
await page.setViewport({
width: 1800,
height: 2400,
deviceScaleFactor: 1
});
// 导航到目标页面
await page.goto(url, { waitUntil: 'networkidle2' });
// 等待页面渲染完成
await page.waitForTimeout(15000);
// 触发图表重绘
await page.evaluate(() => {
window.dispatchEvent(new Event('resize'));
if (typeof echarts !== 'undefined') {
const charts = document.querySelectorAll('.chart-wrapper');
charts.forEach(chart => {
const instance = echarts.getInstanceByDom(chart);
if (instance) {
instance.resize({ animation: false });
}
});
}
});
// 生成 PDF
const pdfBuffer = await page.pdf({
format: options.format || 'A4',
margin: options.margin || { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' },
displayHeaderFooter: options.displayHeaderFooter || false,
headerTemplate: options.headerTemplate || '',
footerTemplate: options.footerTemplate || '',
printBackground: options.printBackground || true,
preferCSSPageSize: true,
outline: [
{ title: '封面', page: 1 },
{ title: '目录', page: 2 },
{ title: '第一章', page: 3 },
{ title: '第二章', page: 4 },
{ title: '第三章', page: 5 },
{ title: '第四章', page: 6 },
{ title: '第五章', page: 7 }
]
});
return pdfBuffer;
} finally {
await page.close();
}
}
}
module.exports = PdfService;
控制器实现
在 app/controller/pdf.js
中实现 API 接口:
javascript
const Controller = require('egg').Controller;
class PdfController extends Controller {
async generateComplete() {
const { ctx } = this;
const { url, filename, options } = ctx.request.body;
try {
const pdfBuffer = await ctx.service.pdf.generateFromUrl(url, options);
// 设置响应头
const title = filename || 'document';
const sanitizedFilename = title.replace(/[^a-zA-Z0-9]/g, '_') || 'document';
ctx.set('Content-Type', 'application/pdf');
ctx.set('Content-Disposition', `attachment; filename="${sanitizedFilename}.pdf"`);
ctx.body = pdfBuffer;
} catch (error) {
ctx.logger.error('PDF generation failed:', error);
ctx.status = 500;
ctx.body = {
error: 'Complete PDF generation failed',
details: error.message
};
}
}
}
module.exports = PdfController;
3. HTML 模板设计
响应式布局
在 app/public/template.html
中实现响应式的 HTML 模板:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>企业报告模板</title>
<style>
@page {
size: A4;
margin: 25mm 20mm 25mm 20mm;
}
.cover-page {
height: 100vh;
min-height: 247mm;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: white;
margin: 0;
overflow: hidden;
}
.chart-container {
width: 100%;
height: 400px;
margin: 20px 0;
padding: 0;
box-sizing: border-box;
}
.chart-wrapper {
width: 100%;
height: 100%;
overflow: visible !important;
}
</style>
</head>
<body>
<!-- 封面页 -->
<section class="cover-page" id="cover">
<h1 class="cover-title">Test Report</h1>
<p class="cover-subtitle">示例报告模板</p>
<div class="cover-info">
<p>生成时间:<span id="current-date"></span></p>
</div>
</section>
<!-- 目录页 -->
<section class="toc-page" id="toc">
<h1>目录</h1>
<ul class="toc-list">
<li><a href="#section1">第一章</a></li>
<li><a href="#section2">第二章</a></li>
<li><a href="#section3">第三章</a></li>
<li><a href="#section4">第四章</a></li>
<li><a href="#section5">第五章</a></li>
</ul>
</section>
<!-- 内容页 -->
<main class="content-page">
<section class="content-section" id="section1">
<h2>第一章</h2>
<p>这是第一章的内容。</p>
<div class="chart-container">
<div class="chart-title">柱状图示例</div>
<div class="chart-wrapper" id="chart1"></div>
</div>
</section>
<!-- 更多章节... -->
</main>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script>
// 初始化图表
function initCharts() {
// 柱状图
const chart1 = echarts.init(document.getElementById('chart1'));
const option1 = {
title: { text: '柱状图示例' },
tooltip: {},
xAxis: { data: ['A', 'B', 'C', 'D', 'E'] },
yAxis: {},
series: [{
name: '数值',
type: 'bar',
data: [120, 200, 150, 80, 70],
grid: { left: '3%', right: '3%', top: '15%', bottom: '15%' }
}]
};
chart1.setOption(option1);
// 更多图表初始化...
}
// 页面加载完成后初始化图表
window.addEventListener('load', initCharts);
// 响应式图表重绘
function resizeAllCharts() {
const charts = document.querySelectorAll('.chart-wrapper');
charts.forEach(chart => {
const instance = echarts.getInstanceByDom(chart);
if (instance) {
instance.resize();
}
});
}
window.addEventListener('resize', resizeAllCharts);
</script>
</body>
</html>
🔧 技术难点与解决方案
1. ECharts 图表渲染问题
问题描述
在 PDF 生成过程中,ECharts 图表经常出现显示不全的问题,特别是右侧部分被截断。
解决方案
通过多次调试和优化,我们采用了以下解决方案:
- 调整视口大小:将 Puppeteer 的视口设置为 1800x2400,为图表提供足够的渲染空间
- 等待策略优化 :使用
page.waitForTimeout(15000)
确保图表完全渲染 - 强制重绘 :在 PDF 生成前触发
window.resize
事件和图表resize()
方法 - CSS 优化 :设置图表容器为
overflow: visible
,避免内容被裁剪
javascript
// 关键代码片段
await page.setViewport({
width: 1800,
height: 2400,
deviceScaleFactor: 1
});
await page.waitForTimeout(15000);
await page.evaluate(() => {
window.dispatchEvent(new Event('resize'));
if (typeof echarts !== 'undefined') {
const charts = document.querySelectorAll('.chart-wrapper');
charts.forEach(chart => {
const instance = echarts.getInstanceByDom(chart);
if (instance) {
instance.resize({ animation: false });
}
});
}
});
2. PDF 书签生成问题
问题描述
用户需要 PDF 左侧显示书签导航,点击可以跳转到对应内容。
解决方案
经过多次尝试,我们最终采用了 Puppeteer 的 outline
选项:
javascript
const pdfBuffer = await page.pdf({
// ... 其他选项
outline: [
{ title: '封面', page: 1 },
{ title: '目录', page: 2 },
{ title: '第一章', page: 3 },
{ title: '第二章', page: 4 },
{ title: '第三章', page: 5 },
{ title: '第四章', page: 6 },
{ title: '第五章', page: 7 }
]
});
3. 内存管理和性能优化
问题描述
长时间运行后可能出现内存泄漏和性能下降。
解决方案
- 浏览器实例管理:实现单例模式,避免重复创建浏览器实例
- 页面资源清理:及时关闭页面,释放内存
- 超时控制:设置合理的等待时间,避免无限等待
- 错误处理:完善的 try-catch 机制,确保资源正确释放
📊 性能测试与优化
基准测试结果
我们进行了详细的性能测试,结果如下:
- 单次 PDF 生成时间:平均 3-5 秒
- 内存使用:约 200-500MB
- 并发支持:建议不超过 5 个并发任务
- 文件大小:A4 格式约 2-5MB
性能优化策略
- 浏览器实例复用:避免每次请求都创建新的浏览器实例
- 页面池管理:实现页面池,减少页面创建和销毁的开销
- 资源预加载:预加载常用的 HTML 模板和资源
- 缓存机制:对相同内容的 PDF 进行缓存
🚀 部署与运维
生产环境配置
环境变量配置
bash
# .env 文件
NODE_ENV=production
PORT=7001
PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome
PUPPETEER_ARGS=--no-sandbox,--disable-setuid-sandbox
PM2 部署
bash
# 安装 PM2
npm install -g pm2
# 启动应用
pm2 start ecosystem.config.js
# 查看状态
pm2 status
# 重启应用
pm2 restart node-pdf
Docker 部署
dockerfile
FROM node:18-alpine
# 安装 Chrome
RUN apk add --no-cache chromium
# 设置工作目录
WORKDIR /app
# 复制依赖文件
COPY package*.json ./
# 安装依赖
RUN npm ci --only=production
# 复制应用代码
COPY . .
# 暴露端口
EXPOSE 7001
# 启动应用
CMD ["npm", "start"]
监控与日志
日志配置
javascript
// config.default.js
config.logger = {
level: 'INFO',
consoleLevel: 'INFO',
dir: path.join(appInfo.root, 'logs', appInfo.name),
appLogName: `${appInfo.name}-web.log`,
coreLogName: 'egg-web.log',
agentLogName: 'egg-agent.log',
errorLogName: 'common-error.log',
outputJSON: true
};
性能监控
javascript
// 添加性能监控中间件
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
if (ms > 5000) {
ctx.logger.warn(`Slow request: ${ctx.url} took ${ms}ms`);
}
});
💡 使用体验与最佳实践
API 使用示例
基础 PDF 生成
javascript
const response = await fetch('http://localhost:7001/api/pdf/generate-complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'http://localhost:7001/public/template.html',
filename: 'enterprise-report.pdf',
options: {
format: 'A4',
displayHeaderFooter: true,
printBackground: true
}
})
});
const pdfBuffer = await response.arrayBuffer();
自定义模板
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>自定义模板</title>
<style>
@page {
size: A4;
margin: 20mm;
}
.page-break {
page-break-before: always;
}
</style>
</head>
<body>
<section class="cover-page" id="cover">
<h1>报告标题</h1>
<p>副标题</p>
</section>
<section class="content-page">
<h2>内容章节</h2>
<p>具体内容...</p>
</section>
</body>
</html>
最佳实践建议
- 模板设计:使用语义化 HTML 标签,便于 PDF 书签生成
- 图表配置:合理设置 ECharts 的 grid 参数,避免内容被截断
- 资源管理:及时释放 Puppeteer 资源,避免内存泄漏
- 错误处理:完善的错误处理机制,提供友好的错误信息
- 性能优化:合理设置等待时间,平衡性能和稳定性
🌟 结语
在数字化转型的今天,PDF 生成服务已经成为企业应用的重要组成部分。希望本文的分享能够为有类似需求的开发者提供参考和帮助,也欢迎大家在评论区分享自己的经验和想法。
技术改变世界,代码连接未来! 🚀