Logo App House
ЦЕНЫ БЛОГ OPEN SOURCE

SPA - плохой выбор

React, Angular, Vue и другие SPA-фреймворки завоевали большую популярность в веб-разработке и вышли за её пределы, проникнув на мобильные и десктопные системы. И это настоящая трагедия.

Фото от Ferenc Almasi с Unsplash

Мне доводилось работать с разными фреймворками, я использовал разные подходы к разработке, видел удачные и откровенно плохие идеи. Сейчас я попробую убедить вас в том, что модель Single Page Application практически всегда является плохим выбором.

Сильные стороны

Продолжительная популярность не возникает просто так, всё-таки многие разработчики любят эти фреймворки. Что же они сделали правильно?

1. Переиспользуемые компоненты

Любые сложные задачи можно и нужно делить на более простые составляющие. Большинство SPA-фреймворков умеют превосходно работать с компонентами, у каждого из которых может быть своя логика и свой внешний вид.

2. Декларативный UI

Вместо того, чтобы описывать отдельные манипуляции с интерфейсом, разработчик описывает интерфейс как функцию, зависящую от состояния приложения. Проще говоря, UI = f(state), и это удобно.

3. Сохранение состояния при смене страницы

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

4. Кроссплатформенность

React Native, Electron и Expo позволяют существенно сократить время разработки приложений под большое количество платформ, просто используя одну кодовую базу.

Уникальные недостатки

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

1. Начальная страница не содержит полезных данных

Вместо непосредственного контента поcетитель SPA-сайта получает пустую страницу со скриптами, которые сначала необходимо загрузить, и лишь потом запросить данные от сервера, чтобы представить их пользователю. То есть мы всё равно запрашиваем важные для пользователя данные, но в одном случае мы делаем это сразу и получаем их в виде HTML, а в другом случае мы скачиваем их спустя некоторое время в машиночитаемом формате (например, JSON), преобразуем его в HTML и всё равно показываем пользователю. Такие усложнения плохо сказываются на времени загрузки и, следовательно, на пользовательском опыте.

Иногда даже загрузка JavaScript может стать проблемой, если разработчики не обращают внимание на размер скриптов. В критичных случаях пользователь загружает 20-30 МБ скриптов без сжатия или кэширования, чтобы показать более-менее статичную страницу. Это особенно проблематично на мобильных устройствах, которые не всегда обладают быстрым интернетом или производительным процессором.

2. Побочные эффекты как основа архитектуры

Чистые функции без побочных эффектов выглядят привлекательно, особенно когда речь идёт о пользовательском интерфейсе. UI может генерироваться много раз, и мы вряд ли хотим видеть непредсказуемые результаты.

В попытке упростить код React пришёл к функциональным компонентам, и многие другие фреймворки скопировали такое решение. Возможно, функциональные компоненты действительно требуют меньше кода, но вы только задумайтесь, что происходит на самом деле.

React часто упоминает чистые функции и побочные эффекты в своей документации, но на самом деле заставляет программистов соединять их воедино. Компоненты в React нередко самостоятельно хранят состояние, пользуясь useState. Если нужно изменить состояние при определённых событиях, React предлагает использовать useEffect. Эти функции являются определением побочного эффекта, и при этом постоянно используются в якобы чистых функциях.

Чистая функция не должна спонтанно вызывать себя заново, если только речь не идёт про рекурсию. Она не должна иметь доступ к какому-то глобальному состоянию, которое она сама же и создаёт при помощи useState. Однако всё это, к сожалению, происходит в React, и перетекает в другие фреймворки.

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

Хороший пример можно увидеть в фреймворке Phoenix, который самостоятельно хранит состояние компонентов (на стороне сервера!). Различные методы компонента (например, нажатия на кнопку или отправка формы) получают текущее состояние на вход и возвращают новое состояние. Фреймворк затем самостоятельно передаёт состояние на вход функции для генерации интерфейса. Эта функция не хранит состояние и не может его изменить.

Вот простой пример компонента в Phoenix. Сможете понять, что он делает?

defmodule WebApp.CounterLive do
  def render(assigns) do
    ~H"""
    Counter: {@counter}.
    <button phx-click="inc">+</button>
    """
  end

  def mount(_params, _session, socket) do
    counter = 0
    {:ok, assign(socket, :counter, counter)}
  end

  def handle_event(:inc, _params, socket) do
    {:noreply, update(socket, :counter, &(&1 + 1))}
  end
end

Почувствуйте разницу между настоящей абстракцией проблемы и возведением её в абсолют.

Неуникальные достоинства

Подливает масла в огонь и тот факт, что плюсы SPA-фреймворков отнюдь не уникальны, они доступны и в других парадигмах.

1. Переиспользуемые компоненты

На самом простом уровне компоненты доступны в большинстве старомодных фреймворков путём импорта одних HTML файлов в другие. В простых проектах это может покрыть все потребности разработчиков. При необходимости подобная система не слишком сложно реализуются самостоятельно. Одна из эталонных реализаций опять-таки существует в Phoenix, в котором вы получаете компоненты, полностью обрабатываемые на стороне сервера.

2. Декларативный UI

Независимо от того, на какой стороне будет генерироваться интерфейс, его можно генерировать декларативно. Сама методика генерации никак не привязана к исполнителю.

3. Сохранение состояния при смене страницы

Даже если нам нужно сохранить состояние компонентов, их количество обычно невелико, это может быть аудио-плеер внизу страницы или видео во сплывающем окне. Такие компоненты можно легко отделить от остальной части страницы. При переходе по ссылке небольшой скрипт может перехватывать нажатие, запрашивать обновлённый фрагмент с сервера и вставлять его в DOM.

4. Кроссплатформенность

Начнём с простого: не все кроссплатформенные решения используют SPA-фреймворки в своей основе, достаточно вспомнить Hotwire Native, Flutter, Kotlin Multiplatform.

Однако я бы зашёл чуть дальше и сказал, что возможность запустить на 100% идентичный код на нескольких платформах не всегда хорошо реализована. У всех систем есть свои особенности, и пытаясь угодить всем вы можете в итоге не угодить никому.

Довольно легко можно использовать общую логику, вынеся её в отдельную библиотеку. Но вот делить интерфейс может быть плохо как с точки зрения UI/UX, так и с точки зрения производительности. Я видел примеры, когда компании переписывали универсальные приложения на нативные, и пользователи подмечали, как эти приложения становились удобнее и быстрее (WhatsApp, Bitwarden).

Прогрессивное улучшение

Это не статья про то, что мы должны полностью отказаться от JS и вернуться в эпоху HTML 4.0, всё-таки интерактивность и отзывчивость тоже важны. Важно понимать, что SPA - не единственное решение.

Сейчас в качестве золотой середины я рассматриваю подход Progressive Enhancement, при котором базовый функционал доступен сразу после загрузки страницы, а продвинутые функции добавляются при помощи скриптов, если они поддерживаются. Например, на странице может быть кнопка для удаления записи, такой функционал в самом простом варианте не требует никаких скриптов. При нажатии на кнопку будет отправлена форма, и страница будет перезагружена. Если клиент поддерживает JavaScript, скрипт может перехватить отправку формы, и удалить запись без перезагрузки страницы.

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

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

Веб-стандарты огромны, реализовать их полностью крайне сложно. Стоит вам покинуть мир Chromium/Firefox, как внезапно на некоторых сайтах появляются разные неприятные моменты: сломанная разметка, нерабочие функции. Поэтому лучше использовать в основе самые простые функции, совершенствуя UX при возможности.

SSR не всегда решает проблему

Конечно же, проблема поздней загрузки интерфейса не осталась проигнорированной. Next.js, Nuxt.js и им подобные фреймворки сделали полный круг и перенесли рендеринг обратно на сервер. Иногда это действительно работает, надо отдать им должное, но только если вам повезёт.

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

Мне доводилось в небольшом проекте пытаться понять, почему Next.js добавляет тяжёлую библиотеку на страницу, и решение появилось лишь спустя несколько часов экспериментов. Будь это обычный HTML, я бы мог просто убрать соответствующий тэг <script>, но в Next.js разработчик не может их полностью контролировать.

Где допустимо использовать SPA

Давайте методом исключения докопаемся до случаев, в которых SPA-фреймворки могут аргументированно быть органичной частью проекта.

У вас простой информационный сайт, на котором пользователи не совершают большое количество действий на одной странице? Используйте обычный HTML.

Вы хотите иметь хорошую скорость загрузки, поддержку большого количества устройств? Используйте обычный HTML.

Однако если вы разрабатываете сложное приложение, в котором пользователи десятки раз на одной странице взаимодействуют с сервером, SPA может быть не самым плохим вариантом. Например: интерактивные приложения, такие как карты, редакторы изображений и документов. Чаще всего, на таких сайтах существует одна крайне сложная страница, на которой большую часть занимает canvas, управляемый скриптами. SPA-фреймворки в таком случае могут быть органичным выбором.

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

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

Клиент должен быть простым

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

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


Содержимое статьи доступно по лицензии CC BY 4.0, если не указано иное.

Список статей