Главная / Статьи / Острова, которые можно возобновить: уроки дизайна из подхода к керамике

Острова, которые можно возобновить: уроки дизайна из подхода к керамике

Как фреймворк с ориентацией на сервер рассматривает HTML в качестве стандартного формата, ограничивает действие JavaScript на отдельные изолированные блоки, а также обрабатывает статическую экспортацию, ошибки, CSP и процесс развертывания.

3252 слов

Большинство страниц в Интернете содержат лишь несколько интерактивных элементов управления, однако в основном используемые модели разработки отправляют всю страницу в браузер в виде JavaScript-приложения. Stoneware — молодая фреймворк на TypeScript с открытым исходным кодом, построенный на Bun — исходит из противоположной предпосылки: HTML — это то, что браузер получает по умолчанию, и компонент должен явно согласиться на использование дополнительных функций, прежде чем для него будет отправлен JavaScript. Изучение его архитектуры помогает понять такие концепции, как модель «островов», возможность повторного запуска, статический экспорт, а также менее заметные аспекты разработки фреймворков, такие как сообщения об ошибках, правила безопасности и упаковка для развертывания. К концу вы сможете определить, когда архитектура, основанная на сервере и принципе «островов», подходит вашему проекту, и какие опасности следует учитывать при её создании или внедрении.

Почему страница с контентом не должна превращаться в приложение

Представьте типичную страницу товара. На ней есть заголовок, фото, описание, список характеристик, цена, связанные товары, отзывы и навигация по сайту. Из всего этого, возможно, только два элемента действительно реагируют на пользователя: мобильный меню и кнопка «Добавить в корзину». Всё остальное — это статический контент, который сервер уже умеет генерировать.

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

Ментальная модель: HTML плюс острова

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

Web page
                            │
                ┌───────────┴───────────┐
                │                       │
             HTML                    Islands
                │                       │
        Server rendered          JavaScript
                │                       │
                └───────────┬───────────┘
                            │
                         Browser

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

Использование Bun вместо сборки инструментального набора

Выбор Bun не означает, что Node.js устарел. У Node есть огромная экосистема, он используется в большинстве проектов на JavaScript в промышленных целях, и остается отличной средой выполнения. Интересно то, что меняется, когда фреймворк разрабатывается с учётом среды выполнения, уже включающей инструменты, необходимые большинству проектов.

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

Bun
                     │
        ┌────────────┼────────────┐
        │            │            │
      Runtime     Tooling       Testing
        │            │            │
        └────────────┼────────────┘
                     ↓
                 Stoneware

Важно четко понимать их роли: Bun — это платформа, а Stoneware — фреймворк, построенный на ней. Если вы хотите более подробное сравнение самих сред выполнения, ознакомьтесь с сравнением Node.js, Deno и Bun.

HTML во главе угла: серьезный подход

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

Возьмём компонент, отображающий товар с названием и ценой.

<ProductCard
  title="MacBook Pro"
  price={1999}
/>

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

<div class="product-card">
  <h2>MacBook Pro</h2>
  <span>$1999</span>
</div>

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

Явное принятие решения о использовании JavaScript

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

<AddToCart product={product} />

Именно в этом компоненте обосновывается поведение с клиентской стороны, поэтому он превращается в отдельный остров. Остальная часть страницы остается в формате HTML, и готовая страница выглядит так:

Product page
│
├── Product title          HTML
├── Product description    HTML
├── Product image          HTML
├── Product specifications HTML
│
└── Add to cart            JavaScript island

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

Где проходит граница острова

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

Documentation page
│
├── Article ─────────────── SSR
├── Code blocks ─────────── SSR
├── Images ──────────────── SSR
├── Navigation ──────────── SSR
│
├── Search ──────────────── Island
├── Theme switcher ──────── Island
└── Copy button ─────────── Island

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

Гидратация против возобновимости

Вполне обоснованным возражением может быть утверждение, что всё это — это просто SSR. Частично это действительно так: сервер генерирует HTML. Разница заключается в том, что происходит после прибытия этого HTML.

При классической гидратации последовательность действий примерно такая:

  • сервер отправляет HTML;
  • браузер загружает JavaScript приложения;
  • фреймворк выполняет и воссоздаёт дерево компонентов в памяти;
  • обрабатчики событий привязываются к существующему DOM.
  • Браузер в итоге воссоздаёт приложение, вывод которого он уже получил. С точки зрения пользователя эта работа представляет собой чистую нагрузку: пиксели уже находились на экране.

    Stoneware, в свою очередь, разработан с учётом возможности возобновления работы отдельных областей. Сервер отправляет HTML вместе со всем состоянием, необходимым для интерактивных областей, и браузер продолжает работу с тех мест, где сервер остановился, вместо того чтобы снова выполнять страницу для восстановления этого состояния. Цель — не позволять небольшим интерактивным областям заставлять к полной пересборке страницы.

    Другая цель оптимизации

    Идея возобновления работы переосмысливает вопрос производительности. Вместо того чтобы спрашивать, как ускорить процесс гидратации всего контента, необходимо понимать, сколько работы браузера можно полностью избежать. Нагрузка меняется с «HTML плюс весь JavaScript приложения плюс этап гидратации» на «HTML плюс только код, необходимый для взаимодействия, с возобновлением работы из состояния, генерированного на сервере». Для сайтов с большим объемом контента это часто приносит гораздо большую пользу, чем любая оптимизация процесса гидратации.

    Если вы работаете с React, аналогичное давление лежит в основе серверных компонентов; архитектура нулевой генерации пакета при отрисовке описывает этот подход и предоставляет полезное сравнение.

    Подход «сначала сервер» — это не только сервер

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

    Статический экспорт основан на той же идее

    Если HTML является стандартом, естественным следующим вопросом становится: зачем вообще запускать сервер для страниц, которые не меняются при каждом запросе? Stoneware может экспортировать приложение в виде статических файлов и передать их CDN.

    Stoneware application
            │
            ▼
         export
            │
            ▼
          dist/
            │
            ▼
           CDN
    

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

    Динамическим маршрутам нужен явный список путей

    Экспорт статических контентов становится сложнее, как только у маршрута появляются параметры. Маршрут вроде /products/[sku] в принципе может соответствовать неограниченному количеству URL, поэтому инструмент экспорта не может определить, какие страницы следует генерировать. Поэтому фреймворк просит маршрут перечислить эти пути:

    export function staticPaths() {
      return products.map(product => ({
        sku: product.sku
      }));
    }
    

    С помощью этого списка экспортер создаёт настоящий HTML-файл для каждого известного SKU, например dist/products/laptop-1/index.html. Именно здесь дизайн фреймворка выходит за рамки простого отображения JSX: фреймворку необходимо понимать, как между собой связаны маршруты, данные, этап сборки и цель развертывания. Практический крайний случай, на который следует подготовиться, — это ситуация, когда у динамического маршрута вообще нет метода staticPaths(); фреймворк должен либо явно сообщить об ошибке при экспорте, либо оставлять такой маршрут в режиме серверной обработки, причём беззвучное игнорирование его — худший вариант.

    Сообщения об ошибках являются частью рендерера

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

    Распространенная ошибка — написание <span>{product}</span> вместо <span>{product.name}</span>. Общее сообщение о том, что «невозможно отобразить значение типа объект», почти ничего не говорит о том, где искать проблему. Stoneware же описывает само проблемное значение:

    Cannot render a plain object with keys: id, title, price.
    

    а затем указывает путь к компоненту, который привел к этой проблеме:

    in <span>
    in <Price>
    in <ProductCard>
    in <Home>
    

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

    Когда микротест скрывает реальные затраты

    Изначально сбор такого следа компонента подразумевал обертывание множества операций отрисовки в блоки try/catch. Отдельный микротест показал, что дополнительные затраты были незначительными. Однако при реальной отрисовке страницы обертывание каждого элемента увеличивало стоимость отрисовки примерно на 38%.

    Это объяснение знакомо всем, кто проводит тестирование JavaScript: в небольшом, повторяющемся тесте оптимизирующий компилятор движка может устранить или отложить выполнение большей части операций, которые вы пытаетесь измерить, поэтому полученные цифры отражают версию кода после оптимизаций.

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

    Решение было структурным: нагрузка от отслеживания ошибок ограничивалась границами компонентов, а отдельные элементы использовали более дешевые операции сохранения и восстановления для поддержания состояния. Тестирование версий A/B на реальных отрисовках не показало значимых различий. Более общий урок заключается в том, что производительность рендерера зависит не только от скорости, но и от отсутствия ухудшений при добавлении функций, удобных для разработчиков, причем любые заявления о производительности должны проверяться на репрезентативных нагрузках.

    Стандартные настройки безопасности и порядок обработки данных

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

    Порядок выполнения этапов обработки запроса выбран намеренно:

    • Сначала выполняется защита от CSRF;
    • Затем — промежуточные компоненты приложения;
    • Далее происходит сопоставление маршрутов и отрисовка контента;
    • Все результаты выводятся через один единственный пункт ответа.

    Если промежуточные компоненты приложения бы выполнялись до проверки на CSRF, пользовательский код мог бы попасть на путь, позволяющий обойти меры безопасности на уровне фреймворка, например, путем раннего возврата или изменения запроса. Закрепление такого порядка выполнения в самом фреймворке, а не просто его описание с надеждой на соблюдение всеми, — именно тот тип правил, которые должен определять фреймворк. Единая точка выхода также обеспечивает единообразное применение заголовков, таких как CSP, ко всем ответам.

    Расширение строгих правил CSP без их отключения

    Рестриктивная политика является хорошим стандартным вариантом, но на реальных сайтах загружаются инструменты аналитики, вспомогательные элементы для оплаты, API, веб-шрифты и карты. Частой проблемой является то, что разработчик сталкивается с заблокированным скриптом и полностью отключает CSP. Лучший подход позволяет расширять отдельные директивы, сохраняя при этом остальную часть стандартной политики нетронутой:

    csp: {
      scriptSrc: ["https://www.googletagmanager.com"],
      connectSrc: ["https://www.google-analytics.com"],
      imgSrc: ["https://www.google-analytics.com"],
    }
    

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

    Самые сложные ошибки возникают после процессора отрисовки

    Фреймворк не заканчивается на рендерере. Код должен выдержать весь путь: исходный код, компилятор, рендерер, процесс сборки, ресурсы, развертывание, CDN и, наконец, браузер. Некоторые из самых сложных проблем возникают ближе к концу этой цепочки, далеко от того кода, который большинство людей считает «фреймворком».

    Пакетирование ресурсов для Vercel

    Версия 0.1.8 изменила способ развертывания Stoneware на Vercel. Для этой платформы генерируемые клиентские чанки теперь встроены в серверный пакет в виде данных Base64, что делает их частью пакета, который развертывается платформой; они предоставляются по адресу /_stoneware/*.

    Stoneware build
          ↓
    server bundle
          ├── server code
          ├── CSS assets
          └── island assets
                  ↓
              Vercel
                  ↓
          /_stoneware/*
    

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

    Тесты, подтверждающие исправление

    В проекте имеется более 500 автоматизированных тестов, охватывающих рендерер, маршрутизатор, функции islands и signals, таблицы стилей и обслуживание ресурсов, экспорт статического контента, механизмы CSRF и CSP, цели развертывания, отчетность об ошибках, а также враждебные или необычные данные, такие как попытки просмотра содержимого по путям, двоичные файлы и некорректные пути к ресурсам.

    Само количество тестов не является ключевым показателем. Более полезным критерием является следующий:

    Тест на регрессию должен показать, что он бы провалился до внесения исправления.

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

    Использование агента для программирования без аутсорсинга проектирования

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

    Однако он сам по себе не мог решить, каким должен быть фреймворк. Сложные вопросы касались архитектуры:

    • Для каждого маршрута — является ли SSR или статический экспорт подходящим режимом?
  • Как должно вести себя экспорт для динамического маршрута, у которого отсутствует метод staticPaths()?
  • Как должен реагировать рендерер, когда компонент возвращает обычный объект?
  • В каких местах происходит поиск шаблонов стилей во время сборки?
  • Как генерируемые клиентские чанки попадают в браузер на Vercel?
  • Добавляет ли источник CSP, предоставленный разработчиком, новые правила к стандартным или переопределяет их?
  • Агент может изучить эти вопросы и реализовать выбранное решение, но кто-то всё равно должен оценить эффективность такого подхода и проверить результат.

    Вероятный вариант не всегда является правильным

    Агент для программирования может очень быстро создать убедительную реализацию, и эта скорость имеет большую ценность. Однако это также означает, что он может быстро допустить ошибку. Проблема с ресурсами Vercel иллюстрирует этот цикл: первое решение копировало генерируемые ресурсы в папку public/, что казалось разумным и проходило локальные тесты; однако реальная развертка показала, что это предположение было неверным. Во второй попытке изменили саму модель развертывания. Тестирование крайних случаев затем выявило ещё одну ошибку в этой версии.

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

    Почему выбор среды выполнения важен не только из-за скорости

    Сведение сути до формулировки «Bun быстрее, чем Node» приведёт к упущению смысла, к тому же чистая скорость в любом случае не является философией фреймворка. Более интересным экспериментом будет разработка фреймворка на основе предположения, что с самого начала доступны современная среда выполнения и интегрированный набор инструментов. Комбинация среды выполнения, управления пакетами, объединения кода и тестирования в Bun делает его удобной основой для подобных архитектурных исследований.

    Место в архитектуре

    Этот подход особенно эффективен там, где большая часть страницы состоит из контента, а только часть — из интерактивных элементов:

    • Документация: статьи и примеры кода генерируются на сервере; поиск, меню тем и кнопки копирования являются отдельными элементами.
    • Электронная коммерция: информация о товарах, изображения и контент для SEO генерируются на сервере; корзина и фильтры являются отдельными элементами.
  • Веб-сайты компаний: информация о компании, услуги и рынки генерируются на сервере; форма контактов является отдельным элементом или требует взаимодействия с сервером.
  • Сайты с контентом: статьи генерируются на сервере; комментарии и функция поиска представляют собой отдельные элементы.
  • Когда этот инструмент, вероятно, не подходит

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

    Попробовать сами

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

    bun create stoneware my-app
    cd my-app
    bun dev
    

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

    Основные выводы

    • Рассматривайте HTML как стандартный формат вывода и делайте использование клиентского JavaScript опциональным для каждого компонента; так страница будет иметь селективное использование скриптов, а не быть полноценным приложением.
  • Возможность возобновления работы меняет цель с ускорения загрузки на полное избежание нагрузки на браузер, что особенно эффективно на страницах с большим объемом контента.
  • Статический экспорт и SSR могут сосуществовать в одной модели программирования, но динамические маршруты требуют явного списка путей.
  • Инвестируйте в сообщения об ошибках, указывающие некорректное значение и путь компонента; проверяйте производительность на реалистичных отрисовках, а не только с помощью микротестов.
  • Внесите в фреймворк правила безопасности, такие как защита от CSRF перед мидлвэром, и позвольте разработчикам расширять директивы CSP вместо того, чтобы отключать политику безопасности.
  • Цели развертывания отличаются; проверяйте обработку ресурсов полностью, от начала до конца, и пишите тесты на обратную совместимость, которые будут проваливаться без исправления.
  • Искусственные интеллектуальные агенты ускоряют реализацию, но архитектурные решения и проверка в реальных условиях по-прежнему остаются обязанностями людей.