Ошибка сериализации в Redux: как организовать хранение файлов в React-приложении
Введение
Мы пишем онлайн-компилятор для отладки ИИ-сервисов. И статья — это скорее приглашение к обсуждению. Хочу поделиться практическим подходом, который показался простым и удобным в этом проекте. Ваши комментарии и критика будут очень полезны.
Компилятор выглядит достаточно привычно: в левой части экрана находится редактор с вкладками для файлов, а справа — поля для ввода и вывода данных от ИИ-сервисов. Пользователи могут создавать, загружать, скачивать, переименовывать и удалять файлы. Файлы также должны кэшироваться в браузере.
Структура файла в редакторе (псевдокод)
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 описано, что мы можем и не сериализовывать данные, если нас не смущает регидратация и time-travel debugging. Почему же тогда при нарушении вылетает не Warning
, а целая ошибка?
Тут стоит капнуть в философию Redux. И понять, что всё же нас должна смущать невозможность регидратации при отправке файлов на endpoint
.
Основная цель Redux
Сделать состояние приложения предсказуемым и легко управляемым. В этом контексте сериализация состояния — это не просто техническое требование, а ключевая часть философии Redux. И вот почему Redux так строго относится к сериализуемости данных:
Time-travel debugging: несериализуемые объекты (например, экземпляры классов) могут не воспроизводить состояние корректно при «перемотке», так как могут не сохранять методы и прототипы, нарушая предсказуемость.
Регидратация и серверный рендеринг (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 в подходящем формате, не жертвуя гибкостью и возможностью работы с полноценными объектами класса. Если вам знакомы подобные ситуации, делитесь опытом в комментариях — буду рад обсудить альтернативные решения!