JavaScript: практическое руководство по Blob, File API и оптимизации памяти
- четверг, 8 января 2026 г. в 00:00:06
В современных фронтенд-приложениях работа с файлами встречается постоянно: загрузка изображений, экспорт CSV, превью и интерактивные редакторы. Но когда файлы увеличиваются в размере или их количество растет, начинаются проблемы: интерфейс подвисает, расход памяти увеличивается, а браузер иногда просто падает.
В этом руководстве мы разберем шесть практических приемов работы с Blob, которые помогают обрабатывать файлы эффективно и безопасно:
правильное создание Blob
разбивка больших файлов на части (chunks)
сжатие и конвертация изображений
реализация надежных превью файлов
экспорт данных в виде загружаемых файлов
управление памятью во избежание утечек Blob URL
Цель руководства — сделать работу с файлами быстрой, стабильной и готовой к продакшну.
Прим. пер.: набросал несколько форм на React, в которых используются некоторые функции и классы из статьи.
Многие разработчики начинают с работы с огромными строками или base64-URL, что приводит к:
дублированию данных в памяти
резкому увеличению использования кучи (heap)
замедлению интерфейса
// ❌ Плохо: длинная строка + data URL = удвоенное потребление памяти
const hugeText = 'Very long text...'.repeat(100_000);
const dataUrl =
'data:text/plain;charset=utf-8,' + encodeURIComponent(hugeText);
Blob — это легкая оболочка для бинарных данных. Браузер может передавать их потоками, делить на части и создавать URL без копирования всего содержимого.
/**
* Безопасное создание Blob из частей данных.
*/
const createBlob = (
parts: BlobPart[],
options: BlobPropertyBag = {}
): Blob => {
return new Blob(parts, {
type: options.type ?? 'text/plain',
endings: options.endings ?? 'transparent',
});
};
// Текстовый Blob
const messageBlob = createBlob(['Hello, Blob!'], {
type: 'text/plain',
});
// JSON Blob
const userProfile = { name: 'Alex', age: 29 };
const profileBlob = createBlob([JSON.stringify(userProfile)], {
type: 'application/json',
});
// HTML Blob
const htmlSnippet = '<h1>Dynamically Generated HTML</h1>';
const htmlBlob = createBlob([htmlSnippet], { type: 'text/html' });
const downloadConfig = (config: unknown) => {
const serialized = JSON.stringify(config, null, 2);
const blob = new Blob([serialized], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'config.json';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
Основные идеи
Blob оборачивают данные без их копирования
всегда указываем правильный MIME-тип (например, application/json, image/jpeg)
используем URL.createObjectURL(blob), чтобы получить быстрый URL, удобный для работы с потоками (streams)
Попытка прочитать двухгигабайтный лог-файл одним вызовом FileReader.readAsText() может привести к:
зависанию вкладки браузера
превышению лимитов памяти
сбою браузера
// ❌ Плохо: загрузка всего файла в память
const processLargeFile = (file: File) => {
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
// Огромная строка в памяти
handleWholeFile(text);
};
reader.readAsText(file);
};
/**
* Обрабатываем файл порциями фиксированного размера, чтобы избежать исчерпания памяти.
*/
const processFileInChunks = async (
file: File,
chunkSize = 1024 * 1024,
onProgress?: (info: {
current: number;
total: number;
percentage: number;
}) => void
) => {
const totalChunks = Math.ceil(file.size / chunkSize);
const results: unknown[] = [];
for (let index = 0; index < totalChunks; index++) {
const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const result = await readChunk(chunk, index);
results.push(result);
onProgress?.({
current: index + 1,
total: totalChunks,
percentage: Math.round(((index + 1) / totalChunks) * 100),
});
}
return results;
};
const readChunk = (
chunk: Blob,
index: number
): Promise<{ index: number; size: number; sample: string }> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
resolve({
index,
size: chunk.size,
sample: text.slice(0, 100), // Первые 100 символов как пример
});
};
reader.onerror = () => reject(reader.error);
reader.readAsText(chunk);
});
};
class ChunkedUploader {
private file: File;
private chunkSize: number;
private uploadUrl: string;
private onProgress?: (info: {
uploaded: number;
total: number;
percentage: number;
}) => void;
constructor(file: File, options: {
// Размер части файла
chunkSize?: number;
// URL для загрузки части файла
uploadUrl: string;
// Функция отслеживания процесса загрузки файла
onProgress?: (info: { uploaded: number; total: number; percentage: number }) => void;
}) {
this.file = file;
this.chunkSize = options.chunkSize ?? 2 * 1024 * 1024; // 2 Mб по умолчанию
this.uploadUrl = options.uploadUrl;
this.onProgress = options.onProgress;
}
// Метод загрузки файла
async upload() {
// Общее количество частей
const totalChunks = Math.ceil(this.file.size / this.chunkSize);
// Уникальный идентификатор
const uploadId = this.createUploadId();
// Загрузка файла по частям
for (let index = 0; index < totalChunks; index++) {
const start = index * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
const chunk = this.file.slice(start, end);
await this.uploadChunk(chunk, index, uploadId);
this.onProgress?.({
uploaded: index + 1,
total: totalChunks,
percentage: Math.round(((index + 1) / totalChunks) * 100),
});
}
// Объединение частей и возврат файла
return this.mergeChunks(uploadId, totalChunks);
}
// Метод загрузки части файла
private async uploadChunk(chunk: Blob, index: number, uploadId: string) {
const body = new FormData();
body.append('chunk', chunk);
body.append('index', String(index));
body.append('uploadId', uploadId);
const response = await fetch(this.uploadUrl, { method: 'POST', body });
if (!response.ok) {
throw new Error(`Chunk ${index} upload failed`);
}
}
// Метод объединения частей файла
private async mergeChunks(uploadId: string, totalChunks: number) {
const response = await fetch('/api/merge-chunks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, totalChunks }),
});
if (!response.ok) {
throw new Error('Failed to merge chunks');
}
return response.json();
}
// Метод формирования уникального идентификатора
private createUploadId() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
}
}
Прямая отправка необработанных 8 Мб фото с телефонов или камер:
расходует лишнюю пропускную способность
замедляет загрузку
ощутимо замедляет работу приложения
// ❌ Без сжатия: большие файлы загружаются долго, пользовательский опыт ухудшается
const uploadOriginalImage = (file: File) => {
const body = new FormData();
body.append('image', file);
return fetch('/upload', { method: 'POST', body });
};
и Blob
interface CompressionOptions {
maxWidth?: number;
maxHeight?: number;
quality?: number;
outputType?: string;
}
const compressImage = (
file: File,
options: CompressionOptions = {}
): Promise<Blob> => {
const {
maxWidth = 1920,
maxHeight = 1080,
quality = 0.8,
outputType = 'image/jpeg',
} = options;
return new Promise((resolve, reject) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas 2D context not available'));
return;
}
img.onload = () => {
const { width, height } = fitIntoBox(
img.width,
img.height,
maxWidth,
maxHeight
);
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
if (blob) resolve(blob);
else reject(new Error('Failed to compress image'));
},
outputType,
quality
);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = URL.createObjectURL(file);
});
};
const fitIntoBox = (
originalWidth: number,
originalHeight: number,
maxWidth: number,
maxHeight: number
) => {
let width = originalWidth;
let height = originalHeight;
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
if (height > maxHeight) {
width = (width * maxHeight) / height;
height = maxHeight;
}
return { width: Math.round(width), height: Math.round(height) };
};
class AvatarService {
private maxSize: number;
private quality: number;
constructor(options?: { maxSize?: number; quality?: number }) {
// Максимальный размер - ширина и высота
this.maxSize = options?.maxSize ?? 200;
// Качество
this.quality = options?.quality ?? 0.9;
}
// Метод подготовки аватара к загрузке
async prepareAvatar(file: File) {
if (!this.isSupportedType(file)) {
throw new Error('Please choose a valid image file');
}
const compressed = await compressImage(file, {
maxWidth: this.maxSize,
maxHeight: this.maxSize,
quality: this.quality,
outputType: 'image/jpeg',
});
const previewUrl = URL.createObjectURL(compressed);
return {
blob: compressed,
previewUrl,
originalSize: file.size,
compressedSize: compressed.size,
// Процент сжатия
compressionRatio: Math.round(
(1 - compressed.size / file.size) * 100
),
};
}
// Метод загрузки аватара
async uploadAvatar(blob: Blob) {
const body = new FormData();
body.append('avatar', blob, 'avatar.jpg');
const response = await fetch('/api/upload-avatar', {
method: 'POST',
body,
});
if (!response.ok) {
throw new Error('Failed to upload avatar');
}
return response.json();
}
// Метод определения поддерживаемого типа файла
private isSupportedType(file: File) {
return ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(
file.type
);
}
}
Иногда требуется показать превью:
изображений
текста / JSON
аудио / видео
PDF или "неизвестных" файлов
Если делать это отдельно для каждого типа файлов, код получается громоздким и повторяющимся.
class FilePreviewer {
private container: HTMLElement;
private textEncoding: string;
constructor(container: HTMLElement, options?: { textEncoding?: string }) {
// Контейнер для превью
this.container = container;
// Кодировка текста
this.textEncoding = options?.textEncoding ?? 'utf-8';
}
// Общий метод превью
async preview(file: File | Blob) {
this.container.innerHTML = '';
const type = this.detectType(file);
switch (type) {
case 'image':
return this.renderImage(file);
case 'text':
return this.renderText(file);
case 'audio':
return this.renderAudio(file);
case 'video':
return this.renderVideo(file);
case 'pdf':
return this.renderPdfPlaceholder(file);
default:
return this.renderUnknown(file);
}
}
// Метод определения типа файла
private detectType(file: File | Blob) {
const mime = (file as File).type?.toLowerCase();
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('text/') || mime === 'application/json') return 'text';
if (mime.startsWith('audio/')) return 'audio';
if (mime.startsWith('video/')) return 'video';
if (mime === 'application/pdf') return 'pdf';
return 'unknown';
}
// Превью изображения
private async renderImage(file: File | Blob) {
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.src = url;
img.style.maxWidth = '100%';
img.style.maxHeight = '480px';
img.style.objectFit = 'contain';
img.onload = () => URL.revokeObjectURL(url);
this.container.appendChild(img);
}
// Превью текста
private async renderText(file: File | Blob) {
const content = await this.readAsText(file);
const pre = document.createElement('pre');
pre.textContent =
content.length > 10_000
? content.slice(0, 10_000) +
'
... (truncated to 10,000 characters)'
: content;
pre.style.whiteSpace = 'pre-wrap';
pre.style.maxHeight = '400px';
pre.style.overflow = 'auto';
pre.style.padding = '10px';
pre.style.background = '#f5f5f5';
this.container.appendChild(pre);
}
// Превью аудио
private async renderAudio(file: File | Blob) {
const url = URL.createObjectURL(file);
const audio = document.createElement('audio');
audio.controls = true;
audio.src = url;
// Прим. пер.: onloadmetadata срабатывает до загрузки всего файла,
// вызов revokeObjectURL() в этот момент делает невозможным его воспроизведение.
// Я бы сделал так:
// audio.onload = () => URL.revokeObjectURL(url);
audio.onloadedmetadata = () => URL.revokeObjectURL(url);
this.container.appendChild(audio);
}
// Превью видео
private async renderVideo(file: File | Blob) {
const url = URL.createObjectURL(file);
const video = document.createElement('video');
video.controls = true;
video.style.maxWidth = '100%';
video.style.maxHeight = '400px';
video.src = url;
// Та же проблема, что с аудио
video.onloadedmetadata = () => URL.revokeObjectURL(url);
this.container.appendChild(video);
}
// Превью PDF
private renderPdfPlaceholder(file: File | Blob) {
const box = document.createElement('div');
box.style.padding = '32px';
box.style.textAlign = 'center';
box.innerHTML = `
<p>PDF preview placeholder</p>
<p>Type: ${(file as File).type || 'application/pdf'}</p>
`;
this.container.appendChild(box);
}
// Превью файла неизвестного типа
private renderUnknown(file: File | Blob) {
const box = document.createElement('div');
box.style.padding = '32px';
box.style.textAlign = 'center';
box.innerHTML = `
<p>Preview is not available for this file type.</p>
<p>Name: ${(file as File).name ?? 'unknown'}</p>
`;
this.container.appendChild(box);
}
// Метод чтения текстового файла
private readAsText(file: File | Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) =>
resolve((event.target?.result as string) ?? '');
reader.onerror = () => reject(reader.error);
reader.readAsText(file, this.textEncoding);
});
}
}
Применение data URL для экспорта большого объема данных:
дублирует содержимое в памяти
ограничено по длине URL
не работает с большими наборами данных
class DownloadService {
// Метод скачивания JSON
downloadJson(data: unknown, fileName = 'data.json', pretty = true) {
const serialized = pretty
? JSON.stringify(data, null, 2)
: JSON.stringify(data);
const blob = new Blob([serialized], {
type: 'application/json',
});
this.triggerDownload(blob, fileName);
}
// Метод скачивания CSV
downloadCsv(
rows: Record<string, unknown>[],
fileName = 'data.csv',
headers?: string[]
) {
if (!rows.length) return;
const headerRow = headers ?? Object.keys(rows[0]);
const lines: string[] = [];
lines.push(headerRow.join(','));
for (const row of rows) {
const values = headerRow.map((key) => {
const raw = row[key];
const text =
raw === null || raw === undefined ? '' : String(raw);
// Экранируем запятые и кавычки
if (/[",]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
});
lines.push(values.join(','));
}
// Добавляем BOM для UTF-8 для корректного отображения в Excel
const BOM = '�';
// Прим. пер.: так в оригинале, но, кажется, должно быть lines.join('\n')
const blob = new Blob([BOM + lines.join('')], {
type: 'text/csv;charset=utf-8',
});
this.triggerDownload(blob, fileName);
}
// Метод скачивания текста
downloadText(
text: string,
fileName = 'data.txt',
mimeType = 'text/plain'
) {
const blob = new Blob([text], { type: mimeType });
this.triggerDownload(blob, fileName);
}
// Метод скачивания файла
private triggerDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
}
Каждый вызов URL.createObjectURL(blob) потребляет ресурсы. Если никогда не вызывать URL.revokeObjectURL(url):
растет использование памяти
долгоживущие приложения постепенно деградируют
редакторы изображений и файловые менеджеры сильно расходуют память
class BlobUrlManager {
// Ссылки, созданные с помощью createObjectUrl()
private urls = new Map<string, { blob: Blob; createdAt: number }>();
// Срок жизни ссылок
private maxAgeMs = 5 * 60 * 1000; // 5 минут
constructor() {
// Проверка срока жизни ссылок каждую минуту
setInterval(() => this.cleanupExpired(), 60_000);
window.addEventListener('beforeunload', () => this.cleanupAll());
}
// Метод создания ссылки
createUrl(blob: Blob): string {
const url = URL.createObjectURL(blob);
this.urls.set(url, {
// Прим. пер.: так в оригинале, не очень понятно,
// зачем сохранять blob, если он нигде не используется
blob,
createdAt: Date.now(),
});
return url;
}
// Метод высвобождения ресурсов, выделенных для ссылки
revokeUrl(url: string) {
if (this.urls.has(url)) {
URL.revokeObjectURL(url);
this.urls.delete(url);
}
}
// Метод создания и автоматического уничтожения ссылки через 30 секунд
createAutoUrl(blob: Blob, timeoutMs = 30_000) {
const url = this.createUrl(blob);
setTimeout(() => this.revokeUrl(url), timeoutMs);
return url;
}
// Метод очистки по интервалу
cleanupExpired() {
const now = Date.now();
for (const [url, entry] of this.urls.entries()) {
if (now - entry.createdAt > this.maxAgeMs) {
this.revokeUrl(url);
}
}
}
// Метод мгновенной очистки, например, перед закрытием вкладки
cleanupAll() {
for (const url of this.urls.keys()) {
URL.revokeObjectURL(url);
}
this.urls.clear();
}
}
class ManagedImagePreview {
private container: HTMLElement;
// Прим. пер.: дублирование переменной для активных ссылок необходимо
// для очистки (clear() ниже) в случае, когда blobUrlManager
// используется где-то еще: вызов blobUrlManager.cleanupAll()
// удалит все активные ссылки, независимо от их принадлежности
// этому классу
private activeUrls = new Set<string>();
constructor(container: HTMLElement) {
this.container = container;
}
show(file: File | Blob) {
this.clear();
const url = blobUrlManager.createUrl(file);
this.activeUrls.add(url);
const img = document.createElement('img');
img.src = url;
img.style.maxWidth = '100%';
img.style.maxHeight = '400px';
img.onerror = () => {
blobUrlManager.revokeUrl(url);
this.activeUrls.delete(url);
};
this.container.innerHTML = '';
this.container.appendChild(img);
}
clear() {
for (const url of this.activeUrls) {
blobUrlManager.revokeUrl(url);
}
this.activeUrls.clear();
this.container.innerHTML = '';
}
}
Используем Blob, если нужно:
создавать файлы на клиенте (JSON, CSV, изображения)
обрабатывать большие файлы по частям, не загружая их в память целиком
реализовать сжатие или конвертацию изображений
обеспечить единый опыт превью файлов
организовать безопасную загрузку или экспорт больших наборов данных
заботиться о долгоживущих приложениях и предотвращении утечек памяти
Blob вместе с File API и URL.createObjectURL() — основа надежной работы с файлами на клиенте. Освоив их, большие загрузки, превью и экспорты перестанут вас пугать и станут естественной частью вашего инструментария.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩