Как создать загружаемый файл в браузере
Создание загружаемого файла в браузере объединяет четыре браузерных API: Blob для хранения данных, URL.createObjectURL() для создания ссылки в памяти, якорный элемент с атрибутом download для инициации сохранения и URL.revokeObjectURL() для освобождения ссылки после завершения. Полный шаблон занимает менее десяти строк:
function downloadBlob(data, filename, type) {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
downloadBlob(JSON.stringify({ ok: true }), 'config.json', 'application/json');
Это канонический шаблон, который охватывает типичные случаи сохранения сгенерированного CSV, JSON-конфига или изображения. Однако за внешней простотой скрываются реальные проблемы: атрибут download молча отключается для межсайтовых URL, iOS Safari открывает загрузки в новой вкладке, object URL утекают при повторных рендерах компонентов, а Excel искажает UTF-8 CSV. В этой статье рассматриваются рабочий шаблон, современный File System Access API, потоковая передача для файлов, превышающих объём доступной памяти, и особенности платформ, из-за которых загрузки ломаются в продакшне.
Ключевые выводы
- Канонический клиентский способ загрузки:
new Blob([data], { type })→URL.createObjectURL()→ якорный элемент с атрибутомdownload→click()→URL.revokeObjectURL(). - Время жизни object URL привязано к документу, который его создал, поэтому отзывайте его в функции очистки фреймворка (возврат из
useEffect, VueonUnmounted), а не полагаясь на неопределённую временну́ю связь сa.click(). showSaveFilePicker()— единственный встроенный в браузер способ дать пользователю выбрать место сохранения, однако он является экспериментальным и поддерживается только в Chromium, поэтому необходимо выполнять проверку наличия функции и предусматривать запасной вариант на основе якорного шаблона.- Excel корректно открывает UTF-8 CSV только при наличии BOM, поэтому добавляйте
\uFEFFв начало строки. - iOS Safari исторически ненадёжно поддерживает загрузки через атрибут
download, из-за чего файлы там нередко открываются в новой вкладке вместо сохранения.
Канонический шаблон: загрузка файла с помощью JavaScript в браузере
Discover how at OpenReplay.com.
Надёжный способ загрузить файл, сгенерированный с помощью JavaScript, — создать Blob, получить object URL с помощью URL.createObjectURL(), присвоить его атрибуту href якорного элемента, задать атрибуту download имя файла, программно кликнуть по якорю, а затем освободить URL с помощью URL.revokeObjectURL(). Конструктор Blob позволяет задать MIME-тип независимо от данных, а object URL представляет собой короткую ссылку (blob:https://…), по которой якорный элемент может выполнить переход.
Не используйте data URI по умолчанию. Data URI — неправильный выбор по умолчанию для клиентской генерации файлов: Base64 кодирует каждые 3 байта как 4 символа, увеличивая размер данных примерно на треть до добавления символов заполнения (согласно RFC 4648), а вся закодированная строка должна помещаться в памяти как значение атрибута DOM. Текущие ограничения на размер data: URL составляют 512 МБ в Chromium и Firefox и 2048 МБ в Safari/WebKit (справочник по data: URL на MDN) — однако накладные расходы на кодирование и хранение строки в памяти делают Blob предпочтительным вариантом задолго до достижения этих пределов.
Якорный элемент не обязательно должен быть добавлен в DOM для работы click() в современных браузерах, что позволяет сохранить вспомогательную функцию самодостаточной:
// Работает в Chrome 14+, Firefox 20+ (атрибут download, HTML5).
// caniuse: https://caniuse.com/download
function downloadBlob(data, filename, type = 'application/octet-stream') {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a); // добавляется для максимальной совместимости
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const csv = 'name,role\nAda,Engineer\nGrace,Architect';
downloadBlob(csv, 'team.csv', 'text/csv;charset=utf-8;');
Вызов URL.revokeObjectURL(url) важнее, чем предполагает большинство примеров. Время жизни object URL привязано к документу, который его создал, поэтому он остаётся в памяти до явного отзыва или выгрузки документа. В одноразовом скрипте это несущественно, но в компонентном приложении, где вспомогательная функция вызывается при каждом нажатии кнопки, неотозванные URL накапливаются. Синхронный отзыв в примере выше безопасен, поскольку ничто в этой функции не существует после её завершения — однако, как показано в разделе о фреймворках, такое размещение неверно внутри компонента.
HTML-атрибут download и его незаметный сбой при межсайтовых запросах
Если файл уже размещён на вашем собственном источнике, JavaScript не нужен вовсе — достаточно добавить атрибут download к обычному якорному элементу:
<a href="/reports/q3.pdf" download="q3-report.pdf">Скачать отчёт</a>
Атрибут download указывает браузеру сохранить связанный ресурс вместо перехода к нему. Он был добавлен в HTML5 и поддерживается в Chrome 14+ и Firefox 20+ (caniuse: атрибут download). Атрибут также принимает blob: и data: URL, что и обеспечивает его совместимость с описанным выше каноническим шаблоном.
Проблема возникает при межсайтовых URL. Для межсайтовых загрузок атрибут download учитывается только при наличии в ответе заголовка Content-Disposition: attachment; без него браузер игнорирует атрибут, и ссылка не обеспечивает надёжное принудительное скачивание (согласно стандарту WHATWG HTML, раздел о загрузке ресурсов). Это частый источник путаницы: та же разметка, которая скачивает файл с того же источника, будет выполнять переход или отображать файл, размещённый на CDN другого источника. Если вы управляете сервером — установите заголовок там. Если нет — загрузите файл и предоставьте его как blob с вашего собственного источника:
// Повторная отдача межсайтового файла как blob того же источника,
// чтобы атрибут `download` работал корректно.
async function downloadCrossOrigin(remoteUrl, filename) {
const res = await fetch(remoteUrl); // требует разрешения CORS для выполнения запроса
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
File System Access API: предоставление пользователю выбора места сохранения
showSaveFilePicker() из File System Access API — единственный встроенный в браузер способ дать пользователю выбрать место сохранения и имя файла перед записью. Функция открывает системный диалог сохранения, возвращает дескриптор файла и позволяет выполнять запись через FileSystemWritableFileStream. MDN помечает её как «Limited availability» и «Experimental»; она появилась в браузерах семейства Chromium начиная с версии 86 (Chrome Status) и не поддерживается в Firefox и Safari, что делает обязательным обнаружение возможности и запасной вариант на основе якорного шаблона для продакшн-использования.
showSaveFilePicker() выбрасывает DOMException с именем AbortError, если пользователь закрывает диалог, поэтому обрабатывайте этот случай явно, не считая его ошибкой:
async function saveFile(data, filename, type = 'application/octet-stream') {
// Проверка наличия функции. Примечание: свойство может существовать,
// но выбрасывать исключение в некоторых контекстах изолированных iframe,
// поэтому сам вызов также защищён.
if ('showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'File', accept: { [type]: ['.' + filename.split('.').pop()] } }],
});
const writable = await handle.createWritable();
await writable.write(new Blob([data], { type }));
await writable.close();
return;
} catch (err) {
if (err.name === 'AbortError') return; // пользователь отменил — это не ошибка
// при любой другой ошибке переходим к запасному варианту с якорем
}
}
// Запасной вариант: якорь + object URL (без выбора места; по умолчанию браузера).
downloadBlob(data, filename, type);
}
Проверка 'showSaveFilePicker' in window является правильным условием, однако она не является исчерпывающей: в некоторых контекстах изолированных iframe свойство существует, но выбрасывает исключение при вызове — именно поэтому try/catch оборачивает непосредственно вызов, а не только проверку наличия.
Потоковая передача больших файлов, превышающих доступный объём памяти
Потоковая передача позволяет обойти ограничение памяти Blob, записывая фрагменты данных непосредственно на диск вместо буферизации всей нагрузки в ОЗУ. Фиксированного порогового значения для этого ограничения не существует — оно зависит от устройства, ОС и доступной кучи браузера. Два подхода к потоковой передаче позволяют избежать буферизации всего файла.
В Chromium можно выполнять инкрементальную запись через FileSystemWritableFileStream, возвращаемый showSaveFilePicker(). Каждый вызов write() добавляет фрагмент в поток записи, поэтому приложению не нужно держать весь файл в памяти одновременно:
// Только Chromium 86+. Запись по фрагментам; весь файл никогда не хранится в памяти.
async function streamToDisk(filename, chunks) {
const handle = await window.showSaveFilePicker({ suggestedName: filename });
const writable = await handle.createWritable();
for await (const chunk of chunks) {
await writable.write(chunk); // chunk: string | ArrayBuffer | Blob
}
await writable.close();
}
Для браузеров без File System Access API кроссбраузерным решением является StreamSaver.js. Согласно его README, StreamSaver.js сохраняет большие клиентски сгенерированные файлы, создавая поток записи и эмулируя серверную загрузку с помощью заголовков ответа и service worker, так что данные записываются на диск по мере поступления, а не собираются в единый Blob. Библиотека основана на стандарте WHATWG Streams:
import streamSaver from 'streamsaver';
// Эмулирует серверную загрузку через service worker; данные передаются на диск потоком.
function streamWithStreamSaver(filename, readableStream) {
const fileStream = streamSaver.createWriteStream(filename);
// readableStream: WHATWG ReadableStream из фрагментов Uint8Array
return readableStream.pipeTo(fileStream);
}
Прибегайте к потоковой передаче только при работе с действительно большими файлами — экспортами объёмом в сотни мегабайт, генерируемым видео или длительными потоками данных. Для CSV из нескольких тысяч строк канонический шаблон с Blob проще и вполне достаточен.
Особенности iOS Safari и способы их обхода
iOS Safari исторически ненадёжно поддерживает файлы, генерируемые и загружаемые через атрибут download (ошибка WebKit 167341), поэтому сгенерированный файл нередко открывается в новой вкладке или отображается встроенно вместо сохранения — именно iOS чаще всего является платформой, где код загрузки ломается в продакшне. В README FileSaver.js задокументировано практическое следствие: на iOS saveAs() должен вызываться непосредственно в обработчике пользовательского взаимодействия, например onClick, а setTimeout не позволит ему сработать; из-за ограничений iOS saveAs() может открыть новое окно вместо загрузки (README FileSaver.js).
Конкретные способы обхода:
- Сохраняйте синхронность загрузки с жестом пользователя. Инициируйте клик непосредственно в обработчике события, а не после
awaitилиsetTimeout, которые разрывают связь с жестом. Если необходима асинхронная подготовка данных — выполните её до нажатия кнопки пользователем, а затем синхронно инициируйте сохранение. - Предоставьте пользователю явный запасной вариант. Если файл открылся в новой вкладке и отображается встроенно, покажите подсказку «нажмите «Поделиться» → «Сохранить в Файлы»», поскольку пользователю придётся завершить сохранение вручную.
- Для текстовых экспортов на iOS предпочтительнее использовать
text/plainили другой отображаемый тип, принимая, что файл может открыться в просмотрщике, из которого пользователь его сохранит, вместо того чтобы добиваться принудительной загрузки, которую платформа не поддерживает надёжно.
Эти сбои невидимы для серверных логов и аналитики, поскольку ни один запрос не достигает бэкенда и ни одно исключение не выбрасывается. Записи сессий в продакшне нередко показывают, как пользователи многократно нажимают кнопку загрузки из-за отсутствия видимой обратной связи — на iOS это часто выглядит как открытие новой вкладки, которую пользователь тут же закрывает и возвращается в замешательстве. Воспроизведение сессий — один из немногих инструментов, позволяющих выявить этот класс молчаливых сбоев, зависящих от жеста и платформы.
// С учётом iOS: сначала выполняем асинхронную работу, затем синхронно сохраняем в обработчике жеста.
async function prepareThenDownload(button, getData, filename, type) {
const data = await getData(); // сетевые/вычислительные операции выполняются до пути клика
button.onclick = () => {
// синхронно с жестом пользователя — никаких setTimeout, никаких await здесь
downloadBlob(data, filename, type);
};
}
Проблемы с MIME-типами: встроенное отображение, CSV в Excel и переносы строк
MIME-тип, передаваемый конструктору Blob, помогает браузеру определить способ интерпретации файла, и его неправильное указание является распространённой причиной того, что загруженный контент открывается неожиданным образом или обрабатывается некорректно. Задавайте тип, соответствующий данным, и сочетайте его с атрибутом download, чтобы браузер обрабатывал результат как вложение.
Наиболее часто встречающаяся ошибка с CSV — искажение символов с диакритическими знаками при открытии файла в Excel. Решение — метка порядка байтов (BOM). Excel корректно открывает UTF-8 CSV-файл при наличии BOM, поэтому добавляйте её в начало строки (поддержка Microsoft: открытие UTF-8 CSV в Excel):
// Без BOM: Excel может отображать искажённые символы для текста с диакритикой (é, ü, ñ).
// С BOM: Excel корректно открывает UTF-8 файл.
function downloadCSV(csvString, filename = 'export.csv') {
downloadBlob('\uFEFF' + csvString, filename, 'text/csv;charset=utf-8;');
}
Два дополнительных момента из RFC 4180, который регистрирует text/csv как MIME-тип для CSV: спецификация определяет CRLF (\r\n) как разделитель записей, поэтому для максимальной совместимости с электронными таблицами предпочтительнее использовать \r\n вместо \n между строками; поля, содержащие запятые, кавычки или переносы строк, должны быть заключены в двойные кавычки, а внутренние кавычки — удвоены.
Не используйте application/octet-stream как универсальный способ принудительной загрузки. Это зарегистрированный IANA тип для произвольных двоичных данных, однако он не является надёжным механизмом принудительной загрузки — в README FileSaver.js отмечается, что использование application/octet-stream для принудительных загрузок может вызывать проблемы в Safari. Используйте правильный, конкретный тип и полагайтесь на атрибут download (или Content-Disposition) для обеспечения сохранения.
Особенности фреймворков: SSR, жизненный цикл object URL и утечки памяти
Два связанных с жизненным циклом компонента дефекта нарушают работу канонического шаблона загрузки в React, Vue и Svelte: вызов DOM API во время серверного рендеринга (где document не определён) и отзыв object URL в неправильный момент жизненного цикла компонента. Оба дефекта имеют одну корневую причину — компоненты рендерятся на сервере и повторно рендерятся на клиенте, тогда как обычный шаблон предполагает один документ и одно выполнение.
Защищайтесь от SSR. В Next.js, Nuxt или SvelteKit код компонента может выполняться там, где document не определён; вызов document.createElement в таком контексте выбросит исключение. Ограждайте любую вспомогательную функцию загрузки проверкой во время выполнения:
function triggerDownload(data, filename, type) {
if (typeof document === 'undefined') return; // защита от SSR
downloadBlob(data, filename, type);
}
Отзывайте object URL в функции очистки, а не немедленно. Синхронный URL.revokeObjectURL(url) из канонического шаблона подходит для одноразовой вспомогательной функции, завершающейся до начала любых других операций. Но если вы сохраняете blob URL в состоянии для использования в качестве href или src, слишком ранний отзыв нарушит загрузку, а отсутствие отзыва приведёт к утечке памяти при каждом повторном рендере. Проверенное правило: время жизни object URL привязано к документу, который его создал, поэтому отзывайте его, как только он больше не нужен; в компонентных фреймворках сохраняйте blob URL в состоянии и отзывайте его в функции очистки, а не полагаясь на неопределённую временну́ю связь с a.click().
import { useEffect, useState } from 'react';
function DownloadLink({ data, filename, type = 'application/json' }) {
const [url, setUrl] = useState(null);
useEffect(() => {
const blob = new Blob([data], { type });
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
// Очистка выполняется при размонтировании и перед повторным запуском эффекта:
return () => URL.revokeObjectURL(objectUrl);
}, [data, type]);
// АНТИПАТТЕРН — НЕ отзывайте URL здесь:
// <a href={url} download={filename} onClick={() => URL.revokeObjectURL(url)}>
// Отзыв внутри обработчика клика может освободить URL до начала загрузки.
return url ? <a href={url} download={filename}>Скачать</a> : null;
}
Эквивалент для Vue создаёт URL в onMounted (или в watch) и отзывает его в onUnmounted; Svelte использует onDestroy. Во всех трёх случаях утечка, которая даёт о себе знать, — это новый object URL, создаваемый при каждом повторном рендере или каждом нажатии кнопки без соответствующего отзыва; память растёт на протяжении всего времени жизни документа.
Для устаревших браузеров, в которых атрибут download полностью отсутствует, FileSaver.js остаётся однострочным полифиллом — однако для современных целевых платформ описанный выше нативный шаблон его заменяет.
Заключение
Последовательность Blob → object URL → якорь → download → отзыв охватывает подавляющее большинство задач клиентской загрузки файлов; showSaveFilePicker() является улучшенным вариантом только для Chromium, когда пользователям нужно выбрать место сохранения, а потоковая передача — запасным решением, когда файлы превышают доступный объём памяти. Ошибки, достигающие продакшна, редко связаны с основным сценарием — это межсайтовый атрибут, который молча выполняет переход, CSV, которому нужен BOM, вкладка iOS, открывающаяся вместо сохранения, и object URL, отзываемый в неподходящий момент. Встраивайте очистку в жизненный цикл компонента, выполняйте проверку наличия функции перед вызовом экспериментальных API и тестируйте процесс загрузки на реальном устройстве iOS перед выпуском.
Часто задаваемые вопросы
Blob хранит необработанные данные и адресуется через короткий object URL, создаваемый с помощью URL.createObjectURL(), тогда как data URI встраивает всю нагрузку в виде строки Base64 непосредственно в URL. Кодирование Base64 увеличивает размер данных примерно на треть до добавления символов заполнения, а полная закодированная строка должна помещаться в памяти как значение атрибута. Blob позволяет задавать MIME-тип независимо от данных и избегает обоих недостатков, что делает его предпочтительным вариантом по умолчанию для клиентской генерации файлов.
Атрибут download учитывается для URL того же источника, но игнорируется для межсайтовых URL, если ответ не содержит заголовок Content-Disposition: attachment, согласно стандарту WHATWG HTML. Файл, размещённый на CDN другого источника, будет открываться или отображаться встроенно вместо загрузки. Решение — установить заголовок на подконтрольном вам сервере или загрузить межсайтовый файл и предоставить его как blob того же источника с помощью URL.createObjectURL().
Нет. showSaveFilePicker() из File System Access API поддерживается только в браузерах семейства Chromium начиная с версии 86 и не реализован в Firefox и Safari. MDN помечает его как «Limited availability» и «Experimental». По этой причине продакшн-код должен выполнять проверку наличия функции с помощью 'showSaveFilePicker' in window и предусматривать запасной вариант на основе якоря и object URL. Оборачивайте вызов в try/catch, поскольку свойство может существовать, но выбрасывать исключение в некоторых контекстах изолированных iframe; кроме того, при закрытии диалога пользователем выбрасывается AbortError.
Отзыв object URL до начала загрузки нарушит её, поскольку браузер больше не сможет разрешить blob-ссылку, на которую указывает якорный элемент. В одноразовой вспомогательной функции, завершающейся синхронно, отзыв сразу после click() безопасен. В компоненте, хранящем blob URL в состоянии для использования в href или src, отзывайте его в функции очистки — например, в возвращаемом значении useEffect, в onUnmounted Vue или в onDestroy Svelte. Отсутствие отзыва, напротив, приводит к утечке памяти при каждом повторном рендере на протяжении всего времени жизни документа.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.