Всем привет! Этот пост посвящен работе над ошибками. Поводом для него стал один развернутый комментарий к моей предыдущей статье от пользователя @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/

Всем пока!