Раньше я шутил, что историю фронтенд-разработки можно проследить по спорам о том, кому принадлежит состояние. И правда в том, что за прошедшие годы обе стороны бросались из крайности в крайность. Мы начинали с «документного» веба, где состояние хранилось на сервере. Затем мы добавили JavaScript и апплеты, позволивтивные размещать элементы с состоянием на стороне клиента. Но разработчикам того времени не особо нравилось ни то, ни другое, и они пытались связать состояние с обеих сторон с помощью серверных языков — так появились ASP.NET и тому подобное.

Я сижу здесь более 30 лет спустя, и это продолжается до сих пор. Замените свое React-приложение на HTMX? Возможно. А может, всё не так просто. Хотя это кажется бесконечной спиралью, я не думаю, что проблема неразрешима. Несмотря на то, что это длится так долго, мне кажется, ответ находится прямо перед нами.

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


Основные зоны ответственности

Мое наблюдение заключается в том, что все фронтенд-архитектуры сводятся к трехуровневой структуре ответственности. Какие-то уровни выражены ярче, какие-то слабее, но все они присутствуют в той или иной форме.

1. Навигация (клиент)

URL — это основа веб-опытa, и он принадлежит браузеру. Оркестрация — это ответственность клиента, и с этим согласны все решения, от SPA до частичного рендеринга HTML (HTML partials). JavaScript в браузере — это корень нашего приложения.

2. Контент (сервер)

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

3. Интерактивные возможности / Аффордансы (клиент)

Интерактивные возможности возвращают нас в браузер. Локальное состояние UI, незавершенная работа, оптимистичные обновления. Нам нужно предоставлять пользователю обратную связь быстрее, чем за один сетевой запрос к серверу и обратно.


Следование форме

Все архитектуры веб-приложений следуют этому принципу многоуровневости: клиент -> сервер -> клиент. Пользователь переходит по страницам или выполняет действие, сервер возвращает контент, а клиент фиксирует результат. Это архитектура Single Page Application (SPA) после загрузки, но это также и HTMX. А также LiveView, если протокол взаимодействия сервера с клиентом достаточно мощный.

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

Именно эта асимметрия делает систему компонуемой. Ее нарушение приводит к тому, что две системы с собственными состояниями начинают бороться друг с другом. Именно поэтому такие возможности, как оптимистичные обновления (уровень 3), органично ложатся поверх, если рассматривать их как оверлей. Клиент никогда не записывает серверный контент напрямую, поскольку он ему не принадлежит.


Новый взгляд на спектр

BhCbuUoS3S5csqZDsfxD5VN0H1hLX8y7Tt7LdAo8.webp

Я много раз пытался разместить пространство решений в сетке из 4 квадрантов, но так и не нашел правильную систему координат. Однако теперь я вижу, как все решения могут уместиться на одной диаграмме.

Очевидно, это примерное расположение: решения занимают области, а не точки, и что-то вроде React может использоваться в качестве фронтенда в движке синхронизации вроде Zero. Здесь React представляет классические SPA + JSON API. Но большинство фреймворков жестко фиксируют вес транспорта и интерактивных возможностей, поэтому они почти не смещаются.

Но если взглянуть на это через призму ответственности:

  •  HTMX: минимальный уровень 1, HTML берет на себя основную часть работы на уровне 2, а уровень 3 практически отсутствует.

  •  LiveView: уровень 2 расширяет свои возможности, в то время как уровень 3 остается практически несуществующим. Тот же сценарий, но окно чтения растянуто на всю длину сессии.

  •  DataStar: больше механизмов на уровне 2 за счет добавления заполняемых сервером сигналов (Signals), позволяющих осуществлять более тесное взаимодействие с клиентом.

  •  Astro(+ View Transitions): навигация (view transition) -> сервер (разметка) -> клиент (острова). Все 3 части четко разделены.

  •  Server Components (Next.js): то же самое, что Astro, за исключением того, что общее клиентское состояние сохраняется.

  •  SPA + JSON (React): уровень 3 разрастается, пока не сливается с уровнем 1. А уровень 2 делегируется JSON API.

  •  Sync Engine (Zero): уровень 2 становится постоянным чтением реплицируемого хранилища, а уровень 3 разрастается до полной его локальной копии. Оптимистичные обновления для всего набора данных.

Любое решение при небольшом усилии способно выразить все это. Они различаются транспортом, длиной окна и объемом поставляемого кода интерактивных возможностей. Так что всё сводится к эффективности и удобству разработки. Это значит, что один фреймворк мог бы покрыть их все, если каждая зона ответственности является настраиваемой.


Современный подход

Все, что я изложил, ничем не отличается от того, как базовая платформа работала всегда, но можем ли мы отразить это так, чтобы охватить весь спектр последних 30 лет?

Я обрисую картину, используя наши 3 зоны ответственности в Solid 2.0.


Навигация: делегирование и прогрессивное улучшение

Роутер Solid не владеет ничем в дереве рендеринга. Здесь нет компонента  <Link>  (или  <A> ). Вместо этого наш JSX-компилятор регистрирует привязку для каждого создаваемого им элемента  a[href]  и  form[action] , а контент, передаваемый с сервера с помощью потоковой передачи (streaming), получает те же привязки на любое поддерево, рендеримое рантаймом.

 // lib/users.ts
export const renameUser = action(
  async (id: string, formData: FormData) => {
    "use server";
    updateUser(id, { name: String(formData.get("name")) });
  }
);

// routes/users/[id].tsx — plain HTML. The compiler claims the
// anchor and the form. The router intercepts both. Works before 
// hydration and after.
<a href={`/users/${id}`}>{user().name}</a>
<form action={renameUser.with(id)} method="post">
  <input name="name" value={user().name} />
  <button>Rename</button>
</form>
 

Примечание: В этой разметке нет компонентов.

Роутер SolidJS использует эти привязки для управления  aria-current , активными классами и перехватом. Это означает, что отрендеренная на сервере разметка без клиентских компонентов полностью участвует в клиентской навигации. Таким образом, она работает не только в режиме «без JS», но и с JavaScript, в котором нет интерактивных компонентов.

Формы и кнопки точно так же делегируют задачи действиям серверных функций (server function actions). Вы правильно настраиваете предзагрузку (preloads) и инвалидацию, а мутации за один сетевой запрос (single-flight mutations) означают, что один круговой запрос обрабатывает каждый элемент интерфейса, который инвалидировала мутация — будь то питаемые через JSON клиентские компоненты или разметка, принадлежащая серверу.


Контент: реактивное чтение по сети

Мощный протокол Solid для второго уровня опирается на наши функции  "use server" . Solid сериализует данные через границу с помощью Seroval, который обрабатывает не только простые данные, но и асинхронные структуры, такие как Promise и асинхронные итераторы. Взаимодействие между сервером и клиентом вращается вокруг данных, включая данные, которые еще не разрешились.

 export async function report(id: string) {
  "use server";
  return {
    title: await getTitle(id),
    // AsyncIterable<string> — crosses the wire as-is
    progress: watchProgress(id), 
  };
}

// client: the iterable's latest yield is just a reactive read
const r = createMemo(() => report(props.id));
const progress = createMemo(() => r().progress);
<h1>{r().title}</h1>
<p>{progress()}</p>
 

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

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

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

 export async function countdown(n: number) {
  "use server";
  const ticks = tick(n); // AsyncIterable<number>, one per second

  // returning a function returns a Server Component
  return () => {
    const count = createMemo(() => ticks);
    return <p>Counted to {count()} of {n}</p>;
  };
}

// client — zero component code ships for this
const Counter = dynamic(() => countdown(10));
<Counter />
 

Поскольку коммуникация является однонаправленной с единой моделью разработки, транспорт можно заменять. Сегодня мы используем Request/Response, поэтому он живет до тех пор, пока запрос открыт, а при необходимости вы переподключаетесь. Замените его на что-то персистентное, например SSE или сокет, и тот же серверный компонент превратится в бесконечно работающий живой компонент.

 // request/response — left side of the grid
export const getAuction = GET(async () => {
  "use server";
  return read();
});

// persistent — right side of the grid
export const liveAuction = live(async function* () {
  "use server";
  while (true) { yield read(); await changed(); }
});

// the consumer doesn't care which one it got
const [auction] = createOptimisticStore(() => liveAuction(), {});
 

Безопасность этого подхода обеспечивается тем, чему нас учит сбой в LiveView:  Живой граф сервера должен быть проекцией долговечного состояния, которую можно вычислить заново. Он никогда не является источником истины. Переподключение не зависит от сессий, повтора событий (event replay) или чего-либо еще, что умирает вместе с процессом.


Интерактивные возможности: асинхронно-осведомленный граф, гидратируемый один раз

Это область, в которой SolidJS всегда преуспевал. Но в версии 2.0 асинхронность стала нативной частью графа. Значение, поступающее из канала серверного контента, и значение, вычисленное локально, неотличимы для потребителя.

Оптимистичные обновления — это примитив первого уровня. Это не просто нечто, поставляемое в комплекте с серверными функциями или библиотекой кэширования запросов. Оптимизм представляет любое незафиксированное состояние, спрогнозированное поверх асинхронности, каков бы ни был ее источник. Это общая концепция. Она не просто представляет то, что некоторые могут счесть «обманом пользователя», ей не нужно маскироваться под невидимый успех. Она представляет любую предиктивную эфемерную возможность. Она либо в конечном итоге разрешается как истина, либо откатывается назад, но она никогда не переживает свою транзакцию. Это предотвращает рассинхронизацию.

 const [auction, setAuction] = createOptimisticStore(() => liveAuction(), {});

const placeBid = action(function* (amount: number) {
  // overlay — reverts on settle
  setAuction(a => { a.highBid = amount });
  // mutation - the write goes up
  yield bid(amount);
  // hold until truth comes back down             
  yield until(() => auction.highBid >= amount);
});
 

Все это работает потому, что мы  никогда не гидратируем больше одного раза. После запуска сервер никогда не должен рендерить клиентский компонент, так как клиентское состояние уже разошлось с тем, о чем сервер мог бы знать. Это критическая уязвимость в решениях вроде HTML Partial и Islands, которые разделяют клиентское состояние. Гидратация после обновления состояния — верный путь к несовпадениям при гидратации (hydration mismatches). Хотя мы можем защититься во время первоначального потокового процесса, это нереалистично на протяжении всего жизненного цикла приложения.

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

В рамках этой единой модели начальная загрузка обрабатывается точно так же без двойной сериализации. Клиентские компоненты заявляют права на свои отрендеренные на сервере ноды, а загрузка не делает ни одного запроса. Единственные сериализованные записи — это значения, необходимые клиенту. Контент, предназначенный только для сервера, передается как HTML или как данные, но никогда не одновременно. Разметка страницы — это полезная нагрузка. Для серверных компонентов это означает всегда одну копию. Посмотрите исходный код (View-source) и найдите любой фрагмент контента — вы найдете его ровно один раз.

rwhqofZB3r8wQwUKgcmcu8Dx3jbSOZB1uwJZzm4u.webp

Когда-то я позиционировал это как «Столкновение фронтенда с экзистенциальным кризисом», но теперь это решенная проблема.


Перемещение по спектру

svquXhtvaBl0fFTOSRJzLDLjOHDSpM90OYzu2w1c.webp

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

Если вы начнете в нижнем левом углу, это будет похоже на приложение SolidJS без клиентских компонентов, которое просто меняет фрагменты серверных компонентов по действиям сервера  "use server" , привязанным к  <form action> . Серверная разметка передается потоком и подвергается морфингу, а навигация обрабатывается без каких-либо клиентских компонентов.

Верхний левый угол — это когда мы расширяем уровень 3 до тех пор, пока он не начинает доминировать, и у вас появляется SPA без каких-либо серверных компонентов или серверного рендеринга вообще. Только JSON API.

И мы можем продолжать двигаться по верхней части вправо с помощью персистентных функций  "use server"  и нашего гранулированного оптимистичного слоя. Операторы Solid  live  и  until  помогают сгладить разрыв, когда клиентское состояние передается потоком по мере доступности. Я бы все равно использовал специализированный движок синхронизации, но одни лишь базовые примитивы производят отличное впечатление.

Наконец, нижний правый угол ближе к тому, с чего мы начинали. За исключением того, что на этот раз наши серверные компоненты имеют реактивные привязки, а их соединение является персистентным. Никаких клиентских компонентов, только наш привычный протокол действий «use server» для обратной записи, в то время как чтение передает по сети мелкозернистые фрагменты разметки. В этом мире даже серверные компоненты могут быть оптимистичными в зависимости от того, как вы напишете свои действия, а  until  снова предоставляет нам механизм подтверждения завершения.

Самое невероятное здесь то, что для этого не потребовалось ничего, с чем пользователи Solid 2.0 еще не были бы знакомы. Асинхронные сигналы (Async Signals) и  use server . С их помощью все эти сценарии можно создавать, используя практически те же паттерны, в том же фреймворке и даже в рамках одного приложения.


Заключение

KC0EI7axPa3bwh24qGUX5tlw1BL9qmHUAndY0hNa.webp

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

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

Все, начиная с HTMX, LiveView, островов и заканчивая SPA — это координаты на этой сетке. Различия между ними сводятся к эффективности и эргономике, а не к выразительности.

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

Для меня неудивительно, что сигналы могут выступать в качестве такого общего представления со всех сторон. Синхронного и асинхронного. Серверного и клиентского. С состоянием и без состояния.

Это безумно амбициозно. Серверные компоненты (RSC) ставили своей целью обеспечить лишь левую половину сетки, и многие считали, что они все еще не дотягивают. Лагерь HTML-over-the-wire стремился обеспечить нижнюю половину, но игнорирование клиента — это игнорирование целой зоны ответственности.

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