前端 PDF 导出实战:JSPDF 与 HTML2Canvas 技术详解
前端生成 PDF 的两种核心方案:JSPDF 用于直接生成 PDF 文档,HTML2Canvas 用于将网页元素渲染为 Canvas。文章详细解析了 JSPDF 的初始化、文本、图片、表格及高级功能如水印和书签;深入讲解了 HTML2Canvas 的工作原理与配置参数。最后提供了两者集成的实战代码,涵盖基础导出、智能分页保护、响应式适配等场景,帮助开发者实现高质量的前端 PDF 导出功能。

前端生成 PDF 的两种核心方案:JSPDF 用于直接生成 PDF 文档,HTML2Canvas 用于将网页元素渲染为 Canvas。文章详细解析了 JSPDF 的初始化、文本、图片、表格及高级功能如水印和书签;深入讲解了 HTML2Canvas 的工作原理与配置参数。最后提供了两者集成的实战代码,涵盖基础导出、智能分页保护、响应式适配等场景,帮助开发者实现高质量的前端 PDF 导出功能。

在现代 Web 开发中,将网页内容导出为 PDF 是一项常见但颇具挑战的需求。无论是生成报告、发票、合同还是数据可视化图表,前端 PDF 导出都能为用户提供便捷的离线查看和打印体验。传统的 PDF 生成通常需要在后端完成,但随着前端技术的发展,现在我们可以直接在浏览器中实现高质量的 PDF 导出功能。
本文将深入探讨两个核心工具——JSPDF 和 HTML2Canvas,通过详细的原理分析、实战案例和最佳实践,帮助您掌握前端 PDF 导出的核心技术。
JSPDF是一个纯 JavaScript 实现的 PDF 生成库,它不依赖任何服务器端组件,完全在客户端运行。这个库最初发布于 2010 年,经过多年的发展,已经成为前端 PDF 生成的事实标准。
// 最简单的 PDF 生成示例
const doc = new jsPDF();
// 添加文本
doc.text('Hello World!', 10, 10);
// 保存 PDF
doc.save('document.pdf');
HTML2Canvas是一个强大的 JavaScript 库,它可以将 HTML 元素渲染为 Canvas。虽然名字中包含"canvas",但它实际上是通过模拟浏览器渲染引擎来实现 HTML 到 Canvas 的转换。
组合优势:
适用场景对比:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 简单文本 PDF | 纯 JSPDF | 轻量、快速、代码简洁 |
| 表格报表 | JSPDF + 表格插件 | 结构化数据友好 |
| 复杂网页截图 | HTML2Canvas + JSPDF | 保留视觉样式 |
| 大量数据导出 | 后端生成 | 性能更好 |
// 创建 PDF 实例
const doc = new jsPDF({
orientation: 'p', // 方向:p-纵向,l-横向
unit: 'mm', // 单位:pt, mm, cm, in
format: 'a4', // 格式:a3, a4, a5, letter 等
compress: true, // 是否压缩
precision: 16 // 浮点数精度
});
// 页面尺寸获取
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
// 添加新页面
doc.addPage();
// 删除页面
doc.deletePage(2);
// 设置页面背景色
doc.setFillColor(240, 240, 240);
doc.rect(0, 0, pageWidth, pageHeight, 'F');
// 基本文本设置
doc.setFont("helvetica"); // 字体
doc.setFontSize(16); // 字号
doc.setTextColor(0, 0, 0); // 颜色
doc.setFontStyle("bold"); // 样式:normal, bold, italic, bolditalic
// 添加文本
doc.text("单行文本", x, y);
doc.text("多行文本", x, y, {
maxWidth: 100, // 最大宽度
align: 'left', // 对齐:left, center, right
baseline: 'top' // 基线:top, middle, bottom
});
// 自动换行文本
const lines = doc.splitTextToSize(
"这是一个很长的文本,需要自动换行显示",
pageWidth - 40
);
doc.text(lines, 20, 30);
// 多行文本带行高
const text = "第一行\n第二行\n第三行";
doc.text(text, 20, 50, { lineHeightFactor: 1.5 });
// 旋转文本
doc.textWithRotation("旋转文本", 100, 100, 45); // 旋转 45 度
// 添加中文字体
// 1. 首先需要加载字体文件
const font = 'AAEAAAAQAQAABAAAR0RFRgE...'; // Base64 编码的字体
// 2. 添加到 JSPDF
doc.addFileToVFS('chinese-normal.ttf', font);
doc.addFont('chinese-normal.ttf', 'chinese', 'normal');
// 3. 使用中文字体
doc.setFont('chinese');
doc.text('中文字体测试', 20, 20);
// 或者使用内置的亚洲字体
doc.setFont('simhei');
doc.setFontSize(16);
doc.text('使用黑体显示中文', 20, 40);
// 添加图片
const imgData = 'data:image/png;base64,iVBORw0KGgo...';
doc.addImage(imgData, 'PNG', 15, 40, 180, 160);
// 完整参数
doc.addImage({
imageData: imgData,
x: 15,
y: 40,
width: 180,
height: 160,
compression: 'FAST', // 压缩等级:NONE, FAST, MEDIUM, SLOW
rotation: 0, // 旋转角度
alias: 'myImage', // 图片别名
format: 'PNG' // 格式:JPEG, PNG
});
// 获取图片属性
const imgProps = doc.getImageProperties(imgData);
console.log(`图片尺寸:${imgProps.width}x${imgProps.height}`);
// 图片缩放模式
const scaleToFit = (imgWidth, imgHeight, maxWidth, maxHeight) => {
const widthRatio = maxWidth / imgWidth;
const heightRatio = maxHeight / imgHeight;
const ratio = Math.min(widthRatio, heightRatio);
return { width: imgWidth * ratio, height: imgHeight * ratio };
};
// 线条
doc.setLineWidth(0.5); // 线宽
doc.setDrawColor(0, 0, 255); // 线条颜色
doc.line(20, 20, 100, 20); // 直线
// 矩形
doc.setFillColor(255, 0, 0); // 填充色
doc.rect(20, 30, 50, 30, 'F'); // 填充矩形
doc.rect(80, 30, 50, 30, 'S'); // 描边矩形
doc.rect(140, 30, 50, 30, 'FD'); // 填充 + 描边
// 圆形/椭圆
doc.circle(60, 80, 20, 'FD'); // 圆形
doc.ellipse(120, 80, 30, 20, 'FD'); // 椭圆
// 多边形
const triangle = [[100, ], [, ], [, ]];
doc.(, , );
doc.(triangle, );
doc.(, , );
doc.();
doc.();
doc.([, ], );
doc.(, , , );
doc.([], );
// 使用 autoTable 插件
import jsPDF from 'jspdf';
import 'jspdf-autotable';
const doc = new jsPDF();
// 简单表格
doc.autoTable({
head: [['ID', '姓名', '年龄', '城市']],
body: [
['1', '张三', '28', '北京'],
['2', '李四', '32', '上海'],
['3', '王五', '25', '广州']
],
startY: 20,
theme: 'grid', // 主题:striped, grid, plain
styles: { fontSize: 10, cellPadding: 3, overflow: 'linebreak' },
headStyles: { fillColor: [22, 160, 133], textColor: 255, fontStyle: 'bold' },
columnStyles: {
0: { cellWidth: 20 }, // 第一列宽度
1: { : }
},
: { : },
: () {
doc.();
doc.(, data..., doc... - );
}
});
largeData = [];
( i = ; i < ; i++) {
largeData.([i + , , .(.() * + ), ]);
}
doc.({
: [[, , , ]],
: largeData,
: ,
: ,
: ,
:
});
function addHeaderFooter(doc, totalPages) {
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
// 页眉
doc.setFontSize(10);
doc.setTextColor(100, 100, 100);
doc.text('公司名称', 20, 15);
doc.text(new Date().toLocaleDateString(), pageWidth - 20, 15, { align: 'right' });
// 页脚
doc.setFontSize(9);
doc.text(`第 ${doc.internal.getCurrentPageInfo().pageNumber} 页 / 共 ${totalPages} 页`, pageWidth / 2, pageHeight - 10, { align: 'center' });
// 页脚线
doc.setDrawColor(200, 200, 200);
doc.line(20, pageHeight - 15, pageWidth - 20, pageHeight - 15);
}
// 在每页绘制时调用
const totalPages = ;
( i = ; i <= totalPages; i++) {
(i > ) doc.();
(doc, totalPages);
}
function addWatermark(doc, text) {
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
// 保存当前状态
doc.saveGraphicsState();
// 设置水印样式
doc.setFontSize(40);
doc.setTextColor(200, 200, 200, 0.3); // 半透明灰色
doc.setFont('helvetica', 'bold');
// 计算文本宽度
const textWidth = doc.getStringUnitWidth(text) * doc.internal.getFontSize() / doc.internal.scaleFactor;
// 旋转和重复水印
const angle = -45 * Math.PI / 180;
const spacing = 150;
for (let x = -pageWidth; x < pageWidth * 2; x += spacing) {
for (let y = -pageHeight; y < pageHeight * 2; y += spacing) {
doc.saveGraphicsState();
doc.translate(x, y);
doc.rotate(angle, { origin: [0, ] });
doc.(text, , );
doc.();
}
}
doc.();
}
doc = ();
doc.(, , );
(doc, );
// 内部链接
doc.textWithLink('点击跳转到第 2 页', 20, 20, { pageNumber: 2 });
// 外部链接
doc.textWithLink('访问官网', 20, 40, { url: 'https://example.com' });
// 邮件链接
doc.textWithLink('发送邮件', 20, 60, { url: 'mailto:[email protected]' });
// 添加书签
doc.outline.add(null, "封面", 1);
doc.outline.add(null, "第一章", 2);
doc.outline.add(1, "1.1 简介", 3); // 子书签
// 可点击目录
const tocY = 30;
const chapters = [
{ title: "第一章 简介", page: 1 },
{ title: "第二章 基础", page: 3 },
{ title: "第三章 高级", page: 5 }
];
chapters.forEach((chapter, i) => {
doc.(chapter., , tocY + i * );
doc.(, , tocY + i * , { : chapter. });
});
HTML2Canvas 的工作原理相当复杂,它本质上是一个简化的浏览器渲染引擎。让我们深入了解它的工作流程:

// HTML2Canvas 内部结构示意
class HTML2CanvasRenderer {
constructor(element, options) {
this.element = element;
this.options = options;
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
}
async render() {
// 1. 解析 DOM 树
const root = this.parseDOM(this.element);
// 2. 计算样式
this.calculateStyles(root);
// 3. 创建渲染树
const renderTree = this.createRenderTree(root);
// 4. 布局计算
this.calculateLayout(renderTree);
// 5. 绘制
await this.draw(renderTree);
return this.canvas;
}
// ... 其他方法
}
const options = {
// 基本配置
allowTaint: false, // 是否允许污染 canvas
useCORS: true, // 是否使用 CORS 加载图片
backgroundColor: '#ffffff', // 背景色
scale: 2, // 缩放比例(设备像素比)
width: null, // 自定义宽度
height: null, // 自定义高度
// 日志和调试
logging: true, // 启用日志
onclone: null, // DOM 克隆后的回调
// 图像配置
imageTimeout: 15000, // 图片加载超时 (ms)
proxy: null, // 代理服务器 URL
removeContainer: true, // 是否移除临时容器
// 高级配置
foreignObjectRendering: false, // 使用 foreignObject(SVG)
ignoreElements: (element) => false, // 忽略特定元素
onrendered: null, // 渲染完成回调
// 性能配置
async: true, // 异步渲染
cacheBust: ,
: ,
: ,
: ,
: ,
: ,
: ,
: .,
: .
};
// 基础集成函数
async function exportToPDF(elementId, fileName = 'document.pdf', options = {}) {
// 默认配置
const defaultOptions = {
pdf: { orientation: 'p', unit: 'mm', format: 'a4', compress: true },
html2canvas: { scale: 2, useCORS: true, logging: false },
margin: { top: 15, right: 15, bottom: 15, left: 15 }
};
// 合并配置
const config = {
pdf: { ...defaultOptions.pdf, ...options.pdf },
html2canvas: { ...defaultOptions.html2canvas, ...options.html2canvas },
margin: { ...defaultOptions.margin, ...options.margin }
};
try {
// 1. 获取目标元素
const element = document.getElementById(elementId);
if (!element) {
throw new ();
}
.();
canvas = (element, config.);
imgData = canvas.(, );
imgProps = { : canvas., : canvas. };
pdfWidth = config.. === ? : ;
pdfHeight = config.. === ? : ;
usableWidth = pdfWidth - config.. - config..;
pixelsPerMM = imgProps. / usableWidth;
scale = usableWidth / imgProps.;
imgHeightMM = imgProps. * scale;
.();
doc = (config.);
pageHeight = pdfHeight - config.. - config..;
position = ;
(imgHeightMM <= pageHeight) {
doc.(imgData, , config.., config.., usableWidth, imgHeightMM);
} {
(position < imgHeightMM) {
(position > ) {
doc.();
}
doc.(imgData, , config.., config.. - position, usableWidth, imgHeightMM);
position += pageHeight;
}
}
doc.(fileName);
.();
doc;
} (error) {
.(, error);
error;
}
}
.().(, () => {
{
(, , {
: { : , : },
: { : ,
:
},
: { : , : , : , : }
});
} (error) {
( + error.);
}
});
class SmartPDFExporter {
constructor(options = {}) {
this.options = this.mergeOptions(options);
this.pageBreaks = [];
}
mergeOptions(options) {
const defaults = {
elementId: null,
fileName: 'export.pdf',
pdf: { orientation: 'p', unit: 'mm', format: 'a4' },
html2canvas: { scale: 2, logging: false, useCORS: true },
styling: { tableProtection: true, keepTogetherClass: 'keep-together', avoidBreakClass: 'avoid-break' },
margins: { top: 15, right: 15, bottom: 15, left: 15 },
features: { pageNumbers: true, header: true, footer: true, : }
};
.(defaults, options);
}
() {
( key source) {
(source[key] && source[key] === && !.(source[key])) {
(!target[key]) target[key] = {};
.(target[key], source[key]);
} {
target[key] = source[key];
}
}
target;
}
() {
{
.();
(...) {
.();
}
analysis = .();
.(analysis);
canvas = .();
pdf = .(canvas, analysis);
pdf.(..);
{ : , pdf };
} (error) {
.(, error);
error;
}
}
() {
(!..) {
();
}
element = .(..);
(!element) {
();
}
. = element;
}
() {
style = .();
style. = ;
oldStyles = ..();
oldStyles.( s.());
style.(, );
..(style);
tables = ..();
tables.( {
table..(...);
});
importantElements = ..();
importantElements.( {
el..(...);
});
}
() {
tables = ..();
importantElements = ..();
elementRect = ..();
analysis = {
: [],
: [],
: ..,
: ..
};
tables.( {
rect = table.();
analysis..({
index,
: table,
: rect. - elementRect.,
: rect. - elementRect.,
: rect.,
: table.().,
: !table..(...)
});
});
importantElements.( {
(el. !== ) {
rect = el.();
analysis..({
index,
: el,
: rect. - elementRect.,
: rect. - elementRect.,
: rect.,
: el.
});
}
});
analysis..( a. - b.);
analysis..( a. - b.);
analysis;
}
() {
pdf = (..);
pdfWidth = pdf...();
pdfHeight = pdf...();
usableWidth = pdfWidth - ... - ...;
usableHeight = pdfHeight - ... - ...;
pxToMM = usableWidth / analysis.;
breaks = [];
currentPageHeightMM = ;
allElements = [...analysis., ...analysis.]
.( a. - b.);
allElements.( {
elementHeightMM = element. * pxToMM;
(currentPageHeightMM + elementHeightMM > usableHeight && currentPageHeightMM > ) {
(index > ) {
prevElement = allElements[index - ];
breaks.({ : , : element, : element., : });
currentPageHeightMM = elementHeightMM;
}
} {
currentPageHeightMM += elementHeightMM;
}
});
. = breaks;
.(, .);
}
() {
originalStyle = {
: ...,
: ...,
: ...
};
... = ;
... = ;
... = ;
{
canvas = (., ..);
canvas;
} {
.(.., originalStyle);
}
}
() {
pdf = (..);
pdfWidth = pdf...();
pdfHeight = pdf...();
usableWidth = pdfWidth - ... - ...;
usableHeight = pdfHeight - ... - ...;
scale = usableWidth / canvas.;
totalHeightMM = canvas. * scale;
(.. > ) {
.(pdf, canvas, scale, usableWidth, usableHeight);
} {
.(pdf, canvas, scale, usableWidth, usableHeight);
}
(... || ...) {
.(pdf, .. + );
}
(...) {
.(pdf);
}
pdf;
}
() {
imgData = canvas.(, );
breakPoints = ..( breakInfo.);
breakPoints.( a - b);
allPoints = [, ...breakPoints, canvas.];
startY = ;
( i = ; i < allPoints.; i++) {
segmentHeight = allPoints[i] - startY;
(segmentHeight <= ) ;
segmentCanvas = .();
segmentCanvas. = canvas.;
segmentCanvas. = segmentHeight;
ctx = segmentCanvas.();
ctx.(
canvas,
, startY, canvas., segmentHeight,
, , canvas., segmentHeight
);
segmentImgData = segmentCanvas.(, );
segmentHeightMM = segmentHeight * scale;
(i > ) {
pdf.();
}
pdf.(
segmentImgData, , ..., ..., usableWidth, segmentHeightMM
);
startY = allPoints[i];
}
}
() {
imgData = canvas.(, );
totalHeightMM = canvas. * scale;
(totalHeightMM <= usableHeight) {
pdf.(
imgData, , ..., ..., usableWidth, totalHeightMM
);
} {
position = ;
(position < totalHeightMM) {
(position > ) {
pdf.();
}
pdf.(
imgData, , ..., ... - position, usableWidth, totalHeightMM
);
position += usableHeight;
}
}
}
() {
pdfWidth = pdf...();
pdfHeight = pdf...();
currentPage = pdf..().;
originalFont = pdf..();
originalSize = pdf..();
originalColor = pdf..();
(...) {
pdf.();
pdf.(, , );
pdf.(
...(, ),
...,
... -
);
dateStr = ().();
pdf.(
dateStr,
pdfWidth - ...,
... - ,
{ : }
);
pdf.(, , );
pdf.(
..., ... - ,
pdfWidth - ..., ... -
);
}
(...) {
pdf.(, , );
pdf.(
..., pdfHeight - ... + ,
pdfWidth - ..., pdfHeight - ... +
);
(...) {
pdf.();
pdf.(, , );
pageText = ;
pdf.(
pageText,
pdfWidth / ,
pdfHeight - ... + ,
{ : }
);
}
}
pdf.(originalFont[], originalFont[]);
pdf.(originalSize);
pdf.(originalColor[], originalColor[], originalColor[]);
}
() {
pdfWidth = pdf...();
pdfHeight = pdf...();
pdf.();
pdf.();
pdf.(, , , );
pdf.(, );
angle = - * . / ;
text = .... || ;
( x = -pdfWidth; x < pdfWidth * ; x += ) {
( y = -pdfHeight; y < pdfHeight * ; y += ) {
pdf.();
pdf.(x, y);
pdf.(angle, { : [, ] });
pdf.(text, , );
pdf.();
}
}
pdf.();
}
}
exporter = ({
: ,
: ,
: { : , : },
: { : , : , : },
: {
: ,
: ,
: ,
: { : , : }
},
: { : , : }
});
.().(, () => {
{
exporter.();
();
} (error) {
( + error.);
}
});
class ResponsivePDFExporter {
constructor(options = {}) {
this.options = {
breakpoints: {
mobile: 768,
tablet: 1024,
desktop: 1200
},
scaling: {
mobile: 1,
tablet: 1.5,
desktop: 2
},
...options
};
}
detectDeviceType() {
const width = window.innerWidth;
if (width < this.options.breakpoints.mobile) {
return 'mobile';
} else if (width < this.options.breakpoints.tablet) {
return 'tablet';
} else {
return 'desktop';
}
}
async exportResponsive(elementId, fileName) {
const deviceType = this.detectDeviceType();
const scale = ..[deviceType] || ;
.();
element = .(elementId);
originalStyles = .(element);
{
.(element, deviceType);
canvas = (element, {
scale,
: ,
:
});
pdf = ({ : , : , : });
pdfWidth = pdf...();
imgWidth = canvas.;
imgHeight = canvas.;
margin = ;
usableWidth = pdfWidth - * margin;
scaleFactor = usableWidth / imgWidth;
pdf.(
canvas.(, ),
,
margin, margin,
usableWidth, imgHeight * scaleFactor
);
pdf.(fileName);
} {
.(element, originalStyles);
}
}
() {
{
: element..,
: element..,
: element..,
: element..,
: element..,
: element..
};
}
() {
styles = {
: { : , : , : , : , : },
: { : , : , : , : , : },
: { : , : , : , : , : }
};
deviceStyles = styles[deviceType] || styles.;
.(element., deviceStyles);
tables = element.();
tables.( {
(deviceType === ) {
table.. = ;
table.. = ;
}
});
}
() {
.(element., originalStyles);
}
}
responsiveExporter = ();
.().(, {
responsiveExporter.(, );
});

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online