JSON в браузере: import или fetch — руководство по выбору

Сравниваем два способа загрузить JSON: import { type: "json" } и fetch() + response.json(). Разбираем безопасность MIME, управление памятью, отмену запросов и кеширование. Даём чек-лист для выбора под ваш сценарий.

Введение

С появлением нативной поддержки import с type: 'json' в браузерах возник соблазн заменить им повсеместно используемый fetch(). Оба механизма загружают JSON, но их поведение различается в пяти аспектах: валидация MIME-типа, кеширование, возможность отмены запроса, потоковая обработка и глубина информации об ошибках.

Мы уже сравнивали модульные системы в статье «require и import в JavaScript», а в материале о проксировании fetch() на сервере рассматривали гибкость этого API. Здесь сосредоточимся на браузерном окружении и на том, как не ошибиться в выборе между import и fetch.

Цель статьи — не запретить один из подходов, а дать системный критерий выбора. Мы разберём каждое из пяти отличий, закрепим их матрицей решений и чек-листом, а затем покажем готовые примеры кода. После прочтения вы сможете аргументированно обосновать выбор коллегам на код-ревью.

Критерийimportfetch()
Безопасность MIME✅ автоматически❌ вручную
Кешированиенавсегдауправляемое
Отмена запроса
Потоковая обработка
Информация об ошибкахограниченнаяполная

Теперь перейдём к деталям. Начнём с самого недооценённого различия — безопасности MIME-типов.

Безопасность JSON: как MIME-типы защищают от атак

Когда вы пишете import data from './config.json' with { type: 'json' }, браузер не просто загружает файл и парсит его как JSON. Он делает нечто более важное: требует от сервера правильный заголовок Content-Type: application/json. Если сервер отдаст файл с любым другим MIME-типом (text/plain, text/html или, что опаснее, text/javascript), импорт завершится ошибкой (промис перейдёт в состояние rejected).

Подробнее о синтаксисе import() и атрибутах импорта — в MDN: import() и MDN: Import attributes.

Это не баг и не излишняя строгость. Это защита от целого класса уязвимостей, когда злоумышленник или неаккуратный администратор подменяет содержимое JSON-эндпоинта.

Атака через подмену MIME

Представьте: ваш сайт загружает конфигурацию с https://api.example.com/config.json. В обычной ситуации сервер возвращает:

Content-Type: application/json

{ "theme": "dark", "lang": "ru" }

Но представьте, что злоумышленник каким-то образом (через взлом DNS, атаку на CDN, или эксплуатацию уязвимости на самом сервере) заставляет сервер отдавать вместо этого:

Content-Type: text/javascript

alert('XSS'); // а это уже не JSON, а JavaScript

Теперь посмотрим, что произойдёт при разных способах загрузки.

При использовании import: браузер видит Content-Type: text/javascript, ожидая application/json, и немедленно отклоняет импорт. Код alert('XSS') не выполняется. Пользователь в безопасности.

При использовании fetch() без проверки MIME: вызов const data = await response.json() попытается распарсить alert('XSS') как JSON. Парсер JSON обнаружит недопустимый токен alert, и промис, возвращённый response.json(), перейдёт в состояние rejected с синтаксической ошибкой. В этом конкретном случае атака не удалась бы — но только потому, что злоумышленник неудачно выбрал полезную нагрузку.

А вот если злоумышленник подберёт валидную JSON-строку, содержащую XSS-код, например:

Content-Type: text/javascript

"<img src=x onerror=alert('XSS')>"

Это валидный JSON (строка в кавычках). response.json() успешно распарсит её, и вы получите строку "<img src=x onerror=alert('XSS')>" в переменной data. Дальнейшее зависит от вашего кода. Если вы затем подставите эту строку в innerHTML:

document.getElementById('output').innerHTML = data;

— XSS сработает. Если вы передадите её в eval() или в new Function() — тоже. А если используете данные безопасно (например, через textContent), атака не удастся. Но fetch() не даёт никаких гарантий, что вы получили именно JSON, а не подставленный код. Он просто парсит то, что пришло, и надеется на лучшее.

import в этом же сценарии просто перейдёт в состояние rejected с ошибкой. Вредоносная строка даже не попадёт в вашу программу.

Почему статический JSON не безопасен

Часто думают: У меня статический JSON на своём сервере, меня никто не взломает. Но MIME-подмена может произойти не только из-за злого умысла. Достаточно ошибки в конфигурации сервера, когда он начинает отдавать application/octet-stream для всех файлов, или когда прокси-сервер сбрасывает заголовки. Внезапно ваш надёжный import перестаёт работать, а fetch() продолжает загружать данные, создавая ложное ощущение, что всё в порядке. Но самое опасное — когда вместо данных начинает приходить HTML-страница с ошибкой 500, и ваш код парсит её как JSON, не видя проблемы.

Как защитить fetch вручную

При использовании fetch() MIME не проверяется автоматически, но вы можете сделать это сами. Минимальная защита выглядит так:

const response = await fetch('https://api.example.com/config.json');

const contentType = response.headers.get('Content-Type');
if (!contentType || !contentType.includes('application/json')) {
throw new Error(`Сервер вернул не JSON, а ${contentType}`);
}

const data = await response.json();

Когда эта проверка критична:

  • Всегда, когда вы загружаете JSON из внешнего источника (чужая API, CDN, открытый эндпоинт).
  • Когда JSON содержит данные, которые могут быть подставлены в DOM (конфиги тем, переводы, пользовательский контент).
  • Когда вы не контролируете сервер на 100% (а такой контроль встречается реже, чем кажется).

Когда можно пропустить проверку:

  • Вы загружаете JSON с того же origin, и сервер полностью под вашим контролем (но даже здесь проверка стоит трёх строк кода).

Всегда ли безопасен import

Не совсем. import проверяет MIME, но не проверяет содержимое внутри валидного JSON. Если сервер отдаст Content-Type: application/json и тело "<img src=x onerror=alert('XSS')>", import успешно загрузит эту строку. Дальнейшая безопасность зависит от того, как вы её используете. import гарантирует, что вы получили именно JSON (а не HTML, не JS-код вне кавычек), но не может защитить от вредоносных данных внутри валидной структуры. Это уже зона ответственности вашего кода: санитизация, экранирование, использование textContent вместо innerHTML.

Краткий итог по безопасности

  • import даёт автоматическую защиту от подмены MIME — это его главное преимущество. Если сервер отдаёт не JSON, вы просто не получите данные, и вредоносный код не попадёт в программу.
  • fetch() без ручной проверки Content-Type уязвим для атак через подмену типа — даже если содержимое выглядит как JSON.
  • Оба механизма не защищают от XSS внутри валидного JSON. Это задача обработки данных на уровне приложения.
  • Правило для запоминания: если используете fetch() с JSON — всегда проверяйте Content-Type. Три строки кода отделяют безопасный код от потенциально уязвимого.

В следующем разделе разберём второе ключевое отличие — пожизненное кеширование модулей и его последствия для управления памятью.

Кеширование import: почему данные живут на странице вечно

Когда вы загружаете модуль через import (статический или динамический), среда выполнения кеширует его на весь жизненный цикл страницы или воркера. Повторный вызов import с тем же самым спецификатором и теми же атрибутами не приведёт к повторной загрузке и выполнению модуля — вы получите то же самое модульное пространство имён (module namespace object) из кеша. Это стандартное поведение спецификации модулей, и в большинстве случаев оно полезно: модули с состоянием (синглтоны, сервисы) ведут себя предсказуемо, а повторные импорты не создают лишней сетевой нагрузки.

Однако та же самая медаль имеет обратную сторону. Если вы используете import() для загрузки динамических данных — например, результатов поиска или любого другого контента, который меняется от запроса к запросу, — каждый уникальный URL навсегда останется в кеше модулей.

Пример утечки памяти при import

Рассмотрим код, который на первый взгляд кажется безобидным:

async function loadSearchResults(query) {
const { default: results } = await import(`/api/search?q=${query}`, {
with: { type: 'json' }
});
return results;
}

Пользователь вводит "javascript" — загружается модуль /api/search?q=javascript. Потом "react" — загружается /api/search?q=react. Потом "vue" — и так далее. Каждый результат поиска становится отдельным модулем, который никогда не будет удалён из памяти, даже если пользователь больше никогда не вернётся к этому запросу. Через час активного поиска в памяти могут храниться сотни или тысячи ответов. Это не просто неэффективно — это классическая утечка памяти, только вызванная не ошибкой в коде, а архитектурным решением платформы.

Почему обнуление переменной не помогает

Разработчик, знакомый с управлением памятью в JavaScript, может попытаться исправить ситуацию так:

async function loadSearchResults(query) {
const { default: results } = await import(`/api/search?q=${query}`, {
with: { type: 'json' }
});
const smallPart = results.slice(0, 10);
return smallPart; // results больше не используется, сборщик мусора должен собрать его, верно?
}

К сожалению, нет. Переменная results исчезает из области видимости, но сам объект results по-прежнему хранится в кеше модулей — во внутренней структуре движка, которая не доступна для прямого управления из вашего кода. Ссылка на весь загруженный объект живет там до конца жизни страницы. Вы можете обнулить results, вы можете даже обнулить импортированный модуль целиком — кеш останется нетронутым.

fetch() в этом смысле ведёт себя предсказуемо и управляемо:

async function loadSearchResults(query) {
const response = await fetch(`/api/search?q=${query}`);
const results = await response.json();
const smallPart = results.slice(0, 10);
return smallPart; // весь объект results становится доступен сборщику мусора
}

Здесь, как только results выходит из области видимости и на него больше нет ссылок, сборщик мусора может освободить память. fetch() не добавляет скрытых ссылок из кеша.

Диаграмма временной шкалы, показывающая разницу между import() и fetch(): import кеширует данные навсегда, fetch позволяет освобождать память после использования.
Жизненный цикл данных при использовании import() и fetch(). import кеширует модуль навсегда, повторные запросы возвращают данные из кеша без очистки памяти. fetch() позволяет сборщику мусора освобождать память после того, как данные перестали быть нужны.

Когда пожизненное кеширование — это хорошо

Было бы нечестно представить эту особенность только как проблему. Для статических ресурсов пожизненное кеширование — преимущество:

  • Конфигурация приложения, загруженная один раз, остаётся доступной всегда без повторных запросов.
  • JSON-схемы, словари для локализации, статические списки (страны, валюты) — идеальные кандидаты для import.
  • Если вам нужны все данные целиком и они не меняются в течение сессии, пожизненное кеширование экономит сетевой трафик и время.

Хороший и плохой import: как отличить

Задайте себе два вопроса:

  1. Может ли URL, который я передаю в import(), повторяться с другим содержимым? Если да (как в примере с поиском), каждый уникальный запрос навсегда останется в памяти, что приведёт к неограниченному росту её потребления. Если нет (каждый URL ведёт к одному и тому же неизменному файлу), то безопасно.
  2. Нужны ли мне все данные целиком и навсегда? Если да, то import делает именно это. Если вам нужна только часть данных или они нужны временно, fetch() даст больше контроля.

Для дополнительной защиты рассмотрите использование CSP (Content Security Policy) с директивой script-src и SRI (Subresource Integrity) для JSON-файлов — хотя SRI для модулей поддерживается не везде.

В следующем разделе разберём три возможности fetch(), которых у import нет и не будет: отмена запроса, потоковая обработка и гибкая работа с CORS.

Отмена запроса, стриминг и CORS: возможности fetch

У fetch() есть три возможности, которых import лишён принципиально. Их нельзя «добавить» через полифиллы или обходные пути — они вытекают из самой природы модульной системы. Если ваш сценарий требует хотя бы одной из них, выбор однозначен: только fetch().

Документация по Fetch API и примерам использования — MDN: Fetch API и MDN: Using Fetch.

Отмена запроса через AbortController

Представьте, что пользователь вводит текст в поисковую строку, и вы отправляете запрос на каждый символ. При быстром наборе «javascript» вы успеете отправить запросы на j, ja, jav, java, javas и так далее. Если не отменять предыдущие запросы, они будут завершаться в произвольном порядке, и результат более раннего (и уже неактуального) запроса может перезаписать результат более позднего.

fetch() решает эту задачу через AbortController:

let currentController = null;

async function search(query) {
// Отменяем предыдущий запрос, если он ещё не завершился
if (currentController) {
currentController.abort();
}

currentController = new AbortController();

try {
const response = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal
});
const data = await response.json();
return data;
} catch (error) {
if (error.name === 'AbortError') {
console.log('Запрос отменён, это нормально');
return null;
}
throw error;
}
}

Синтаксис import() не предусматривает параметр signal и не предоставляет механизма отмены. Если вы отправили динамический импорт — вы будете ждать его завершения или ошибки. Никакой возможности сказать остановись, это уже не нужно. В сценариях с активным взаимодействием пользователя (поиск, фильтрация, бесконечная лента, смена вкладок) это делает import() практически непригодным.

Потоковая обработка больших JSON

import() возвращает уже распарсенный объект. Чтобы его получить, браузер должен:

  1. Загрузить весь файл целиком.
  2. Распарсить весь JSON в память.
  3. Только после этого передать объект в вашу программу.

Для файла размером 100 МБ это означает, что все 100 МБ одномоментно окажутся в памяти. И так — для каждого загруженного таким образом файла.

fetch() позволяет читать ответ потоково, обрабатывая данные по частям по мере их поступления из сети:

const response = await fetch('/api/large-data.json');
const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
const { done, value } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });
// Обрабатываем часть данных, не дожидаясь остальных
}

Для реальной работы с потоковым JSON придётся использовать библиотеки вроде oboe.js или ручной парсинг с накоплением токенов, но сама возможность потокового чтения принципиальна. Она позволяет:

  • Отображать прогресс загрузки.
  • Начинать обработку до завершения всего ответа.
  • Держать в памяти только текущий фрагмент, а не весь ответ целиком.

Если ваш JSON весит больше пары мегабайт или вы работаете на устройствах с ограниченной памятью (мобильные браузеры), возможность стриминга может стать решающим фактором.

Гибкая работа с CORS и режимами запроса

import() следует строгим правилам загрузки модулей. Вы не можете повлиять на то, как браузер выполняет CORS-проверку, не можете отправить запрос в no-cors режиме, не можете контролировать отправку учётных данных. Всё решается автоматически на основе типа модуля и атрибутов импорта.

fetch() даёт полный контроль через опцию mode:

  • mode: 'cors' — стандартное поведение с CORS-проверкой (значение по умолчанию).
  • mode: 'same-origin' — запрещает кросс-доменные запросы, только на свой origin.
  • mode: 'no-cors' — ограниченный режим для запросов к ресурсам, которые не поддерживают CORS (не позволяет читать ответ, но сам запрос уходит).

А также через credentials:

  • credentials: 'omit' — не отправлять куки и HTTP-аутентификацию.
  • credentials: 'same-origin' — отправлять только для своего origin (значение по умолчанию).
  • credentials: 'include' — отправлять всегда, даже для кросс-доменных запросов.

Для публичных API без CORS-заголовков import() завершится ошибкой (промис перейдёт в состояние rejected). fetch() в режиме no-cors хотя бы позволит отправить запрос, даже если вы не сможете прочитать ответ (для некоторых сценариев логирования или трекинга этого достаточно). А для API, требующих авторизации через куки, настройка credentials: 'include' жизненно необходима.

Краткий итог по отмене, стримингу и CORS

Возможностьimportfetch()Когда критично
Отмена запросаПоиск, фильтры, любое активное взаимодействие
Потоковая обработкаБольшие файлы (от 5–10 МБ), ограниченная память
Контроль CORS и credentialsСторонние API, авторизация, нестандартные режимы

В следующем разделе разберём последнее ключевое отличие — обработку ошибок и сценарии с fallback-логикой.

Обработка ошибок: fetch vs import

Когда запрос на загрузку JSON завершается неудачей, import() и fetch() ведут себя принципиально по-разному. fetch() предоставляет детальную диагностику, позволяя различать сетевые ошибки, HTTP-статусы и проблемы с парсингом. import() в этом смысле более «чёрный ящик»: он либо даёт данные, либо отклоняет промис с ошибкой, но причины успеха или отказа часто скрыты за обобщённым сообщением.

Что видит разработчик при ошибке

При использовании import():

try {
const { default: data } = await import('/api/config.json', {
with: { type: 'json' }
});
} catch (error) {
console.error(error);
// Что именно случилось? 404? 500? Сервер не отвечает?
// Ошибка парсинга? Неверный MIME? Понять невозможно без дополнительных инструментов.
}

Промис, возвращённый import(), будет отклонён (rejected), но тип и сообщение ошибки варьируются в зависимости от браузера и причины сбоя. В большинстве случаев вы получите TypeError с текстом вроде Failed to fetch или Import of JSON module failed. Различить 404, 500, CORS-проблему или неверный MIME из кода нельзя — всё это приводит к одному и тому же внешнему проявлению.

При использовании fetch():

try {
const response = await fetch('/api/config.json');

if (!response.ok) {
// HTTP-статус 404, 500 и т.д. - доступен явно
console.error(`Ошибка HTTP: ${response.status}`);
const errorText = await response.text();
console.error(`Тело ошибки: ${errorText}`);
throw new Error(`HTTP ${response.status}`);
}

const contentType = response.headers.get('Content-Type');
if (!contentType || !contentType.includes('application/json')) {
throw new Error(`Неверный MIME: ${contentType}`);
}

const data = await response.json();
} catch (error) {
if (error.name === 'TypeError') {
console.error('Сетевая ошибка: сервер не отвечает или CORS');
} else {
console.error('Ошибка приложения:', error.message);
}
}

Вы видите статус, заголовки, тело ответа (даже если это не JSON, можно прочитать как текст). Вы можете различать категории ошибок и строить разную стратегию восстановления.

Когда нужна обработка ошибок (fallback)

Сторонние API. Если ваш сайт зависит от внешнего сервиса погоды, курсов валют или новостей, этот сервис может временно не работать. При использовании fetch() вы можете:

  • Показать пользователю кешированные данные из localStorage.
  • Отобразить сообщение «Сервис временно недоступен, попробуйте позже».
  • Запросить данные из альтернативного источника.

import() в такой ситуации просто перейдёт в состояние rejected, и вы не сможете отличить временную недоступность от постоянной ошибки конфигурации.

Постепенная деградация. Представьте приложение, которое загружает дополнительную функциональность из JSON. Если загрузка не удалась, основная функциональность должна остаться работоспособной:

async function loadOptionalFeature() {
try {
const response = await fetch('/features/enhanced-ui.json');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const config = await response.json();
enableEnhancedUI(config);
} catch (error) {
// Fallback: работаем без расширенного UI
console.warn('Расширенный интерфейс недоступен, используем базовый');
enableBasicUI();
}
}

С import() такая стратегия тоже возможна (вы можете обернуть его в try/catch), но вы не сможете понять, почему загрузка не удалась. Возможно, JSON просто временно недоступен (404), и через секунду он появится. А возможно, сервер вернул HTML с ошибкой 500, и повторять запрос бесполезно. У вас нет информации, на основе которой можно принять решение о повторной попытке или выборе fallback-стратегии.

Опасность ложного чувства безопасности

У fetch() есть одна ловушка, о которой важно сказать прямо. Разработчики часто пишут:

const response = await fetch(url);
const data = await response.json();

И думают: «Вот теперь всё правильно, если что-то пойдёт не так, промис отклонится, и я обработаю ошибку в catch». Но это не так. Промис, возвращённый fetch(), не переходит в состояние rejected при HTTP-статусах 404 или 500. Он отклоняет его только при сетевых ошибках (нет соединения, CORS, некорректный URL). HTTP-404 — это успешный ответ с точки зрения сети, и fetch() перейдёт в resolve, а не в reject.

В результате код без проверки response.ok продолжит выполнение, вызовет response.json(), а тот, получив HTML-страницу с ошибкой 404, вернёт промис, который перейдёт в состояние rejected с синтаксической ошибкой парсинга. Но исходная информация о том, что сервер вернул именно 404, будет потеряна. Вы будете знать, что «ошибка парсинга JSON», но не будете знать, что на самом деле ресурс не найден.

Когда достаточно ограниченной диагностики import

Для статических ресурсов, которые являются частью вашего приложения (конфиги, переводы, схемы), сложная fallback-логика часто избыточна. Если import config.json упал — приложение, скорее всего, не сможет работать в принципе. Просто показать пользователю «Ошибка загрузки приложения, обновите страницу» — адекватная стратегия.

Более того, в таких сценариях дополнительные проверки fetch() только усложняют код без реальной выгоды. Вы знаете, что файл существует, знаете, что он на вашем сервере, знаете, что MIME-тип правильный. Если что-то пошло не так — это катастрофа, которую не нужно «лечить» fallback'ом, её нужно логировать и показывать сообщение об ошибке.

Итог по ошибкам

  • fetch() даёт полную информацию об ошибке: статус, заголовки, тело ответа.
  • import() даёт минимальную информацию — только факт отказа.
  • Для сторонних API и динамических данных гибкость fetch() критична.
  • Для своих статических ресурсов избыточная диагностика не нужна.
  • Основное правило fetch: всегда проверяйте response.ok и Content-Type — не полагайтесь на автоматическое отклонение промиса.

Все пять отличий — краткое резюме

Отличиеimportfetch()
Безопасность MIME✅ Автоматическая❌ Требует ручной проверки
КешированиеПожизненное (нельзя очистить)Управляемое (HTTP + GC)
Отмена запроса❌ Невозможно✅ AbortController
Потоковая обработка❌ Невозможноresponse.body
Информация об ошибкахМинимальнаяПолная (статус, заголовки, тело)

Чек-лист: как выбрать между import и fetch

Блок-схема выбора: последовательные вопросы о динамичности данных, защите MIME, размере, отмене запроса и диагностике ошибок с конечными выборами import или fetch
Пошаговый алгоритм выбора между import и fetch для загрузки JSON. Ответьте на пять вопросов, чтобы получить однозначную рекомендацию.

Теперь, когда мы разобрали пять ключевых отличий, можно свести всё к практическому инструменту. Ниже — последовательность вопросов, которая займёт не более минуты и приведёт вас к однозначному выбору.

Вопрос 1. JSON меняется в течение одной сессии?

Имеется в виду: могут ли данные измениться между двумя запросами к одному и тому же URL? Результаты поиска — меняются. Курс валют — может измениться. Список стран — нет, он статичен.

  • Даfetch()
  • Нет → переходите к вопросу 2

Вопрос 2. Вам нужна автоматическая защита от подмены MIME?

Если JSON загружается с того же origin и вы полностью контролируете сервер, защита от подмены MIME может быть избыточной. Если JSON приходит со стороннего API или CDN, автоматическая проверка — весомое преимущество.

  • Да → склоняйтесь к import, но проверьте следующие вопросы
  • Нетfetch() с ручной проверкой Content-Type

Вопрос 3. Размер JSON превышает 1–2 МБ?

Граница условная, но важная. Небольшие JSON (конфиги, переводы) безболезненно загружаются целиком. Крупные данные (гео-данные, исторические архивы, результаты аналитики) могут создавать пиковую нагрузку на память.

  • Даfetch() (возможно, с потоковой обработкой)
  • Нет → переходите к вопросу 4

Вопрос 4. Пользователь может отменить запрос до его завершения?

Типичные сценарии: поиск с автодополнением, бесконечная лента с пагинацией, переключение вкладок с разными данными, повторный запрос с новыми параметрами.

  • Даfetch() с AbortController
  • Нет → переходите к вопросу 5

Вопрос 5. Вам нужна детальная диагностика ошибок (статус, заголовки, тело ответа)?

Для своих статических ресурсов — скорее нет. Для сторонних API, особенно ненадёжных — скорее да.

  • Даfetch()
  • Нетimport

Если после прохождения по вопросам у вас не появилось ни одного аргумента в пользу fetch() — используйте import. Если появился хотя бы один — выбирайте fetch() с обязательной проверкой Content-Type.

Проще говоря:

import — для статики, доверия, малого объёма и без необходимости отмены. Всё остальное — fetch().

FAQ по import и fetch для JSON

А если я использую Webpack/Vite, что тогда выбирать?

Бандлеры умеют превращать import с type: 'json' в статическую подстановку на этапе сборки. Это безопасно и эффективно — данные попадают в бандл, нет лишних запросов. Но помните: бандлер не добавляет защиту MIME (её обеспечивает браузер в рантайме). Если вы собираетесь отдавать JSON отдельным файлом — используйте fetch с проверкой заголовков. Если JSON должен быть внутри JS-бандла — import подходит идеально.

Что быстрее: import или fetch?

Сам запрос — одинаково (оба используют HTTP/2+). Разница в постобработке: import парсит JSON один раз и кеширует навсегда. fetch парсит каждый раз при вызове response.json(). Для статики выигрывает import. Для динамики разница незаметна, но fetch даёт контроль над кешем через HTTP-заголовки.

Можно ли взломать JSON через import?

Нет, если вы используете with { type: 'json' }. Браузер потребует Content-Type: application/json. Если сервер отдаст что-то другое — импорт завершится ошибкой, и вредоносный код не выполнится. Это главное преимущество перед fetch, где вам нужно самим проверять заголовок.

А в сервис-воркерах работает import?

Динамический import() — нет (вызов в service worker или worklet завершится ошибкой). Статический import — да, но он загрузит модуль только один раз при установке воркера. Для частых обновлений данных в service worker используйте fetch() и кеш через Cache API.

Поддерживается ли import with type: 'json' в старых браузерах?

Фича стала baseline widely available с апреля 2025. Она работает в актуальных версиях Chrome, Firefox, Safari и Edge. Для старых браузеров (2024 и ранее) — не подходит. Используйте fetch() или транспиляцию через бандлер.

Почему я не могу сделать named import из JSON?

Спецификация JSON-модулей разрешает только один default экспорт. Это сделано намеренно, чтобы избежать конфликтов с потенциальными будущими полями JSON (например, если в JSON появится ключ then). Если вам нужен tree-shaking — используйте нестандартный синтаксис бандлера: import { version } from './package.json'. Но в чистом браузерном ESM это не сработает.

Заключение

import data from './file.json' with { type: 'json' } и fetch().json() — это не конкуренты, а инструменты для разных задач. Первый даёт безопасность MIME из коробки и пожизненное кеширование, но лишает контроля над отменой, стримингом и детальной диагностикой. Второй требует ручной проверки заголовков, но взамен предлагает гибкость на всех уровнях.

Главный критерий выбора — статичность данных. Если JSON не меняется в сессии, исходит из доверенного источника и имеет небольшой объём — import будет элегантным решением. В остальных случаях безопаснее и прагматичнее выбрать fetch() с обязательной проверкой Content-Type.

Возьмите чек-лист из этого раздела, примените к вашему текущему проекту и зафиксируйте решение в код-ревью. Теперь у вас есть все основания его аргументировать.


Дополнительные материалы

Предыдущая Статья

Проксирование fetch() в серверном JavaScript