Обновление SPA приложения в браузере пользователя Node/React

08d491e27ee3cc8e114a9d08dbbd5e10

Всем привет. Мне читатели иногда присылают сообщения с одним и тем же вопросом, что ты же Software Engineer и Solution Architect, но почти все твои статьи касаются бизнеса, менеджмента, процессов, управления командами и так далее. Но нет статей технического характера, про разработку и создание разных фич (feature) для проекта. Причина по которой это происходит в том, что весь интернет забит информацией о том, как программировать, но очень мало информации о том, что именно программировать, и о том, что за пределами кодинга огромное количество нерешенных проблем, которые нивелируют весь процесс программирования. Но сегодня я расскажу об одной фиче, которая может оказаться очень полезной для многих.

Вот на этой страничке Technical info можно увидеть технологии и список функционала (фич), которые я применял для создания сервисного маркетплейса. Список достаточно большой, и каждое решение было проработано и создано именно под бизнес задачу, а не как это обычно бывает, найдено на просторах интернета. И сегодня я хочу рассказать об одной очень интересной фиче, о важности которой многие даже не догадываются. А именно о том, как можно перезагрузить SPA приложение в браузере пользователя.

Начнем с того, что такое SPA (Single-page application) приложение. Простыми словами — это подход к разработке фронтенд части онлайн приложения, как единого JavaScript приложения. По сути вся html структура создается с помощью JavaScript. Это абсолютно другой подход, по сравнению с классическим, где страница генерируется на стороне сервера, путем вставки данных в html шаблон и передачи всей страницы в браузер пользователя.

В чем плюсы и минусы SPA и зачем его вообще придумали

Самая главная проблема классического подхода к генерации страниц на стороне сервера — это производительность, сложность поддержки и масштабирования. Каждый запрос со стороны клиента заставляет сервер заново генерировать страницу для браузера пользователя. А это приводит к большой нагрузке на сам сервер, а именно, на память и процессор. И чем больше пользователей сейчас на сайте, тем выше эта нагрузка. А также, большая нагрузка на сетевой трафик. Так как страница для сайта может занимать большой размер, особенно со сложной структурой и версткой, то это означает, что вся страница, которая возвращается на сторону клиента, будет отъедать ширину канала, который не безграничен. И еще одна большая проблема классического подхода — это невозможность быстрого масштабирования и создания сложных интерфейсов. 

Исходя из тех минусов классического подхода, о котором я сказал выше, и появилось решение, которое представляет из себя JavaScript приложение, которое работает только на стороне клиента, и выполняется в браузере пользователя. По сути, это нужно рассматривать как приложение, которое работает как единое целое, без необходимости перезагружать страницу. Ненужность перезагрузки страницы — это ключевой момент, который нужно запомнить. Об этом речь пойдет дальше. Что в итоге это дает. Возможность разрабатывать сложные и гибкие интерфейсы, формы и другие интерактивные элементы, которые очень сильно влияют на качество восприятия пользователей сайта (юзабилити). Легкое переиспользование уже созданного функционала. Возможность относительно простой поддержки кода и быстрого масштабирования проекта. Что касается быстрого масштабирования, то нужно не забывать, что тут важна продуманная архитектура. Просто использование какого-либо фреймворка не является само по себе архитектурой. Этот момент, кстати, очень часто игнорируется многими командами, что в последствии приводит к проблемам на проекте. Следующий плюс — это то, что приложение работает у каждого пользователя в его браузере, что снимает необходимость в нагрузке на сервер для генерации страниц, и снимает сетевую нагрузку. Так как SPA загружается один раз в браузер, а дальше получает и отправляет только данные по сети. А уже полученные данные будут вставлены JavaScript в страницу. Другими словами, скорость работы SPA намного выше, чем классический подход.

Сразу отмечу один момент, который я наблюдаю чаще, чем бы мне хотелось. Достаточно многие программисты гоняют по сети сумасшедшие объемы данных, такие как CSS стили, огромные JSON с какими-то настройками, большущие куки, с каждым запросом, как на сервер, так и на клиента. И таким образом полностью убивают главную идею SPA, как легкость и скорость. Но сегодня не об этом. 

В чем же минусы одностраничных сайтов (SPA). Главный минус — это сложность. Здесь вся разработка ведется на JavaScript. Для помощи существуют разные библиотеки и фреймворки, которые помогают в этом процессе. Например, я предпочитаю React, на котором я создаю фронтенд часть еще с момента его появления, то есть более 10 лет. Поскольку я еще и архитектор, то я не использую дополнительных тяжелых фреймворков, а пишу код на базе разработанной бизнес архитектуры. Но это редкий кейс. Я не ханжа, и сам использовал тяжелые фреймворки более 10 лет на разных языках программирования (Symfony/Laravel/Django/RoR/Nest/Next и другие). Но всегда происходит одна и та же проблема, при разрастании проекта и его сложности, начинается борьба именно с фреймворком и его ограничениями. Слишком много времени начинает тратится не на создание бизнес логики, а поиск решений и временные костыли, которые все хотят исправить, но как-то потом. Далее происходит мажорное обновление фреймворка, и работа вообще останавливается, так как требуется все переделать и заставить работать текущий код. И этому нет конца. Поэтому, мой выбор — это очень минималистичные фреймворки, которые дают только самый необходимый базис, например Express.js. Но это требует строгой дисциплины, общей спецификации и высокой квалификации для команды разработки. Я об этом уже создавал статью ранее: «Организация рабочего процесса в команде на IT-проекте». Я обратил внимание, что многие программисты идут по пути изучения именно фреймворков, но не очень глубоко понимают JavaScript. Не синтаксис языка программирования, а именно его достоинства, недостатки и его философию, как простого, но мощного функционального языка. Плюс не понимают базовых принципов того, как же это все работает под капотом самого фреймворка. Я ни кого не хочу обидеть, и конечно же есть очень крутые специалисты, но, к сожалению, это факт. И такое положение дел часто приводит к утечкам памяти, тормозам, а то и зависаниям приложения, а также к оверкодингу. То что можно сделать парой строчек кода, превращается в большое количество файлов, лишних пакетов и каким-то странным решениям. Таким образом, опять подчеркну, что главный минус SPA — это высокая сложность, которая требует от разработчиков высокой квалификации и архитектурного мышления. 

Следующий минус — это проблемы с СЕО продвижением сайта. Только Google умеет обрабатывать сайты на JavaScript, и то, делает это очень плохо. Другие поисковики этого вообще не умеют делать. Именно по этому нужно дополнительно продумывать SSR (Server side rendering — серверный рендеринг), чтобы «скармливать» поисковым ботам не JavaScript, а уже готовую страничку на html, которая содержит необходимый контент. Только тогда поисковые боты смогут ее правильно проиндексировать. Но об этом я тоже расскажу в другой раз.

И последний минус SPA — это большой размер самого приложения, которое нужно загрузить в браузер пользователя. То есть первоначальная загрузка может быть медленной. Хотя этот вопрос тоже легко решаем, если продумывать архитектуру. А именно, модульность в приложении с ленивой подгрузкой решает эту проблему. Разбиение финального бандла на чанки, плюс использование gzip сжатия при передаче приложения от сервера до браузера пользователя. Вообще, оптимизация приложения — это отдельный вид искусства. Но многими разработчиками, почему-то, это полностью игнорируется. Возможно все дело в том, что чтобы что-то оптимизировать, необходимо очень глубоко уходить в тестирование приложения. А разработчики, как правило, очень не любят это делать и ограничиваются автотестами, которые не на оптимизацию нацелены. 

Итак, когда мы разобрались с тем, что такое SPA, мы можем вернуться к основной теме топика. Какая же еще есть не совсем очевидная проблема с такими сайтами. Помните, выше по тексту я говорил о том, что нет необходимости перезагружать страницу для SPA. Все что пользователь загрузит к себе в браузер, будет закэшировано в браузерном кэше. А значит пользователь будет работать с этим приложением всегда, так как весь JavaScript хранится на его стороне. А теперь представим себе ситуацию, когда команда разработчиков выкатывает на сайт новый релиз. Например, меняет бэкенд часть, или фронтенд часть (в идеале, бэкенд и фронтенд всегда должны быть разделены, как независимые проекты). На бэкенде изменилось АПИ, а на фронтенде переделали какую-то форму. Что же тогда произойдет с клиентским приложением, которое уже устарело в браузере пользователя? В лучшем случае, он начнет получать какие-то странные ошибки от сервера. В худшем случае, его фронтенд приложение просто закрашится. И в первом и во втором случае, это очень сильно снижает доверие клиентов к вашему продукту.

Проблема ясна, теперь нужно поставить задачу для ее решения. Задачу можно сформулировать следующим образом: При деплое на бэкенд или фронтенд необходимо сделать перезагрузку SPA в браузере пользователя, чтобы с сервера подтянулось новое JavaScript приложение.

Теперь, зная задачу, нужно начать думать, как ее решить. В первую очередь необходимо выделить базовые требования: решение должно быть простым и универсальным. Это означает, что кода должно быть минимум, а решение не должно зависеть от разных технологий и языков программирования. Конечно же, синтаксис будет отличаться для разных языков программирования, но сама базисная идея и ее реализация не должны быть зависимыми. То есть мы говорим об универсальной концепции.

Начинаем анализировать дальше, какие же инструменты у нас есть для доступа к браузеру пользователя с сервера. Допустим, что это вебсокеты, но проблема в том, что коннект может быть потерян в любой момент. Значит не годится. Стучаться с фронтенда на бэкенд по таймауту. Это вариант, но такого рода подходы могут приводить к утечкам памяти и непредвиденному поведению. Лучше вообще избегать такие вещи, как бесконечные запросы по таймауту. Нужно еще не забывать, что клиент может отправить компьютер в режим сна или гибернации, что браузер может находиться в фоне, и еще разные ситуации, о которых сложно заранее догадаться. А что у нас есть универсальное, что работает всегда на 100%? Конечно же http заголовки. Мы в них и так передаем JWT токен, текущую локаль и много еще чего. Наше SPA приложение в любом случае отправляет заголовки на сервер при любом запросе. А что, если создать новый заголовок, например,  client-versions, который мы будем отправлять из фронтенд приложения на сервер. Основная идея в том, чтобы при каждом запросе с фронтенда на сервер передавать текущие версии приложений (бэкенд + фронтенд), которые хранятся в локальном хранилище, и сравнивать с актуальными версиями, которые хранятся на сервере. При этом, делать это нужно только для тех пользователей, которые в данный момент залогинены и работают с приложением. Так как гости, которые будут заходить на сайт, и так подгрузят последнюю версию SPA.

Хотя я и знаю разные языки программирования, но предпочитаю использовать JavaScript. Для бэкенда использую Node + Express.js, а для фронтенда — React. Это не из-за какой-то особой любви или хайпа, а сугубо, как стратегический подход в разработке. Это позволяет мне сосредоточиться на бизнес задачах, не переключая постоянно мозги из одной технологии на другую. Плюс команда, которой я управляю, без проблем может создавать, как бэкенд часть, так и фронтенд. Все это сильно увеличивает производительность и снижает разные риски. При создании архитектуры использую подход распределенного монолита, основанного на модульных сервисах со слабой связью. Это позволяет полностью контролировать весь функционал, а в случае необходимости, быстро распилить все на микросервисы. А также дает возможность быстро собирать новые проекты, путем переиспользования уже созданного функционала. Именно поэтому все примеры будут на JavaScript.

Итак, с чего начать. Нам нужно как-то получать версии бэкенда и фронтенда, а также их где-то хранить. Что использовать в виде версий, и где их хранить, не особо принципиально. Самое главное, чтобы при каждом деплое фронтенд части или бэкенд, эти версии обновлялись. В идеале, бэкенд приложение и фронтенд приложение, должно иметь свои отдельные git репозитории. А это означает, что при каждом деплое, вы можете получить последнюю версию коммита, а именно, его номер, как для бэкенда, так и для фронтенда. Например:  

git rev-parse HEAD

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

RELEASE=$(tail -n 1 ../.deploys | tr -d '\n')echo $RELEASE > version.back

Как вы заметили в примере, то я использую файл для хранения версии для бэкенда с названием version.back Он хранится в корне бэкенд проекта на сервере. Для фронтенд части все тоже самое, только файл с названием version.front храниться также рядом в корне именно бэкенд проекта. Это нужно, чтобы нода могла легко в одном месте находить версии для их проверки. То есть в пайплайнах (конфиг для автодеплоя) и бэкенд части и фронтенд части, нужно получить последние версии коммитов для вашей git ветки деплоя, и сохранить их в файлы версий, перезаписав старые значения.

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

import { readFile, writeFile } from 'node:fs/promises';
import path from 'path';

const appDir = path.resolve('./');

/**
 * Get current release version
 */
export const getCurrentReleaseVersion = async () => {
  let backend,
    frontend = null;

  try {
    backend = await readFile('version.back', 'utf8');
    backend = backend.trim();
  } catch (err) {
    console.error(appDir, err);
    await writeFile(`${appDir}/version.back`, '0');
  }

  try {
    frontend = await readFile('version.front', 'utf8');
    frontend = frontend.trim();
  } catch (err) {
    console.error(appDir, err);
    await writeFile(`${appDir}/version.front`, '0');
  }

  if (!backend || !frontend) return { backend: 0, frontend: 0 };

  return { backend, frontend };
};

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

  /**
   * Get release versions
   */
  getVersions = () => {
    const auth = parseJSON(localStorage.getItem('auth'));
    return auth?.versions;
  };

  /**
   * Update release versions
   *
   * @param {object} versions data
   */
  updateVersions = (versions) => {
    if (!versions) return;

    const auth = parseJSON(localStorage.getItem('auth'));
    if (!auth) return;

    auth.versions = versions;

    this.setAuthData(auth);
  };

Функция updateVersions сохраняет/обновляет данные с версиями внутри авторизационных данных пользователя в локальном хранилище. А первая функция получает эти данные. parseJSON — это кастомная функция с обработкой возможных ошибок. Именно этот пример имеет отношение к mobX файлу. Но смысл сохранения данных в локальном хранилище, думаю понятен.

Теперь мы умеем получать версии, сохранять их в файлы и передавать от сервера в браузер пользователя и сохранять в локальном хранилище. Следующий шаг — это отправка http заголовка с этими версиями при каждом запросе на бэкенд. Поскольку я использую graphQL, то у меня на фронтенде установлен Apollo graphQL. Поэтому все запросы и ответы на бэкенд происходят через аполло контроллер. По факту, это единая точка входа для всех запросов на фронтенде, как для отправки запросов, так и для ответов. Если вы используете fetch или axios, то вам все равно нужно создать свой контроллер, через который будут обрабатываться все запросы и ответы. Это необходимо для того, чтобы в одном месте гибко управлять всеми заголовками, а также отлавливать все ошибки от сервера. Некоторые ошибки можно отловить в контроллере и сразу на них реагировать, а некоторые отдать дальше в код, где был вызван запрос к серверу. По сути вы строите гибкую абстракцию, что избавляет вас от необходимости вносить изменения по всему коду в случае необходимости.

 // Generate request header with token(before sending request)
 const authLink = setContext((_, { headers }) => {   
   // Get token from auth store(localStorage)
   const token = AuthStore.getToken();
   const versions = AuthStore.getVersions() || {};
   // Return the headers to the context so httpLink can read them
   return {     headers: {       ...headers,
       'Accept-Encoding': 'gzip, compress, br',
       Authorization: token ? Bearer ${token} : '',
       'User-Locale': i18n.language,
       'Client-Versions': JSON.stringify(versions),
     },
   };
 });

Как можно увидеть, то мы добавляем в заголовок информацию о JWT токене, локале пользователя, а также и версии, которые получаем из локального хранилища. Следующий шаг — это получить заголовки в middleware бэкенда и проверить версии, но только в том случае, если пользователь имеет JWT токен (залогинен): ​​

  // ------------------------------ Check versions of releases ----------------------------------
  const versions = parseJSON(req?.headers?.['client-versions']);

  if (typeof versions === 'object' && token) {
    const backend = String(versions?.backend).trim();
    const frontend = String(versions?.frontend).trim();

    const status = await checkReleaseVersion({ backend, frontend });

    // Need to reload frontend application (add new versions to header)
    if (!status) {
      // Make registration the new header
      res.append('Access-Control-Expose-Headers', 'new-release-versions');

      // Send the new release versions to the frontend
      res.header({ 'new-release-versions': JSON.stringify(await getCurrentReleaseVersion()) });

      res.status(STATUS_426).type('application/json');
      return res.send({ error: { message: i18next.t('errors.updateApp', { lng: res.locale }) } });
    }
  }
  // --------------------------------------------------------------------------------------------

В функции checkReleaseVersion мы проверяем, соответствуют ли полученные с фронтенда версии тем, которые хранятся на сервере или нет. Если версии верные, то ничего не делаем, а вот если есть различие и статус у нас = false, тогда создаем новый заголовок new-release-versions,  куда помещаем актуальные версии и возвращаем ошибку 426 на фронтенд с новым заголовком. Ошибка 426 (Upgrade Required) — это стандартная серверная ошибка, которая говорит о том, что требуется обновление. По сути можно вместо номера ошибки использовать что угодно. Главное чтобы и бэкенд и фронтенд использовали тот же самый словарь ошибок. После того, как сервер прекратил выполнение запроса и вернул ошибку, что версии неверные, нам нужно получить эту ошибку на фронтенде, извлечь из заголовка новые версии, сохранить их в локальное хранилище, и сделать перезагрузку страницы в браузере пользователя:

// Catch network's error
const errorLink = onError(({ graphQLErrors, networkError }) => {
  let foundError = false;

  // If incorrect release version
  if (networkError?.response?.status === STATUS_426) {
    const errorMessage = networkError?.result?.error?.message;
    foundError = true;

    toast.info(NotificationText({ message: errorMessage }), {
      position: 'top-right',
      closeOnClick: true,
      pauseOnHover: true,
      autoClose: 3000,
      theme: 'light',
      onClose: () => {
        window.location.reload();
      },
    });

    // Get new release versions from response headers
    const newVersions = parseJSON(networkError.response.headers.get('new-release-versions'));

    // Make updating release versions in local storage
    AuthStore.updateVersions(newVersions);

    // Stop execution JS code
    window.stop();
  }

   // Other code ...
 });

Здесь произойдет следующее, аполло отловит 426 ошибку, которую мы создали на сервере, затем выведет сообщение пользователю, в котором будет написано, что есть обновление на сайте и сейчас произойдет автоматическое обновление страницы. Это нужно чтобы пользователь не пугался, что происходит какой-то странный глюк. Сообщение будет показываться в течении трех секунд, и когда оно закроется, или когда пользователь закроет сообщение сам, то произойдет полная перезагрузка страницы. При этом выполнение всего кода будет остановлено. А перед перезагрузкой, новые версии будут записаны в локальное хранилище. Сразу после перезагрузки с сервера подтянется новая версия SPA и все будет работать, как и положено.

В будущем, если понадобится сделать сохранение статистики версионности по пользователям, то это решение можно легко доработать.

Ну вот, в принципе, и все. Общего кода, как кот наплакал. Мы создали очень простой алгоритм, который полностью универсален и прост в использовании, но при этом очень полезен, и позволяет клиентам доверять продукту, а также всегда иметь последнюю версию приложения. Как видите, тут самое главное, это не программирование, а системный анализ и архитектурный подход. Всем удачи на проектах.

© Habrahabr.ru