作为前端开发,在浏览器上打印算是一个比较常见的需求了。最简单的做法就是直接打印整个网页,在浏览器直接打印或者调用window.print()
。
这样就能将当前页面整个打印出来了。
然而,实际上的需求往往都不是这样简单,它更多的可能是需要打印整个网页中的某一段“特定”的内容。
Google 一下就能能网上找到与很多与自定义打印相关的 js 库。 这些打印 js 库各有其优缺点,这里不做分析和评价。
这里介绍两个最简单的方法:
方法一:直接调用window.print()
。
调用之前将不需要被打印的元素先通过display: none
隐藏掉,当打印执行完毕再将其显示display: block
。
方法二:利用 iframe 进行打印。
将所需要打印的内容创建为 html 字符串再传入 iframe 内部进行渲染,最后执行iframe.contentWindow.print()
。
其中方法一适合简单的页面,操作起来方便快捷。 方法二适合复杂的打印需求,几乎可以满足所有的打印需求。
本文主要介绍的是 iframe 打印,同时介绍了本人设计的一整套打印方案,基本能满足日常基本打印需求。
Iframe打印其实最终也是调用了浏览器apiwindow.print()
进行打印的。
只不过是在其 iframe 内部调用,这样只需要将我们需要打印的内容渲染在 iframe 内部,就能实现自定义打印了。
首先需要实现一个基本的 iframe 打印方法。
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);
}
};
有了打印方法,接下来就需要创建 iframe 内部的 html 字符串了。
为了将业务和打印功能分开,这里将打印的 html 页面做成了一个 html 模板,将模板单独处理。 处理完成之后,将 css 样式 和 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);
至此,一个最最基本的打印功能就完成了,针对单页打印、普通文本的打印已经足够用。
只是,这就结束了吗? 当然不会,实际需求中还有更复杂的打印场景,比如当打印报表。 而打印报表的时候就会涉及到页眉、页脚、分页等等。
甚至还有一些合理但是毕竟复杂的要求: 比如:第一页需要页头,每一页都需要表头,最后一页需要签名,等等。
很显然,面对这些“有理”要求,上面这个方案是无法实现了。
上文实现的简单的打印,其实现原理就是手动拼接成 html 字符串,然后将字符串传入 iframe,然后进行打印。 而作为一名前端开发,操作 html 就像呼吸一样简单,想要在网页上画出来分页、表头、页眉、页脚这些根本没什么难度可言。 因此,理论上只需要在原方案基础上做“亿点优化”就可以解决了。
下面介绍一下本人的设计实现方案:
首先从接口拿到数据并将其转换成下面的数据结构。其核心就是 pageList
,这个 pageList 保存的就是打印的时候每页用到的数据和相关配置。
1)约定的数据格式
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 的空间所以多两条
],
},
],
};
这个数据是通过手动计算出来的,计算方法如下:
/**
* 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
语法
<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 容器的高度将每一页设置为固定高度,这样我们打印出来的内容就是我们最终期望的分页数据了。
/* 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.连接和管理电脑设备上的打印机
2.能够与浏览器进行通信。
连接和管理电脑设备上的打印机这个实现这里不展开说,使用 Electron 就能很轻松的实现。
其实也不麻烦,我们只需要在此应用上启用一个 socket 服务。 这个 socket 服务和我们服务器上启动的服务是一样的,只不过此服务是直接部署到我们用户的本地机器上的,只给当前用户使用的。 此 Socket 服务端,监听一个端口,比如:18877。
之后我们只需要在浏览器端启动一个 Websocket 本地客户端,然后直接建立与 ws://127.0.0.1:18877
的连接即可。
至此,一整套打印控件打印方案就算完成了。
当我们在浏览器页面上点击一个打印按钮的时候,直接通过 Websocket 将打印事件、打印文本及其他相关打印信息发送给打印控件服务。 打印控件接收到请求之后再调用电脑的打印功能,调用打印机即可。