基于 Egg.js + Puppeteer 构建企业级 PDF 生成服务

在数字化转型的浪潮中,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 生成服务需要满足以下核心需求:

  1. 多源输入支持:支持 HTML 字符串、HTML 文件、URL 地址等多种输入方式
  2. 专业 PDF 格式:支持 A4、A3、A0 等多种纸张格式,满足不同场景需求
  3. 丰富内容支持:封面页、目录、页眉页脚、ECharts 图表等
  4. 智能书签导航:自动生成 PDF 书签,支持内容跳转
  5. 响应式设计:支持移动端和桌面端自适应布局

🏗️ 技术架构设计

整体架构

复制代码
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   客户端应用     │    │   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 图表经常出现显示不全的问题,特别是右侧部分被截断。

解决方案

通过多次调试和优化,我们采用了以下解决方案:

  1. 调整视口大小:将 Puppeteer 的视口设置为 1800x2400,为图表提供足够的渲染空间
  2. 等待策略优化 :使用 page.waitForTimeout(15000) 确保图表完全渲染
  3. 强制重绘 :在 PDF 生成前触发 window.resize 事件和图表 resize() 方法
  4. 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. 内存管理和性能优化

问题描述

长时间运行后可能出现内存泄漏和性能下降。

解决方案

  1. 浏览器实例管理:实现单例模式,避免重复创建浏览器实例
  2. 页面资源清理:及时关闭页面,释放内存
  3. 超时控制:设置合理的等待时间,避免无限等待
  4. 错误处理:完善的 try-catch 机制,确保资源正确释放

📊 性能测试与优化

基准测试结果

我们进行了详细的性能测试,结果如下:

  • 单次 PDF 生成时间:平均 3-5 秒
  • 内存使用:约 200-500MB
  • 并发支持:建议不超过 5 个并发任务
  • 文件大小:A4 格式约 2-5MB

性能优化策略

  1. 浏览器实例复用:避免每次请求都创建新的浏览器实例
  2. 页面池管理:实现页面池,减少页面创建和销毁的开销
  3. 资源预加载:预加载常用的 HTML 模板和资源
  4. 缓存机制:对相同内容的 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>

最佳实践建议

  1. 模板设计:使用语义化 HTML 标签,便于 PDF 书签生成
  2. 图表配置:合理设置 ECharts 的 grid 参数,避免内容被截断
  3. 资源管理:及时释放 Puppeteer 资源,避免内存泄漏
  4. 错误处理:完善的错误处理机制,提供友好的错误信息
  5. 性能优化:合理设置等待时间,平衡性能和稳定性

🌟 结语

在数字化转型的今天,PDF 生成服务已经成为企业应用的重要组成部分。希望本文的分享能够为有类似需求的开发者提供参考和帮助,也欢迎大家在评论区分享自己的经验和想法。


技术改变世界,代码连接未来! 🚀

相关推荐
skeletron201110 分钟前
【基础】React工程配置(基于Vite配置)
前端
怪可爱的地球人11 分钟前
前端
蓝胖子的小叮当19 分钟前
JavaScript基础(十四)字符串方法总结
前端·javascript
跟橙姐学代码1 小时前
Python 函数实战手册:学会这招,代码能省一半!
前端·python·ipython
森之鸟1 小时前
审核问题——鸿蒙审核返回安装失败,可以尝试云调试
服务器·前端·数据库
jiayi1 小时前
从 0 到 1 带你打造一个工业级 TypeScript 状态机
前端·设计模式·状态机
轻语呢喃1 小时前
CSS水平垂直居中的9种方法:原理、优缺点与差异对比
前端·css
!win !1 小时前
uni-app支付宝端彻底禁掉下拉刷新效果
前端·小程序·uni-app
xw51 小时前
uni-app支付宝端彻底禁掉下拉刷新效果
前端·支付宝
@大迁世界2 小时前
这次 CSS 更新彻底改变了我的 CSS 开发方式。
前端·css