1. 1
  2. 2
  3. 3
  4. 4
  5. 5

# теория · шаг 2 из 5

SPA: одна строка и три ловушки вокруг

Коротко

Одностраничное приложение держится на одной строке: «есть файл - отдай файл, нет файла - отдай index.html». Всё остальное в уроке - про то, что ломается вокруг неё.

  • Строка: try_files $uri /index.html;. Корень - каталог сборки (dist), а не корень репозитория.
  • Опечатка в адресе API вернёт не 404, а index.html с кодом 200. Фронтенд получит HTML вместо JSON и упадёт с сообщением, которое ни на что не указывает.
  • Пропавший ассет (файл сборки: скрипт, стиль, шрифт, картинка) ведёт себя так же: 200 и HTML вместо честного 404.
  • Лечение - не переписать строку, а поставить рядом ещё два блока: /api/ и /assets/ с try_files $uri =404.
  • Четвёртая беда - index.html в кеше браузера, ей посвящена следующая глава.

Если про три блока рядом с fallback знаешь - листай до «Теперь сам».

Сначала ответь сам

Конфиг ровно такой, как советует любая инструкция:

/etc/nginx/conf.d/shop.confhttp server
root /var/www/shop/dist;
index index.html;

location / {
    try_files $uri /index.html;
}

Фронтенд просит /api/carts, а в адресе опечатка - у бэкенда endpoint называется /api/cart. Что придёт в ответ?

Инстинктивный ответ - 404, и фронтенд покажет ошибку. Настоящий - 200 и index.html. Приложение получит HTML там, где ждало JSON.

На что это похоже

Fallback - это дежурный, который всех отправляет в справочную.

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

В конфиге роль такого дежурного играет location /. Он не знает, что /api/ - это не адрес страницы, а /assets/ - не маршрут приложения. Различать их надо до него.

Механизм: строка, ради которой всё затевалось

/etc/nginx/conf.d/shop.confhttp
server {
    listen 80;
    server_name shop.local;
    root /var/www/shop/dist;
    index index.html;

    location / {
        try_files $uri /index.html;
    }
}

/assets/app.4f2c1b.js - файл есть, отдаётся файл. /catalog/42 - файла нет, отдаётся index.html, и роутер приложения разбирается сам. Пользователь обновляет страницу на глубоком адресе и не получает 404 - ради этого строка и нужна.

Корень - каталог сборки. Не корень репозитория: там лежат исходники, .env и node_modules, и всё это стало бы публичным.

запрос блок если файла нет /api/carts location /api/ ответ бэкенда /assets/app.js location /assets/ честный 404 /catalog/42 location / index.html Три блока - три разных ответа на «файла нет». Без первых двух все три вопроса получают третий ответ.
Именно это разделение отличает рабочий конфиг SPA от строчки, скопированной из инструкции.

Правило

Fallback на index.html ставится только на маршруты приложения. У API и у каталога сборки должны быть свои блоки, отвечающие честно.

Разбор: что происходит без выделенных блоков

Проверено на nginx 1.31.5. Файлов /api/carts и /assets/propal.js на диске нет. Сначала конфиг из одной строки - тот, что выше:

$  команда
for u in /catalog/42 /api/carts /assets/propal.js; do printf '%-20s -> ' $u; curl -so /dev/null -w '%{http_code} %{content_type}\n' localhost$u; done
↳  выводfor u in /catalog/42 /api/carts /assets/propal.js; do printf '%-20s -> ' $u; curl -so /dev/null -w '%{http_code} %{content_type}\n' localhost$u; done
/catalog/42          -> 200 text/html
/api/carts           -> 200 text/html
/assets/propal.js    -> 200 text/html

Теперь с тремя блоками. На /api/ отвечает подставной бэкенд - nginx на порту 8080 с return 404 и JSON, как настоящее API на неизвестный адрес:

/etc/nginx/conf.d/shop.confhttp server
root /var/www/shop/dist;
index index.html;

location / {
    try_files $uri /index.html;
}
location /api/ {
    proxy_pass http://127.0.0.1:8080;
}
location /assets/ {
    try_files $uri =404;
}
↳  выводfor u in /catalog/42 /api/carts /assets/propal.js; do printf '%-20s -> ' $u; curl -so /dev/null -w '%{http_code} %{content_type}\n' localhost$u; done
/catalog/42          -> 200 text/html
/api/carts           -> 404 application/json
/assets/propal.js    -> 404 text/html

-o /dev/null выбрасывает тело ответа, а -w печатает код и тип содержимого.

Вторая строка первого замера и есть ответ на вопрос из начала урока. Фронтенд получил HTML, попытался разобрать его как JSON и упал с сообщением вида Unexpected token '<'. Отладка съедает вечер, потому что сообщение указывает на что угодно, только не на конфиг веб-сервера.

Третья строка - та же беда с ассетами. Удалённый шрифт или картинка вернут index.html с кодом 200. Браузер не покажет ошибку, просто нарисует пустоту, а во вкладке «Сеть» будет успешный запрос со странным типом содержимого.

Разбор: где наивное правило подводит

Наивное правило: «поставил три блока - конфиг SPA готов».

Готов, кроме одного, и это самая дорогая из бед. Имена файлов сборки содержат хеш содержимого (app.4f2c1b.js) - короткий отпечаток, который сборщик вычисляет из самого файла: поменялся файл - поменялось имя. Ссылается на них index.html. Если сам index.html закешировался у пользователя надолго, браузер будет тянуть старые ассеты по старым именам - и человек останется на предыдущей версии приложения, не подозревая об этом.

Особенно неприятно то, что у тебя это не воспроизведётся: ты выкладывал, у тебя кеш свежий. Жалобы придут от части пользователей, будут выглядеть как «у меня не работает кнопка» и лечиться советом нажать Ctrl+Shift+R.

Разбору пары «html не кешируем, ассеты кешируем навсегда» посвящена следующая глава. Пока достаточно знать, что конфиг SPA без решения про кеш не закончен.

Что ломается без этого

  • Unexpected token '<' во фронтенде. API-запрос получил index.html. Проверь, есть ли блок /api/ и стоит ли он раньше fallback.
  • Пустое место вместо картинки, а в «Сети» код 200. Ассет не найден и подменён страницей. Нужен try_files $uri =404 в блоке сборки.
  • Публичными оказались .env и node_modules. Корнем указан репозиторий, а не каталог сборки.
  • Часть пользователей сидит на старой версии приложения. index.html в кеше браузера.

Зачем это в работе

Проверять конфиг SPA надо не главной страницей - она откроется всегда, - а тремя запросами на несуществующие адреса:

$  команда
curl -so /dev/null -w '%{http_code} %{content_type}\n' https://сайт/catalog/42
curl -so /dev/null -w '%{http_code} %{content_type}\n' https://сайт/api/net-takogo
curl -so /dev/null -w '%{http_code} %{content_type}\n' https://сайт/assets/net-takogo.js

Правильный ответ: первый - 200 и HTML, второй и третий - не 200 и не HTML. Если все три одинаковые, конфиг состоит из одной строки, и вечер на Unexpected token уже оплачен, просто счёт ещё не пришёл.

Это же три проверки стоит держать в тестах выкладки: они ловят потерянный блок /api/ быстрее, чем это заметит фронтенд.

Теперь сам

1. Зачем location /assets/ { try_files $uri =404; }, если ассеты и так лежат на диске?

Затем, что однажды не лягут: сборка сломалась, файл переименовали, деплой прошёл наполовину. Без этого блока пропажа выглядит как успешный запрос, и дефект доезжает до пользователей молча.

2. Нужен ли $uri/ в списке кандидатов SPA-конфига?

Обычно нет: в типичной сборке каталогов с index.html внутри не бывает, и $uri/ добавляет лишнюю проверку каталога на каждый запрос. Он нужен, если рядом живёт статика в старом стиле - /docs/ с собственным index.html.

3. Почему блок /api/ обязан стоять раньше fallback, если приоритет location определяется не порядком строк?

Он и не про порядок строк: location /api/ - более длинный префикс, чем location /, и выигрывает первую фазу. «Раньше» тут значит «до того, как запрос дойдёт до fallback по логике выбора», а не «выше в файле». Порядок в файле решал бы, только если бы оба блока были регулярками.

Главное

SPA держится на try_files $uri /index.html - «нет файла, отдай приложение». Рядом обязаны стоять ещё два блока: location /api/, иначе ошибки API превращаются в HTML с кодом 200, и location /assets/ с try_files $uri =404, иначе пропавший файл притворяется страницей. Корень - каталог сборки, а не корень репозитория.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.