纯 JS 实现 HTML 转 PDF:一个让前端想骂人的需求,我是这么啃下来的
前言:客户需求这种东西,永远比你想象得离谱
先吐槽一句。
这周接了个需求,评审会上产品轻飘飘来一句:「咱们这个页面,加个导出 PDF 的按钮哈,表格、SVG 图、样式,一个都不能少,要跟用户看到的一模一样。」
我当时的表情,大概就是那种……嘴角想抽但还得保持职业微笑的感觉。
说白了人家要的是:像截图一样,咔嚓一下,整页原封不动塞进 PDF 里,而且得是用户在系统里点一下就自己下载,不能让人家自己去按 Ctrl+P。
咋不上天呢。
更绝的是,客户还补了一句「别给我搞那种打开一堆乱码的」。行吧,难度直接拉满。
一、先把能走的弯路走一遍(都是泪)
拿到需求第一反应,肯定是先看看前人踩过啥坑,别傻乎乎自己造轮子。
方案一:浏览器自带打印 → 直接枪毙
这个不用说,Chrome 右键打印存 PDF,确实能出来,样式也还在。但问题是用户得自己动手,而且不同浏览器出来的效果那叫一个百花齐放,IE / Edge 老版本更是灾难现场。客户明确要「系统里点按钮触发」,这条当场 pass。
方案二:上 wkhtmltopdf
这玩意儿名气挺大,后端装个二进制,传个 URL 或者 HTML 过去就给你吐 PDF。我在本地 demo 里试了一把,普通文字表格还行,一遇到 SVG……直接空白,或者干脆给你截一半。查了下 issue 列表,SVG 支持本来就是它的老大难,而且这货已经很久不怎么活跃维护了。再想想还得在项目里挂个外部进程,算了,pass。
方案三:iText 后台生成
Java 系的 iText,报表场景用得很多,可控性确实强。但它是从头布局那种思路,你得自己把页面元素一个个往里画。我们这是个动态页面啊,前端那堆 CSS、flex 布局、响应式折腾出来的样子,你让 iText 复刻一遍?传后台还丢样式,等于白干。pass。
三条路全堵死。
到这儿其实挺崩溃的,感觉自己像个在死胡同里转圈的人。最后实在没招了,退一步想:既然要的是「像截图」,那我就真把它截下来呗 —— 先 HTML → 图片 → 再图片塞进 PDF。全程纯 JS 在前端跑,样式不丢,用户点一下就下载,逻辑上居然说得通。
二、技术选型:三个库,各干一件事
思路定下来,工具也就清晰了:
| 环节 | 用的库 | 干啥的 |
|---|---|---|
| HTML → Canvas | html2canvas | 把 DOM 按当前样子画到画布上 |
| SVG → Canvas | canvg | 补 html2canvas 搞不定的 svg |
| Canvas/图片 → PDF | jsPDF | 把图片往里一塞,输出下载 |
html2canvas 网上教程一大把,我就不啰嗦它原理了。坑主要出在后面两个,尤其是 SVG 和字体图标,这俩才是真正的磨人精。
坑点预警:html2canvas 对 svg 基本是睁眼瞎
你页面里要是直接写 <svg>,html2canvas 跑一遍,那块直接给你留白。它压根不会去解析 svg 内部的路径。所以得在调 html2canvas 之前,手动把页面里所有 svg 先换成 canvas,canvg 就是干这个的。
还有个更阴间的:glyphicons 那种字体图标。它本质上不是图片,是字体里某个字符码渲染出来的图形。不同浏览器、不同字体加载时机下,表现完全不一样,html2canvas 经常给你画个空白方框。最后我的野路子是——拿到图标对应的 char code,自己用 canvas 的 fillText 重新画一遍。土,但管用。
三、上代码:三步走,一步一步别乱
第一步:svg 统统换成 canvas
svg2canvas: function(targetElem) {
var svgElem = targetElem.find('svg');
svgElem.each(function(index, node) {
var parentNode = node.parentNode;
// IE 这坑货不支持直接读 svg 节点的内容,得在外面套一层 div,
// 靠外层 div 的 innerHTML 把 svg 字符串抠出来
var tempNode = document.createElement('div');
tempNode.appendChild(node);
var svg = tempNode.innerHTML;
var canvas = document.createElement('canvas');
// canvg 上场:把 svg 字符串画进 canvas
canvg(canvas, svg);
parentNode.appendChild(canvas);
});
}
这里有个细节,我卡了快一个小时:IE 下直接读 svg.outerHTML 或者拿不到完整内容,套个临时 div 用 innerHTML 取,才老老实实把那段 svg 吐出来。写兼容代码真的会让人怀疑人生。
第二步:glyphicons 字体图标,自己画
页面没用这玩意儿的,这步直接跳过,别给自己找事。
glyphicons2canvas: function(targetElem, fontClassName, fontFamilyName) {
var iconElems = targetElem.find('.' + fontClassName);
iconElems.each(function(index, inconNode) { // 嗯,inconNode 是当时手滑写的,懒得改了
var fontSize = $(inconNode).css("font-size");
var iconColor = $(inconNode).css("color");
var styleContent = $(inconNode).attr('style');
fontSize = fontSize.replace("px", ""); // 把单位扒掉
var charCode = getCharCodeByGlyphiconsName(iconName);
var myCanvas = document.createElement('canvas');
// 宽高各多给 2px,不然图标边缘会被削掉一丢丢
myCanvas.width = parseInt(fontSize) + 2;
myCanvas.height = parseInt(fontSize) + 2;
myCanvas.style = styleContent;
var ctx = myCanvas.getContext('2d');
ctx.fillStyle = iconColor;
ctx.font = fontSize + 'px ' + fontFamilyName;
ctx.fillText(String.fromCharCode(charCode), 1, parseInt(fontSize) + 1);
$(inconNode).replaceWith(myCanvas); // 图标变画布,神不知鬼不觉
});
},
// 图标类名 → char code,这表得按你们项目用的字体自己对
getCharCodeByGlyphiconsName: function(iconName) {
switch (iconName) {
case "glyphicons-resize-full":
return "0xE216";
case "glyphicons-chevron-left":
return "0xE225";
default:
return "";
}
}
说白了就是查表 + 手绘。charCode 那张表得对着你们项目里字体文件的映射自己填,我没找到通用的全自动办法,要是谁有更优雅的方案,求分享,真的。
第三步:html → canvas → 图片 → pdf,一气呵成
html2canvas($("#myExportArea"), {
onrendered: function(canvas) {
var imgData = canvas.toDataURL('image/jpeg');
var img = new Image();
img.src = imgData;
// 注意:pdf 尺寸得等图片加载完再定,不然拿不到宽高
// 那个 *0.225 是比例换算出来的,别问咋来的,试出来的
img.onload = function() {
// 横版竖版得看宽高比,搞反了 PDF 里就只显示一截,巨坑
if (this.width > this.height) {
var doc = new jsPDF('l', 'mm', [this.width * 0.225, this.height * 0.225]);
} else {
var doc = new jsPDF('p', 'mm', [this.width * 0.225, this.height * 0.225]);
}
doc.addImage(imgData, 'jpeg', 0, 0,
this.width * 0.225, this.height * 0.225);
doc.save('report_pdf_' + new Date().getTime() + '.pdf');
};
},
background: "#fff",
// 根节点没设背景的话,不写这行,出来一片黑,跟灵异事件似的
allowTaint: true
// 默认 false,碰到跨域图或者它认不出的图直接罢工,
// 开成 true 至少能跑完,代价是可能有点瑕疵
}).catch(function(e){
console.log("完犊子了:", e);
});
几个我踩过的点,你们注意下:
- 0.225 这个魔数是 px → mm 的换算加上我页面自己的缩放比例,你们照搬肯定不对,得按自己页面量一下。我当时就是导出完发现 PDF 要么巨小要么被裁,调了七八遍才对上
- 横竖版判断别偷懒。页面是宽屏的话,硬塞进竖版 PDF,右边直接没了,客户第一个来找你
- allowTaint: true 是把双刃剑。开了能跑通,但如果你页面里有跨域图片,canvas 会被污染,toDataURL 直接抛错。真遇到跨域图,得让服务端开 CORS 或者先转成 base64 再塞进来
四、效果咋样?说实话,勉强及格
折腾了大半天,流程总算跑通了:点按钮 → 转 canvas → 出图 → 吐 PDF,客户要的「表格在、svg 在、样式在、能自己下载」,算是都交差了。
但要说清楚:清晰度是硬伤。
毕竟这条路的本质是「截图」,canvas 的分辨率取决于设备和页面缩放,放大一看字边缘是发虚的,跟真正浏览器打印出来的矢量文字完全没法比。你想啊,人家打印 PDF 是文字型、可无限缩放,咱这个是位图塞进去的,天生矮一头。
所以如果你是要做归档级、印刷级的 PDF,这条路建议别走,老老实实服务端渲染或者劝产品妥协。但要是像我这样,客户就要个「长得一样的电子版报告」,那这套纯前端方案,够用,而且部署成本低得离谱 —— 几个 js 文件往页面一丢,完事。
哦对了,后来听说 html2canvas 的新版加上了部分 svg 的实验支持,canvg 那边也有些新写法,我这套是当时项目里的老版本组合拳,你们新项目可以先试试能不能少写一步。能少踩坑就少踩吧,前端这活儿,有时候真不是技术难,是跟各种妖魔鬼怪的兼容细节磨。
顺带提一句:这套方案产出的是位图型 PDF,文件通常也不小。如果你只是想把导出的东西再压一压,或者反过来要把 PDF 变回可编辑文档,可以直接用 FreeConvertBox 的PDF 压缩和PDF 转 Word,在浏览器里即开即用不留存,隐私安全有保障。
行了,就写到这,我去把那个 inconNode 改掉先。
说明:部分素材由 AI 辅助生成。

