告别后端依赖:纯前端技巧实现完美多行表格与分页打印近期拿到一个需求,需要根据富文本编辑器配置的打印文件模板,结合后端接口
为了节省时间,先进行成果展示
成果展示
需求描述
近期拿到一个需求,需要根据富文本编辑器配置的打印文件模板,结合后端接口数据,动态生成打印文件。首先分析一下这个需求,本次需要打印的文件模板是一个多行的表格,后端接口返回一个对象数组,然后根据数组的数量,在表格中渲染出对应数量的表格行。
那么从前端层面应如何实现这个需求呢?
整理思路
首先了解到富文本编辑器保存的内容格式是一段 HTML 格式文本,那么就可以考虑使用 HTML DOM 方式获取到需要打印的表格节点,然后根据接口返回的数组对应渲染到表格中,最后合成的文件模板内容进行打印。
大体开发思路已经想好,那么在实际开发中,还存在以下问题亟待解决:
- 从接口中获取到富文本编辑器保存的文件模板是一段 HTML 格式文本,如何将其转化成 HTML DOM呢?
- 第 1 点解决后,又如何获取到打印文本模板中需要渲染的表格呢?(因为打印模板中除了表格以外,还有一些标题、页眉、页脚、文件使用描述等内容)
- 第 2 点解决后,如何将接口返回的数据内容渲染赋值到对应的单元格中呢?
- 前面 3 点解决后,如果表格的行数超过了当前页面的高度,需要自动分页并且在第二页中重复展现页面的页头和页尾内容
接下来,我们对以上几个问题进行逐一分析
将富文本转换为 HTML DOM
将富文本渲染到页面展示的常规方式是通过 div
的 dangerouslySetInnerHTML
属性
如:
<div dangerouslySetInnerHTML={{ __html: value }} />
但此操作仅限于展示富文本内容,若要在渲染前对富文本的内容进行操作就不行了。
那么我们想要在渲染前或渲染函数内部对富文本内容进行解析怎么办呢?
这里我们可以使用XML DOM - DOMParser 对象
DOMParser 对象解析 XML 文本并返回一个 XML Document 对象。要使用 DOMParser,使用不带参数的构造函数来实例化它,然后调用其 parseFromString() 方法
示例:
var doc = (new DOMParser()).parseFromString(text)
使用DOMParser对富文本进行解析后,我们就可以得到一个富文本的 Document 对象,然后我们就可以在渲染前使用 HTML DOM 对富文本内容进行操作了。
获取打印文件模板中需要渲染的表格
上面我们解决了如何将富文本内容转换成一个可以操作的对象,现在我们就需要处理如何从这个对象中获取需要渲染的表格节点。
对于这个问题,我的解决办法是在富文本编辑器中,对需要渲染的表格的 table 标签增加一个 class 属性,定义一个专用的classname名称标识,然后再通过Document的getElementsByClassName()
方法获取到对应的表格节点。
getElementsByClassName() 方法返回文档中所有指定类名的元素集合,作为 NodeList 对象。 NodeList 对象代表一个有顺序的节点列表。NodeList 对象 我们可通过节点列表中的节点索引号来访问列表中的节点(索引号由0开始)
渲染数据至表格对应的单元格中
上一步我们获取到了需要渲染的表格节点,现在我们着重解决将接口获取到的数据渲染到表格节点对应的单元格中。
这一步我们需要先在富文本编辑器的打印文件模板表格的单元格中填写与后端接口返回数据对象对应的参数名。
然后在程序中,我们通过HTML DOM querySelectorAll()
方法获取到表格节点中所有的 tr
节点
printTableNode.querySelectorAll('tr')
这里对querySelectorAll
方法进行简要介绍
querySelectorAll() 方法返回文档中匹配指定 CSS 选择器的所有元素,返回 NodeList 对象。NodeList 对象表示节点的集合。可以通过索引访问,索引值从 0 开始。
通过querySelectorAll
方法获取到tr
节点数组后,我们取数组的第二项,因为数组的第一项是表头行。第二项才是有与后端数据对象有对应关系的行节点(下面我们称这一行为"模板行")。
取到模板行节点后,使用HTML DOM cloneNode()
方法对模板行节点进行克隆,方便后续根据接口返回的数据对应的渲染到表格节点中。
templateTr.cloneNode(true);
cloneNode() 方法可创建指定的节点的精确拷贝。
cloneNode() 方法 拷贝所有属性和值。
该方法将复制并返回调用它的节点的副本。如果传递给它的参数是 true,它还将递归复制当前节点的所有子孙节点。否则,它只复制当前节点。
接着通过Node节点的childNodes
属性,对其子节点进行遍历,将接口的数据渲染到对应的单元格中
cloneNode.childNodes.forEach((node) => {
if (node.nodeName === 'TD') {
node.childNodes.forEach((node1) => {
if (node1.nodeName === 'P') {
const textContent = node1.textContent || '';
if (textContent) {
node1.textContent = textContent?.replace(
/\$\{(\w+)\}/g,
(match, key) => {
return resData[j][key] || '';
}
);
}
}
});
}
});
表格分页打印
解决完后端接口数据与打印文件模板进行数据渲染的问题后,我们又迎来另外一个问题,如果表格数据的行数超过了当前页面内容,应该怎样对表格进行分页打印呢?
首先我们需要了解一个 CSS 属性:page-break-before
page-break-before 元素在指定元素前添加分页符。
属性值
值 描述 auto 默认值。如果必要则在元素前插入分页符。 always 在元素前插入分页符。 avoid 避免在元素前插入分页符。 left 在元素之前足够的分页符,一直到一张空白的左页为止。 right 在元素之前足够的分页符,一直到一张空白的右页为止。 inherit 规定应该从父元素继承 page-break-before 属性的设置。
可以了解到该 CSS 属性可以在浏览器打印的使得页面进行分页。那么我们应该怎么利用该属性呢?
这里我的方法是:人为判断一页可以展示多少行数据,然后在程序中对后端返回的数据进行分页,将第二步解析的富文本内容不停的追加在默认的打印 Node 节点中,并在上一页与下一页的间隔中增加一个空的 div
标签,标签中带有CSS 属性:page-break-before:always
。
const pageSize = 10;
const resData = isArray(value) ? value : [];
const page = ceil(resData.length / pageSize);
const resStr: string[] = [];
for (let i = 1; i <= page; i++) {
const parser = new DOMParser();
const doc = parser.parseFromString(str, 'text/html');
// if (cloneBody) {
const printTableNode = doc.body
.getElementsByClassName('print-content')
.item(0)?.firstElementChild;
if (printTableNode) {
const trNodeList = printTableNode.querySelectorAll('tr');
if (trNodeList && trNodeList?.length > 1) {
const templateTr = trNodeList[1];
for (let j = (i - 1) * pageSize; j < pageSize * i; j++) {
if (j > resData.length - 1) {
break;
}
const cloneNode = templateTr.cloneNode(true);
cloneNode.childNodes.forEach((node) => {
if (node.nodeName === 'TD') {
node.childNodes.forEach((node1) => {
if (node1.nodeName === 'P') {
const textContent = node1.textContent || '';
if (textContent) {
node1.textContent = textContent?.replace(
/\$\{(\w+)\}/g,
(match, key) => {
return resData[j][key] || '';
}
);
}
}
});
}
});
printTableNode.appendChild(cloneNode);
}
printTableNode.removeChild(templateTr);
}
}
// 增加分页
if (page > 1 && i < page) {
const divNode = document.createElement('div');
divNode.style.pageBreakBefore = 'always';
divNode.style.marginTop = '3em';
doc.body.appendChild(divNode);
}
resStr.push(doc.body.innerHTML);
}
return resStr.join('');
最后
贴上完整代码
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useRef,
} from 'react';
import { queryStaticText } from 'services/api';
import classname from 'classnames/bind';
import { useReactToPrint } from 'react-to-print';
import { useRequest } from 'ahooks';
import './printGlobal.less';
import styles from './index.less';
import { ceil, isArray, isNil } from 'lodash';
const cm = classname.bind(styles);
export interface CustomPrintRef {
print: () => void;
}
export interface CustomPrintProps {
code: string;
data: any;
documentTitle: string;
}
const Index = forwardRef<CustomPrintRef, CustomPrintProps>((props, ref) => {
const { data: staticText } = useRequest(queryStaticText, {
defaultParams: [props.code],
});
const currentRef = useRef(null);
const handlePrint = useReactToPrint({
removeAfterPrint: true,
documentTitle: props.documentTitle,
content: () => currentRef.current,
});
const replacePlaceholders = useCallback(
(str: string, value: any, multiple: boolean) => {
if (isNil(str)) {
return '';
}
if (isNil(value)) {
return str;
}
if (!multiple) {
return str.replace(/\$\{(\w+)\}/g, (match, key) => {
return value[key] || '';
});
} else {
const pageSize = 10;
const resData = isArray(value) ? value : [];
const page = ceil(resData.length / pageSize);
const resStr: string[] = [];
for (let i = 1; i <= page; i++) {
const parser = new DOMParser();
const doc = parser.parseFromString(str, 'text/html');
// if (cloneBody) {
const printTableNode = doc.body
.getElementsByClassName('print-content')
.item(0)?.firstElementChild;
if (printTableNode) {
const trNodeList = printTableNode.querySelectorAll('tr');
if (trNodeList && trNodeList?.length > 1) {
const templateTr = trNodeList[1];
for (let j = (i - 1) * pageSize; j < pageSize * i; j++) {
if (j > resData.length - 1) {
break;
}
const cloneNode = templateTr.cloneNode(true);
cloneNode.childNodes.forEach((node) => {
if (node.nodeName === 'TD') {
node.childNodes.forEach((node1) => {
if (node1.nodeName === 'P') {
const textContent = node1.textContent || '';
if (textContent) {
node1.textContent = textContent?.replace(
/\$\{(\w+)\}/g,
(match, key) => {
return resData[j][key] || '';
}
);
}
}
});
}
});
printTableNode.appendChild(cloneNode);
}
printTableNode.removeChild(templateTr);
}
}
// 增加分页
if (page > 1 && i < page) {
const divNode = document.createElement('div');
divNode.style.pageBreakBefore = 'always';
divNode.style.marginTop = '3em';
doc.body.appendChild(divNode);
}
resStr.push(doc.body.innerHTML);
}
return resStr.join('');
}
return '';
},
[]
);
useImperativeHandle(ref, () => ({
print: () => {
handlePrint();
},
}));
return (
<div style={{ display: 'none' }}>
<div
id='custom-print'
ref={currentRef}
className={cm('custom-print')}
dangerouslySetInnerHTML={{
__html: replacePlaceholders(
staticText?.text || '',
props?.data || {},
staticText?.description?.indexOf('MULTI-LINE') > -1
),
}}
/>
</div>
);
});
Index.displayName = 'CustomPrint';
export default Index;
转载自:https://juejin.cn/post/7387380715661541417