react前端使用fetch请求后端接口,接受后端返回的流式数据

26 阅读3分钟

下面是近期用React写的一个处理流式数据实例:

import { useState, useRef } from 'react'
import { Button, Form, Input, Modal, Spin, message } from 'antd'
import { ArrowUpOutlined } from '@ant-design/icons'
import AIAskAndAnswer, { type AIAskAndAnswerProps } from './AIAskAndAnswer'

function AIAsk() {
    // 打开发起AI聊天的弹窗
    const [isOpen, setIsOpen] = useState(false)
    // 提问id
    const [askId, setAskId] = useState('')
    // 提问子组件Ref
    const askAnswerRef = useRef<AIAskAndAnswerProps>({
        // 暴露的完成方法
        onFinish() {},
        // 重置内容
        resetContent() {}
    })
    
    // 提问弹窗中的表单Ref
    const [askDialogForm] = Form.useForm()
    // 问题内容
    const question = Form.useWatch('question', askDialogForm)
    
    // 提问处理事件
    const onHandleAsk = (id: string) => {
        setIsOpen(true)
        setAskId(id)
    }
    
    // 回车处理事件
    const handlePressEnter = () => {
        askRef.current.onFinish(question)
    }
    
    // 关闭
    const handleClose = () => {
        askRef.current.resetContent()
        askDialogForm.resetFields()
        setIsOpen(false)
    }
    
    return (<>
        <Button
            type="link"
            size="small"
            onClick={() => onHandleAsk(id)}
        >
            发起提问
        </Button>
        <Modal
            title={'标题…'}
            width={1000}
            open={isOpen}
            maskClosable={false}
            onCancel={handleClose}
            footer={() => <></>}
        >
            <Spin spinning={false}>
                <Form
                    className="my-[24px] px-[12px] pb-[2px]"
                    form={askDialogForm}
                    layout="vertical"
                >
                    <Form.Item
                        label={''}
                        name="answer"
                        rules={[{ required: false }]}
                    >
                        <AIAskAndAnswer
                            childRef={askAnswerRef}
                            askId={askId}
                        />
                    </Form.Item>
                    <Form.Item
                        label={''}
                        name="question"
                        rules={[{ required: false, message: '' }]}
                    >
                        <Input
                            showCount
                            size="large"
                            maxLength={50}
                            placeholder={'请输入要咨询的问题'}
                            suffix={
                                <ArrowUpOutlined
                                    style={{ color: 'rgba(0,0,0,.45)' }}
                                    onClick={handlePressEnter}
                                />
                            }
                            onPressEnter={handlePressEnter}
                        />
                    </Form.Item>
                </Form>
            </Spin>
        </Modal>
    </>)
}
import { useEffect, useImperativeHandle, useRef, useState } from 'react'
import { Card, Spin, message } from 'antd'
import { v4 as uuidCreate } from 'uuid'
import { useAskApi } from '@/apis/hooks'

// 暴露的api方法
export interface AIAskAndAnswerProps {
    onFinish: (text: string) => void
    resetContent: () => void
}

function AIAskAndAnswer(props: {
    childRef: React.MutableRefObject<AIAskAndAnswerProps>
    askId: string
}) {
    const { childRef, askId } = props
    // 内容区Ref
    const contentRef = useRef<HTMLDivElement>(null)
    // 聊天随机 sessionId Ref
    const sessionIdRef = useRef<string>(uuidCreate())

    const [loading, setLoading] = useState(false)
    // 历史对话
    const [history, setHistory] = useState<
        { role: 'user' | 'ai'; text: string }[]
    >([])

    // 请求接口
    const { askApi } = useAskApi()

    // 自动滚动到底部
    useEffect(() => {
        if (contentRef.current) {
            contentRef.current.scrollTop = contentRef.current.scrollHeight
        }
    }, [history])

    // 插入历史聊天文字
    const handleText = (text: string) => {
        setHistory((h) => {
            const newH = [...h]
            const lastIdx = newH.length - 1
            if (lastIdx >= 0 && newH[lastIdx].role === 'ai') {
                newH[lastIdx] = {
                    ...newH[lastIdx],
                    text
                }
            }
            return newH
        })
    }

    // 接收流式内容并实时展示
    const doFetchAIContent = async (text: string) => {
        setLoading(true)
        try {
            const params = {
                sessionId: sessionIdRef.current,
                content: text,
                askId
            }

            // 先插入一条空的ai消息
            setHistory((h) => [...h, { role: 'ai' as const, text: '' }])

            const res = await askApi(params, {})
            if (!res?.body) throw new Error('暂时无答案')
            const reader = res.body.getReader()
            const decoder = new TextDecoder('utf-8')
            let done = false
            let resText = ''
            
            while (!done) {
                const { value, done: isReading } = await reader.read()
                done = isReading
                if (value) {
                    // decode 时需要加上 { stream: true } 参数,因为流式传输时,一个汉字符号可能会被拆成多个码点,可能正好不在同一块数据中,{ stream: true } 可以在下次解码时自动将缓存字节与新数据块开头拼接,保证正确解码
                    const chunk = decoder.decode(value, { stream: true })
                    // 处理流式返回的多行 data: 内容
                    const lines = chunk.split(/\r?\n/)
                    for (const line of lines) {
                        // data: 开头,正常内容
                        if (line.startsWith('data:')) {
                            // 有数据返回就开始展示
                            setLoading(false)
                            // 提取内容
                            const str = line.replace('data:', '').trim()
                            if (str) {
                                try {
                                    const content = JSON.parse(str)?.data?.content || ''
                                    // 过滤掉 '[DONE]' 内容
                                    if (content && content !== '[DONE]') {
                                        resText += content
                                        handleText(resText)
                                    }
                                } catch (err) {
                                    console.log(err)
                                }
                            }
                        } else if (line.startsWith('{"code":"')) {
                            // 直接以 {"code":" 开头,请求为 非 200 状态,需要特殊处理
                            const pieces = line.split(',')
                            // 提取出第一段中的 数字,即是状态码信息
                            const code = pieces[0].match(/\d+/)
                            // 非200
                            if (code && code[0] !== '200') {
                                // 状态消息文字匹配
                                const pies = line.split('message":')
                                const message = pies[1].match(/\"(.*?)\"/)
                                if (message) {
                                    resText += message[1] ?? ''
                                } else {
                                    resText += '查询有误~~'
                                }
                                handleText(resText)
                            }
                        }
                    }
                }
            }
        } catch (e) {
            message.error('查询有误')
            handleText('查询有误')
        } finally {
            setLoading(false)
        }
    }

    // 发送输入区内容,流式生成
    const handleSend = async (text: string) => {
        if (!text?.trim()) return
        setHistory((h) => [...h, { role: 'user', text }])
        await doFetchAIContent(text)
    }

    useImperativeHandle(childRef, () => ({
        onFinish: (text: string) => handleSend(text),
        resetContent: () => setHistory([])
    }))

    return (
        <Card className="w-full h-[300px]">
            {/* 问答内容区 */}
            <div
                className="h-[260px] text-[18px] overflow-y-auto"
                ref={contentRef}
            >
                {history.map((item, idx) => (
                    <div
                        key={idx}
                        className={`flex ${item.role === 'user' ? 'justify-end' : 'justify-start'} mb-3`}
                    >
                        <div
                            className={
                                'max-w-[80%] p-3 relative break-all text-[#222] leading-[1.8]',
                                'rounded-xl border border-solid border-[#D8E0F0] whitespace-pre-wrap',
                                ` ${item.role === 'user' ? 'bg-[#E8F3FF] shadow-[0_2px_8px_0_#E8F3FF]' : 'bg-[#fff] shadow-[0_2px_8px_0_#F2F6FA]'} `
                            }
                        >
                            {item.role === 'ai' && idx === history.length - 1 &&
                                (loading) ? (
                                    <span className="flex items-center gap-2">
                                        <Spin size="small" />
                                        <span className="text-12px text-[#888]">
                                            回答中...
                                        </span>
                                    </span>
                                ) : (
                                    item.text
                                )}
                        </div>
                    </div>
                ))}
            </div>
        </Card>
    )
}

export default AIAskAndAnswer