网页 PDF 生成实践

2021-03-30

背景

在一些数据报表类型的项目中,用户一般会希望能够将数据保存下来。除了常规的下载 excel 文件之外,也可以尝试生成 PDF 文件。相比于 excel,PDF 最大的优点是能够附带复杂的样式,而且方便用户打印。

前端?后端?

在项目中 Excel 生成一般由后端承担,因为有许多很方便的 SQL 结果直接导出生成 Excel 文件的工具库,开发成本比较低。那么 PDF 呢?

在我们的项目中,选择了前端生成的方案,why?

  • PDF 带有大量的排版样式,属于前端的关注点
  • 相较于在后端命令式的绘制,基于前端网页样式“打印” PDF 开发成本更低

技术方案对比

浏览器打印

这是能够直接想到的方案,也是最符合浏览器标准的。简单描述步骤为: 调用浏览器打印机,选择 PDF 打印机,将页面打印保存即可。

对于 PDF 样式,可以使用媒体查询调整,例如:

@media print {
  body { font-size: 10pt; }
}

浏览器打印的方案看起来很理想,实际很难用。最麻烦的是,没有浏览器接口来配置打印机。在标准中,调用浏览器打印就一个方法:

window.print()

对,在w3c标准中这个方法就没有任何配置项,调用这个方法,会直接调用浏览器的打印对话框。

image.png

这就需要用户手动配置打印机,体验比较差。而且不同的浏览器实现的打印对话框可能还不相同,这就很难搞了。如果对于 PDF 样式要求比较低,可以采用这种方案。

很多浏览器厂商也在推进更好的打印体验,比如 firefox 和 chrome 都推出了 silent print 功能,不过目前属于需要用户手动配置开启的实验功能,没法在生产环境使用。可以期待今后的发展。

使用 PDF 开源库

相比于不可控的浏览器打印,手动控制 PDF 生成可以达到对样式更精准控制。也可以做到静默生成,不会像浏览器打印一样弹出打印对话框。

有很多 JavaScript 开源库可以用来操作 PDF,其中比较热门的一个是 JsPDF,在 Github 上拥有 20k starts,我们的项目中就采用了他。使用起来比较类似 Canvas 的命令式绘制:

var doc = new jsPDF();

doc.text('Hello world!', 10, 10);
doc.line(0,0,10,0,'S');

doc.save('a4.pdf');

另外还有 html2pdf这个库,基于 JsPDF 开发,据作者描述是可以用来直接将页面转化为 pdf 文件。但在本文编写时已经有1年没有更新,issues 也都没有回复,看起来是弃坑了。

代码实现

首先需要介绍另外一个开源库 html2canvas,这是一个基于 Canvas 实现的 html 渲染器。

实现思路可以简单地描述为: html -> canvas -> image -> pdf,其中每一步:

  • 使用 html2canvas 将页面 HTML 结构绘制到一个 canvas 上
  • CanvasElement.toDataURL() 可以将 canvas 导出为图片数据
  • 使用 js2pdf 将图片添加到 PDF 文件中,并按业务调整 PDF 分页
  • 保存文件

大体思路就是这样,如果你还有时间,我们还有很多实现细节优化可以谈谈。

with React

集成在 React 组件中是一个常见的需求。我们为页面根组件添加一个新的 prop onGeneratePDF 和新的副作用:

const { onGeneratePDF } = props;

/**
* effect: 在pdf模式下且页面已经加载完成,构造 canvas generator
*
* 其他 dependencies: 
* loadComplete 页面加载的标志state
* containerRef 页面根组件的 ref
*/
React.useEffect(() => {
    if (loadComplete && onGeneratePDF && containerRef.current) {
      const canvasNode = html2canvas(containterRef.current)
	  onGeneratePDF(new JsPDF(canavsNode))
    }
}, [loadComplete, onGeneratePDF, containerRef]);

当用户点击下载按钮:

const onClickDownload = async () => {
  const pdf = await new Promise((res) => {
  ReactDOM.render(
    /** 页面组件 */
    <MyPage onGeneratePDF={res}></MyPage>,
    /** 一个隐藏节点 */
    rootDiv,
  );
		
  pdf.save('数据报告.pdf');
  });
}

这里的代码经过了大量简化,真实实现可以参考文章末尾的链接。

避免过大 Canvas 绘制导致卡顿

由于 html2canvas 依赖浏览器 canvas 实现,如果一次绘制大量内容,可能会导致页面帧数突然降低。为了避免这种情况,我们可以采用将页面的子元素串行绘制,一次只画一小块内容,最后拼接到一起。

我写了一个小的实用类来做这件事:

export class CanvasGenerator {
  private elementsToPaint: HTMLElement[] = [];
  private currentPaint = 0;

  constructor(generateRoot: HTMLElement) {
    this.elementsToPaint = Array.from(generateRoot.children)
  }

  public *generateSlice() {
    while (this.elementsToPaint.length > this.currentPaint) {
      yield {
        canvasNode: html2canvas(this.elementsToPaint[this.currentPaint]),
        index: this.currentPaint,
        total: this.elementsToPaint.length,
      };
      this.currentPaint = this.currentPaint + 1;
    }
  }

  public getNumOfSlices() {
    return this.elementsToPaint.length;
  }
}

这个类接受页面的根元素,构造一个 generator 函数,让开发者可以手动控制。

🌰

// 使用页面跟组件构造 generator
const generator = new CanavsGenerator(pageRoot).generateSlice();
const slices = [];

let slice = generator.next();

while (!slice.done) {
    const canvasNode = await slice.value.canvasNode;
    slices.push({
      width: canvasNode.width,
      height: canvasNode.height,
      datauri: canvasNode.toDataURL('image/png'),
    });

    slice = generator.next();
  }

console.log(slices) // 一个图片数组,下文还有用

这样手动控制串行生成还有一个好处:能够获取当前绘制进度信息。有了这个信息,我们就可以画一个进度条,具体细节后文讨论。

with Webworker

将 PDF 生成的任务放进 Webworker 中,可以避免浏览器主线程阻塞。

如果不了解 webworker,可以参考 MDN 使用 WebWorker

我们用了workerize-loader这个webpack loader 来简化 worker 开发。这个库非常赞,作者是 preact 的作者 developit,大神出品 👍

worker 进程:

// pdf.worker.js

/*
* @params slices 上一小结生成的图片数组
*/
export function generatePDF(slices) {
  const pdf = new Jspdf('portrait', 'px');

  for (let index = 0; index < slices.length; index = index + 1) {
    const slice = slices[index];
    // 将每个子元素作为 pdf 的一页内容
    pdf.addPage([slice.width, slice.height]);
    pdf.addImage(slice.datauri, 'PNG', 0, 0, slice.width, slice.height);
  }

  // 将 pdf 转为字符串输出,因为 worker 和主线程之间传递消息 postMessage 只能接受字符串
  return pdf.output('datauristring');
}

在页面进程中,构造 worker 并等待完成后下载:

// page.tsx

import PDFWorker from 'workerize-loader!./pdf-utils/pdf.worker.js'; 

const dataUri = await PDFWorker().generatePDF(slices)
const a = document.createElement('a');
a.href = dataUri;
a.download = 'file_output.pdf';
a.click();

漂亮的进度条

与普通的下载不同,这样前端生成 PDF 可能会消耗更多的时间在绘制,并不会直接将文件加入浏览器下载列表。如果时间过长,用户可能会觉得页面没反应卡住了。

在这样的场景下,如果有一个进度条,指示当前进度,可以大大提升用户体验。

前文提到,在 Canvas 绘制中,我们用了一个 Generator 函数,在循环中可以很方便地获取当前绘制进度:

let paintIndex = 0;

/** 一共需要绘制的元素数量 */
const totalElementToPaint = root.children.length;

while(!slice.done){
  await slice.canvas;
  paintIndex = paintIndex + 1;
  // 计算得到当前绘制进度
  console.log(paintIndex / totalElementToPaint);
  slice = generator.next()
}

在生成 PDF 的 worker中,我们也可以用 postMessage 机制来向主进程传递当前进度:

// workder.js
for (let index = 0; index < slices.length; index = index + 1) {
    const slice = slices[index];
    pdf.addPage([slice.width, slice.height]);
    pdf.addImage(slice.datauri, 'PNG', 0, 0, slice.width, slice.height);
	
    self.postMessage({
      type: 'process-report',
      value: (index + 1) / slices.length,
    });
}
// page.tsx
workder.addEventListener('message', msg => {
    if (msg.data.type !== 'process-report') {
      return;
    }
	
    // 从 worker 中获得当前进度消息
    console.log(msg.data.value);
})

这样进度信息就齐全了,剩下的就是调调动画,画一个好看的进度条,这里就是个人发挥了。

image.png

html2canvas 中的坑

首先我必须说,这个项目还是非常厉害的,相当于作者用 canvas 实现了一个浏览器绘制引擎,能够解析 html 结构、css 样式等等。但是商业团队做的浏览器都可能有 bug,何况是个人的开源项目呢?

  • 不能在页面未加载完成时生成 Canvas
    • 在 React 组件中添加一些 effect 和 hooks 来监控页面加载状态
  • 关闭动画
  • 避免使用一些 css hack,可能 html2canvas 并不能实现
  • 注意浏览器的单个 canvas 是绘制像素数量上限的
  • 在多个浏览器中测试