浏览器分页静默打印

2023-12-29 10:05:41 浏览数 (2)

作为前端开发,在浏览器上打印算是一个比较常见的需求了。最简单的做法就是直接打印整个网页,在浏览器直接打印或者调用window.print()。 这样就能将当前页面整个打印出来了。 然而,实际上的需求往往都不是这样简单,它更多的可能是需要打印整个网页中的某一段“特定”的内容。

一、如何自定义打印

Google 一下就能能网上找到与很多与自定义打印相关的 js 库。 这些打印 js 库各有其优缺点,这里不做分析和评价。

这里介绍两个最简单的方法:

方法一:直接调用window.print()。 调用之前将不需要被打印的元素先通过display: none隐藏掉,当打印执行完毕再将其显示display: block

方法二:利用 iframe 进行打印。 将所需要打印的内容创建为 html 字符串再传入 iframe 内部进行渲染,最后执行iframe.contentWindow.print()

其中方法一适合简单的页面,操作起来方便快捷。 方法二适合复杂的打印需求,几乎可以满足所有的打印需求。

本文主要介绍的是 iframe 打印,同时介绍了本人设计的一整套打印方案,基本能满足日常基本打印需求。

二、使用 iframe 打印

Iframe打印其实最终也是调用了浏览器apiwindow.print()进行打印的。 只不过是在其 iframe 内部调用,这样只需要将我们需要打印的内容渲染在 iframe 内部,就能实现自定义打印了。

1.创建 Iframe 并进行打印

首先需要实现一个基本的 iframe 打印方法。

代码语言:javascript复制
const handlePrintByLocalIframe = ({ printHtml }) => {
  // 判断是否已经存在该iframe
  let iframe: any = document.getElementById('J_printIframe');
  if (!iframe) {
    // 新建一个隐藏起来的iframe,并将其添加到当前页面的dom里面
    iframe = document.createElement('IFRAME');
    iframe.setAttribute('id', 'J_printIframe');
    iframe.setAttribute('style', 'position: absolute; width: 0px; height: 0px;left:-5000px;top:-5000px;');
    document.body.appendChild(iframe);
  }
  const doc = iframe.contentWindow.document;
  // 将需要打印的html字符串写入iframe
  doc.write(printHtml);
  doc.close();
  iframe.contentWindow.focus();
  setTimeout(function () {
    // 对iframe执行打印操作
    //延迟50ms是为了解决第一次样式不生效的问题
    iframe.contentWindow.print();
  }, 50);

  // 网上有人加了这一段代码,应该是为了兼容ie,这个看个人需求添加上。
  if (navigator.userAgent.indexOf('MSIE') > 0) {
    document.body.removeChild(iframe);
  }
};

2.生成 Iframe 内嵌页面字符串并执行打印

有了打印方法,接下来就需要创建 iframe 内部的 html 字符串了。

为了将业务和打印功能分开,这里将打印的 html 页面做成了一个 html 模板,将模板单独处理。 处理完成之后,将 css 样式 和 html 模板打包到一起,上传到 cdn。

后,分别拉取 html 模板、接口数据、然后通过第三方库 mustache 来组装生成 html 字符串。

代码语言:javascript复制
// 从cdn上获取html字符串
const htmlStr = await fetchRemoteData('这里填写html模板字符串的cdn地址');
// 从服务端获取数据
const data = await fetchRemoteData('这里获取接口数据,用于打印文件的数据');
// 使用mustache模板语法进行渲染(需要和html模板字符串模板一致,可以使用其他模板如 handlebars)
const printHtml = mustache.render(htmlStr, data);
// 执行打印
handlePrintByLocalIframe(printHtml);

至此,一个最最基本的打印功能就完成了,针对单页打印、普通文本的打印已经足够用。

只是,这就结束了吗? 当然不会,实际需求中还有更复杂的打印场景,比如当打印报表。 而打印报表的时候就会涉及到页眉、页脚、分页等等。

甚至还有一些合理但是毕竟复杂的要求: 比如:第一页需要页头,每一页都需要表头,最后一页需要签名,等等。

很显然,面对这些“有理”要求,上面这个方案是无法实现了。

三、更灵活的自定义打印

上文实现的简单的打印,其实现原理就是手动拼接成 html 字符串,然后将字符串传入 iframe,然后进行打印。 而作为一名前端开发,操作 html 就像呼吸一样简单,想要在网页上画出来分页、表头、页眉、页脚这些根本没什么难度可言。 因此,理论上只需要在原方案基础上做“亿点优化”就可以解决了。

下面介绍一下本人的设计实现方案:

具体打印方案

首先从接口拿到数据并将其转换成下面的数据结构。其核心就是 pageList,这个 pageList 保存的就是打印的时候每页用到的数据和相关配置。

1)约定的数据格式

代码语言:javascript复制
const data = {
  pageTitle: '多页模板的数据',
  pageList: [
    {
      // 只有第一页有head,后面的页没有
      pageHead: true,
      pageNum: 1, // 当前页属于第1页
      list: [
        {
          dataId: 1,
          dataName: 'dataName1',
          dataNum: 8,
        },
        //...第一页的其他数据 28 条
      ],
    },
    {
      pageHead: false, // 除了第1页其他页面都不需要标题信息。
      pageNum: 2, // 当前页属于第2页
      list: [
        {
          dataId: 2,
          dataName: 'dataName2',
          dataNum: 6,
        },
        //...第2页的其他数据 28   2 条,多了pageHead 的空间所以多两条
      ],
    },
  ],
};

这个数据是通过手动计算出来的,计算方法如下:

代码语言:javascript复制
/**
 * listData 为接口返回的原始数组数据
 */
const calculatePageNum = (listData) => {
  // 这里的数值需要手动测量,毕竟每一行的高度都不一样,需要根据实际情况测试出来
  const firstPageMaxNum = 36;
  const otherPageMaxNum = 40;

  const pageList = [];
  let currentPage = 0; // 当前遍历到第几页
  listData.forEach((item, index) => {
    const { dataId, dataName, dataNum } = item;
    currentPage = index < firstPageMaxNum ? 1 : 1   Math.ceil((index   1 - firstPageMaxNum) / otherPageMaxNum);

    if (!pageList[currentPage - 1]) {
      pageList[currentPage - 1] = {
        pageHead: currentPage === 1,
        pageNum: currentPage,
        list: [item],
      };
    } else {
      pageList[currentPage - 1].list.push(item);
    }
  });
  return pageList;
};

不难看出,上述方法最终输出的是一个大的 pageList, 内部有一个小的 list。 pageList 包含的是各个页面的数据,而 list 包含的是某一页的列表数据。 除此之外,还有当前页面的页码,是否应该包含头部信息等。

这些数据其实就是为了分页服务的,有了这些数据,我们只需要设计响应的 html 模板. 然后将对应的数据传入模板进行渲染就能得到相应的分页 html 字符串了。

2)对应的 html 模板 html模板可以是任何模板语法,这里我们采用的最简单的mustache语法

代码语言:javascript复制
<body class="a4-body">
  <!-- pageList的数组长度就是当前页数,这里是一个遍历循环 -->
  {{#pageList}}
  <section class="a4-page">
    {{#pageHead}}
    <header class="head">
      <h2>{{pageTitle}}</h2>
    </header>
    {{/pageHead}}
    <table class="a4-table">
      <tr>
        <th>数据ID</th>
        <th>数据名称</th>
        <th>数据数量</th>
      </tr>
      <!-- 这里list就是当前页面的数据,每一页的长度可以不一样,如果有header这里就少几行 -->
      {{#list}}
      <tr>
        <td>{{dataId}}</td>
        <td>{{dataName}}</td>
        <td>{{dataNum}}</td>
      </tr>
      {{/list}}
    </table>
  </section>
  <ul class="a4-footer">
    <li>第{{pageNum}}页 总{{pageList.length}}页</li>
  </ul>
  {{/pageList}}
</body>

不难看出,当我们将 pageList 渲染到如上模板就能得到多个pageList,每个 pageList 又包含多个数据栏。 这就是一个分页的结构了。

当然,仅仅有对应的结构是不够的,虽然数据是按照分页的,渲染也是按照分页的。 但是作为 html 页面,没有对应的 css 样式是行不通的。

所以,我们还需要用 css 来做一些布局来保证 pageList 里面的一个 item 的总高度为 A4 的高度。 只要保证这个高度,其内部样式如何变化都没关系,多一个 header、或者某个特殊页面多一个特殊元素都无所谓。 无非是在计算 pageList 的时候对数据进行增减即可。

因此,此文件通过设置各个 body 容器和 page 容器的高度将每一页设置为固定高度,这样我们打印出来的内容就是我们最终期望的分页数据了。

代码语言:javascript复制
/* css全部使用mm作为单位 */
.a4-body {
  width: 208mm; /** 这里的宽度就是A4纸的宽度 */
  margin: 0 auto;
  text-align: center;
}

.a4-page {
  width: 100%;
  padding: 6mm;
  /** 这里高度   a4-footer 的高度就是整张A4纸的高度(297mm) */
  height: 288mm;
  margin: 0 auto;
  box-sizing: border-box;
}
.a4-footer {
  line-height: 9mm;
}
小结

想要实现了一个灵活的分页打印,我们需要处理数据分页、css分页、html 模板渲染分页三部分。 其中模板和 css 负责处理 ui 和布局,数据和模板则是将对应的数据进行结构分割。

只需要处理这三个部分,不论需要打印的内容如何变化,我们都能得到对应页面字符串,将其塞入 iframe 就能自由打印了。

四、静默打印

前面我们都是调用的浏览器自带的打印能力,即 window.print()方法触发的浏览器预览打印。这种方式非常简单,接入也不麻烦。然而,它有一个不容疏忽的缺点(也不算确定,毕竟浏览器并不是专业打印设备,需要考虑到安全性和通用性),那就是它一定会弹出一个“预览”。

而有时候我们的需求是点击按钮就实现打印,直接给打印机发出打印指令,不要弹出打印预览弹窗。

通过各种途径了解到,这是无法实现的,至少纯“前端”,通过浏览器端的 js 无法实现。

那就没有办法了吗?

当然有,那就是自己开发一个打印控件。

所谓打印控件其实就是一个 App 应用,而浏览器本身其实也可以看做是一个特殊的“打印App”。 浏览器能调用打印机,自定义打印控件照样可以。

1、如何设计打印控件的功能

打印控件需要实现两个核心能力:

代码语言:javascript复制
1.连接和管理电脑设备上的打印机
2.能够与浏览器进行通信。

连接和管理电脑设备上的打印机这个实现这里不展开说,使用 Electron 就能很轻松的实现。

2、如何与浏览器进行通信呢?

其实也不麻烦,我们只需要在此应用上启用一个 socket 服务。 这个 socket 服务和我们服务器上启动的服务是一样的,只不过此服务是直接部署到我们用户的本地机器上的,只给当前用户使用的。 此 Socket 服务端,监听一个端口,比如:18877。

之后我们只需要在浏览器端启动一个 Websocket 本地客户端,然后直接建立与 ws://127.0.0.1:18877 的连接即可。 至此,一整套打印控件打印方案就算完成了。

当我们在浏览器页面上点击一个打印按钮的时候,直接通过 Websocket 将打印事件、打印文本及其他相关打印信息发送给打印控件服务。 打印控件接收到请求之后再调用电脑的打印功能,调用打印机即可。

3、最终实现整体架构图

0 人点赞