浏览器分页静默打印

在浏览器上打印应该一个比较常见的操作。
最简单的打印方式就是直接点击浏览器右上角,找到“打印”按钮或者调用window.print()
这就能将当前页面整个打印出来了。

默认打印

只是,很多情况下需求都不会如此简单,更多的可能是需要打印页面中的某一段“特定”的内容或者自定义一段内容。
这就需要用到自定义打印了。

一、自定义打印两个方法

实现自定义打印的方法网上能找到很多,有各种不同的实现方案和 js 库。
其中有两个比较常用和简单的方法:

1)直接调用window.print()
在调用此方法之前将不需要被打印的元素先通过display='none'隐藏掉,打印执行完毕h后再通过display='block'还原显示。

2)创建临时 iframe 进行打印。
创建一个临时的 Iframe 标签,后将需要打印的内容拼接成 html 字符串渲染到 Iframe 里面,最后执行iframe.contentWindow.print()

方法 1 操作起来方便快捷适合简单的页面,对于稍微复杂一点的页面就很不方便了。
方法 2 适合复杂的打印需求,几乎可以满足所有的打印需求,本人设计的自定义打印方案也是基于此方案的。

1.iframe 打印基本使用

Iframe 打印最终也是调用 window.print() 的。
只不过我们将需要打印的内容渲染在 iframe 内部,后再 iframe 内部调用打印。

打印方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 打印方法实现
*/
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);
}
};

html 字符串拼接方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 生成 Iframe 内嵌页面字符串并执行打印
* 为了将业务和打印功能分开,这里将打印的 html 页面做成了一个 html 模板,并上传至 cdn。
* 后分别拉取 html 模板、接口数据、然后通过第三方库 mustache 来组装生成 html 字符串。
* 最后将其传入前面的打印方法进行打印
*/
// 从cdn上获取html字符串
const htmlStr = await fetchRemoteData('这里填写html模板字符串的cdn地址');
// 从服务端获取数据
const data = await fetchRemoteData('这里获取接口数据,用于打印文件的数据');
// 使用mustache模板语法进行渲染(需要和html模板字符串模板一致,可以使用其他模板如 handlebars)
const printHtml = mustache.render(htmlStr, data);
// 执行打印
handlePrintByLocalIframe(printHtml);

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

只是,这就结束了吗?

当然不会,实际需求中还有更复杂的打印场景,比如当打印报表。
打印报表的时候往往会涉及到分页、页头、页眉、页脚等比较复杂的元素。
甚至还有一些合理但是比较复杂的要求。

比如:

1
2
3
第一页需要页头其它也不需要
每一页都需要表头,最后一页需要签名其它也不需要
...

很显然,面对这些“有理”要求,仅靠上面这个方案还做不到。

二、定制化的自定义打印

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

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

具体打印方案

首先从接口拿到数据并将其转换成下面的数据结构。
其核心为 pageList,这个 pageList 保存的就是打印的时候每页用到的数据和相关配置。
我们为每一页定制配置当页渲染所需要的特定的数据。
比如只有第一页有页头、每一页都自己的页码。

1)约定的数据格式示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 的空间所以多两条
],
},
],
};

这分数据属于是定制化数据,具体数据格式和需要打印的 html 模板文件有关。
每一页的数据都是通过手动计算出来的,计算方法示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* serverDataList 为接口返回的原始数组数据
* 此方法将原始的数据转换成每一页单独需要的特定数据格式
* 这里仅是一个示例,具体复杂度跟其打印的业务和模板文件有关
* 理论上可以实现任何打印
*/
const calculatePageNum = (serverDataList) => {
// 这里的数值需要手动测量,毕竟每一行的高度都不一样,需要根据实际情况测试出来
const firstPageMaxNum = 36;
const otherPageMaxNum = 40;

const pageList = [];
let currentPage = 0; // 当前遍历到第几页
serverDataList.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语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<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 字符串结构了。

当然,仅仅有对应的结构是不够的,作为 html 页面,还需要配合对应的 css 样式。

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

因此,根据上文的 html 模板,对模板里面的元素设置其 body 容器和 page 容器的高度,使其每一页高度固定。
这样我们打印出来的内容就是我们最终期望的分页数据了。

CSS 核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 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;
}

至此,有了 html 模板和 css 负责处理 ui 和布局,传入分割好的数据,最终就能渲染出固定样式的 html 页面内容了。
后面不论需要打印的内容如何变化,只需要处理好这几部分之间的关系,我们就能得到对应的 html 页面,将其塞入 iframe 就能打印任意内容。


三、静默打印

前面我们都是调用的浏览器自带的打印能力,即 window.print()方法触发的浏览器预览打印。
这种方式非常简单,接入也不麻烦。

然而,它有一个不容疏忽的缺点(也不算缺点,毕竟浏览器并不是专业打印设备,需要考虑到安全性和通用性),那就是打印触发之前它一定会弹出一个“预览”大弹窗。

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

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

那就没有办法了吗?

当然有,那就是自己开发一个打印App

浏览器本身其实也可以看做是一个特殊的“打印App”。
浏览器能调用打印机,自定义打印App当然可以。

1、如何设计打印App的功能

打印App就一个PC端的应用,用 Electron 就能很轻松的做出来。
其需要实现两个核心功能:

1
2
1.连接和管理电脑设备上的打印机
2.能够与浏览器进行通信。

连接和管理电脑设备上的打印机这个这里不做详细介绍,网上有成熟的第三方库来实现。

至于如何与浏览器进行通信,这里简单介绍下实现思路。
其实也很简单,无非就是 socket 通信。

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

之后我们只需要在浏览器端启动一个 Websocket 本地客户端,然后建立与 ws://127.0.0.1:18877 的连接即可。

当我们需要打印的时候,只需发送socket 信息给打印 App,将打印事件、打印文本及其他相关打印信息发送给打印控件服务。
打印控件接收到请求之后再调用电脑的打印功能,调用打印机即可。

至此,一整套打印控件打印方案就算完成了。

最终实现整体架构图

最终架构

打印结果示例

默认打印