В современных фронтенд-приложениях работа с файлами встречается постоянно: загрузка изображений, экспорт CSV, превью и интерактивные редакторы. Но когда файлы увеличиваются в размере или их количество растет, начинаются проблемы: интерфейс подвисает, расход памяти увеличивается, а браузер иногда просто падает.
В этом руководстве мы разберем шесть практических приемов работы с Blob, которые помогают обрабатывать файлы эффективно и безопасно:
правильное создание Blob
разбивка больших файлов на части (chunks)
сжатие и конвертация изображений
реализация надежных превью файлов
экспорт данных в виде загружаемых файлов
управление памятью во избежание утечек Blob URL
Цель руководства — сделать работу с файлами быстрой, стабильной и готовой к продакшну.
Прим. пер.: набросал несколько форм на React, в которых используются некоторые функции и классы из статьи.
❯ 1. Безопасное и эффективное создание объектов Blob
Распространенные проблемы
Многие разработчики начинают с работы с огромными строками или base64-URL, что приводит к:
дублированию данных в памяти
резкому увеличению использования кучи (heap)
замедлению интерфейса
// ❌ Плохо: длинная строка + data URL = удвоенное потребление памяти const hugeText = 'Very long text...'.repeat(100_000); const dataUrl = 'data:text/plain;charset=utf-8,' + encodeURIComponent(hugeText);
Использование Blob
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)
❯ 2. Разбивка больших файлов на части во избежание переполнения памяти
Распространенные проблемы
Попытка прочитать двухгигабайтный лог-файл одним вызовом 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); };
Обработка файла частями с помощью slice()
/** * Обрабатываем файл порциями фиксированного размера, чтобы избежать исчерпания памяти. */ 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)}`; } }
❯ 3. Сжатие и конвертация изображений на клиенте
Распространенные проблемы
Прямая отправка необработанных 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 ); } }
❯ 4. Единый компонент превью файлов
Иногда требуется показать превью:
изображений
текста / 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); }); } }
❯ 5. Экспорт данных с помощью Blob (JSON, CSV, "Excel")
Распространенные проблемы
Применение data URL для экспорта большого объема данных:
дублирует содержимое в памяти
ограничено по длине URL
не работает с большими наборами данных
Класс для скачивания данных на основе Blob
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); } }
❯ 6. Управление памятью и предотвращение утечек
Распространенные проблемы
Каждый вызов 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
Используем Blob, если нужно:
создавать файлы на клиенте (JSON, CSV, изображения)
обрабатывать большие файлы по частям, не загружая их в память целиком
реализовать сжатие или конвертацию изображений
обеспечить единый опыт превью файлов
организовать безопасную загрузку или экспорт больших наборов данных
заботиться о долгоживущих приложениях и предотвращении утечек памяти
Blob вместе с File API и URL.createObjectURL() — основа надежной работы с файлами на клиенте. Освоив их, большие загрузки, превью и экспорты перестанут вас пугать и станут естественной частью вашего инструментария.
❯ Полезные материалы:
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩
