You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React Native流式更新时复制按钮失效及UI卡顿问题解决

解决React Native流式消息更新的UI卡顿与复制按钮失效问题

问题背景

你开发React Native聊天应用时,通过XMLHttpRequest流式获取API响应,每次收到文本块就追加到messages数组state中。但出现两个问题:

  • 频繁的state更新导致UI卡顿
  • 流式更新期间,代码块的复制按钮无响应,传输结束后功能恢复正常

以下是针对两个问题的具体解决方案:


一、优化频繁流式更新的UI流畅度

核心思路

减少不必要的state更新次数,优化FlatList渲染逻辑:

  1. 合并流式文本块,减少state更新
    不要每次收到chunk就往messages数组里加新元素,而是维护一个「正在接收的消息」state,把新chunk追加到这个消息里,流式结束后再将完整消息加入messages。同时用防抖逻辑,避免极短时间内多次更新state。

  2. 优化FlatList渲染性能

    • 用React.memo包装渲染项组件,避免无意义重渲染
    • 使用稳定的keyExtractor(不要用数组索引,数组变化会导致索引失效)
    • 开启removeClippedSubviews减少内存占用

代码修改示例

import React, { useState, useCallback, useRef } from "react";
// ... 其他导入保持不变

export default function ChatScreen() {
  const [messages, setMessages] = useState([]);
  // 新增:维护正在接收的流式消息
  const [currentStreamingMsg, setCurrentStreamingMsg] = useState('');
  // 防抖定时器引用
  const debounceTimer = useRef(null);

  const startStreaming = () => {
    // 开始前清空当前流式消息
    setCurrentStreamingMsg('');
    const xhr = new XMLHttpRequest();
    xhr.open("POST", "https://example.com/stream", true);
    // 存储已接收的总文本,避免重复处理responseText
    let receivedText = '';

    xhr.onprogress = (event) => {
      const newText = event.currentTarget.responseText.slice(receivedText.length);
      receivedText = event.currentTarget.responseText;

      // 防抖:每200ms更新一次state,减少触发频率
      if (debounceTimer.current) clearTimeout(debounceTimer.current);
      debounceTimer.current = setTimeout(() => {
        setCurrentStreamingMsg(prev => prev + newText);
      }, 200);
    };

    xhr.onload = () => {
      // 流式结束,把完整消息加入messages
      if (currentStreamingMsg) {
        setMessages(prev => [...prev, currentStreamingMsg]);
        setCurrentStreamingMsg('');
      }
      clearTimeout(debounceTimer.current);
    };

    xhr.send(JSON.stringify({ prompt: "hello" }));
  };

  // ... handleCopy 后续修改
}

二、修复流式更新时复制按钮失效问题

核心原因

流式更新期间,频繁的state更新导致FlatList的所有渲染项频繁卸载重建,复制按钮的点击事件绑定被打断,无法正常响应。

解决方案

  1. 将代码块组件抽离为独立的memoized组件,避免每次渲染都重新创建组件,保留事件绑定
  2. 用useCallback包裹复制逻辑,确保函数引用稳定,不会因为重渲染失效
  3. 配合上面的流式消息合并优化,减少组件重渲染次数

代码修改示例

// 抽离独立的CodeBlock组件,用React.memo包裹
const CodeBlock = React.memo(({ code, language, handleCopy }) => {
  return (
    <View
      style={{
        marginVertical: 10,
        backgroundColor: "#1e1e1e",
        borderRadius: 8,
        padding: 8,
      }}
    >
      <ScrollView horizontal>
        <SyntaxHighlighter
          language={language}
          style={atomOneDark}
          highlighter="hljs"
        >
          {code}
        </SyntaxHighlighter>
      </ScrollView>

      <TouchableOpacity
        style={{
          marginTop: 6,
          padding: 6,
          backgroundColor: "#333",
          borderRadius: 6,
          alignSelf: "flex-start",
        }}
        onPress={() => handleCopy(code)}
      >
        <Text style={{ color: "white" }}>Copy</Text>
      </TouchableOpacity>
    </View>
  );
});

export default function ChatScreen() {
  // ... 其他state和方法

  // 用useCallback包裹handleCopy,确保引用稳定
  const handleCopy = useCallback(async (text) => {
    await Clipboard.setStringAsync(text);
  }, []);

  // 修改renderRules中的fence规则,使用CodeBlock组件
  const renderRules = {
    fence: (node, children, parent, styles) => {
      const language = node.info || "javascript";
      const code = node.content;

      return <CodeBlock key={node.key} code={code} language={language} handleCopy={handleCopy} />;
    },
  };

  // 用React.memo包装renderItem组件
  const renderItem = React.memo(({ item }) => (
    <Markdown
      markdownit={markdownItInstance}
      rules={renderRules}
    >
      {item}
    </Markdown>
  ));

  // 修改FlatList的数据源,包含已完成的消息和正在接收的消息
  const flatListData = [...messages, ...(currentStreamingMsg ? [currentStreamingMsg] : [])];

  return (
    <View style={{ flex: 1, padding: 10 }}>
      <FlatList
        data={flatListData}
        // 使用更稳定的key,比如结合消息内容的哈希或者唯一标识,这里简化用内容+索引
        keyExtractor={(item, index) => `${index}-${item.slice(0, 20)}`}
        renderItem={renderItem}
        // 开启裁剪不可见视图,优化性能
        removeClippedSubviews={true}
      />
      <Button title="Start Streaming" onPress={startStreaming} />
    </View>
  );
}

完整优化后的代码

import React, { useState, useCallback, useRef, memo } from "react";
import { View, Text, Button, FlatList, TouchableOpacity, ScrollView } from "react-native";
import * as Clipboard from "expo-clipboard";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
import SyntaxHighlighter from "react-native-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/styles/hljs";

const markdownItInstance = MarkdownIt({ typographer: true });

// 抽离独立的CodeBlock组件
const CodeBlock = memo(({ code, language, handleCopy }) => {
  return (
    <View
      style={{
        marginVertical: 10,
        backgroundColor: "#1e1e1e",
        borderRadius: 8,
        padding: 8,
      }}
    >
      <ScrollView horizontal>
        <SyntaxHighlighter
          language={language}
          style={atomOneDark}
          highlighter="hljs"
        >
          {code}
        </SyntaxHighlighter>
      </ScrollView>

      <TouchableOpacity
        style={{
          marginTop: 6,
          padding: 6,
          backgroundColor: "#333",
          borderRadius: 6,
          alignSelf: "flex-start",
        }}
        onPress={() => handleCopy(code)}
      >
        <Text style={{ color: "white" }}>Copy</Text>
      </TouchableOpacity>
    </View>
  );
});

export default function ChatScreen() {
  const [messages, setMessages] = useState([]);
  const [currentStreamingMsg, setCurrentStreamingMsg] = useState('');
  const debounceTimer = useRef(null);

  const startStreaming = () => {
    setCurrentStreamingMsg('');
    const xhr = new XMLHttpRequest();
    xhr.open("POST", "https://example.com/stream", true);
    let receivedText = '';

    xhr.onprogress = (event) => {
      const newText = event.currentTarget.responseText.slice(receivedText.length);
      receivedText = event.currentTarget.responseText;

      if (debounceTimer.current) clearTimeout(debounceTimer.current);
      debounceTimer.current = setTimeout(() => {
        setCurrentStreamingMsg(prev => prev + newText);
      }, 200);
    };

    xhr.onload = () => {
      if (currentStreamingMsg) {
        setMessages(prev => [...prev, currentStreamingMsg]);
        setCurrentStreamingMsg('');
      }
      clearTimeout(debounceTimer.current);
    };

    xhr.send(JSON.stringify({ prompt: "hello" }));
  };

  const handleCopy = useCallback(async (text) => {
    await Clipboard.setStringAsync(text);
  }, []);

  const renderRules = {
    fence: (node, children, parent, styles) => {
      const language = node.info || "javascript";
      const code = node.content;
      return <CodeBlock key={node.key} code={code} language={language} handleCopy={handleCopy} />;
    },
  };

  const renderItem = memo(({ item }) => (
    <Markdown
      markdownit={markdownItInstance}
      rules={renderRules}
    >
      {item}
    </Markdown>
  ));

  const flatListData = [...messages, ...(currentStreamingMsg ? [currentStreamingMsg] : [])];

  return (
    <View style={{ flex: 1, padding: 10 }}>
      <FlatList
        data={flatListData}
        keyExtractor={(item, index) => `${index}-${item.slice(0, 20)}`}
        renderItem={renderItem}
        removeClippedSubviews={true}
      />
      <Button title="Start Streaming" onPress={startStreaming} />
    </View>
  );
}

内容的提问来源于stack exchange,提问作者Anwar Shah

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.12 10:44:53