一般信息填写类的需求页面,都会增设「预览」和「打印」功能。我们会通过编写 DOM 及样式来绘制出预览视图,而打印则是基于预览来生成 PDF 文件。

浏览器原生 API window.print() 可以用于打印当前窗口(window.document)视图内容。调用此方法会产生一个打印预览弹框,用户可以根据具体设置来得到打印结果。

接下来将从 code 层面带领大家熟悉「打印」的使用。

一、打印样式

默认情况下,基于页面上的内容,会将元素,布局和样式都进行打印;

如果仅想在打印上设置特殊样式,可以通过以下方式:

  • 使用打印样式表:
  • <link href="print.css" media="print" rel="stylesheet" />
    
  • 使用媒介查询:
  • @media print {
        color: lavender;
        background: #ccc;
        color: lightblue;
        background: #ccc;
    
  • 使用内联 media 属性
  • <style media="print">
        color: lavender;
        background: #ccc;
        color: lightblue;
        background: #ccc;
    </style>
    

    默认情况下,元素的背景色不会被打印,可通过设置属性来支持:

    div{
      // Chrome、Safari 等 webkit 浏览器内核
      -webkit-print-color-adjust: exact;
      // 火狐
      print-color-adjust: exact;
      color-adjust: exact;
    

    二、打印指定区域内容

    默认情况下,调用 window.print() 会对整个 document.body 进行打印,当需要打印指定容器内容时,可以通过以下几种方式:

    1. 对容器进行打印

    <div id="container"> <p>这是一个段落</p> <h1>这是一个标题</h1> </div> <input type="button" value="打印此页面" onclick="printpage()" /> <script> const printpage = () => { let newstr = document.getElementById("container").innerHTML; let oldstr = document.body.innerHTML; document.body.innerHTML = newstr; window.print(); document.body.innerHTML = oldstr; </script> </body>

    2. 对容器内的部分内容进行打印

    当只需要打印容器内某一部分内容时,可以通过注释标识进行截取。

    <div id="container"> <!--startprint--> <p>这是一个段落</p> <!--endprint--> <h1>这是一个标题</h1> </div> <input type="button" value="打印此页面" onclick="printpage()" /> <script> const printpage = () => { let oldStr = window.document.body.innerHTML; // 获取body的内容 let start = "<!--startprint-->"; // 开始打印标识, 17个字符 let end = "<!--endprint-->"; // 结束打印标识 let newStr = oldStr.substr(oldStr.indexOf(start) + 17); // 截取开始打印标识之后的内容 newStr = newStr.substring(0, newStr.indexOf(end)); // 截取开始打印标识和结束打印标识之间的内容 window.document.body.innerHTML = newStr; // 把需要打印的指定内容赋给body window.print(); // 调用浏览器的打印功能打印指定区域 window.document.body.innerHTML = oldStr; // body替换为原来的内容 </script> </body>

    3. 监听打印前后事件

    通过监听打印前后事件,对不需要进行打印的元素进行隐藏和放开隐藏。

    <div id="container"> <p>这是一个段落</p> <h1 id="title">这是一个标题</h1> </div> <input type="button" value="打印此页面" onclick="printpage()" /> <script> const printpage = () => { window.print(); window.onbeforeprint = function() { // 将一些不需要被打印的元素隐藏 document.getElementById('title').style.display = 'none'; window.onafterprint = function() { // 放开隐藏的元素 document.getElementById('title').style.display = 'block'; </script> </body>

    4. iframe

    上面几种方式都在当前窗口进行打印,并且都需要更改 document.body 内容,这会出现视图切换,带来的体验不是太好。

    下面我们借助 iframe 来实现打印,并且不影响当前视窗的内容展示。

    <div id="container"> <p>这是一个段落</p> <h1 id="title">这是一个标题</h1> </div> <input type="button" value="打印此页面" onclick="printpage()" /> <script> const printpage = () => { const printContent = document.querySelector('#container').innerHTML; const iframe = document.createElement('iframe'); iframe.setAttribute('style', 'position: absolute; width: 0; height: 0;'); document.body.appendChild(iframe); const iframeDoc = iframe.contentWindow.document; // 设置打印展示方式 - 横向展示 iframeDoc.write('<style media="print">@page {size: landscape;}</style>'); // 向 iframe 中注入 printContent 样式 iframeDoc.write(`<link href="./print.css" media="print" rel="stylesheet" />`); // 写入内容 iframeDoc.write('<div>' + printContent + '</div>'); setTimeout(function(){ iframe.contentWindow?.print(); document.body.removeChild(iframe); }, 50); </script> </body>

    值得注意的是,iframe 是一个新的 window 窗口,不会复用当前窗口的样式,需要为 iframe 注入打印内容所需的样式。

    三、强行插入分页

    当需要自定义打印分页时机时,可通过如下方式将指定 DOM 设为分割点。

  • 在指定元素前添加分页符
  • @media print {
        page-break-before: always;
    
  • 在指定元素后添加分页符
  • @media print {
        page-break-after: always;
    

    四、打印设置

  • 设置打印布局
  • @media print {
      @page {
        /* 纵向展示(高度展示内容更多) */
        /* size: portrait;  */
        /* 横向(宽度展示内容更大) */
        size: landscape;
        /* 打印的边距 上右下左 */
        margin: 1cm 2cm 1cm 2cm;
    

    注意,一旦设置为 size: landscape,在打印时将不能切换展示模式,包括纸张类的设置。

    五、最佳实践(React)

    1. 背景:

    有一个信息填写页面,支持进行预览和打印,预览是一个 Dialog 弹框,打印取自于预览的内容。因此,在打印前,需要将预览内容呈现在 DOM 树上。

    2. 思路:

  • 点击打印,将预览 Dialog open state 设置为 true,Dialog 渲染到 DOM 树上;
  • 执行 setTimeout 延迟任务,在 Dialog 渲染在 DOM 树上后对其隐藏(disabled: none),目的是实现视图上不展示 Dialog;
  • 创建 iframe,并将 Dialog 内容及其样式,写入 iframe.document 中;
  • 执行 iframe.contentWindow.print() 进行打印;
  • 打印完成后做一些重置处理:移除 iframe、将 Dialog 隐藏逻辑去掉、将 Dialog open state 置为 false;
  • 这样,在不影响现有页面内容的展示,同时实现了打印 Dialog 内容。
  • 3. 实现:

    const printFocus = () => { // 打印事件
      // 1. 挂载要打印的内容
      setPreviewOpen(true);
      setTimeout(() => { // 延迟,等待 Dialog 渲染在 DOM 树上
        // 2. 隐藏要打印的内容
        const container = document.querySelector('.preview-wrapper');
        container.setAttribute('style', 'display: none;');
        // 3. 创建 iframe
        const iframe = document.createElement('iframe');
        const printContent = container.innerHTML;
        iframe.setAttribute('style', 'position: absolute; width: 0; height: 0;');
        document.body.appendChild(iframe);
        const doc = iframe.contentWindow.document;
        // 4. 写入内容
        // doc.write('<style media="print">@page {size: landscape;}</style>');
        doc.write(`<link href="./preview-focus.css" media="print" rel="stylesheet" />`);
        doc.write('<div>' + printContent + '</div>');
        const link = doc.getElementsByTagName('link')[0];
        link.onload = () => { // 样式文件加载完毕后打印
          // 5. 执行打印
          iframe.contentWindow?.print();
          // 6. 重置工作
          document.body.removeChild(iframe);
          setPreviewOpen(false);
          container?.removeAttribute('style');
      }, 0);
    

    六、如果干预打印分页

    通常我们会遇到这种情况:
    在打印内容多于一页时会自动进行分页,若分页的分割点恰巧是一行文字,就会出现文字被切割分别显示在上下两页。

    尽管我们可以通过 CSS 属性 page-break-before: always; 来干预分页,但页面内容并非固定的,如何将这个属性恰巧应用在分割点的 DOM 元素之上呢?

    下面有一个思路可以参考一下:

  • 为可能会被分割的元素设置自定义属性,用于查找访问;
  • 根据打印视窗的每页高度,粗估一个高度值,作为页面分割的参考;
  • 遍历可分割元素,判断它们是否处于页面分割位置(top < pageHeight && botton > pageHeight);
  • 若处于页面分割位置,为此 DOM 设置分割属性 page-break-before: always;
  • 代码实现:

    <!-- 自定义属性标识 -->
    <div key={index} data-ident="page-break" className="module-paragraph">
    <script>
      // 1、获取可能会被分页符分割的元素
      const pageBreakEles = container?.querySelectorAll("[data-ident = 'page-break']") || [];
      // 2、定义打印页面的高度,假设粗估后为 877px
      const printPageHeight = 877;
      // 3、匹配元素,是否处于页面分割线位置
      Array.from(pageBreakEles).forEach(ele => {
        const { top, bottom } = ele.getBoundingClientRect();
        // 根据高度计算元素处于第几页
        const currentPage = Math.floor(top / printPageHeight);
        // 处于分页符位置的元素,设置分割属性
        if ((top - currentPage * printPageHeight) < printPageHeight && (bottom - currentPage * printPageHeight) > printPageHeight) {
          (ele as HTMLElement).style.setProperty('page-break-before', 'always');
    </script>
    

    1. 打印背景色丢失解决方案
    2. window.print() 前端实现网页打印详解