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

带分页的React图片查看器选择卡顿问题及优化请求

React图片网格组件性能优化:解决大数量图片选择卡顿问题

问题背景

开发了一个带分页的图片网格React组件,需求包括:

  • 分页展示,每页150张图片,支持上/下页切换
  • 支持单选、Ctrl/Command键多选单个、Shift键多选连续图片
  • 选中范围覆盖所有上传文件,不局限于当前页

但上传500张及以上图片时,图片选择操作卡顿,点击后需3-4秒才能完成选中,核心问题出在handleClickOnGridItem及相关状态管理的性能瓶颈上。

性能瓶颈分析

  1. 数组操作效率低下:用string[]存储选中文件名,每次includes、filter都是O(n)时间复杂度,选中数量越多,操作越慢。
  2. Shift多选逻辑冗余:每次通过displayedFiles.findIndex查找选中项,不仅遍历当前页元素,还存在跨页时无法正确定位的问题,额外增加性能开销。
  3. 重渲染时重复计算:每张图片的选中状态isSelected通过selectedFilenames.includes(file.name)实时计算,每次状态更新都要遍历整个选中数组,150张图片就是150次O(n)操作。
  4. 无唯一Key导致DOM复用失效:img元素没有设置唯一key,React重渲染时无法复用现有DOM节点,强制重新创建元素,加剧卡顿。

优化方案

1. 改用Set存储选中状态

将selectedFilenames从数组改为Set<string>,利用Set的has、add、delete操作都是O(1)的特性,大幅提升选中判断和更新效率。

2. 记录全局索引优化Shift多选

新增lastSelectedGlobalIndex状态,记录上一次选中的全局文件索引,Shift多选时直接基于全局索引计算范围,避免跨页查找的性能损耗和逻辑错误。

3. 缓存选中状态计算

用useMemo缓存当前页所有图片的选中状态映射,避免每次渲染重复计算。

4. 给元素添加唯一Key

为每张图片设置唯一key(结合文件名、大小、修改时间,避免重名文件冲突),让React能复用DOM节点,减少重渲染开销。

5. 优化点击事件逻辑

简化handleClickOnGridItem中的状态更新逻辑,利用Set的特性减少数组拷贝和遍历操作。

优化后的完整代码

import {
  useState,
  useMemo,
  ChangeEvent,
  CSSProperties,
  MouseEvent,
} from 'react';

function App() {
  const [files, setFiles] = useState<File[]>([]);
  const [startIndex, setStartIndex] = useState(0);
  // 改用Set存储选中的唯一标识,提升操作效率
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
  // 记录上一次选中的全局索引,用于Shift多选
  const [lastSelectedGlobalIndex, setLastSelectedGlobalIndex] = useState<number | null>(null);

  const imagesPerPage = 150;

  const endIndex = useMemo(() => {
    return Math.min(startIndex + imagesPerPage, files.length);
  }, [files, startIndex]);

  const displayedFiles = useMemo(() => {
    return files.slice(startIndex, endIndex);
  }, [files, startIndex]);

  // 生成文件的唯一标识,避免重名文件冲突
  const getFileId = (file: File) => {
    return `${file.name}-${file.size}-${file.lastModified}`;
  };

  // 缓存当前页图片的选中状态映射
  const displayedFileSelectionMap = useMemo(() => {
    const map = new Map<string, boolean>();
    displayedFiles.forEach(file => {
      map.set(getFileId(file), selectedIds.has(getFileId(file)));
    });
    return map;
  }, [displayedFiles, selectedIds]);

  const handlePrevClick = () => {
    if (startIndex - imagesPerPage >= 0) {
      setStartIndex(startIndex - imagesPerPage);
    }
  };

  const handleNextClick = () => {
    if (endIndex < files.length) {
      setStartIndex(startIndex + imagesPerPage);
    }
  };

  const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    const newFiles = Array.from(e.target.files!);
    setFiles(newFiles);
    // 上传新文件时重置选中状态
    setSelectedIds(new Set());
    setLastSelectedGlobalIndex(null);
  };

  const handleClickOnGridItem = (
    localIndex: number,
    e: MouseEvent<HTMLImageElement, globalThis.MouseEvent>
  ) => {
    const globalIndex = startIndex + localIndex;
    const file = files[globalIndex];
    const fileId = getFileId(file);

    if (e.ctrlKey || e.metaKey) {
      setSelectedIds(prev => {
        const newSet = new Set(prev);
        if (newSet.has(fileId)) {
          newSet.delete(fileId);
        } else {
          newSet.add(fileId);
        }
        setLastSelectedGlobalIndex(globalIndex);
        return newSet;
      });
    } else if (e.shiftKey) {
      if (lastSelectedGlobalIndex === null) {
        // 第一次Shift点击,选中当前项
        const newSet = new Set<string>();
        newSet.add(fileId);
        setSelectedIds(newSet);
        setLastSelectedGlobalIndex(globalIndex);
      } else {
        // 计算全局范围
        const start = Math.min(lastSelectedGlobalIndex, globalIndex);
        const end = Math.max(lastSelectedGlobalIndex, globalIndex);
        const newSet = new Set<string>();
        for (let i = start; i <= end; i++) {
          newSet.add(getFileId(files[i]));
        }
        setSelectedIds(newSet);
      }
    } else {
      // 单选,重置选中状态
      const newSet = new Set<string>();
      newSet.add(fileId);
      setSelectedIds(newSet);
      setLastSelectedGlobalIndex(globalIndex);
    }
  };

  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        alignItems: 'center',
      }}
    >
      <input
        type='file'
        className='mt-3'
        id='singleFileInput'
        title='Upload an image'
        accept='image/*'
        multiple
        onChange={handleFileInputChange}
      />
      <div
        style={{
          display: 'flex',
          flexDirection: 'row',
          justifyContent: 'evenly',
          alignItems: 'center',
          width: '25vw',
          marginLeft: 'auto',
        }}
      >
        <button onClick={handlePrevClick}> Prev </button>
        <button onClick={handleNextClick}> Next </button>
      </div>
      <div
        style={{
          display: 'flex',
          flexDirection: 'row',
          justifyContent: 'flex-start',
          alignItems: 'center',
          flexWrap: 'wrap',
        }}
      >
        {displayedFiles.map((file, localIndex) => {
          const fileId = getFileId(file);
          const isSelected = displayedFileSelectionMap.get(fileId) || false;
          const maxHeight = 80;
          const margin = 2;
          const imgStyle: CSSProperties = {
            marginLeft: margin,
            marginTop: margin,
            maxHeight,
            opacity: isSelected ? 1 : 0.5,
            cursor: 'pointer'
          };

          return (
            <img
              key={fileId}
              style={imgStyle}
              src={URL.createObjectURL(file)}
              onClick={(e) => handleClickOnGridItem(localIndex, e)}
              alt={file.name}
            />
          );
        })}
      </div>
    </div>
  );
}

export default App;

关键改动说明

  • 选中状态存储:从string[]改为Set<string>,所有选中判断和更新操作从O(n)降为O(1)。
  • 全局索引跟踪:通过lastSelectedGlobalIndex记录上一次选中的全局位置,Shift多选时直接计算全局范围,无需跨页查找,同时解决了跨页Shift多选的逻辑问题。
  • 选中状态缓存:用useMemo生成当前页的选中状态映射,避免每次渲染重复计算每张图片的选中状态。
  • 唯一Key:为img元素设置基于文件唯一标识的key,确保React能复用DOM节点,减少重渲染开销。
  • 简化逻辑:重构handleClickOnGridItem,利用Set的特性简化选中/取消选中的逻辑,减少不必要的数组操作。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.22 17:15:00