资讯动态

Vue项目自定义PDF导出:html2canvas与jsPDF实现任意页面尺寸方案

发布时间:2026/10/14 11:44:00 来源:尧图企业网站定制
第一次接到导出PDF的需求我心里想的是这还不简单浏览器自带打印、页面直接生成PDF不就行了。结果需求方把尺寸规格表发过来的时候我沉默了——不是A4不是A3而是一堆见过没见过的自定义尺寸什么长条标签、圆形吊牌、220mm×110mm的小票纸。浏览器打印对话框确实也能自定义纸张但真正要在前端代码里控制每一页的大小、分页位置和最终产品形态还是要靠JS库自己算。这篇就把我在Vue项目里做“导出PDF自定义页面大小”的完整过程和踩坑记录写出来给同样被这个需求折磨的人一个可复用的方案。1. 方案选型为什么最终选了html2canvas jsPDF1.1 主流方案横向盘点我自己的对比结论我把市面上能搜到的方案都过了一遍整理出来其实就四条路方案Ahtml2canvas jsPDF 手工组合方案B直接用 html2pdf.js 封装库方案Cpdfmake 纯数据驱动生成方案Dvue-html2pdf 一类的早期封装组件先说结论方案A最稳。不是因为它代码写得少恰恰是因为它代码写得多你才有机会控制分页、尺寸、边距、清晰度这些真正影响交付质量的东西。方案B看起来很省事它的本质就是把html2canvas和jsPDF在内部串了一遍。问题在于它想“智能”替你分页但真实页面里的表格、弹层、懒加载图片、固定定位元素它全要猜。猜对了是运气猜错了你连从哪里下手干预都不知道因为逻辑封在黑盒里。我试过一次导出带有多个区块的长报表出来的PDF把一张表格从表头正中间劈成了两半你还没法通过配置解决只能苦笑。方案D基本是方案B的老版本很多年没大更新了有的还停留在对旧版Vue的适配。新项目不建议碰。方案Cpdfmake适合另一种场景内容本身就是结构化的比如简历、合同、报价单你用JSON描述标题、段落、表格它生成PDF又快又干净。但它不适合你已经写好的一整块DOM页面——你没法把现有的CSS排版直接抓过来等于为PDF重新做一遍模板工作量翻倍。1.2 为什么pdfmake被排除我在某个内部系统里试过用pdfmake替代截图方案。那个项目里需要导出的内容是一份运营报表前端已经用Element Plus写好了表格有合并单元格、条件背景色、自定义字体大小。如果用pdfmake我等于把这份表格在JSON里再画一遍合并行要算好rowspan条件背景色要写一堆if判断最后做出来的样式和页面还总有细小的偏差。pdfmake的优势是文本选择、矢量清晰度、文件体积劣势是它和你的DOM是两个世界。如果你的需求是“把正在展示的页面导出成PDF”html2canvas截图天然匹配如果需求是“用数据生成一份版式固定的文档”pdfmake才是更好的答案。我接手的大部分导出需求都是前者所以选型上几乎没有悬念。1.3 自己封装组合的可控性把html2canvas和jsPDF组合起来做事的是两个库做主的还是你的代码。你想在页面里加页码、想在导出前临时改样式、想把某个区块自动拆分到多页、想控制scale提高清晰度——每一个环节都有明确的挂钩点出了问题也知道去查哪一行。对于自定义页面大小这个需求组合方案还有一个杀手级优势jsPDF的format参数原生支持任意数组尺寸你只需要把用户选择的尺寸映射到数组里就行。今天做A4明天做标签纸后天做客户自定义的120×180mm都不用换技术栈改参数即可。2. 自定义页面尺寸的核心format、unit和像素换算2.1 jsPDF的format参数和单位关系先看一个最基本的实例化代码import { jsPDF } from jspdf const pdf new jsPDF({ orientation: portrait, unit: mm, format: [120, 180] })这里的format: [120, 180]就是自定义页面宽高单位跟随unit参数。我平时统一用unit: mm因为国内打印场景、纸张行业都以毫米为常用单位不用再做换算。jsPDF支持的单位有mm、cm、in、pt、px。其中px最容易引起误会jsPDF内部是矢量绘制所谓px只是在换算时按固定DPI默认96转成磅值。你实际操作的CSS布局是物理屏幕像素两者不是一个体系。所以我的经验是在jsPDF里尽量别用px当unit逻辑会绕晕统一用mm或者pt。如果需要设置标准纸张大小jsPDF内部也支持format: a4、format: letter这种字符串快捷方式效果等价于传数组这里不再展开。2.2 canvas图片尺寸到PDF物理尺寸的换算html2canvas把DOM渲染成canvas得到的是像素尺寸比如一个区块宽度可能是1200px、高度是800px。PDF需要的是物理尺寸比如你想让图片显示在PDF页面上占120mm宽。中间必须要有一个比例换算图片在PDF上的显示高度 canvas.height × (PDF内容区宽度 / canvas.width)对应到代码就是const pageWidth pdf.internal.pageSize.getWidth() // PDF页面宽度单位mm const pageHeight pdf.internal.pageSize.getHeight() // PDF页面高度单位mm const contentWidth pageWidth - margin * 2 // 页面里真正能放内容的宽度 const imgWidth contentWidth const imgHeight (canvas.height * imgWidth) / canvas.width这段逻辑是整个分页算法的基础。可以这样理解canvas是一张印好内容的长卷画布PDF相当于一排固定大小的展示窗口。你的任务就是让长卷按窗口宽度等比例缩放然后一窗一窗地裁开贴进去。窗口的高度不够长卷的高度就多开几个新窗口这就是分页的实质。2.3 方向、宽高顺序和单位混用的坑自定义页面大小最常见的翻车点有三个第一format数组的高宽顺序。这是最容易写反的地方。jsPDF的format数组约定是[width, height]你在portrait模式下想生成一张120mm宽、180mm高的竖版页面传[120, 180]没问题。但如果你需要landscape横向页面我的建议还是传真实的宽高比如[180, 120]加orientation: landscape。有些开发者会传[120, 180]再加landscape结果页面变成了180宽、120高方向就成了竖的跟你预期完全相反。遇到这种问题先检查数组里谁在前面。第二页面尺寸和内容区块的尺寸不匹配。有的页面内容写的是栅格布局宽度基于百分比在浏览器里看上去是响应式的。但你导出的PDF有固定的物理尺寸内容宽度被压缩到pageWidth - margin*2之后长宽比变了图片里的元素间距、换行位置都会发生变化。这个没法完全避免最好的办法是导出前给目标容器套一套“导出专用样式”把内容宽度固定成和PDF内容区一致的尺寸再让html2canvas截图。我这个习惯在后面的样式中会给出示例。第三边距要不要算进内容尺寸。如果你在pdf.addImage时把图片从坐标margin开始放那么图片可用的宽度就是页面宽度减左右边距。如果忘了扣margin图片就会超宽超出部分直接右溢出去。别小看这个细节很多“导出以后右边被裁掉”的问题就是这么来的。3. Vue导出PDF完整实现可直接抄的封装3.1 工具函数的设计思路我最终落地的方案是把导出逻辑封装成一个独立的工具函数放在src/utils/exportPdf.ts里。设计参数时把自定义尺寸放第一位接口长这样export interface ExportPdfOptions { target: HTMLElement // 要导出的DOM根节点 fileName?: string // 导出文件名默认 exported.pdf pageSize?: [number, number] // 自定义页面宽高默认 [210, 297] 即A4 unit?: mm | cm | pt | in orientation?: portrait | landscape margin?: number // 页面边距默认10 scale?: number // canvas采样倍率默认2 sectionSelector?: string // 分块标记默认 .pdf-section }所有可选项都给了默认值调用方只需要传入要导出的DOM节点就能得到一份A4的PDF一旦客户换了尺寸规格调用时传一个pageSize数组就行。这样的设计保证了灵活性的同时也降低了日常调用成本。3.2 分块与分页算法详解直接上完整代码关键位置我加了注释import html2canvas from html2canvas import { jsPDF } from jspdf export interface ExportPdfOptions { target: HTMLElement fileName?: string pageSize?: [number, number] unit?: mm | cm | pt | in orientation?: portrait | landscape margin?: number scale?: number sectionSelector?: string } export async function exportPdf(options: ExportPdfOptions) { const { target, fileName exported.pdf, pageSize [210, 297], unit mm, orientation portrait, margin 10, scale 2, sectionSelector .pdf-section } options const pdf new jsPDF({ orientation, unit, format: pageSize }) const pageWidth pdf.internal.pageSize.getWidth() const pageHeight pdf.internal.pageSize.getHeight() // 判断目标下是否有标记好的分块元素 const hasSections target.querySelectorAll(sectionSelector).length 0 const sections: HTMLElement[] hasSections ? Array.from(target.querySelectorAll(sectionSelector)) : [target] let used margin // 当前页已经用掉的高度 for (const section of sections) { const canvas await html2canvas(section, { scale, useCORS: true, backgroundColor: #ffffff, logging: false, imageTimeout: 20000 }) const imgWidth pageWidth - margin * 2 const imgHeight (canvas.height * imgWidth) / canvas.width // 单个区块超过整页可用高度走切片分页 if (imgHeight pageHeight - margin * 2) { sliceAndPlace(pdf, canvas, imgWidth, pageHeight, margin) used margin continue } // 当前页放不下另开一页 if (used imgHeight pageHeight - margin) { pdf.addPage() used margin } pdf.addImage( canvas.toDataURL(image/png), PNG, margin, used, imgWidth, imgHeight ) used imgHeight margin } addPageNumbers(pdf, pageWidth, pageHeight) pdf.save(fileName) }sliceAndPlace负责把一张超过整页高度的canvas按页高切片边计算边贴入PDFfunction sliceAndPlace( pdf: jsPDF, canvas: HTMLCanvasElement, imgWidth: number, pageHeight: number, margin: number ) { const ratio imgWidth / canvas.width const fullImageHeight canvas.height * ratio const maxContentHeight pageHeight - margin * 2 let offset 0 let firstPage true while (offset fullImageHeight) { const sliceHeight Math.min(maxContentHeight, fullImageHeight - offset) // 把毫米高度换算回canvas像素高度 const sourceHeight Math.floor(sliceHeight / ratio) const clip document.createElement(canvas) clip.width canvas.width clip.height sourceHeight const ctx clip.getContext(2d) if (!ctx) throw new Error(未能创建canvas上下文) ctx.drawImage( canvas, 0, Math.floor(offset / ratio), canvas.width, sourceHeight, 0, 0, canvas.width, sourceHeight ) if (!firstPage) pdf.addPage() pdf.addImage(clip.toDataURL(image/png), PNG, margin, margin, imgWidth, sliceHeight) offset sliceHeight firstPage false } }3.3 分块标记为什么一定要加pdf-section很多第一次接触截图导出的人容易直接把整个页面的根节点丢给工具函数然后期待它自动把内容排到多页上。功能上确实能跑——代码会把整个长页面切成若干页——但效果很差。一个很长的表格或一段连续文本在切割点会被硬生生截断头半个字留在上一页后半截跑下一页阅读体验和打印效果都很难看。所以我强烈建议在模板里给每个“语义完整”的区块加上pdf-section类div refcontentRef classreport-body div classpdf-section report-header !-- 标题、日期、单号 -- /div table classpdf-section report-table !-- 业务数据表 -- /table div classpdf-section report-footer !-- 签字、备注、说明 -- /div /div每个pdf-section都是一个独立的截图单元它们之间的分页会以完整块为单位不会把块拦腰截断。只有当一个块本身的高度超过了一整页可用高度时比如超长表格才被迫在这个块内部做切片。也就是说你在模板里把粒度控制得越细最终的PDF分页就越自然。用区块截图还有一个隐藏好处内存更稳。如果一次性把整个长页面渲染成一张超长canvas很容易触发浏览器canvas像素上限或者让页面崩溃。拆成多个小区块每次截图的尺寸都不大平缓很多。3.4 组件封装和使用示例工具函数封装好之后在Vue组件里调用非常直接。以Vue 3组合式API为例script setup langts import { ref } from vue import { exportPdf } from /utils/exportPdf const contentRef refHTMLElement | null(null) const loading ref(false) async function handleExport() { if (!contentRef.value) return loading.value true try { await exportPdf({ target: contentRef.value, fileName: 自定义尺寸报表.pdf, pageSize: [120, 180], unit: mm, margin: 8, scale: 2 }) } finally { loading.value false } } /script template div div refcontentRef classreport-body div classpdf-section外部样式适合作为新手前端在导出功能上的第一个完整练习。如果你已经做到多尺寸输出把尺寸参数映射成一个下拉框用户选什么你传什么导出函数不用动一行这就能应对大多数业务需求了。”这句话有点多我想收一下。 我在实际项目中遇到过一个特别刁钻的尺寸客户要的是210×99mm的不干胶标签纸。这种规格你不可能在某个现成组件库里找到网上也不会有对应配置。但我做的方案只需要把pageSize传成 [210, 99]页面、表格、二维码、文字说明全都能按这个幅面正常排版。这是让我觉得折腾这一圈值得的地方。如果你也要做类似功能建议从这份代码开始改省去从零踩坑的时间。 补充一个我个人的习惯导出前我总会先在页面里把目标内容滚动到当前位置再调用导出函数。为什么因为html2canvas对某些懒加载、视口内渲染的元素尤其是表格的虚拟滚动场景会有不可预料的渲染遗漏把内容滚动到可视区通常能规避一部分奇怪问题。这个操作不一定每次都有用但在不确定因素多的时候多此一举总比导出后发现缺失交不了差强。 这套方案我已经在几个内部项目里稳定跑了一段时间核心就是**分块DOM html2canvas截图 jsPDF排版 自定义format页面尺寸四件事分开做出了问题逐层排查。** 如果你的需求也是自定义页面大小直接抄这份代码没问题。遇到具体报错对照第4章的清单走一遍大概率能找到答案。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑