likes
comments
collection
share

跟源码手写自己打印预览组件

作者站长头像
站长
· 阅读数 16

前言

前一段时间碰到预览打印功能,也是第一次碰到,查一下资料window.print()就可以实现,但是实践起来的时候发现并没有那么简单。那就先使用一下插件先解决一下吧。后来去看了一下插件(react-to-print)怎么实现的。后面有完整代码。

正文

还是先来看看最后的结果,原理也很简单,只是我们通常不会想那么多。

原理:将页面中的DOM,style,link全都复制到iframe中,然后再在iframe中调用print()

跟源码手写自己打印预览组件

代码详解

  1. 组件参数props
interface PrintProps {
  /** 是否显示*/
  isShow: boolean,
  /** 打印的元素*/
  children: React.ReactNode,
  /** 打印时的样式*/
  pageStyle?: string,
  /** 点击取消按钮*/
  handleCancel?: () => any
  /** 打印前触发的事*/
  onBeforePrint?: () => any,
  /** 打印后触发的事*/
  onAfterPrint?: () => void
}
  1. 判断是否有正在进行中的打印任务和执行onBeforePrint函数,对于onBeforePrint的返回值进行判断。

跟源码手写自己打印预览组件

  1. 创建iframe,并去除上一次打印添加的iframe,克隆需要打印的Dom结点。获取打印节点中全部的link、img、video标签,这里是为了预加载资源。为什么要预加载资源下面解释。

跟源码手写自己打印预览组件

  1. canvas、form、style进行复制,对img、video、link的资源进行预加载。

跟源码手写自己打印预览组件

  1. 使用requestAnimationFrame判断资源是否加载完毕,因为资源的加载需要时间,要是没有加载完成资源就调用打印,那么打印是空白的或者样式不对。

跟源码手写自己打印预览组件

完整代码

import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import './Print.scss'
import { findDOMNode } from 'react-dom';

interface PrintProps {
  /** 是否显示*/
  isShow: boolean,
  /** 打印的元素*/
  children: React.ReactNode,
  /** 打印时的样式*/
  pageStyle?: string,
  /** 点击取消按钮*/
  handleCancel?: () => any
  /** 打印前触发的事*/
  onBeforePrint?: () => any,
  /** 打印后触发的事*/
  onAfterPrint?: () => void
}
export default function Print(props: PrintProps) {
  const {
    children,
    pageStyle = '',
    isShow,
    onBeforePrint = () => { },
    onAfterPrint = () => { },
    handleCancel = () => { }
  } = props
  const isPrinting = useRef(false);
  const printDom = useRef(null);

  //防止遮盖层下页面滚动
  useEffect(() => {
    if (isShow) {
      document.documentElement.style.overflowY = "hidden";
    } else {
      document.documentElement.style.overflowY = "scroll";
    }
  }, [isShow])

  const onPrint = useCallback(() => {
    if (document === null) {
      return
    }

    /** 打印的窗口页面*/
    const printWindow = document.createElement("iframe");
    printWindow.style.width = `${document.documentElement.clientWidth}px`;
    printWindow.style.height = `${document.documentElement.clientHeight}px`;
    printWindow.style.position = "absolute";
    printWindow.style.top = `-${document.documentElement.clientHeight + 100}px`;
    printWindow.style.left = `-${document.documentElement.clientWidth + 100}px`;
    printWindow.id = "printWindow";
    //目的是让浏览器能够正确地渲染页面
    printWindow.srcdoc = "<!DOCTYPE html>";

    /** 清除上一次的iframe*/
    const documentPrintWindow = document.getElementById("printWindow");
    if (documentPrintWindow) {
      document.body.removeChild(documentPrintWindow);
    }

    document.body.appendChild(printWindow);

    /** 克隆的打印结点*/
    const contentNodes = findDOMNode(printDom.current);
    const contentNodesClone = (contentNodes as any).cloneNode(true);

    const globalStyleLinkNodes = document.querySelectorAll("link[rel='stylesheet']");
    const imgNodes = (contentNodesClone as Element).querySelectorAll("img");
    const videoNodes = (contentNodesClone as Element).querySelectorAll("video");

    //iframe加载完成时触发
    printWindow.onload = () => {
      /** 资源是否加载中*/
      let isLoading = true;
      let timer: any = null;

      let linkNum = 0;
      let imgNum = 0;
      let videoNum = 0;

      /** 文档对象*/
      const domDoc: Document = printWindow.contentDocument as Document

      if (domDoc) {
        domDoc.body.appendChild(contentNodesClone);
      }


      /** 改善页面打印的样式*/
      if (typeof pageStyle === 'string') {
        const styleEl = domDoc.createElement("style");
        styleEl.appendChild(domDoc.createTextNode(pageStyle));
        domDoc.head.appendChild(styleEl);
      } else {
        console.warn('pageStyle类型错误,应该为string')
      }

      //处理canvas,复制canvas
      const originalCanvas = (contentNodes as Element).querySelectorAll("canvas");
      const copiedCanvas = (domDoc).querySelectorAll("canvas");
      for (let i = 0; i < originalCanvas.length; ++i) {
        const sourceCanvas = originalCanvas[i];
        const targetCanvas = copiedCanvas[i];
        const targetCanvasCtx = targetCanvas.getContext("2d");
        if (targetCanvasCtx) {
          targetCanvasCtx.drawImage(sourceCanvas, 0, 0);
        }
      }

      // 图片
      for (let i = 0; i < imgNodes.length; i++) {
        const imgNodeItem = imgNodes[i];
        const imgSrc = imgNodeItem.getAttribute("src");

        if (imgSrc) {
          const img = new Image();
          img.src = imgSrc;
          img.onload = () => {
            imgNum++;
          }
        }
      }


      // 预加载视频
      for (let i = 0; i < videoNodes.length; i++) {
        const videoNodeItem = videoNodes[i];
        videoNodeItem.preload = 'auto';
        const videoPoster = videoNodeItem.getAttribute('poster')
        if (videoPoster) {
          const img = new Image();
          img.src = videoPoster;
          img.onload = () => {
            videoNum++;
          }
        } else {
          videoNodeItem.onloadeddata = () => {
            videoNum++;
          }
        }
      }

      //复制input
      const originalInputs = (contentNodes as HTMLElement).querySelectorAll('input');
      const copiedInputs = domDoc.querySelectorAll('input');
      for (let i = 0; i < originalInputs.length; i++) {
        copiedInputs[i].value = originalInputs[i].value;
      }

      // 复制 checkbox, radio checks
      const checkedSelector = 'input[type=checkbox],input[type=radio]';
      const originalCRs = (contentNodes as HTMLElement).querySelectorAll(checkedSelector);
      const copiedCRs = domDoc.querySelectorAll(checkedSelector);
      for (let i = 0; i < originalCRs.length; i++) {
        (copiedCRs[i] as HTMLInputElement).checked =
          (originalCRs[i] as HTMLInputElement).checked;
      }

      // 复制 select
      const selectSelector = 'select';
      const originalSelects = (contentNodes as HTMLElement).querySelectorAll(selectSelector);
      const copiedSelects = domDoc.querySelectorAll(selectSelector);
      for (let i = 0; i < originalSelects.length; i++) {
        copiedSelects[i].value = originalSelects[i].value;
      }


      //处理style
      const originalStyle = document.querySelectorAll("style");
      for (let i = 0; i < originalStyle.length; i++) {
        const styleItem = originalStyle[i];
        const newStyleItem = domDoc.createElement(styleItem.tagName);
        const sheet = (styleItem as HTMLStyleElement).sheet as CSSStyleSheet;
        if (sheet) {
          let styleCSS = "";
          try {
            const cssLength = sheet.cssRules.length;
            for (let j = 0; j < cssLength; ++j) {
              if (typeof sheet.cssRules[j].cssText === "string") {
                styleCSS += `${sheet.cssRules[j].cssText}\r\n`;
              }
            }
          } catch (error) {

          }
          newStyleItem.setAttribute("id", `react-to-print-${i}`);
          newStyleItem.appendChild(domDoc.createTextNode(styleCSS));
          domDoc.head.appendChild(newStyleItem);
        }
      }

      //处理link
      const originalLink = document.querySelectorAll("link[rel='stylesheet']");
      for (let i = 0; i < originalLink.length; i++) {
        const linkItem = originalLink[i];
        if (linkItem.getAttribute("href")) {
          if (linkItem.hasAttribute("disabled") === false) {
            const newLinkItem = domDoc.createElement(linkItem.tagName);
            for (let j = 0; j < linkItem.attributes.length; j++) {
              const attr = linkItem.attributes[j];
              if (attr) {
                newLinkItem.setAttribute(attr.nodeName, attr.nodeValue || "");
              }
            }
            newLinkItem.onload = () => {
              linkNum++;
            }
            domDoc.head.appendChild(newLinkItem);
          }
        }
      }

      /** 等待图片和视频加载完毕和link*/
      const readyToPrint = () => {
        if (imgNum === imgNodes.length && videoNum === videoNodes.length
          && linkNum === globalStyleLinkNodes.length
        ) {
          (printWindow as any).contentWindow.print()
          onAfterPrint()
          isPrinting.current = false
          isLoading = false
          cancelAnimationFrame(timer);
        }
        if (isLoading) {
          timer = requestAnimationFrame(readyToPrint)
        }
      }
      readyToPrint()
    }
  }, [pageStyle, onAfterPrint])


  /** 执行打印*/
  const startPrint = useCallback(() => {
    if (isPrinting.current) {
      console.warn('正在打印中')
      return
    }
    isPrinting.current = true
    const onBeforePrintOutput = onBeforePrint();
    if (onBeforePrintOutput && typeof onBeforePrintOutput.then === "function") {
      onBeforePrintOutput.then(() => {
        onPrint()
      }).catch((error: Error) => {

      });
    } else {
      onPrint()
    }
  }, [onPrint, onBeforePrint])


  return (
    <>
      {
        isShow && <div className='print'>
          <div className='print__content'>
            <div className='print__title'>打印预览</div>
            <div className='print__result' ref={printDom}>
              {
                children
              }
            </div>
            <div className='print__bottom'>
              <button onClick={startPrint}>打印</button>
              <button onClick={handleCancel}>取消</button>
            </div>
          </div>
        </div>
      }
    </>
  )
}
.print {
  position: fixed;
  top: 0;
  left: 0;
  height: 100vh;
  width: 100vw;
  background-color: rgba(0, 0, 0, 0.2);

  &__content {
    position: absolute;
    display: flex;
    flex-direction: column;
    top: 50%;
    left: 50%;
    height: 50vh;
    width: 50vw;
    transform: translate(-50%, -50%);
    background-color: #fff;
    border-radius: 5px;
  }

  &__title {
    font-size: 19px;
    text-align: center;
    padding: 10px 0px;
  }

  &__result {
    flex: 1;
    width: 100%;
    overflow: auto;
  }

  &__bottom {
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 10px 0px;

    button {
      &:nth-of-type(2) {
        margin-left: 10px;
      }
    }
  }
}

//打印时的样式
@media print {
  //....
}

参考代码

  1. react-to-print插件 (github.com)

结语

感兴趣的可以去试试