Skip to content

[hook]: Create useFileReader hook #493

Description

@super4el2005

useFileReader - хук для чтения файлов

Категория: Browser / Async

Мотивация

Стандартные способы прочитать File/Blob в браузере — FileReader.readAsText/readAsArrayBuffer или промис-методы blob.text()/blob.arrayBuffer() — читают файл целиком за один раз. Для больших файлов (видео, дампы БД, датасеты, логи) это создаёт несколько проблем:

  • весь файл разом попадает в память как одна строка/буфер, что может уронить вкладку на файлах в сотни МБ-ГБ;
  • нет прогресса по мере чтения — только событие onload в самом конце;
  • невозможно обработать файл потоково (посчитать инкрементальный хэш, распарсить CSV/NDJSON построчно, провалидировать содержимое) без предварительной полной загрузки в память;
  • нет встроенной паузы/резюма/отмены на середине чтения;
  • нет ретраев отдельного фрагмента при сбое обработки.

File/Blob поддерживают .slice(start, end), что позволяет читать/обрабатывать файл кусками (chunk'ами) произвольного размера, но каждый раз писать этот цикл руками (slice → decode → await callback → следующий slice, плюс пауза/отмена/ретраи) — рутина, которую хочется закрыть один раз хуком.

Дополнительный мотив: такой хук может стать общим фундаментом для более специфичных сценариев — например, для загрузки больших файлов чанками на бэкенд (см. предложение useChunkedFileUpload). Чтобы это работало, useFileReader должен уметь отдавать в колбэк сырой Blob-чанк (без обязательной декодизации в текст/ArrayBuffer) и поддерживать параллельную обработку чанков (concurrency) и ретраи — тогда useChunkedFileUpload можно будет реализовать как тонкую обёртку над этим хуком, а не как отдельную реализацию с дублированной логикой нарезки/очереди/паузы.

Предлагаемое API

export type FileReaderDataType = 'ArrayBuffer' | 'Text' | 'DataURL';

export type FileReaderChunk<DataType extends FileReaderDataType | undefined> = DataType extends 'ArrayBuffer'
  ? ArrayBuffer
  : DataType extends 'Text' | 'DataURL'
    ? string
    : Blob;

export interface UseFileReaderOptions<DataType extends FileReaderDataType | undefined = undefined> {
  /** Размер одного чанка в байтах */
  chunkSize?: number;
  /** Количество чанков, обрабатываемых параллельно (1 = последовательно) */
  concurrency?: number;
  /** Количество повторных попыток на чанк перед тем, как считать его ошибкой */
  retries?: number;
  /** Как декодировать сырой Blob-чанк перед передачей в onChunk. Без указания в колбэк приходит сырой Blob (удобно, если чанк надо просто переслать дальше, например в сеть) */
  dataType?: DataType;
  /** Обрабатывает один чанк. Может быть async - следующий чанк (или следующая партия при concurrency > 1) стартует по мере освобождения слотов */
  onChunk: (
    chunk: FileReaderChunk<DataType>,
    meta: { index: number; offset: number; size: number; totalChunks: number; totalBytes: number; signal: AbortSignal }
  ) => void | Promise<void>;
  /** Вызывается после каждого успешно обработанного чанка (для прогресса/сохранения состояния) */
  onChunkComplete?: (index: number, totalChunks: number) => void;
  /** Вызывается один раз, когда все чанки успешно обработаны */
  onComplete?: () => void;
  /** Вызывается, если чанк не обработался после исчерпания ретраев */
  onError?: (error: unknown, index: number) => void;
}

export interface UseFileReaderReturn {
  /** 'idle' | 'reading' | 'paused' | 'error' | 'done' | 'aborted' */
  status: 'idle' | 'reading' | 'paused' | 'error' | 'done' | 'aborted';
  /** Общий прогресс, 0-100 */
  progress: number;
  /** Сколько байт уже обработано */
  bytesRead: number;
  /** Полный размер файла в байтах */
  totalBytes: number;
  /** Запускает чтение/обработку */
  start: () => void;
  /** Приостанавливает после текущего(их) в-полёте чанка(ов) */
  pause: () => void;
  /** Продолжает с места остановки */
  resume: () => void;
  /** Прерывает чтение и сбрасывает прогресс */
  abort: () => void;
  /** Повторяет конкретный упавший чанк (или все упавшие, если index не передан) */
  retry: (index?: number) => void;
}

export interface UseFileReader {
  (file: File | Blob, options: UseFileReaderOptions<undefined>): UseFileReaderReturn;
  (file: File | Blob, options: UseFileReaderOptions<'ArrayBuffer'>): UseFileReaderReturn;
  (file: File | Blob, options: UseFileReaderOptions<'Text' | 'DataURL'>): UseFileReaderReturn;
}

export const useFileReader = ((file: File | Blob, options: UseFileReaderOptions<any>): UseFileReaderReturn => {
  /* ... */
}) as UseFileReader;

Пример использования

// Инкрементальный подсчёт SHA-256 большого файла без загрузки его целиком в память
function FileHashDemo() {
  const [file, setFile] = useState<File | null>(null);
  const [hash, setHash] = useState<string | null>(null);

  const { open } = useFileDialog((files) => setFile(files?.[0] ?? null), { multiple: false });

  const chunksRef = useRef<ArrayBuffer[]>([]);

  const { status, progress, start, pause, resume, abort } = useFileReader(file!, {
    chunkSize: 8 * 1024 * 1024, // 8MB
    dataType: 'ArrayBuffer',
    onChunk: (chunk) => {
      chunksRef.current.push(chunk);
    },
    onComplete: async () => {
      const full = await new Blob(chunksRef.current).arrayBuffer();
      const digest = await crypto.subtle.digest('SHA-256', full);
      setHash([...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''));
    }
  });

  return (
    <div>
      <button onClick={() => open()}>Выбрать файл</button>
      {file && (
        <div>
          <button onClick={start} disabled={status === 'reading'}>Старт</button>
          <button onClick={pause} disabled={status !== 'reading'}>Пауза</button>
          <button onClick={resume} disabled={status !== 'paused'}>Продолжить</button>
          <button onClick={abort}>Отмена</button>
          <progress value={progress} max={100} />
          <span>{status}</span>
          {hash && <div>SHA-256: {hash}</div>}
        </div>
      )}
    </div>
  );
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions