react-native 渲染在线 Excel

2,380 阅读5分钟

本文总结一下最近实现的一个功能,在 RN 上展示从后端接口获取的 excel 文档,先上效果(数据已做脱敏处理):

rn报表.gif

实现思路

主要的实现思路如下:

使用 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}
        />
    )
}

参考