一、业务场景

公司的样本检测报告以React页面的形式生成,已调整为A4大小的样式并已实现分页,业务上需要将这个网页生成PDF文件,并上传到服务器,后续会将这个文件发送给客户(这里不考虑)。

二、原来的实现形式

浏览器原生方法: window.print()可以将网页保存为PDF文件 ,由于检测报告的网页已经调整为A4的样式,所以保存下来后即是一个标准的PDF文档,然后将保存下来的PDF文件上传到服务器,即可实现需求。

三、存在的问题

调用window.print()方法后 需要手动保存PDF到本地,然后手动上传到服务器。 所以本文的目的是点击上传PDF后 自动 将网页生成PDF,然后自动上传到服务器, 省略操作者手动保存、手动上传这两个步骤

四、解决方法

根据“自动”这个需求,找到了两种实现方式:

  • 纯前端方式 ,前端生成pdf后通过接口上传到服务器
  • 后端(node)方式 ,通过另起一个node服务来生成pdf并上传( 推荐,以后介绍
  • 四、纯前端方法

    前端采用了 React 框架。另需要 html2canvas , jspdf 两个库。

    1、场景1-上传一个尚未打开的React页面,这种情况下需要将需要上传的页面通过iframe以visiblity:hidden的形式打开或者被遮挡在看不到的地方,不可以display:none,因为这样获取到的DOM元素样式不正确,html2canvas会表现不正常。

    由于流程较多,直接见代码吧,说明见注释:

    // 生成或者获取报告页面的外部容器
    const getIframeContainer = () => {
      const ic = document.getElementById("iframeContainer");
      if (!ic) {
        const iframeContainer = document.createElement("div");
        iframeContainer.id = "iframeContainer";
        iframeContainer.style.visibility = "hidden";
        document.body.appendChild(iframeContainer);
        return iframeContainer;
      return ic;
    class SendModal extends React.Component {
      // ...
      // 点击开始上传
      handleUpload = () => {
        // 获取iframe容器和这个报告的ID
        const iframeContainer = getIframeContainer();
        const iframeId = `iframe_${this.state.id}`;
        // iframe的load事件回调,执行该回调后开始执行this.createAndUpload()
        const onloadCallback = () => {
          this.createAndUpload(iframeId).then(
            // resolve和reject后移除报告iframe
            () => {
              ReactDOM.unmountComponentAtNode(iframeContainer);
            errMsg => {
              ReactDOM.unmountComponentAtNode(iframeContainer);
              console.error(errMsg);
        // 开始渲染报告的iframe
        ReactDOM.render(
          <ReportIframe
            id={iframeId}
            src={reportURL}
            onLoad={onloadCallback}
            key={iframeId}
          iframeContainer
      createAndUpload = iframeId => {
        return new Promise((resolve, reject) => {
          // 从iframe中获取需要保存为PDF的DOM元素
          let pages = Array.from(
            document
              .getElementById(iframeId)
              .contentDocument.querySelectorAll(".pdfpage")
          console.log(pages);
          const pagesLen = pages.length;
          if (!pagesLen) {
            reject("打开报告失败!");
          // 初始化一个pdf待用
          const doc = new jsPDF("p", "mm", "a4");
          const imgArr = [];
          console.log("成功抓取pages");
          // 将每个元素作为一个页面处理
          pages.forEach((page, idx) => {
            console.log(`正在绘制canvas[${idx}]`);
            html2canvas(page, {
              scale: 2,
              logging: false,
              useCORS: true,
              imageTimeout: 60000
            }).then(canvas => {
              // canvas保存为图片
              let imgData = canvas.toDataURL("image/jpeg", 1.0);
              imgArr.push({ index: idx, value: imgData });
              if (imgArr.length === pagesLen) {
                console.log("canvas绘制完成,正在生成pdf");
                // 通过idx保证页面顺序
                let sortedArr = imgArr.sort((a, b) => a.index - b.index);
                sortedArr = sortedArr.map(item => item.value);
                sortedArr.forEach((img, idx) => {
                  // 将图片放入pdf文件中
                  if (idx > 0) {
                    doc.addPage();
                  doc.addImage(img, "JPEG", 0, 0, 210, 297);
                  if (idx + 1 === pagesLen) {
                    // 全部放入pdf文件后,保存并上传
                    const pdf = doc.output("blob");
                    console.log("成功生成pdf,正在上传");
                    const formData = new FormData();
                    formData.append("file", pdf);
                    fetch(`uploadURL`, {
                      method: "post",
                      body: formData
                      .then(response => response.json())
                      .then(resp => {
                        if (resp.Status === 0) {
                          console.log("上传成功");
                          resolve("success");
                        } else {
                          console.log("上传失败");
                          reject("上传报告失败!");
      // ...
    class ReportIframe extends React.Component {
      // React通过js渲染页面,所以iframe触发onload后可能页面是一个空白页面,所以通过getPages方法确保React渲染完成后出发onLoad回调
      getPages = (e, times = 1) => {
        const pages = Array.from(
          this.iframe.contentDocument.querySelectorAll(".pdfpage")
        if (pages.length || times >= 5) {
          this.props.onLoad();
          this.iframe.removeEventListener("load", this.getPages);
        } else {
          setTimeout(() => {
            times++;
            this.getPages(e, times);
          }, 1000);
      componentDidMount() {
        this.iframe.addEventListener("load", this.getPages, false);
      render() {
        return (
          <iframe
            id={this.props.id}
            src={this.props.src}
            ref={node => (this.iframe = node)}
    

      2、场景2-在已打开页面中生成pdf并上传,代码同上,直接执行createAndUpload即可,不考虑iframe的相关处理。

    五、效果演示

      首先在报告列表页点击发送按钮,将进入待发送页面:

       ↑点击确认发送将会以iframe的形式自动打开页面并保存为pdf上传到服务器然后发送到客户。

      ↑生成的iframe元素

      ↑上传流程    

    六、遇到的坑及说明

      1、生成的pdf模糊

      html2canvas设置scale:2可解决,即使用2倍图保证清晰度。

      2、页面中每页的顺序已排好,但是生成pdf后乱了

      由于canvas生成图片这个过程是异步的,所以我没有直接将生成的图片插入pdf中,而是通过idx排序后统一插入pdf。

      3、图片跨域

      公司使用的阿里云OSS,所以将图片设置了Access-Control-Allow-Origin:*即可解决,如果是外部图片,需要使用代理,具体使用见html2canvas相关文档。

      4、页面中有虚线,但是html2canvas生成的是实线

      见我之前的文章

      5、新建iframe后getPages作用是什么

      React通过js渲染页面,所以iframe触发onload后可能页面是一个空白页面,所以通过getPages方法确保React渲染完成后出发onLoad回调

    七、前端生成PDF总结

      前端生成pdf并上传的流程:获取将要作为PDF页面的DOM元素 -> 将DOM元素生成canvas -> 将canvas转为图片 -> 将图片插入pdf中 -> 将pdf上传

      由于是通过转成图片生成的PDF,即使是2倍图,清晰度依然不如原生PDF,且无法选择文字,所以这种方式生成PDF并不是最优解

      可能写的比较乱,可能属于自己知道咋回事但是说不出来那种……