React中csv导出

201 阅读2分钟

项目中需要导出列表功能.

设计

  • 列表页一共有两个按钮,一个搜索,一个导出
  • 用户点击导出按钮
    1. 获取当前搜索条件

    2. 拼接搜索条件&current=1&pageSize=100请求后端接口,current不断自增循环请求

    3. 用react中的ref去累加返回的数据

    4. 判断当前接口返回的数据大小是否等于pageSize. 如果小于,则结束当前循环

    5. 如果过程中发生任何错误,显示错误信息到页面,并让用户重试

npm包的选择

这两个npm下载量都比较大,但第一个react-csv已经好几年不更新. 本来想用第一个,发现他也不支持typescript. ignore后发现又报了其他错误. 具体什么错误,我也忘记了.最后换了react-csv-downloader. 发现这个使用起来更方便一些

补充: 以上只是一些demo使用. 实际生产中还需要考虑以下几点

  1. 导出进度条的显示.
  2. 导出可取消. 如果用户导出大量数据不能取消, 体验很差,浪费时间,只能刷新页面,或重新打开
  3. 导出不能阻塞页面的其他动作
  4. 特殊符号的转义. csv是以逗号分割数据的,所以这些符号需要转义.
  5. 时间数据的转义, 注意时区的处理
  6. 组件的封装,因为基本每个列表都需要导出功能,所以需要将以上再次封装为一个业务组件
  • csv数据转义函数
function escapeCsvField(field) {
    if (field.includes('"')) {
        field = field.replace(/"/g, '""');
    }
    if (field.includes(',') || field.includes('\n') || field.includes('\r')) {
        field = `"${field}"`;
    }
    return field;
}
  • 时间的处理函数
new Date().toLocaleString() //'2024/8/3 18:52:27'
new Date().toISOString() //'2024-08-03T10:53:18.681Z'
new Date().toJSON()     // '2024-08-03T10:53:46.224Z'

可以看见以上时间转字符, 只有第一个是正确的时区, 但第一个不能显示毫秒 后端接口返回的是'2024-08-03T10:53:18.681Z'
前端对日期的几种处理方法

  1. toLocaleString()
//1 直接toLocaleString()
new Date('2024-08-03T10:53:18.681Z').toLocaleString()
  1. ISO date+ 时区补偿 是否会纳闷有点多此一举, 并没有,因为toLocaleString只能显示到秒,但这种方案可以显示到毫秒
let date = new Date('2024-08-03T10:53:18.681Z');
date.setMinutes(date.getMinutes() - date.getTimezoneOffset()); 
// toJSON 的时区补偿
date.toJSON().substring(0, 22).replace(/[T]/g, ' ');; 

导出组件,伪代码

import {
    forwardRef,
    useImperativeHandle,
    useMemo,
    useRef,
    useState,
} from 'react'
import CsvDownloader from 'react-csv-downloader'

export const delay = (ms: number) => {
    return new Promise((resolve) => setTimeout(resolve, ms))
}

const columns = [
    {
        id: 'name',
        displayName: 'name',
    },
    {
        id: 'created_at',
        displayName: 'created_at',
    },
    {
        id: 'updated_at',
        displayName: 'updated_at',
    },
   
]
const CsvExportModal = forwardRef(
    (_props, ref) => {
        const [progress, setProgress] = useState()
        const [visible, setVisible] = useState(false)
        const [searchParams, setSearchParams] = useState()
        const dataRef = useRef([])
        const interruptExportRef = useRef(true)
        const btnRef = useRef<any>(null)
        const pageSizeRef = useRef(200)
        const [confirmLoading, setConfirmLoading] = useState(false)

        const confirmSearchParams = useMemo(() => {
            if (searchParams && Object.keys(searchParams).length > 0) {
                return Object.entries(searchParams)
                    .filter(
                        ([, v]) => v !== undefined && v !== null && v !== ''
                    )
                    .map(([k, v]) => {
                        return {
                            label: k,
                            children: <p>{v}</p>,
                        }
                    })
            }
            return undefined
        }, [searchParams])

        const open = (params) => {
            setSearchParams(params)
            setVisible(true)
        }
        const handleOk = async () => {
            setConfirmLoading(true)
            interruptExportRef.current = false
            setProgress('start fetch data')
            let _currentPage = 1
            let _count = pageSizeRef.current
            while (
                _count === pageSizeRef.current &&
                !interruptExportRef.current
            ) {
                const data = await fetch({
                    ...searchParams,
                    current: _currentPage,
                    pageSize: pageSizeRef.current,
                }).catch((e) => {
                    console.error(e)
                    return {
                        list: [],
                        total: undefined,
                    }
                })
                const { list, total } = data
                await delay(500)
                if (interruptExportRef.current) {
                    return handleCancel(undefined, true)
                }
                _count = list.length
                _currentPage++
                list.forEach((row) => {
                    dataRef.current.push(row)
                })
                setProgress(
                    `start fetch ${total} row:${dataRef.current.length} had ok`
                )
                await delay(1000)
                if (interruptExportRef.current) {
                    return handleCancel(undefined, true)
                }
            }
            if (interruptExportRef.current) {
                return handleCancel(undefined, true)
            }
            await delay(1000)
            if (interruptExportRef.current) {
                return handleCancel(undefined, true)
            }
            setProgress('ok will export')
            delay(500).then(() => {
                if (interruptExportRef.current) {
                    return handleCancel(undefined, true)
                } else if (dataRef.current.length === 0) {
                    setProgress('current date is null')
                    return handleCancel(true)
                }
                btnRef?.current?.handleClick?.()
                handleCancel(undefined, undefined, true)
            })
        }
        const handleCancel = (
            visible?: boolean,
            showCancelMsg?: boolean,
            showOkMsg?: boolean
        ) => {
            setConfirmLoading(false)
            interruptExportRef.current = true
            dataRef.current = []
            if (showCancelMsg) {
                console.info('cancel')
            }
            if (showOkMsg) {
                console.info('success')
            }
            if (!visible) {
                setProgress(undefined)
                setVisible(() => false)
            }
        }
        useImperativeHandle(ref, () => {
            return {
                open,
            }
        })
        return (
            <Modal
                title={'export'}
                open={visible}
                onOk={handleOk}
                maskClosable={false}
                onCancel={() => handleCancel()}
                confirmLoading={confirmLoading}
            >
        
                <CsvDownloader
                    style={{
                        visibility: 'hidden',
                    }}
                    ref={btnRef}
                    filename={filename}
                    extension=".csv"
                    wrapColumnChar=""
                    columns={columns}
                    datas={dataRef.current as any}
                    text="DOWNLOAD"
                />
                <div>
                {confirmSearchParams}
                </div>
                <div>
                    {progress}
                </div>
            </Modal>
        )
    }
)

export default CsvExportModal

blog.csdn.net/weixin_4324… developer.mozilla.org/zh-CN/docs/…