2835 字
14 分钟
React Hook PDF 生成

背景#

在项目开发过程中,我们经常需要生成 PDF 文件。但是,生成 PDF 文件的流程比较复杂,需要处理很多细节问题。

通常情况下如果按照页面原样生成 PDF 文件,只需要使用 html2canvas 和 jsPDF 即可。

代码也和简单,如下:

import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
const Example = () => {
const onDownload = () => {
html2canvas(document.body).then((canvas) => {
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF();
pdf.addImage(imgData, 'PNG', 0, 0);
pdf.save('example.pdf');
});
};
return <button onClick={onDownload}>Download PDF</button>;
};
export default Example;

这只是一份最简单的代码,但是实际项目中,我们还需要处理很多细节问题,如:

  • PDF 中的超链接可以点击 (实现 PDF 中的超链接可以点击 - 通过遍历页面中所有 a 标签,并添加蒙层,设置对应的位置,点击蒙层时,跳转到对应的超链接)
  • PDF 文件体积较大 (文件体积非常大, > 80 MB - 降低分辨率,压缩图片,压缩文件)
  • PDF 与页面样式不一致 (antd 表格中的额外行的 + 按钮样式错位问题,导出的 PDF 中变为了纵向排列的一个横和一个竖 - 隐藏原 ::before/::after 伪元素,用一个内联 SVG 覆盖,导出后再还原)

解决方案#

为了简化使用方式并解决上述问题,我封装了一个 hook,用于生成 PDF 文件。

hook 使用方式如下:#

import { usePdfGenerator } from '@/src/hooks/usePdfGenerator';
import { Button } from 'antd';
const Example = () => {
const { contentRef, generatePdf, isLoading } = usePdfGenerator();
const onDownload = () => {
generatePdf('example');
// 如果你需要获取到导出的文件,可以传入一个回调函数,或者直接使用 generatePdf 的返回值
const file = generatePdf('example', {}, (file) => {
console.log('file', file);
});
};
return <div ref={contentRef}>
<h1>Hello World</h1>
<Button loading={isLoading} onClick={onDownload}>Download PDF</Button>
</div>;
};
export default Example;
WARNING

回调函数和返回值返回的是生成的图片文件,这是由于我们项目有一个导出 word 的需求,需要前端将图片传给后端使用。

hook 源码如下:#

import { useRef, useCallback, useState } from 'react';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
type GeneratePdfOptions = {
// 输出图片类型:JPEG 体积更小,PNG 无损但体积更大
imageType?: 'jpeg' | 'png';
// 当 imageType 为 JPEG 时生效,范围 0-1,建议 0.6~0.85
jpegQuality?: number;
// 二次缩放比例,0-1。用于在不明显损伤清晰度的前提下降低像素数,建议 0.7~0.9。
downscaleRatio?: number;
// 目标最大文件体积(KB),用于自适应压缩 PNG(通过逐步降低分辨率实现,尽量不损伤清晰度)
maxFileSizeKB?: number;
// 是否下载
isDownload?: boolean;
// 回调函数
callback?: (file: File) => void;
};
export const usePdfGenerator = () => {
// 1. 创建一个 ref 来附加到需要导出的 DOM 节点上
const contentRef = useRef(null);
const [isLoading, setIsLoading] = useState(false);
// 2. 使用 state 来管理生成过程中的加载状态,提升用户体验
// const [isLoading, setIsLoading] = useState(false);
// 3. 使用 useCallback 来包装我们的核心函数,以避免不必要的重渲染
const generatePdf = useCallback(async (
fileName = 'document',
options: GeneratePdfOptions = {},
callback: (file: File) => void = () => { }
): Promise<File | undefined> => {
const { imageType = 'png', jpegQuality = 0.82, downscaleRatio = 0.88, isDownload = true } = options;
const element: HTMLElement = contentRef.current!;
if (!element) {
console.error("无法生成 PDF,因为找不到引用的内容元素。");
return undefined;
}
setIsLoading(true); // 开始生成,设置加载状态为 true
// 预先准备 a 标签样式恢复器,并在渲染前将计算后的颜色写入内联,确保导出跟页面一致
const anchorsInDom = Array.from(element.querySelectorAll('a')) as HTMLAnchorElement[];
const restoreAnchorInlineStyles: Array<() => void> = [];
anchorsInDom.forEach((a) => {
const prevColor = a.style.color;
const prevTdColor = (a.style as any).textDecorationColor as string;
const prevTextDecoration = a.style.textDecoration;
const cs = window.getComputedStyle(a);
const computedColor = cs.color;
const computedTdColor = (cs as any).textDecorationColor || computedColor;
a.style.color = computedColor;
try { (a.style as any).textDecorationColor = computedTdColor; } catch (_) { }
if ((cs.textDecorationLine || '').includes('underline') && !a.style.textDecoration) {
a.style.textDecoration = 'underline';
}
restoreAnchorInlineStyles.push(() => {
a.style.color = prevColor;
try { (a.style as any).textDecorationColor = prevTdColor; } catch (_) { }
a.style.textDecoration = prevTextDecoration;
});
});
// 修复 antd Table 展开/收起图标(+/-)在 html2canvas 下的渲染异常:
// 思路:隐藏原 ::before/::after 伪元素,用一个内联 SVG 覆盖,导出后再还原
const cleanupExpandIconFixers: Array<() => void> = [];
const styleTag = document.createElement('style');
styleTag.setAttribute('data-pdf-export-fix', 'expand-icon');
styleTag.textContent = `
.ant-table-row-expand-icon.__pdf_export_fix::before,
.ant-table-row-expand-icon.__pdf_export_fix::after {
content: none !important;
background: none !important;
border: 0 !important;
}
`;
document.head.appendChild(styleTag);
cleanupExpandIconFixers.push(() => {
try { document.head.removeChild(styleTag); } catch (_) { }
});
const expandButtons = Array.from(
element.querySelectorAll('button.ant-table-row-expand-icon')
) as HTMLButtonElement[];
expandButtons.forEach((btn) => {
const cs = window.getComputedStyle(btn);
const beforeStyle = window.getComputedStyle(btn, '::before');
const afterStyle = window.getComputedStyle(btn, '::after');
const isTransparent = (c: string | null | undefined) => !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)';
const pickColor = (...colors: Array<string | null | undefined>) => colors.find((c) => !isTransparent(c));
const strokeColor = (pickColor(
beforeStyle.backgroundColor,
(beforeStyle as any).borderColor,
afterStyle.backgroundColor,
(afterStyle as any).borderColor,
cs.backgroundColor,
cs.color,
'rgba(0,0,0,0.45)'
) || 'rgba(0,0,0,0.45)') as string;
const prevPosition = btn.style.position;
const prevClassName = btn.className;
btn.style.position = prevPosition || 'relative';
if (!btn.classList.contains('__pdf_export_fix')) btn.classList.add('__pdf_export_fix');
const overlay = document.createElement('span');
overlay.style.position = 'absolute';
overlay.style.inset = '0';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '1';
const expanded = btn.getAttribute('aria-expanded') === 'true';
// 使用 16x16 视口绘制 + 或 -,线宽 1.5,颜色取自按钮 color
const svg = expanded
? `<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<line x1="3" y1="8" x2="13" y2="8" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round"/>
</svg>`
: `<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<line x1="3" y1="8" x2="13" y2="8" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round"/>
<line x1="8" y1="3" x2="8" y2="13" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round"/>
</svg>`;
overlay.innerHTML = svg;
btn.appendChild(overlay);
cleanupExpandIconFixers.push(() => {
try { btn.removeChild(overlay); } catch (_) { }
try { btn.className = prevClassName; } catch (_) { }
btn.style.position = prevPosition;
});
});
try {
// 调用 html2canvas。设置 scale 可以提高清晰度。
const canvasScale = 2; // html2canvas 渲染比例,需要参与后续坐标换算
const canvas = await html2canvas(element, {
scale: canvasScale, // 提高分辨率
useCORS: true, // 允许加载跨域图片
logging: false, // 不在控制台打印日志
backgroundColor: '#FFFFFF',
});
// 对原始画布做高质量下采样,必要时自适应压缩(针对 PNG 通过降低分辨率实现)
const exportCanvas = document.createElement('canvas');
const ctx = exportCanvas.getContext('2d');
if (!ctx) throw new Error('无法获取 2D 上下文');
const originalWidth = canvas.width;
const originalHeight = canvas.height;
let currentDownscale = Math.min(1, Math.max(0.5, downscaleRatio));
const minDownscale = 0.6; // 限制最小降采样比例,避免明显模糊
const maxFileSizeBytes = Math.max(1, Math.round((options.maxFileSizeKB ?? 320) * 1024));
const redrawWithDownscale = (ratio: number) => {
exportCanvas.width = Math.max(1, Math.round(originalWidth * ratio));
exportCanvas.height = Math.max(1, Math.round(originalHeight * ratio));
ctx.imageSmoothingEnabled = true;
// @ts-ignore: 浏览器支持性检测
if (ctx.imageSmoothingQuality) ctx.imageSmoothingQuality = 'high';
ctx.clearRect(0, 0, exportCanvas.width, exportCanvas.height);
ctx.drawImage(
canvas,
0,
0,
originalWidth,
originalHeight,
0,
0,
exportCanvas.width,
exportCanvas.height
);
};
// 初次绘制
redrawWithDownscale(currentDownscale);
// 针对 PNG,自适应降低分辨率以控制文件体积;JPEG 则交给质量参数处理
const getPngBlob = async (): Promise<Blob> => new Promise((resolve, reject) => {
try {
exportCanvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error('无法导出 PNG Blob'));
}, 'image/png');
} catch (e) {
reject(e);
}
});
let tentativePngBlob: Blob | undefined;
for (let attempt = 0; attempt < 6; attempt += 1) {
// 生成 PNG Blob 并检测体积
// eslint-disable-next-line no-await-in-loop
const blob = await getPngBlob();
tentativePngBlob = blob;
if (blob.size <= maxFileSizeBytes || currentDownscale <= minDownscale) {
break;
}
// 进一步轻微缩小 8-12% 之间,尽量减少清晰度损失
currentDownscale = Math.max(minDownscale, Math.round(currentDownscale * 0.9 * 100) / 100);
redrawWithDownscale(currentDownscale);
}
const mime = imageType === 'png' ? 'image/png' : 'image/jpeg';
const quality = imageType === 'png' ? undefined : Math.min(1, Math.max(0.5, jpegQuality));
const imgData = exportCanvas.toDataURL(mime, quality as any);
// 创建 jsPDF 实例 (A4 尺寸)
const pdf = new jsPDF({
orientation: 'portrait', // p: 纵向, l: 横向
unit: 'mm',
format: 'a4',
});
// console.log(canvas);
// 计算 PDF 页面尺寸和图片尺寸
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const imgWidth = exportCanvas.width;
const imgHeight = exportCanvas.height;
// 设置边距:左右 36px,上下 20px(转换为毫米)
const marginLeft = 36 * 0.264583; // 转换像素为毫米 (1px = 0.264583mm)
const marginTop = 20 * 0.264583;
// 计算考虑边距后的可用空间
const availableWidth = pdfWidth - (marginLeft * 2);
const availableHeight = pdfHeight - (marginTop * 2);
// 重新计算比例以适应边距
const ratioWithMargins = Math.min(availableWidth / imgWidth, availableHeight / imgHeight);
const finalWidth = imgWidth * ratioWithMargins;
const finalHeight = imgHeight * ratioWithMargins;
// 计算图片在 PDF 页面中的位置(考虑边距)
const x = marginLeft;
const y = marginTop;
// 将图片添加到 PDF
const addImageType = imageType === 'png' ? 'PNG' : 'JPEG';
pdf.addImage(imgData, addImageType as any, x, y, finalWidth, finalHeight);
// 在 PDF 上叠加可点击链接区域
// 思路:
// 1) 遍历 element 内所有 <a> 标签
// 2) 使用 getClientRects() 获取每个可见行的矩形(处理换行)
// 3) 将 DOM CSS 像素 -> 画布像素(乘以 canvasScale)-> PDF 毫米(乘以 ratioWithMargins)
// 4) 叠加到 (x, y) 的偏移位置
const elementRect = element.getBoundingClientRect();
// 使用 html2canvas 实际产出的画布尺寸与元素 CSS 尺寸,构建精准的 CSSpx->Canvaspx 比例
const elementCssWidth = Math.max(1, element.scrollWidth || elementRect.width);
const elementCssHeight = Math.max(1, element.scrollHeight || elementRect.height);
const cssToCanvasScaleX = imgWidth / elementCssWidth;
const cssToCanvasScaleY = imgHeight / elementCssHeight;
// 由最终绘制尺寸得到 Canvaspx->PDFmm 的比例(分别保留 X/Y,避免浮点误差累积)
const canvasToPdfScaleX = finalWidth / imgWidth;
const canvasToPdfScaleY = finalHeight / imgHeight;
// 辅助:累加从目标节点到根容器(element)之间所有可滚动祖先的滚动偏移
const getAccumulatedScrollOffset = (node: Element, root: Element) => {
let cur: Element | null = node.parentElement;
let accLeft = 0;
let accTop = 0;
while (cur && cur !== root && cur !== document.body && cur !== document.documentElement) {
const style = window.getComputedStyle(cur);
const overflowY = style.overflowY;
const overflowX = style.overflowX;
const isScrollableY = overflowY === 'auto' || overflowY === 'scroll';
const isScrollableX = overflowX === 'auto' || overflowX === 'scroll';
if (isScrollableY) accTop += (cur as HTMLElement).scrollTop;
if (isScrollableX) accLeft += (cur as HTMLElement).scrollLeft;
cur = cur.parentElement;
}
return { accLeft, accTop };
};
const anchors = Array.from(element.querySelectorAll('a[href]')) as HTMLAnchorElement[];
const isValidHref = (href: string | null): href is string => {
if (!href) return false;
const lowered = href.trim().toLowerCase();
return (
lowered.startsWith('http://') ||
lowered.startsWith('https://') ||
lowered.startsWith('mailto:') ||
lowered.startsWith('tel:')
);
};
anchors.forEach((anchor) => {
if (!isValidHref(anchor.getAttribute('href'))) return;
const url = anchor.getAttribute('href') as string;
const clientRects = anchor.getClientRects();
if (!clientRects || clientRects.length === 0) return;
// 滚动累加(处理 antd Table 等内部可滚动容器)
const { accLeft, accTop } = getAccumulatedScrollOffset(anchor, element);
for (let i = 0; i < clientRects.length; i += 1) {
const rect = clientRects[i];
// DOM CSS px -> 相对 element 的 px
// 额外加上所有中间可滚动容器的滚动偏移,映射到内容原点坐标系
const relLeftPx = (rect.left - elementRect.left) + accLeft;
const relTopPx = (rect.top - elementRect.top) + accTop;
const widthPx = rect.width;
const heightPx = rect.height;
if (widthPx <= 0 || heightPx <= 0) continue;
// CSS px -> 画布 px(使用实际比例,避免仅依赖配置 scale 导致的偏差)
const leftCanvasPx = relLeftPx * cssToCanvasScaleX;
const topCanvasPx = relTopPx * cssToCanvasScaleY;
const widthCanvasPx = widthPx * cssToCanvasScaleX;
const heightCanvasPx = heightPx * cssToCanvasScaleY;
// 画布 px -> PDF mm,并考虑边距偏移 (x, y)
const leftMm = x + leftCanvasPx * canvasToPdfScaleX;
const topMm = y + topCanvasPx * canvasToPdfScaleY;
const widthMm = widthCanvasPx * canvasToPdfScaleX;
const heightMm = heightCanvasPx * canvasToPdfScaleY;
const overlayHeightMm = heightMm * 2; // 扩大高度以增强可点击容错
// 叠加链接注解(使用 any 以兼容类型定义差异)
try {
(pdf as any).link(leftMm, topMm, widthMm, overlayHeightMm, { url });
} catch (e) {
// 某些版本也可用 pdf.link(x, y, w, h, urlNumber) 或 textWithLink
try {
// 退化方案:放置一个透明的文本链接(尽量不影响视觉)
const fontSize = 0.01; // 极小字号,尽量不可见
const prevSize = (pdf as any).getFontSize?.() ?? 16;
(pdf as any).setFontSize?.(fontSize);
(pdf as any).textWithLink?.(' ', leftMm, topMm + fontSize, { url });
(pdf as any).setFontSize?.(prevSize);
} catch (_) {
// 忽略
}
}
}
});
if (isDownload) {
// 保存 PDF
pdf.save(`${fileName}.pdf`);
}
// 返回 PNG 图片文件(而非 PDF),若之前已进行自适应压缩则优先使用该 Blob
const pngBlob: Blob = tentativePngBlob ?? await new Promise((resolve, reject) => {
try {
exportCanvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error(`无法导出 ${imageType.toUpperCase()} Blob`));
}, mime);
} catch (e) {
reject(e);
}
});
const pngFile = new File([pngBlob], `${fileName}.${imageType}`, { type: mime });
callback(pngFile);
return pngFile;
} catch (error) {
console.error("生成 PDF 时出错:", error);
} finally {
// 还原 a 标签的临时内联颜色,避免影响真实页面
try { restoreAnchorInlineStyles.forEach((fn) => fn()); } catch (_) { }
// 还原 antd 展开图标修复
try { cleanupExpandIconFixers.forEach((fn) => fn()); } catch (_) { }
setIsLoading(false); // 结束生成,无论成功或失败都将加载状态设为 false
}
return undefined;
}, []); // 空依赖数组意味着此函数在组件生命周期内不会改变
// 4. 返回 ref、生成函数和加载状态,以便在组件中使用
return { contentRef, generatePdf, isLoading };
};
React Hook PDF 生成
https://www.mihouo.com/posts/front/react-hook-pdf-generation/
作者
发布于
2025-08-25
许可协议
CC BY-NC-SA 4.0