Skip to content
文章目录

通过iframe实现局部打印预览

实现思路

  1. 获取待打印 dom 节点
  2. 拷贝待打印 dom 节点(防止原始待打印节点被污染,同时也可以在这个复制的过程中,对原始的 dom 节点做一些修改/替换之类的操作)
  3. 动态创建 iframe 节点
  4. 向 iframe 节点插入写好的打印样式(通过 write 方式写入样式,如果通过 append 或 appendChild 方式插入,样式无法生效)
  5. 等待 iframe 的 onload 事件触发,在 onload 事件函数中向 iframe 插入拷贝后的 dom 节点
  6. 在 onload 事件函数中,使 iframe 获取焦点,调用 iframe 的 print 方法进行打印(此时打印的就是 iframe 中的内容,也就是局部打印)
  7. 在 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)
)