通过iframe实现局部打印预览
实现思路
- 获取待打印 dom 节点
- 拷贝待打印 dom 节点(防止原始待打印节点被污染,同时也可以在这个复制的过程中,对原始的 dom 节点做一些修改/替换之类的操作)
- 动态创建 iframe 节点
- 向 iframe 节点插入写好的打印样式(通过 write 方式写入样式,如果通过 append 或 appendChild 方式插入,样式无法生效)
- 等待 iframe 的 onload 事件触发,在 onload 事件函数中向 iframe 插入拷贝后的 dom 节点
- 在 onload 事件函数中,使 iframe 获取焦点,调用 iframe 的 print 方法进行打印(此时打印的就是 iframe 中的内容,也就是局部打印)
- 在 onload 事件函数中,添加事件监听用于销毁 iframe(之所以要通过事件监听的方式销毁 iframe,而不是直接销毁,是因为 pc 端调用 iframe.print()方法后会阻塞 后续代码的执行,直至打印预览窗口关闭,而移动端部分浏览器在调用了 iframe.print()方法后,并不会阻塞后续代码的执行,如果此时销毁 iframe,那么打印的内容就变成了当前窗口,而非 iframe 窗口)
实现代码
browser.ts 主要做浏览器判断
ts
/**
* 判断是否 Firefox 1.0+
*
*/
export function isFirefox() {
// @ts-ignore
return typeof InstallTrigger !== 'undefined'
}
/**
* 判断是否Internet Explorer 6-11
*/
export function isIE() {
// @ts-ignore
return navigator.userAgent.indexOf('MSIE') !== -1 || !!document.documentMode
}
/**
* 判断是否 Edge 20+
*/
export function isEdge() {
// @ts-ignore
return !isIE() && !!window.StyleMedia
}
/**
* 判断是否 Chrome 1+
*/
export function isChrome(context = window) {
// @ts-ignore
return !!context.chrome
}
/**
* 判断是否至少是 Safari 3+
*/
export function isSafari() {
return (
Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0 ||
navigator.userAgent.toLowerCase().indexOf('safari') !== -1
)
}
/**
* 判断是否IOS 下的 Chrome 浏览器
*/
export function isIOSChrome() {
return navigator.userAgent.toLowerCase().indexOf('crios') !== -1
}
/**
* 判断是否是微信浏览器
*/
export function isWeChat() {
//window.navigator.userAgent属性包含了浏览器类型、版本、操作系统类型、浏览器引擎类型等信息,这个属性可以用来判断浏览器类型
const ua = window.navigator.userAgent.toLowerCase()
//通过正则表达式匹配ua中是否含有MicroMessenger字符串
const ret = ua.match(/MicroMessenger/i)
if (ret) {
return true
} else {
return false
}
}
/**
* 判断是否是支付宝浏览器
*/
export function isAliPay() {
const ua = window.navigator.userAgent.toLowerCase()
const ret = ua.match(/Alipay/i)
if (ret) {
return true
} else {
return false
}
}
/**
* 判断是否移动端
*
*/
export function isMobile() {
return window.navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
)
}
/**
* 判断当前是否为一个html元素
*
* @param {HTMLElement} htmlDomOrHtmlSelector html元素或html选择器
*
*/
export function isHtmlElement(htmlDomOrHtmlSelector: HTMLElement | string) {
// Check if element is instance of HTMLElement or has nodeType === 1 (for elements in iframe)
return (
typeof htmlDomOrHtmlSelector === 'object' &&
htmlDomOrHtmlSelector &&
(htmlDomOrHtmlSelector instanceof HTMLElement ||
// @ts-ignore
htmlDomOrHtmlSelector.nodeType === 1)
)
}
cloneElement.ts 实现对待打印 html 元素的拷贝
ts
/**
* 深度克隆HTML元素(之所以自己写一个clone方法而不是直接使用HTMLElement.cloneNode(true),是为了能对指定的HTML元素做一些特殊处理如:将canvas转换为图片这种操作)
*
* @param {HTMLElement} element 待clone元素
*/
export function cloneElement(element: HTMLElement) {
// cloneNode(false)只会克隆节点本身不会克隆子孙节点(文本属于文本节点所以也是子孙节点,因此也不会被克隆)
const clone = element.cloneNode(false)
const childNodesArray = Array.prototype.slice.call(element.childNodes)
for (let i = 0; i < childNodesArray.length; i++) {
const clonedChild = cloneElement(childNodesArray[i])
clone.appendChild(clonedChild)
}
// Check if the element needs any state processing (copy user input data)
switch (element.tagName) {
case 'SELECT':
// Copy the current selection value to its clone
// @ts-ignore
clone.value = element.value
break
case 'CANVAS':
// Copy the canvas content to its clone
// @ts-ignore
clone.getContext('2d').drawImage(element, 0, 0)
break
}
return clone
}
iframe.ts 实现 iframe 的创建/销毁/以及内容打印
ts
import { isChrome, isEdge, isFirefox, isHtmlElement, isIE } from './browser'
import { cloneElement } from './cloneElement'
/**
* 获取iframe的document
*
* @param {HTMLIFrameElement} iframeElement iframe
*/
function getIframeDocument(iframeElement: HTMLIFrameElement) {
let iframeDoc = iframeElement.contentWindow || iframeElement.contentDocument
// @ts-ignore
if (iframeDoc && iframeDoc.document) iframeDoc = iframeDoc.document
return iframeDoc as Document
}
/**
* 获取iframe的window对象
*
* @param {HTMLIFrameElement} iframeElement [iframeElement description]
*
* @return {[type]} [return description]
*/
function getIframeWindow(iframeElement: HTMLIFrameElement) {
return iframeElement.contentWindow as Window
}
/**
* 执行iframe的打印
*
* @param {HTMLIFrameElement} iframeElement 尚未插入html文档的iframe元素(即:未执行过dom.appendChild(iframeElement)的dom)
* @param {HTMLElement} printDom 待进行局部打印的HTML元素(这应该是真实的待打印html dom的克隆对象)
*
* @return {[type]} [return description]
*/
function iframePrint(iframeElement: HTMLIFrameElement, printDom: HTMLElement, cssLinkStr: string, customTitle: string) {
// 如果iframe尚未插入html文档,则插入
!iframeElement.parentElement && document.getElementsByTagName('body')[0].appendChild(iframeElement)
// 获取iframe的文档对象
const iframeDoc = getIframeDocument(iframeElement)
const iframeWin = getIframeWindow(iframeElement)
iframeElement.onload = () => {
// 实际的内容,必须使用这种方式插入, 否则在部分平板浏览器中打印预览会只有一页(即使你实际应该有多页),且还存在其他问题
iframeDoc.body.appendChild(printDom)
try {
// 使iframe获取焦点,作用是使window失去焦点,为后续删除iframe做准备
iframeElement.focus()
if (isEdge() || isIE()) {
try {
iframeDoc.execCommand('print', false)
} catch (e) {
iframeWin.print()
}
} else {
iframeWin.print()
}
} finally {
if (isFirefox()) {
iframeElement.style.visibility = 'hidden'
iframeElement.style.left = '-1px'
}
}
// 用于在关闭打印预览窗口时,销毁iframe
cleanUp()
}
iframeDoc.open()
// 样式必须通过这种方式写入iframe, 否则iframe中会无样式
iframeDoc.write(
`<html>
<head>
<title>${customTitle}</title>${cssLinkStr}</head><body></body></html>`
)
// close之后会触发onload
iframeDoc.close()
}
const iframeId = `printIframe-${new Date().getTime()}`
function cleanUp() {
// Run onPrintDialogClose callback
let event = 'mouseover'
if (isChrome() || isFirefox()) {
// Ps.: Firefox will require an extra click in the document to fire the focus event.
event = 'focus'
}
/*
* 销毁iframe,只能通过这种事件方式销毁, 不能直接销毁。在PC端打印预览窗体会阻塞iframe销毁的代码执行,
* 直至打印预览窗体关闭才会执行,而部分移动端浏览器,打印预览窗口不会阻塞后续代码的执行,那么iframe就会被销毁,
* 导致最终打印的结果是当前窗体的内容,而非iframe中的内容
*/
const handler = () => {
// Make sure the event only happens once.
window.removeEventListener(event, handler)
// Remove iframe from the DOM
const iframe = document.getElementById(iframeId)
if (iframe) {
iframe.remove()
}
}
window.addEventListener(event, handler)
}
/**
* 自定义打印前的回调方法。可用于打印之前对待打印html dom对象进行一些修改. 比如: 添加水印
*
* @param {HTMLElement} printDom 待打印html节点的复制节点,对该节点的操作不会影响原始节点
*
* @return {HTMLElement} 修改之后的待打印dom节点(最终会打印这个dom节点)
*/
export type CustomPrintBeforeCall = (printDom: HTMLElement) => HTMLElement
/**
* 执行打印/打开打印预览
*
* @param htmlDomOrHtmlSelector 待打印元素或待打印元素的html选择器
* @param printCssArr 打印样式,指的是css文件地址.如: http://test.api/a.css (打印样式应该通过printCssArr传入,内部在克隆待打印html节点时,不会克隆样式)
* @param customTitle 页眉
* @param customPrintBeforeCall 打印之前的自定义回调(printDom是待打印html节点的复制节点,对该节点的操作不会影响原始节点). 可选. 可在打印之前对待打印html dom对象进行一些修改. 比如: 添加水印
*/
export function doPrint(
htmlDomOrHtmlSelector: HTMLElement | string,
printCssArr: string[],
customTitle: string,
customPrintBeforeCall?: CustomPrintBeforeCall
) {
// 获取待局部打印的html元素
const printDom = isHtmlElement(htmlDomOrHtmlSelector)
? (htmlDomOrHtmlSelector as HTMLElement)
: (document.querySelector(htmlDomOrHtmlSelector as string) as HTMLElement)
if (!printDom) return
// 删除已存在的iframe
const usedFrame = document.getElementById(iframeId)
if (usedFrame) usedFrame.parentNode?.removeChild(usedFrame)
// 创建一个新的iframe
const printFrame = document.createElement('iframe')
if (isFirefox()) {
printFrame.setAttribute(
'style',
'width: 1px; height: 100px; position: fixed; left: 0; top: 0; opacity: 0; border-width: 0; margin: 0; padding: 0'
)
} else {
printFrame.setAttribute('style', 'visibility: hidden; height: 0; width: 0; position: absolute; border: 0')
}
// 设置id
printFrame.setAttribute('id', iframeId)
// 克隆待局部打印的html元素
let clonePrintDom = cloneElement(printDom)
// 存在自定义的打印之前回调函数,则执行
if (customPrintBeforeCall) {
/*
// important: 重要
这里之所以最终打印返回值的dom元素,是考虑到,水印要作为待打印元素的背景
(即待打印元素的css的background属性要被占用,而如果待打印元素本身就有背景呢!那岂不是把待打印元素的背景给冲掉了)
为防止待打印元素的背景被水印冲掉,customPrintBeforeCall方法中可以在clonePrintDom的外部再套一个div,在这个div中加水印就没问题了
*/
clonePrintDom = customPrintBeforeCall(clonePrintDom as HTMLElement)
}
let cssLinkStr = ''
printCssArr.forEach(css => {
cssLinkStr += `<link rel="stylesheet" href="${css}">`
})
// 执行iframe的print方法
iframePrint(printFrame, clonePrintDom as HTMLElement, cssLinkStr, customTitle)
}
printWatermark.ts 实现给打印预览加水印
ts
/**
* 构建水印的base64编码
*
* @param {string} text 水印文字
* @param height 水印文字所处方块的高度(高度应该大于等于文字的高度,否则文字会被截断)
* @param width 水印文字所处方块的宽度(宽度应该大于等于文字的宽度,否则文字会被截断)
*
* @return {[string]} 水印的base64编码
*/
function buildWatermarkBase64(text: string, height: number, width: number) {
const canvasDom: HTMLCanvasElement = Object.assign(document.createElement('canvas'), { width, height })
const canvasRender2D = canvasDom.getContext('2d')
if (!canvasRender2D) return
canvasRender2D.rotate((Math.PI / 120) * -20)
canvasRender2D.fillStyle = 'rgba(0, 0, 0, 0.3)'
canvasRender2D.font = '16px Microsoft Yahei'
canvasRender2D.textAlign = 'center'
canvasRender2D.textBaseline = 'middle'
canvasRender2D.fillText(text, width / 10, height / 1.5)
return canvasDom.toDataURL('image/png')
}
/**
* 给待打印的html元素添加水印文字
*
* @param printDom 待打印的html元素
* @param text 水印文字
* @param height 水印文字所处方块的高度(高度应该大于等于文字的高度,否则文字会被截断)
* @param width 水印文字所处方块的宽度(宽度应该大于等于文字的宽度,否则文字会被截断)
*/
export function addPrintWatermark(printDom: HTMLElement, text: string, height: number, width: number) {
const base64 = buildWatermarkBase64(text, width, height)
/*
// important: 重要说明
如果需要在所有内容上都加上水印,那就必须将水印作为待打印dom节点的背景图片,
并使用css方式填充满,否则你无法确定水印需要填充的高度,通过offsetHeight方式获取的高度是px单位,
和打印纸的高度并没有明确的转换方式,所以类似需求都只能使用类似方式实现
*/
Object.assign(printDom.style, {
background: `url(${base64}) left top repeat`,
'color-adjust': 'exact',
'print-color-adjust': 'exact',
'-webkit-print-color-adjust': 'exact',
})
return printDom
}
调用方式
ts
import { doPrint } from '@/utils/html-print/iframe'
import { addPrintWatermark } from '@/utils/html-print/printWatermark'
const baseUrl = import.meta.env.BASE_URL
doPrint('#printTableContent', [`${baseUrl}print-css/dist/table-print.css`], '自定义页眉', (printDom: HTMLElement) =>
addPrintWatermark(printDom, '我是大王呀张三', 200, 100)
)