前阵子在后台管理项目里接了个导出需求:把订单详情页导成 PDF,客户看到什么就得导出什么。当时第一个想到的就是 html2canvas + jsPDF 这个经典组合,先把 DOM 画成 canvas,再把 canvas 转成图片塞进 PDF。这套流程跑 demo 很快,但真正落到业务里,分页截断、样式错位、背景色丢失、图片跨域、导出模糊这些延伸问题接踵而来。这篇就把我实际踩过的坑和最终的解决方案整理出来,给同样在做 Vue 导出 PDF 的朋友一个参考。
1. 为什么选 html2canvas + jsPDF,而不是其他方案
先说结论:这项需求“所见即所得”的要求比“数据完整”更高。用户不关心 PDF 里有没有结构化的数据流,他们只希望打印出来的效果和页面上看到的一模一样。
市面方案大致有这么几类,我简单比对过:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 浏览器 window.print() | 原生支持、CSS 分页可控 | 用户要手动选择打印机,交互割裂;样式兼容性差异大 |
| 后端生成 PDF | 数据结构化、文件体积小 | 需要前后端联调,样式还原成本高,开发周期长 |
| html2canvas + jsPDF | 纯前端、所见即所得、上手快 | 生成的是位图,放大可能模糊;分页和样式有各种坑 |
| 快照类方案(服务端渲染网页再截图) | 效果最还原 | 依赖服务端浏览器环境,成本高,不通用 |
我这个项目选 html2canvas + jsPDF 的原因很简单:团队没有后端配合时间,页面本身是典型的”富样式报表“,表格、色块、签名、图片混在一起,用数据结构去描述这些元素会非常痛苦。位图方案虽然牺牲了文件可编辑性,但换来的是最小的沟通成本和最快的上线速度。
而且这个组合在社区里积累的问题和解决方案已经比较多了,遇到问题基本能搜到思路,不至于卡死。
2. 从 HTML 到 PDF,这中间到底发生了什么
很多同学一上来就抄代码,抄完发现导出的图片歪歪扭扭,然后各种加参数试错。我建议先花十分钟搞清楚这两个库各自干了什么,后面排查问题会快很多。
2.1 html2canvas 不是“截图”,是“重新绘制”
html2canvas 并不是从浏览器获取像素级的截图。它的工作方式是:解析目标 DOM,克隆一份到离屏的 iframe 或容器里,遍历所有节点的计算样式,然后用 canvas API 把每个元素一笔一笔画出来。
这就能解释很多“怪现象”了。为什么某些box-shadow画不出来?因为 canvas 的重绘过程不经过浏览器的原生渲染引擎,它只是用自己的逻辑尽可能还原样式。为什么图片没加载完成时画出来是空白?因为 canvas 绘制图片时,如果图片还没 ready,drawImage 什么都不会画。为什么某些 CSS 渐变背景会丢?因为旧版 html2canvas 对linear-gradient的处理一直不稳定,新版本即便支持了部分场景,遇到复杂叠加依然会翻车。
这里有一个最关键的概念,就是scale参数。html2canvas 生成的 canvas 物理尺寸 = 元素尺寸 × scale。默认值是 1,也就是 1 个 CSS 像素对应 canvas 的 1 个物理像素。浏览器在 96 DPI 下,1px 换算成物理长度是 25.4 / 96 ≈ 0.2646mm。如果直接把默认 scale 的 canvas 塞进 PDF,打印到 A4 纸上的效果会偏小,而且文字边缘有明显锯齿。调大 scale 可以让 canvas 拥有更多物理像素,PDF 里显示就更清晰,但代价是内存占用呈指数级增长,后面单独讲。
const canvas = await html2canvas(element, { scale: 2, // 每个 CSS 像素用 2x2 物理像素去画 useCORS: true, backgroundColor: '#ffffff', })2.2 jsPDF 把“位图”摆进“矢量页面”
jsPDF 创建的 PDF 页面本质上是矢量坐标系,单位默认是毫米。A4 纸是 210mm × 297mm。把 canvas 图片放进 PDF,核心是计算图片在页面上的物理尺寸和位置。
公式很简单:图片的物理宽度 = canvas 像素宽度 × 25.4 / 96。
const pdf = new jsPDF('p', 'mm', 'a4') const pdfWidth = pdf.internal.pageSize.getWidth() const margin = 10 const px2mm = 25.4 / 96 const imgWidth = pdfWidth - margin * 2 const imgHeight = canvas.height * px2mm * (imgWidth / (canvas.width * px2mm)) pdf.addImage(canvasData, 'PNG', margin, margin, imgWidth, imgHeight)这里有个容易绕晕的点:imgHeight不需要单独用canvas.height * px2mm算,因为图片宽高必须等比缩放。先确定宽度占满可用区域,高度按比例自然就出来了。我见过有人直接传canvas.height * px2mm,结果图片超出页面底部一大截,就是这个比例没保持好。
2.3 jsPDF 不会自动分页,一切截断都来自这里
这是整套方案里最容易踩、也最影响观感的问题。jsPDF 的addImage只是把图片放在当前页面的某个坐标上,图片超过页面边界就直接切掉,而且它也不会自动帮你新增页面。
所以如果内容高度超过一页,你需要自己决定:是在第二页重新打印同一张图但调整偏移,还是把 canvas 按像素高度切成几段,分别放到每一页。两者的效果差别很大,后面画一整节讲。
3. 基础实现:一个能跑的导出函数
先把地基打好,后面再谈优化。我直接给出 Vue 3 + TypeScript 环境下的最小可用版本。
3.1 等待图片和字体加载完毕
导出动作发起时,页面上的图片可能还在请求中。如果不管直接调 html2canvas,canvas 里对应的位置就是空白。字体也一样,@font-face没加载完,渲染出的文字用的是 fallback 字体,导出结果和页面显示对不上。
async function ensureAssetsReady(element: HTMLElement): Promise<void> { // 等待页面字体加载完成 if (document.fonts && document.fonts.ready) { await document.fonts.ready } // 找出所有未加载完成的 img 元素,逐个等 load const images = Array.from(element.querySelectorAll('img')) const pending = images.filter((img) => !img.complete) await Promise.all( pending.map( (img) => new Promise<void>((resolve) => { img.onload = () => resolve() img.onerror = () => resolve() }) ) ) }注意onerror也要 resolve,否则某张图挂了,导出流程会一直卡在 pending 状态,用户那边就是白屏转圈。我的习惯是:图片挂了至少让流程继续,出来的 PDF 缺一块图,总比永远导不出来强。
3.2 导出主流程
import html2canvas from 'html2canvas' import { jsPDF } from 'jspdf' const px2mm = (px: number) => px * (25.4 / 96) export async function exportDomToPdf( element: HTMLElement, fileName: string ): Promise<void> { await ensureAssetsReady(element) const canvas = await html2canvas(element, { scale: 2, useCORS: true, allowTaint: false, backgroundColor: '#ffffff', logging: false, }) const imgData = canvas.toDataURL('image/png') const pdf = new jsPDF('p', 'mm', 'a4') const pdfWidth = pdf.internal.pageSize.getWidth() const pdfHeight = pdf.internal.pageSize.getHeight() const margin = 10 const contentWidth = pdfWidth - margin * 2 const contentHeight = pdfHeight - margin * 2 // 图片等比缩放后的物理尺寸 const imgWidth = contentWidth const imgHeight = canvas.height * px2mm(canvas.width) / px2mm(canvas.width) * contentWidth // 简化写法:保持宽高比 const scaledImageHeight = (canvas.height * contentWidth) / canvas.width pdf.addImage(imgData, 'PNG', margin, margin, imgWidth, scaledImageHeight) pdf.save(`${fileName}.pdf`) }上面的scaledImageHeight就是简化的等比计算:canvas 高度 ÷ canvas 宽度 × 目标宽度。这段代码只处理单页内容,超过一页会直接被切。下面重点处理分页。
3.3 分页的基本思路
最常见的做法是:第一页正常 addImage,然后计算剩余高度,如果还有剩余,就pdf.addPage(),再次 addImage 同一张图,但 y 坐标用负的偏移量往上顶,让第二页显示图片的下半部分。
let heightLeft = scaledImageHeight let position = margin pdf.addImage(imgData, 'PNG', margin, position, imgWidth, scaledImageHeight) heightLeft -= contentHeight while (heightLeft > 0) { position -= contentHeight pdf.addPage() pdf.addImage(imgData, 'PNG', margin, position, imgWidth, scaledImageHeight) heightLeft -= contentHeight }这种写法的核心原理:PDF 的坐标系是固定的,图片越界部分会被裁掉。第二页我把 y 坐标设为margin - contentHeight,图片就相当于向上移动了整整一页的高度,页面可见的就是图片的第二段内容。这样能把完整内容分页打出来,代码也简单。
但这个方案有观感问题:如果正好有一行文字跨越两页边界,它会被从中间切断。这是位图分页的天然缺陷,很难彻底消除,只能缓解。后面我给出更可控的切片方案。
4. 延伸问题一:分页截断与内容缺失
上一节的位移分页方案虽然能用,但直接把一整张图往上顶,会有一个隐蔽的问题:如果你的 canvas 高度恰好不是整数页,最后一段内容的排布会不太均匀。更麻烦的是,有些表格的边框线正好落在页边界上,被切断后看起来像表格没有闭合。
在实际项目中,我更推荐直接对 canvas 做像素切片。意思是:把高度超过一页的 canvas,按每页可容纳的像素高度切成若干份,每一份独立转成图片,再放到对应的 PDF 页上。这样每一页的内容是固定的,不会出现“内容重复”或“偏移量算不准”的隐患。
function addCanvasToPdf( pdf: jsPDF, canvas: HTMLCanvasElement, margin: number, pageWidth: number, pageHeight: number ): void { const px2mm = 25.4 / 96 const contentWidth = pageWidth - margin * 2 const contentHeight = pageHeight - margin * 2 // 一页能容纳多少 canvas 像素 const pagePixelHeight = Math.floor(contentHeight / px2mm) const totalPixelHeight = canvas.height let offsetY = 0 let pageIndex = 1 while (offsetY < totalPixelHeight) { // 当前页实际需要切多高 const sliceHeight = Math.min(pagePixelHeight, totalPixelHeight - offsetY) // 创建一个临时 canvas,只截取原图的一部分 const sliceCanvas = document.createElement('canvas') sliceCanvas.width = canvas.width sliceCanvas.height = sliceHeight const ctx = sliceCanvas.getContext('2d') if (!ctx) continue ctx.drawImage( canvas, 0, offsetY, canvas.width, sliceHeight, 0, 0, canvas.width, sliceHeight ) const data = sliceCanvas.toDataURL('image/jpeg', 0.95) if (pageIndex > 1) { pdf.addPage() } pdf.addImage( data, 'JPEG', margin, margin, contentWidth, sliceHeight * px2mm ) offsetY += sliceHeight pageIndex++ } }切片方案里有个细节值得注意:sliceHeight * px2mm不一定等于contentHeight,因为最后一页可能剩下不满一页的高度。剩余部分直接按实际高度放在页面顶部即可,这样最后一页不会出现大片空白,也不用额外调整位置。
切片方案为什么比位移方案稳?因为位移方案里每一页看到的区域是计算出来的“图片的相对位置”,一旦最后一个页面的剩余高度小于图片宽度,整张图片依然会被完整塞进去,容易在底部留白;而切片方案每次都精确控制切下来的高度,最后一页的高度就是剩余量,可控性强得多。
但切片方案同样绕不开“跨页文字被切断”的问题。缓解办法有两个。第一,导出的容器样式尽量紧凑,表格行高调小一些,避免大量文本行正好骑在边界上。第二,可以给切片高度加一个轻微的 overlap,比如每页多切 10px 像素,放到下一页时从 offsetY 减去这 10px,让内容的连续性看起来更好,代价是边界处可能有极轻微的内容重复。我实际项目中用的就是 overlap = 8px,肉眼几乎感知不到重复,但断行的情况明显改善了。
5. 延伸问题二:样式错乱的各种坑
样式问题往往是最让人头疼的,因为不报错,就是结果不对。这里列几个我实际踩过的。
5.1 背景色和渐变消失
html2canvas 对背景色的兼容性比想象中差。最常见的是你明明设置了body的背景色,导出后 PDF 却是白色的。原因在于 canvas 默认背景是透明黑,html2canvas 虽然提供了backgroundColor参数,但也只影响最终的画布底色。如果容器本身没有显式背景色,它就不会向上取 body 的背景色。
解决方法是导出前给目标容器显式加上白色背景:
const cloneElement = element.cloneNode(true) as HTMLElement cloneElement.style.backgroundColor = '#ffffff' cloneElement.style.position = 'fixed' cloneElement.style.left = '-9999px' cloneElement.style.top = '0' document.body.appendChild(cloneElement) // html2canvas 用 cloneElement,结束后 remove更省事的是在 html2canvas 配置里直接设backgroundColor: '#ffffff'。但要注意,这只保证画布底色是白色,各单元格的半透明背景色叠加后会产生色差。如果表格里有大量淡色背景的小格子,叠加结果和屏幕显示不一定一致,这种情况建议导出专用 DOM 模板,不要直接导业务页面。
5.2 字体和单位导致的对不齐
html2canvas 读取的是元素的计算样式,这对我们是好事,意味着大多数样式都能还原。但它对 css 单位兼容有历史遗留问题,尤其是rem。如果你页面根节点的 font-size 不是默认 16px,或者有动态调整,有些版本算出来的尺寸会不准确。所以导出专用模板里,字体大小我全部用 px 写死。
另外字体加载的问题,除了前面说的document.fonts.ready,还要注意部分浏览器对font-display: swap的处理。如果字体的 block 阶段很短,页面显示时 fallback 字体一闪而过,html2canvas 抓取 DOM 时拿到的是最终渲染状态,没问题;但如果字体本身就加载失败,canvas 里会显示 fallback,和页面不一致,这个只能从字体资源的稳定性上解决。
5.3 伪元素和阴影完全不渲染
项目里用::before、::after做的图标、角标、价格标签非常常见。html2canvas 对标准的 content 型伪元素支持还可以,但如果伪元素里放了背景图、渐变或者依赖content: ''+ 背景色绘制图形,就有概率丢失。我的经验是:导出项目里不要依赖伪元素做关键信息展示,价格、状态、金额这些从真实 DOM 节点读取渲染,伪元素只做装饰性视觉,丢失了也不影响内容完整性。
box-shadow在部分版本里会渲染失败或者只渲染一层模糊边缘。如果页面里用大面积的阴影做层次感,导出前建议直接给模板加个box-shadow: none的样式重置,同时用边框或者底色来区分层次,这样 PDF 里不会出现难看的黑色块。
6. 延伸问题三:图片、字体与跨域
图片问题是导出 PDF 里占比最高的报错类型。尤其是系统里有鉴权图片、OSS 图片、CDN 图片的场景。
6.1 图片不显示,最常见的原因是 canvas 被污染
浏览器有一个安全机制:如果 canvas 被绘制了跨域图片,且没有经过 CORS 授权,那canvas.toDataURL()会直接抛SecurityError。这其实是 canvas 在保护用户隐私,防止脚本读取其他域名的像素数据。
解决办法有几层。第一层,给图片加crossOrigin="anonymous"属性,告诉浏览器“我要以匿名方式加载这张跨域图片”。第二层,服务器要返回Access-Control-Allow-Origin响应头,允许该图片被跨域使用。第三层,html2canvas 的配置里打开useCORS: true。
// 导出前遍历图片,动态补 crossorigin element.querySelectorAll('img').forEach((img) => { img.setAttribute('crossorigin', 'anonymous') })但有个现实问题:很多 CDN 图片服务不会返回 CORS 响应头。useCORS打开之后依然可能报错。我在某个老项目里遇到的是:单域名的图片没问题,走 CDN 的图片全部白屏。最后折中方案是把图片先转成 Blob,再用本地 URL 创建对象替换 img 的 src。
async function fetchAndReplaceImages(element: HTMLElement): Promise<void> { const images = Array.from(element.querySelectorAll('img')) for (const img of images) { const src = img.getAttribute('src') if (!src || src.startsWith('data:')) continue try { const resp = await fetch(src) const blob = await resp.blob() const objectUrl = URL.createObjectURL(blob) img.src = objectUrl } catch { // 单条失败不阻塞整体导出 } } }用 Blob 方案前先确认图片服务允许 fetch。允许的话基本能解决跨域问题;如果不允许,就得通过同域后端代理中转,这不属于前端的范畴,排在最后。
6.2 图片加载完成的检测,别只依赖 img.complete
img.complete只能表示浏览器对这张图片的加载流程结束了,不代表绘制一定成功。如果图片有 CORS 限制,complete 是 true,但 drawImage 时依然画不上去。所以在 ensureAssetsReady 之外,我还会在 html2canvas 渲染前强制走一遍替换逻辑,把有问题的图片统一换成已确认可用的数据源。
另外canvg输出的 SVG 图片、或含foreignObject的 SVG,在 html2canvas 里支持也不完整。如果你页面里有这类高级 SVG 场景,别浪费时间调参,直接在导出模板里换成普通 img 或 canvas。
6.3 字体加载,除了等待还有嵌入问题
页面里用了自定义字体时,document.fonts.ready只能保证字体已经加载,但 html2canvas 绘制文字时能不能拿到字体信息,取决于浏览器的 canvas 字体机制。大部分现代浏览器都能正确绘制,但也有坑:如果字体文件本身是跨域的,和图片一样的 CORS 问题也会发生在字体上。
解决办法是给@font-face注册时使用相对同源的字体文件,或者用 base64 内嵌字体。不推荐在业务里为了一个导出功能内嵌字体,文件体积代价太大。实际项目中,我遇到的绝大多数情况是“字体已经加载但因为 fallback 导致字体不一样”,这种直接接受即可,PDF 里用系统字体输出,对阅读影响不大。
7. 延伸问题四:清晰度、内存与性能
7.1 scale 不是越大越好
很多人觉得 PDF 模糊,第一反应就是把 scale 调到 3、4,结果页面直接卡死。这是因为 canvas 的内存占用是像素量的四倍:每个像素需要存 RGBA 四个通道。一张 A4 宽度的内容,元素宽度 794px 左右,scale 2 时 canvas 宽度约 1588px,如果内容高度有 3000px,canvas 像素总量接近 476 万,占用内存接近 19MB。scale 4 就是 76MB。这还只是一张 canvas,切片时还会创建更多临时 canvas。
我的经验是 scale 2 到 3 之间基本够用。导出出来的 PDF 在屏幕上放大看没有明显锯齿,打印出来也够清晰。超过 3 不仅渲染慢,移动端还容易把浏览器内存打爆。
7.2 大页面导出的分段渲染策略
如果页面内容特别长(比如几十页的报表),一次性渲染整张长 canvas 会让主线程长时间无响应,用户会以为浏览器死了。我的做法是分段渲染:先估算总高度,按每页高度分成若干段,每一段单独调用 html2canvas 渲染,再把各段拼进 PDF。这样每一段渲染耗时可控,还可以在每段之间await new Promise(r => setTimeout(r, 0))让出主线程,页面不会完全冻结。
这个方案的问题是段落边界处如果有跨页的行,一样会有截断风险。所以分段点和切片点最好重合。如果分段策略实现成本太高,退一步用 loading 遮罩 + 单次渲染,至少让用户知道程序在干活。
7.3 移动端的固定定位和滚动问题
移动端浏览器对position: fixed元素的处理一直很微妙。如果导出目标里有 fixed 头部,html2canvas 渲染时这个元素可能会出现在每一段的相同位置,而不是跟随文档流,最终 PDF 里会出现内容重叠。
我的习惯是导出之前,给元素加一个独立的类,把所有 fixed 元素改成 absolute,并把父容器滚动位置强制重置到顶部。这不是万能解,但能规避大部分移动端陷阱。
另一个移动端常见问题是:导出内容在页面内滚动容器里。html2canvas 默认只绘制元素的可见区域,如果容器有滚动,导出结果会缺内容。解决办法是导出前临时把容器的滚动高度设置为总高度,或者把scrollTop置 0 后再渲染,具体看布局结构。最稳定的还是用一个离屏的克隆节点,把样式全部强制成静态计算后的值,再挂进 body 渲染。
8. 常见问题速查表
把这段时间积累的问题整理成一个速查表,遇到问题先对着表排查一轮。
| 症状 | 可能原因 | 处理办法 |
|---|---|---|
| PDF 内容模糊 | scale 太低 | 调大 scale 至 2 或 3 |
| 导出结果全白/图片区域空白 | 图片未加载完成 | ensureAssetsReady 等待后再渲染 |
| toDataURL 抛 SecurityError | 跨域图片污染 canvas | 图片加 crossorigin、服务器开 CORS、或转 Blob |
| 背景色不对/变白 | html2canvas 默认透明底色 | 容器显式设置背景色,配置传 backgroundColor |
| 内容从中间切断 | jsPDF 不自动分页 | 使用 canvas 切片方案或 overlap 方案 |
| 渐变色块消失 | html2canvas 对渐变支持不全 | 导出模板改用纯色 |
| 导出页面卡死 | scale 太大或页面过长 | 降 scale、分段渲染 |
| 字体与页面显示不一致 | 字体未加载或跨域 | 等待 fonts.ready,确保字体源同域 |
| 表格边框断裂 | 表格正好跨页边界 | 调整间距或 overlap,必要时优化行高 |
| 滚动容器内容缺失 | 只渲染了可见区域 | 用离屏克隆节点导出完整高度 |
9. 我最后想说的几个习惯
这几个习惯不是我一开始就有的,是踩了无数坑之后沉淀下来的,项目稳定的几乎都靠它们。
第一,给导出功能单独维护一个干净的 DOM 模板,而不是直接引业务页面。业务页面里各种弹窗、按钮、hover 状态、交互动效,导出时全都变成了负担。克隆模板再手动清理干扰元素,是成本最低的稳定方案。
第二,导出永远不要硬编码几个固定参数。scale、margin、页面尺寸、JPEG 压缩比,这些都应该是函数的参数,给业务方留出调整空间。否则每次改版,你都要去翻代码找魔法数字。
第三,导出过程一定给用户反馈。哪怕只是一个简单的 loading 遮罩,也比用户看着页面卡死然后刷新强。渲染百万级像素需要时间,这不是浏览器 bug,是物理限制。
第四,多测试几种入口。同一套导出代码,在不同浏览器、不同缩放比例下的结果可能都不一样。我最常翻车的是 iOS 微信内置浏览器和桌面 Safari。有空的话,准备一张测试清单,上线前每个分支过一遍。
这套方案说不上完美,但在前端资源有限、又要快速交付的情况下,它确实是最能平衡成本和效果的选择。如果你正在折腾 html2canvas + jsPDF 的分页和样式问题,希望对你有启发。