本文总结一下最近实现的一个功能,在 RN 上展示从后端接口获取的 excel 文档,先上效果(数据已做脱敏处理):
实现思路
主要的实现思路如下:
使用 XMLHttpRequest 获取文档数据 > 使用 sheetjs 包解析为 html > 使用 webView 展示 > 注入 js 进行样式渲染
这里需要说明一个坑,一开始是使用 fetch 而不是 XMLHttpRequest 获取的数据(毕竟项目里原先的接口封装就是用的 fetch),但是 fetch 的表现不尽如人意。首先是 sheetjs 包可以解析格式中,fetch 能提供的只有 arrayBuffer,但是偏偏 RN 的 fetch 实现不包含 arrayBuffer()。所以如果真这么干了会报错:
Error: Response.arrayBuffer() is not implemented
于是打算用 FileReader.readAsArrayBuffer 绕点弯路,但是你说巧不巧,RN 的 FileReader 实现同样不包含 readAsArrayBuffer,所以会报和上面差不多的错出来。
没办法,回头看一下发现 sheetjs 也可以使用 base64 进行解析,而 FileReader.readAsDataURL() 不是可以把文件解析为 base64 么?遂尝试,结果解析是成功了,然而生成的 html 文件全是乱码,血崩。
FileReader 的其他几个 readAs 方法都尝试了,无一成功,这里不再赘述。忙活了半天最后还是另起炉灶用 XMLHttpRequest 解决了。
excel 渲染的样式问题
还有一点需要提一下,SheetJS/sheetjs: SheetJS Community Edition -- Spreadsheet Data Toolkit (github.com) 这个包虽然可以解析 excel,但是 免费版不支持 excel 的样式。而付费的专业版可以支持包括样式显示在内的很多其他功能,具体可以看这里 SheetJS - Pro。
我这里用的还是免费版,所以会需要通过 webview 的 js 注入功能完成后期的样式修复(可以看到上面的效果里有一个红色的单元格,这个就是对报警数据的高亮展示)。下文里会详细讲一下,方便有类似需求的同学参考。
具体实现
首先是数据的获取与解析,先安装 excel 解析包:
yarn add xlsx
然后封装成统一的获取-解析工具函数:
import XLSX from 'xlsx';
/**
* 下载/解析 excel 到 html
*
* @param {string} url 要请求的后端地址
* @param {string} token 后端验证 token
* @param {object} postData 接口请求数据
* @returns {string} excel 的 html 内容
*/
export const fetchExcelHtml = async (url, token, postData) => {
return new Promise((resolve, reject) => {
try {
const req = new XMLHttpRequest();
req.open('POST', url, true);
req.setRequestHeader('token', token);
req.setRequestHeader('Content-type', 'application/json;charset=UTF-8');
req.responseType = 'arraybuffer';
req.onload = () => {
const data = new Uint8Array(req.response);
const workbook = XLSX.read(data, { type: 'array' });
const sheetNames = workbook.SheetNames;
const workSheet = workbook.Sheets[sheetNames[0]];
const html = XLSX.utils.sheet_to_html(workSheet);
resolve(html);
};
req.send(JSON.stringify(postData));
}
catch (e) {
reject(e);
}
})
}
这里封装成了 async 函数方便调用,注意:其中的请求类型(POST)和身份验证(headers 中的 token 字段)需要你根据自己的情况进行一些调整。
比较简单,关于解析包 XLSX 的用法可以看这里 SheetJS/sheetjs: SheetJS Community Edition -- Spreadsheet Data Toolkit (github.com)。
用法也很简单:
const html = await fetchExcelHtml('http://localhost:9000/export', {
month: "202112",
name: "工厂名称"
});
接下来是使用 webview 渲染和样式,这里我将其封装成了一个函数组件 ExcelView:
import React, { useEffect, useRef } from 'react';
import { WebView } from 'react-native-webview';
const baseScript = `
document.body.innerHTML += \`
<style>
table {
width: 100vw;
}
table tbody tr:first-child td:first-child {
font-size: 30px;
font-weight: bold;
}
td, span {
padding: 8px 0px;
font-size: 28px!important;
text-align: center;
}
td {
border: 1px solid #eee;
}
.alert-cell {
background-color: #EF5350;
color: white;
}
</style>\`
`
/**
* 渲染超限标红的单元格
*/
const renderAlertCellScript = (alertData) => {
return alertData.map(needAlertCell => {
const { rowIndex, columnIndex } = needAlertCell
return `
const cellDom = document.querySelector(\`table tr:nth-child(${rowIndex + 1}) td:nth-child(${columnIndex + 1})\`)
if (cellDom) cellDom.className = cellDom.className + ' alert-cell';
`
}).join('\n');
}
/**
* excel 渲染组件
*/
export const ExcelView = (props) => {
const { html, alert } = props;
const webViewRef = useRef(undefined);
useEffect(() => {
if (!alert) return;
const renderSrc = renderAlertCellScript(alert);
webViewRef.current.injectJavaScript(renderSrc);
}, [alert]);
return (
<WebView
ref={webViewRef}
originWhitelist={['*']}
source={{ html }}
injectedJavaScript={baseScript}
/>
)
}
稍微解释一下:baseScript 里边包含了表格的基本样式(excel 在解析后会变为 html table),你可以根据自己的情况调整。注意其中包含了一个 .alert-cell 样式类作为报警单元格的样式。
然后是 renderAlertCellScript 函数,由于没办法直接解析 excel 文件中的样式显示出来,所以这里需要 后端同学配合搞一个接口,接口返回报警单元格的行索引和列索引。我这里后端返回的报警单元格数据类型长这样:
[
{ rowIndex: 2, columnIndex: 10 },
{ rowIndex: 5, columnIndex: 7 },
]
而这个 renderAlertCellScript 函数,就是解析上面这个数组,然后将其转换为 webview 中执行的 js 代码。这里用 useEffect 监听一下,如果报警数据拿到了,就转换为 js 然后使用 injectJavaScript 注入到 webview 里。
组件的完整用法如下:
import React, { Component } from 'react';
import { fetchExcelHtml, ExcelView } from './utils';
export default class DemoView extends Component {
constructor(props) {
super(props);
this.state = { excelHtml: '' };
}
componentDidMount() {
const query = { month: "202112", name: "工厂名称" };
// 请求 excel 本体
this.fetchExcelReportData(query);
// 请求报警单元格数据
this.props.fetchExcelAlert(query);
}
render() {
return (
<ExcelView
html={this.state.excelHtml}
// 这里的响应就是 fetchExcelAlert 请求到的
alert={this.props.alertResp?.data}
/>
)
}
fetchExcelReportData = async (query) => {
const html = await fetchExcelHtml('postMonthlyExcelReport', query);
this.setState({ excelHtml: html });
}
}
完整版本
最后再放一个完整的实现(其实就是上面完整用例里的 ./utils),具体用法就在上面:
import React, { useEffect, useRef } from 'react';
import { WebView } from 'react-native-webview';
import XLSX from 'xlsx';
/**
* 下载/解析 excel 到 html
*
* @param {string} url 要请求的后端地址
* @param {string} token 后端验证 token
* @param {object} postData 接口请求数据
* @returns {string} excel 的 html 内容
*/
export const fetchExcelHtml = async (url, token, postData) => {
return new Promise((resolve, reject) => {
try {
const req = new XMLHttpRequest();
req.open('POST', url, true);
req.setRequestHeader('token', token);
req.setRequestHeader('Content-type', 'application/json;charset=UTF-8');
req.responseType = 'arraybuffer';
req.onload = () => {
const data = new Uint8Array(req.response);
const workbook = XLSX.read(data, { type: 'array' });
const sheetNames = workbook.SheetNames;
const workSheet = workbook.Sheets[sheetNames[0]];
const html = XLSX.utils.sheet_to_html(workSheet);
resolve(html);
};
req.send(JSON.stringify(postData));
}
catch (e) {
reject(e);
}
})
}
const baseScript = `
document.body.innerHTML += \`
<style>
table {
width: 100vw;
}
table tbody tr:first-child td:first-child {
font-size: 30px;
font-weight: bold;
}
td, span {
padding: 8px 0px;
font-size: 28px!important;
text-align: center;
}
td {
border: 1px solid #eee;
}
.alert-cell {
background-color: #EF5350;
color: white;
}
</style>\`
`
/**
* 渲染超限标红的单元格
*/
const renderAlertCellScript = (alertData) => {
return alertData.map(needAlertCell => {
const { rowIndex, columnIndex } = needAlertCell
return `
const cellDom = document.querySelector(\`table tr:nth-child(${rowIndex + 1}) td:nth-child(${columnIndex + 1})\`)
if (cellDom) cellDom.className = cellDom.className + ' alert-cell';
`
}).join('\n');
}
/**
* excel 渲染组件
*/
export const ExcelView = (props) => {
const { html, alert } = props;
const webViewRef = useRef(undefined);
useEffect(() => {
if (!alert) return;
const renderSrc = renderAlertCellScript(alert);
webViewRef.current.injectJavaScript(renderSrc);
}, [alert]);
return (
<WebView
ref={webViewRef}
originWhitelist={['*']}
source={{ html }}
injectedJavaScript={baseScript}
/>
)
}