
Всем привет! Этот пост посвящен работе над ошибками. Поводом для него стал один развернутый комментарий к моей предыдущей статье от пользователя @francyfox. Читатель откровенно высказал своё мнение и справедливо указал на слабые места проекта:
«...Странно, сайт сделан на next, но каждая страница подгружается так, словно ее нету в кэше... а смена локализации перезагружает страницу. А есть ли кейс в котором будут анонимные пользователи (без регистрации), как им пользоваться сайтом (избранные, лайки, правка категорий)?»
Этот комментарий заставил меня переосмыслить приоритеты. Я понял, что гнаться за новым функционалом, конечно, нужно, но иногда стоит остановиться и посмотреть на свою работу со стороны. Ведь если сайт «тормозит» и выглядит как первая поделка junior-разработчика — кому нужен этот функционал?
Настало время порефлексировать и посмотреть на результаты труда не с точки зрения разработчика, а глазами конечного пользователя. В этой статье я расскажу, как я боролся с производительностью (спойлер: это не решилось вставкой нескольких useMemo) и как переделал архитектуру для анонимных пользователей.
Статья будет полезна всем, кто думает над производительностью React, и незаменима для тех, кто пишет или планирует писать приложения на Next.js 16+ и React 19+.
Тестирование
Годы работы в суровом энтерпрайзе привели меня к выводу, что для бизнеса самыми ценными являются интеграционные тесты и UI-тесты. Unit-тесты — очень крутая штука, но они не показывают ситуацию на готовом продукте в целом. Можно обложиться unit-тестами у себя на сервисе, но если API отвалилось, то тесты будут зелёными, хотя продукт будет лежать. С какого-то времени я стал относиться к ним как к авто-документации кода. Как и любая документация, она важна, но в случае крупного продукта её будет недостаточно. Конечно, из этого правила есть исключения. К примеру, если ты тестируешь API-сервер, то вариантов, кроме unit-тестов, у тебя немного. Я не беру в расчёт ручное тестирование — это вершина QA-цепочки, мегалодон среди других видов тестирования.
Тесты на Playwright — это радость. Просто посмотрите на этот кейс:
import { test } from "@playwright/test"; test.describe("Archives", () => { test.beforeEach(async ({ page }) => { await page.goto("/archive"); }); test("should archives list be shown", async ({ page }) => { const archiveItemsLocator = page.getByTestId("archive-list-item-link"); const itemCount = await archiveItemsLocator.count(); test.expect(itemCount).toBeGreaterThan(0); }); test("should archive item page be shown", async ({ page }) => { const firstArchiveItem = page.getByTestId("archive-list-item-link").first(); await firstArchiveItem.click(); await page.waitForLoadState("networkidle"); const productsCountLocator = page.getByTestId("archive-price-list-count"); const productsCountText = await productsCountLocator.textContent(); const productsCount = Number(productsCountText); test.expect(productsCount).toBeGreaterThan(0); const archivePriceList = page.getByTestId("catalog-list"); await test.expect(archivePriceList).toBeVisible(); }); });
И это всё. Мы за 20 строчек полезного кода протестировали две страницы — список архивов и страницу архива. А сколько это заняло бы в unit-тестах? Это невероятно круто! На этом моменте главу про тестирование закончу, чтобы оставить вас переваривать новоприобретенное знание.
P.S. Про networkidle я знаю, что он может иногда давать плавающую ошибку на медленном соединении, но для GitHub Actions работает стабильно.
Кэширование и оптимизация
В комментарии абсолютно верно дали понять, что мой сервис тормозит. Когда сам им пользуешься, думаешь, что так и надо, и в целом — ок. Но когда тебе об этом говорят со стороны, стоит задуматься. По моим наблюдениям, основная проблема при разработке — это тестовые данные. Когда у тебя в тестовой базе 100 товаров, всё, конечно, будет летать. QA даст добро, потому что они тоже тестируют на тестовой базе, а потом этот код уйдёт в прод, где у тебя 100,000 товаров, и прод с треском упадёт. У меня есть пара баек о том, как один разработчик тащил из базы все записи без WHERE и уже на стороне сервера вычленял нужную. Ну это я отвлёкся :-)
Тестировать код будем на самой большой базе на текущий момент — на московской. Я выложил на Диск примеры прайс-листов для Самары и для Москвы, чтобы было понятно, с каким объёмом данных придётся работать: https://disk.yandex.ru/d/WmAx_Ht65pyE3g. Вот теперь, когда у нас есть данные, давайте заставим летать 8 МБ данных на клиенте без задержек.
Disclaimer: Ниже описаны все способы кэширования и оптимизации, которые я внедрил на две главные страницы: прайс-лист и обновления. На остальных страницах — в меньшей степени. К сожалению, не всё, что я хотел закэшировать, реализуемо в конкретном стеке. К примеру, кэширование страницы целиком в Redis нереализуемо по фундаментальным причинам в Next.js.
Как будем кэшировать:
Сборка (React Compiler)
Клиент (TanStack Query, Virtualization)
CDN (Картинки/Статика)
Сервер Next.js (unstable_cache, React.cache)
API/DB (Redis)
Первым делом (я бы даже сказал нулевым, пока ни одной строчки кода ещё не написано), следует включить React Compiler. Оптимизируя код в автоматическом режиме, мы получаем меньше узких мест, которые заставят проект тормозить. Начиная с 19-й версии в React появилась возможность автоматической мемоизации. Теперь не нужно вручную писать useMemo и useCallback — компилятор сам анализирует дерево зависимостей на этапе сборки. Включение его на уже крупном проекте — игра в лотерею. Всё может пройти хорошо, а могут возникнуть лютые сайд-эффекты, если на проекте уже решили поиграть в оптимизацию и компоненты обмазаны хуками. Это превращается в боль сначала для разработчика, а потом и для QA: данные не обновляются, когда должны, и наоборот — перерендериваются, когда не должны. Проще окропить сервер святой водой, чем пытаться всё это отладить.
Вот теперь можно включить StrictMode. Да-да, это тот самый процесс, когда в dev-окружении компонент рендерится дважды — и это не баг. В этом процессе React анализирует сайд-эффекты (в виде того же useEffect) и, искря варнингами в консоли, указывает на места, которые должны быть оптимизированы, не давая написать компоненты с потенциальными утечками памяти (Memory Leak). Бежать и искать, как его отключить, не стоит, потому что включить его позже на крупном проекте будет больно. Скорее всего, в консоли появится нескончаемая череда варнингов, пользоваться ей станет невозможно, а исправление всех предупреждений затянется очень надолго.
Следующим этапом оптимизируем код, разбивая его на чанки, чтобы отдавать всю глыбу JS-кода по кусочкам: при переходе со страницы на страницу грузится только то, что нужно. В Next.js всё работает из коробки, поэтому идём дальше.
Оптимизируем каталог с помощью TanStack Virtual. Именно для этих целей был добавлен московский прайс-лист. Самарский каталог с 600+ товарами работал худо-бедно — всё-таки разработчики из Facebook не зря оптимизируют React уже столько лет. Но когда я попытался отрендерить 5000+ товаров из Москвы, Google Chrome полностью лёг. Это как раз к слову о ситуации с тестовой базой.
Кэшируем все картинки в CDN, а сами изображения подключаем через lazy-загрузку. Сам сайт у меня хостится на Vercel, и CDN я получил почти бесплатно. Для всех остальных случаев есть imgix. В целом, там ничего сложного. Вся статика в виде тех же JS-скриптов тоже улетает на CDN.
Кэшируем AJAX-запросы на стороне клиента. Это очень важная штука. Если пользователь гуляет по страницам и перезаходит на одни и те же разделы несколько раз, тянуть одни и те же данные с сервера — это кощунство. Будем использовать TanStack Query — этот инструмент кэширует запросы по адресу и ключу, и если ответ уже есть в кэше, забирает его оттуда вместо похода на сервер. Пока это всё, что мы можем сделать на клиенте.
На стороне сервера возможностей в разы больше. Раз уж мы недалеко ушли от AJAX-запросов, то на сервере оптимизируем ответы с помощью gzip. Текст прекрасно сжимается, и 8 МБ исходных данных превращаются в 500 КБ при пересылке на клиент. Опять-таки, Nginx умеет это из коробки, и, скорее всего, у вас на проекте это уже настроено.
Теперь добавляем новый уровень кэша. Чтобы с запросом от клиента мы не ходили каждый раз к API-серверу, имеет смысл кэшировать ответы у нас на Next.js-сервере. Там есть функционал для работы с кэшем: unstable_cache и revalidateTag — первая функция хранит ответ от обёрнутой в себя логики, а вторая этот ответ очищает. На первый взгляд всё просто, но при работе с ними нужно быть максимально аккуратным. unstable_cache — это функция высшего порядка, и она может сделать только хуже, если обернуть её не там и не так. Или обернуть ею POST-запрос — пожалуйста, не делайте так. Если интересно — дайте знать, там есть о чём написать отдельную статью.
Следующий уровень — на стороне API-сервера. Там у меня находится Redis, который кэширует ответы от БД. В целом, классика. Думаю, в современном мире осталось не так много безумцев, у которых API каждый раз дёргает базу данных напрямую.
Если у вас React c SSR, скорее всего, вы используете этот SSR для общения со сторонними сервисами. В моём случае при каждом запросе к API я забираю токен у Clerk и передаю его вместе с полезной нагрузкой. Соответственно, если у меня рендерится 5 компонентов, я пять раз дёргаю Clerk. Чтобы избежать такого неоптимального DDoS'а в сторону стороннего сервиса, у React есть функция cache (Request Memoization). Это серверная функция, которая кэширует принимаемый ответ и при следующих вызовах отдаёт данные из кэша. Этот кэш живёт ровно один запрос к серверу. Как только страница отрендерилась и улетела клиенту, он очищается. Он не сохраняется между разными пользователями или разными заходами на сайт.
После того как проект полностью покрыт слоями кэша, встаёт проблема: как его теперь локально разрабатывать? Мне тут есть о чем рассказать, но раздувать статью ещё сильнее не хочется. Дайте знать в комментариях, если эта тема интересна.
Гость — это тот же самый пользователь, просто он об этом ещё не знает
В комментарии меня справедливо спросили: «А есть ли кейс…». И это попало в точку. Я искренне полагал, что новоприбывшие гости сразу же будут конвертироваться в пользователей, ради которых я и вылизывал весь функционал. Оказывается, нет: если гостю неудобно и ему нужно регистрироваться ради непонятно каких функций, то он справедливо решит, что и в роли авторизованного пользователя ему будет так же некомфортно. Да ещё и почту свою отдавать непонятно кому.
Проблема ясна, но с решением пришлось повозиться. Нужно как-то сохранять информацию гостя. Для меня было критично свести к минимуму количество AJAX-запросов. Я искренне ненавижу экраны загрузки, поэтому хочу, чтобы при заходе в «Избранное» товары рендерились мгновенно. Такой подход сразу отметает LocalStorage или IndexedDB: бэкенд о них не знает, значит, придётся делать экран загрузки и лишние запросы с клиента. Лучшим вариантом остаются cookies. Они доступны с сервера, но их размер равен всего 4 КБ. Этот подход тоже отпал, так как у меня один сохранённый товар занимает ~1 КБ. Вроде бы тупик: где найти базу данных для гостей, которая будет работать по принципу key=value (как куки), но с большей вместимостью?
Взглянем на архитектурную схему:

У меня уже есть Redis для хранения кэша БД, который почти не нагружен, так как большая часть данных обновляется раз в сутки. Чем не рабочая база данных для гостей?
Теперь внутри cookies в браузере живёт только одна строчка со сгенерированным randomUUID — это ID гостя. По этому ID в Redis лежит запись со всеми теми же полями, которые есть у реального пользователя в базе MongoDB. Срок жизни кэша и куки я поставил в один месяц, но если гость будет заходить и взаимодействовать с сайтом (добавлять в избранное, менять секции), то кэш и куки будут продлеваться и смогут жить бесконечно.
Теперь обычный гость имеет все те же плюшки, что и авторизованный пользователь, только без регистрации и СМС :-) Разве что получать обновления по своим избранным товарам на почту не может по понятным причинам. Технически для меня это теперь просто полноправный пользователь. С моей стороны добавилось работы с проверкой функционала для двух ролей, но я с этим справлюсь. Главное, чтобы пользователь сайта был доволен.
На этом этапе я понимаю, что проект выходит за рамки классического пет-проджекта и обретает форму полноценного production-решения.
Продолжение следует
К сожалению, статья получилась очень объёмной. Боюсь, что она и так уже слишком техническая, и её может ждать участь моего прошлого поста про инфраструктуру. Поэтому я разбил материал на две части. В следующей стат��е подробнее расскажу про работу с требованиями, про UX/UI, про мои попытки сделать самый лучший в мире каталог, про сложности работы с виртуальным списком товаров, желание сделать из строки поиска полноценный Spotlight и ещё много чего интересного.
Ну и немного о поиске работы
В последнее время у меня на собеседованиях часто спрашивают про всплытие событий и отличия useEffect от useLayoutEffect. Это база и это важно, но имея опыт настройки React Compiler, внедрения TanStack Virtual для рендеринга тысяч элементов и построения гибридного кэширования (от Redis до unstable_cache), я смотрю на разработку шире. Мне интересно обсуждать, как эффективно «подружить» Clerk с SSR-мемоизацией, как оптимизировать LCP на тяжелых каталогах и как построить DX, используя Storybook и Playwright на максимум. Если вам нужен разработчик, который понимает, почему 8 МБ данных в браузере — это проблема, и знает, как решить её архитектурно — пишите! Все ссылки и контакты у меня на сайте: https://ebulgakov.com/
Всем пока!
