Ошибка сериализации в Redux: как организовать хранение файлов в React-приложении

0c3e022f5e60ae3bceb4b8b12d8799f4.png

Введение

Мы пишем онлайн-компилятор для отладки ИИ-сервисов. И статья — это скорее приглашение к обсуждению. Хочу поделиться практическим подходом, который показался простым и удобным в этом проекте. Ваши комментарии и критика будут очень полезны.

Компилятор выглядит достаточно привычно: в левой части экрана находится редактор с вкладками для файлов, а справа — поля для ввода и вывода данных от ИИ-сервисов. Пользователи могут создавать, загружать, скачивать, переименовывать и удалять файлы. Файлы также должны кэшироваться в браузере.

Структура файла в редакторе (псевдокод)

export default class UserFile {
    private id: string;
    private name: string;
    private fullName: string;
    private extension: TUserFileExtension;
    private downloadedState: TUserFileIsDownloaded;
    private content: TUserFileContent;
    private cachedState: TuserFileIsCached;

  // Методы сериализации
  public static fromSerializable(data: IUserFileSerializable): UserFile {}
  public toSerializable(): IUserFileSerializable {}

  // Методы для изменения и получения значений атрибутов файла.
  public setId(id: string): void {}
  public setName(name: string): void {}
  public setExtension(extension: TUserFileExtension): void {}
  public setDownloadedState(state: TUserFileIsDownloaded): void {}
  public setFullName(fullName: string): void {}
  public setContent(content: TUserFileContent): void {}
  public setCachedState(state: TUserFileIsCached): void {}

  // Методы для получения значений атрибутов файла.
  public getId(): string {}
  public getName(): string {}
  public getExtension() {}
  public getDownloadedState() {}
  public getFullName() {}
  public getContent() {}
  public getCachedState() {}

  // Вспомогательные методы для обработки имени файла и его расширения.
  private createFullName(name: string, extension: TUserFileExtension): string {}

  // Извлекает имя файла из полного имени.
  private getNameFromFullName(fullName: string): string {}
  
  // Извлекает расширение файла из полного имени.
  private getExtensionFromFullName(fullName: string): TUserFileExtension {}
}

Проблема

Изначально я отправлял в Redux экземляры класса UserFile и ни о чем не парился. Все работало замечательно. И все продолжало работать без сериализации. Мазолило глаз только куча ошибок A non-serializable value в консоли браузера.

При попытке сохранть в Redux экземпляр класса UserFile

При попытке сохранть в Redux экземпляр класса UserFile

В доке Redux описано, что мы можем и не сериализовывать данные, если нас не смущает регидратация и time-travel debugging. Почему же тогда при нарушении вылетает не Warning, а целая ошибка?

Тут стоит капнуть в философию Redux. И понять, что всё же нас должна смущать невозможность регидратации при отправке файлов на endpoint.

Основная цель Redux

Сделать состояние приложения предсказуемым и легко управляемым. В этом контексте сериализация состояния — это не просто техническое требование, а ключевая часть философии Redux. И вот почему Redux так строго относится к сериализуемости данных:

  1. Time-travel debugging: несериализуемые объекты (например, экземпляры классов) могут не воспроизводить состояние корректно при «перемотке», так как могут не сохранять методы и прототипы, нарушая предсказуемость.

  2. Регидратация и серверный рендеринг (SSR): состояние Redux часто сохраняется для последующего восстановления — например, между сессиями или при серверном рендеринге.

То есть в нашем случае, отправленные файлы на endpoint, могут привести к ошибке.

Если состояние содержит несериализуемые (объект с примитивными типами) объекты, его сложно или невозможно восстановить корректно.

То есть риск потерять функции класса UserFile достаточно велик.

Отключение проверки сериализации

Вы можете, конечно, все же проигнорировать требования и отключить проверку, добавив middleware:

import { configureStore } from '@reduxjs/toolkit';

const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: false, // отключаем проверку сериализуемости
    }),
});

Решение кейса

В нашем кейсе я все-таки сериализовал данные. И выглядит это следующим образом:

У нас есть класс UserFile, отвечающий за файл и его контент (описан в начале статьи). И есть класс FilesService, отвечающий за работу со множеством файлов, а также, архивирование и разархивирование файлов. Ниже приведены методы классов и их использование в компонентах и Redux.

Интерфейс сериализованного файла

export interface IUserFileSerializable {
    id: string;
    name: string;
    fullName: string;
    extension: TUserFileExtension; // string
    downloadedState: TUserFileIsDownloaded; // boolean
    content: TUserFileContent; // string
    cachedState: TUserFileIsCached; // boolean
}

Класс UserFile

// Метод для создания объекта UserFile из сериализованных данных,
// используемых для хранения в Redux.
public static fromSerializable(data: IUserFileSerializable): UserFile {
    return new UserFile(
      data.id,
      data.name,
      data.extension,
      data.downloadedState,
      data.content,
      data.cachedState
    );
}

// Метод для преобразования UserFile в формат, который можно
// сохранить в Redux. Возвращает объект интерфейса IUserFileSerializable.
public toSerializable(): IUserFileSerializable {
    return {
      id: this.id,
      name: this.name,
      fullName: this.fullName,
      extension: this.extension,
      downloadedState: this.downloadedState,
      content: this.content,
      cachedState: this.cachedState,
    };
}

Класс FilesService

// Сериализация
public static toSerializableFiles(files: TUserFiles): IUserFileSerializable[] {
    return files.map(file => file.toSerializable());
}

// Десериализация
public static fromSerializableFiles(serializedFiles: IUserFileSerializable[]): TUserFiles {
    return serializedFiles.map(fileData => UserFile.fromSerializable(fileData));
}

Слайс файлов в Redux

const initialState: IUserFilesSlice = {
    files: files,
    currentFileId: initialCurrentFileKey,
};

export const filesSlice: Slice = createSlice({
    name: "projectFiles",
    initialState,
    reducers: {
        setCurrentFileId: (state, action: PayloadAction) => {
          // Устанавливает текущий идентификатор файла.
          state.currentFileId = action.payload;
        },
      
        updateFile: (state, action: PayloadAction) => {
          if (!state.files) {
              return;
          }
          // Обновляет данные файла в массиве файлов по его идентификатору.
          const index = state.files.findIndex((file: IUserFileSerializable) => file.id === action.payload.id);
          if (index !== -1) {
              state.files[index] = action.payload;
          }
        },
      
        addFile: (state, action: PayloadAction) => {
          // Добавляет новый файл в массив файлов.
          state.files.push(action.payload);
        },
      
        removeFile: (state, action: PayloadAction) => {
          if (!state.files) {
              return;
          }
          // Удаляет файл из массива по его идентификатору.
          state.files = state.files.filter((file: IUserFileSerializable) => file.id !== action.payload);
        },
      
        replaceFiles: (state, action: PayloadAction) => {
          // Заменяет весь массив файлов новыми данными.
          state.files = action.payload;
        },
      
        deleteAllFiles: (state) => {
          // Удаляет все файлы из состояния.
          state.files = [];
        },
    },
});

Использование файлов в компонентах

const serializedFiles = useSelector(
  (state: TRootState) => state.projectFiles.files
);

// Преобразуем сериализованные файлы обратно в объекты UserFile.
const [files, setFiles] = useState(
  FilesService.fromSerializableFiles(serializedFiles)
);

Мутация файлов из компонентов

const addFilesToWorkspace = (files: TUserFiles): void => {
  const serializableFiles: IUserFileSerializable[] =
        FilesService.toSerializableFiles(files);
  // Отправляем сериализованные файлы в Redux
  dispatch(replaceFiles(serializableFiles));
};

Заключение

В этом подходе сериализация и десериализация позволили сохранить данные в Redux в подходящем формате, не жертвуя гибкостью и возможностью работы с полноценными объектами класса. Если вам знакомы подобные ситуации, делитесь опытом в комментариях — буду рад обсудить альтернативные решения!

© Habrahabr.ru