跟源码手写自己打印预览组件
前言
前一段时间碰到预览打印功能,也是第一次碰到,查一下资料window.print()
就可以实现,但是实践起来的时候发现并没有那么简单。那就先使用一下插件先解决一下吧。后来去看了一下插件(react-to-print
)怎么实现的。后面有完整代码。
正文
还是先来看看最后的结果,原理也很简单,只是我们通常不会想那么多。
原理:将页面中的DOM,style,link全都复制到iframe中,然后再在iframe中调用print()
代码详解
- 组件参数
props
interface PrintProps {
/** 是否显示*/
isShow: boolean,
/** 打印的元素*/
children: React.ReactNode,
/** 打印时的样式*/
pageStyle?: string,
/** 点击取消按钮*/
handleCancel?: () => any
/** 打印前触发的事*/
onBeforePrint?: () => any,
/** 打印后触发的事*/
onAfterPrint?: () => void
}
- 判断是否有正在进行中的打印任务和执行
onBeforePrint
函数,对于onBeforePrint
的返回值进行判断。
- 创建
iframe
,并去除上一次打印添加的iframe
,克隆需要打印的Dom
结点。获取打印节点中全部的link、img、video标签
,这里是为了预加载资源。为什么要预加载资源下面解释。
- 对
canvas、form、style
进行复制,对img、video、link
的资源进行预加载。
- 使用
requestAnimationFrame
判断资源是否加载完毕,因为资源的加载需要时间,要是没有加载完成资源就调用打印,那么打印是空白的或者样式不对。
完整代码
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import './Print.scss'
import { findDOMNode } from 'react-dom';
interface PrintProps {
/** 是否显示*/
isShow: boolean,
/** 打印的元素*/
children: React.ReactNode,
/** 打印时的样式*/
pageStyle?: string,
/** 点击取消按钮*/
handleCancel?: () => any
/** 打印前触发的事*/
onBeforePrint?: () => any,
/** 打印后触发的事*/
onAfterPrint?: () => void
}
export default function Print(props: PrintProps) {
const {
children,
pageStyle = '',
isShow,
onBeforePrint = () => { },
onAfterPrint = () => { },
handleCancel = () => { }
} = props
const isPrinting = useRef(false);
const printDom = useRef(null);
//防止遮盖层下页面滚动
useEffect(() => {
if (isShow) {
document.documentElement.style.overflowY = "hidden";
} else {
document.documentElement.style.overflowY = "scroll";
}
}, [isShow])
const onPrint = useCallback(() => {
if (document === null) {
return
}
/** 打印的窗口页面*/
const printWindow = document.createElement("iframe");
printWindow.style.width = `${document.documentElement.clientWidth}px`;
printWindow.style.height = `${document.documentElement.clientHeight}px`;
printWindow.style.position = "absolute";
printWindow.style.top = `-${document.documentElement.clientHeight + 100}px`;
printWindow.style.left = `-${document.documentElement.clientWidth + 100}px`;
printWindow.id = "printWindow";
//目的是让浏览器能够正确地渲染页面
printWindow.srcdoc = "<!DOCTYPE html>";
/** 清除上一次的iframe*/
const documentPrintWindow = document.getElementById("printWindow");
if (documentPrintWindow) {
document.body.removeChild(documentPrintWindow);
}
document.body.appendChild(printWindow);
/** 克隆的打印结点*/
const contentNodes = findDOMNode(printDom.current);
const contentNodesClone = (contentNodes as any).cloneNode(true);
const globalStyleLinkNodes = document.querySelectorAll("link[rel='stylesheet']");
const imgNodes = (contentNodesClone as Element).querySelectorAll("img");
const videoNodes = (contentNodesClone as Element).querySelectorAll("video");
//iframe加载完成时触发
printWindow.onload = () => {
/** 资源是否加载中*/
let isLoading = true;
let timer: any = null;
let linkNum = 0;
let imgNum = 0;
let videoNum = 0;
/** 文档对象*/
const domDoc: Document = printWindow.contentDocument as Document
if (domDoc) {
domDoc.body.appendChild(contentNodesClone);
}
/** 改善页面打印的样式*/
if (typeof pageStyle === 'string') {
const styleEl = domDoc.createElement("style");
styleEl.appendChild(domDoc.createTextNode(pageStyle));
domDoc.head.appendChild(styleEl);
} else {
console.warn('pageStyle类型错误,应该为string')
}
//处理canvas,复制canvas
const originalCanvas = (contentNodes as Element).querySelectorAll("canvas");
const copiedCanvas = (domDoc).querySelectorAll("canvas");
for (let i = 0; i < originalCanvas.length; ++i) {
const sourceCanvas = originalCanvas[i];
const targetCanvas = copiedCanvas[i];
const targetCanvasCtx = targetCanvas.getContext("2d");
if (targetCanvasCtx) {
targetCanvasCtx.drawImage(sourceCanvas, 0, 0);
}
}
// 图片
for (let i = 0; i < imgNodes.length; i++) {
const imgNodeItem = imgNodes[i];
const imgSrc = imgNodeItem.getAttribute("src");
if (imgSrc) {
const img = new Image();
img.src = imgSrc;
img.onload = () => {
imgNum++;
}
}
}
// 预加载视频
for (let i = 0; i < videoNodes.length; i++) {
const videoNodeItem = videoNodes[i];
videoNodeItem.preload = 'auto';
const videoPoster = videoNodeItem.getAttribute('poster')
if (videoPoster) {
const img = new Image();
img.src = videoPoster;
img.onload = () => {
videoNum++;
}
} else {
videoNodeItem.onloadeddata = () => {
videoNum++;
}
}
}
//复制input
const originalInputs = (contentNodes as HTMLElement).querySelectorAll('input');
const copiedInputs = domDoc.querySelectorAll('input');
for (let i = 0; i < originalInputs.length; i++) {
copiedInputs[i].value = originalInputs[i].value;
}
// 复制 checkbox, radio checks
const checkedSelector = 'input[type=checkbox],input[type=radio]';
const originalCRs = (contentNodes as HTMLElement).querySelectorAll(checkedSelector);
const copiedCRs = domDoc.querySelectorAll(checkedSelector);
for (let i = 0; i < originalCRs.length; i++) {
(copiedCRs[i] as HTMLInputElement).checked =
(originalCRs[i] as HTMLInputElement).checked;
}
// 复制 select
const selectSelector = 'select';
const originalSelects = (contentNodes as HTMLElement).querySelectorAll(selectSelector);
const copiedSelects = domDoc.querySelectorAll(selectSelector);
for (let i = 0; i < originalSelects.length; i++) {
copiedSelects[i].value = originalSelects[i].value;
}
//处理style
const originalStyle = document.querySelectorAll("style");
for (let i = 0; i < originalStyle.length; i++) {
const styleItem = originalStyle[i];
const newStyleItem = domDoc.createElement(styleItem.tagName);
const sheet = (styleItem as HTMLStyleElement).sheet as CSSStyleSheet;
if (sheet) {
let styleCSS = "";
try {
const cssLength = sheet.cssRules.length;
for (let j = 0; j < cssLength; ++j) {
if (typeof sheet.cssRules[j].cssText === "string") {
styleCSS += `${sheet.cssRules[j].cssText}\r\n`;
}
}
} catch (error) {
}
newStyleItem.setAttribute("id", `react-to-print-${i}`);
newStyleItem.appendChild(domDoc.createTextNode(styleCSS));
domDoc.head.appendChild(newStyleItem);
}
}
//处理link
const originalLink = document.querySelectorAll("link[rel='stylesheet']");
for (let i = 0; i < originalLink.length; i++) {
const linkItem = originalLink[i];
if (linkItem.getAttribute("href")) {
if (linkItem.hasAttribute("disabled") === false) {
const newLinkItem = domDoc.createElement(linkItem.tagName);
for (let j = 0; j < linkItem.attributes.length; j++) {
const attr = linkItem.attributes[j];
if (attr) {
newLinkItem.setAttribute(attr.nodeName, attr.nodeValue || "");
}
}
newLinkItem.onload = () => {
linkNum++;
}
domDoc.head.appendChild(newLinkItem);
}
}
}
/** 等待图片和视频加载完毕和link*/
const readyToPrint = () => {
if (imgNum === imgNodes.length && videoNum === videoNodes.length
&& linkNum === globalStyleLinkNodes.length
) {
(printWindow as any).contentWindow.print()
onAfterPrint()
isPrinting.current = false
isLoading = false
cancelAnimationFrame(timer);
}
if (isLoading) {
timer = requestAnimationFrame(readyToPrint)
}
}
readyToPrint()
}
}, [pageStyle, onAfterPrint])
/** 执行打印*/
const startPrint = useCallback(() => {
if (isPrinting.current) {
console.warn('正在打印中')
return
}
isPrinting.current = true
const onBeforePrintOutput = onBeforePrint();
if (onBeforePrintOutput && typeof onBeforePrintOutput.then === "function") {
onBeforePrintOutput.then(() => {
onPrint()
}).catch((error: Error) => {
});
} else {
onPrint()
}
}, [onPrint, onBeforePrint])
return (
<>
{
isShow && <div className='print'>
<div className='print__content'>
<div className='print__title'>打印预览</div>
<div className='print__result' ref={printDom}>
{
children
}
</div>
<div className='print__bottom'>
<button onClick={startPrint}>打印</button>
<button onClick={handleCancel}>取消</button>
</div>
</div>
</div>
}
</>
)
}
.print {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
background-color: rgba(0, 0, 0, 0.2);
&__content {
position: absolute;
display: flex;
flex-direction: column;
top: 50%;
left: 50%;
height: 50vh;
width: 50vw;
transform: translate(-50%, -50%);
background-color: #fff;
border-radius: 5px;
}
&__title {
font-size: 19px;
text-align: center;
padding: 10px 0px;
}
&__result {
flex: 1;
width: 100%;
overflow: auto;
}
&__bottom {
display: flex;
justify-content: center;
align-items: center;
padding: 10px 0px;
button {
&:nth-of-type(2) {
margin-left: 10px;
}
}
}
}
//打印时的样式
@media print {
//....
}
参考代码
结语
感兴趣的可以去试试