javascript

JavaScript: практическое руководство по Blob, File API и оптимизации памяти

  • четверг, 8 января 2026 г. в 00:00:06
https://habr.com/ru/companies/timeweb/articles/976774/

В современных фронтенд-приложениях работа с файлами встречается постоянно: загрузка изображений, экспорт 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-канале