Рендеринг на стороне сервера (SSR), кажется, сейчас на пике популярности. Стратегии гидратации являются предметом обсуждения в городе, и, честно говоря, это было своего рода освежающим изменением от статус-кво JS-тяжелых фреймворков на стороне клиента. Однако меня всегда удивляет, как мало в этих обсуждениях рассматривается Service Workers.
Архитектура одностраничных приложений Progressive Web App (whew) уже хорошо известна: вы создаете оболочку приложения, предварительно кэшируете необходимые активы и получаете динамические данные, которые заставляют ваше приложение делать то, что оно делает. Кроме того, одностраничные приложения (SPA) обычно относительно легко модифицировать под PWA после того, как они уже созданы.
То же самое нельзя сказать о многостраничных приложениях (MPA), однако для MPA вам действительно придется учитывать любые автономные возможности в своей архитектуре с самого начала проекта. И я просто не могу отделаться от ощущения, что в настоящее время нет действительно хорошего решения для PWA-фикации MPA, которое имело бы отличный опыт разработчика, как многие JS-фреймворки или SSR-фреймворки. Генераторы статических сайтов, похоже, тоже не особо инвестируют в эту область. На самом деле, я смог найти лишь несколько решений для такого типа архитектуры!
- Рабочие службы на сервере
- Сшивание потоков
- Рендеринг на стороне сервисного работника с помощью Astro
- Изоморфный рендеринг
- Недостатки
- Еще не все готово
- Bundlesize
- Astro-service-worker
- Демо
- Сервер-первый, сервер-единственный, сервис-рабочий-первый, сервис-рабочий-единственный
- Только для сети
- Настройка логики Service Worker
- Комбинируйте с другими интеграциями
Рабочие службы на сервере
Одно из таких решений создано блестящим Джеффом Посником. Его блог jeffy.info полностью обслуживается сервисным работником. Первоначальный рендеринг происходит на сервере, в Cloudflare Worker, который использует те же API, что и рабочий сервис, запущенный в браузере. Этот подход интересен тем, что позволяет Джеффу повторно использовать один и тот же код как на сервере, так и на клиенте. Это также известно как изоморфный рендеринг.
Когда пользователь впервые заходит на блог, Cloudflare Worker отрисовывает страницу, а на стороне клиента рабочий службы начинает установку. После установки работник службы может взять под контроль сетевые запросы и самостоятельно обслуживать ответы; в перспективе можно полностью отказаться от сервера и предоставлять мгновенные ответы.
Вы можете прочитать все о том, как Джефф создал свой блог, в его блоге об этом, но в основном все сводится к следующему: Потоки.
Сшивание потоков
Рассмотрим следующий пример:
registerRoute(
new URLPatternMatcher({pathname: '/(.*).html'}).matcher,
streamingStrategy(
[
() => Templates.Start({site}),
async ({event, params}) => {
const post = params.pathname.groups[0];
const response = await loadStatic(event, `/static/${post}.json`);
if (response?.ok) {
const json = await response.json();
return Templates.Page({site, ...json});
}
return Templates.Error({site});
},
() => Templates.End({site}),
],
{'content-type': 'text/html'},
),
);
Здесь Джефф использует шаблон, который я буду называть сшиванием потоков. Это здорово, потому что браузеры уже могут начать рендеринг потокового HTML по мере его поступления. Это также означает, что вы уже можете передавать <head>
вашей страницы, которая уже может начать загрузку скриптов, парсинг стилей и других активов, пока вы ждете, когда поступит остальной HTML.
Хотя с технической точки зрения это действительно интересно, я не могу отделаться от ощущения, что опыт разработчика несколько… недостаточен. Workbox делает отличную работу по предоставлению абстракций над потоковыми API, чтобы вам не приходилось делать все вручную, и помогает с такими вещами, как регистрация и согласование маршрутов, но даже тогда он все еще чувствует себя несколько близким к металлу, особенно по сравнению с опытом разработчиков всех этих ярких SSR фреймворков. Почему мы не можем иметь хорошие вещи с рабочими службами?
Рендеринг на стороне сервисного работника с помощью Astro
Недавно я много работал над проектами Astro SSR, и я хотел создать адаптер Cloudflare для развертывания моего Astro SSR приложения в среде Cloudflare. Когда я читал о Cloudflare workers, мне вспомнился этот разговор Джеффа Посника и Люка Эдвардса о его блоге и архитектуре, описанной ранее в этом посте, и это заставило меня задуматься; если я могу развернуть Astro в окружении, которое так похоже на сервис worker… Почему я не могу запустить Astro в настоящем service worker?
Поэтому я начал копать код, и оказалось, что это вполне возможно. В этом примере вы можете увидеть реальное приложение Astro SSR, запущенное сервисным работником. Это интересно по нескольким причинам:
- Ваше приложение Astro теперь может работать в автономном режиме.
- Ваше приложение теперь можно устанавливать
- Вызовы функций вашего хостинг-провайдера значительно сокращаются, потому что запросы могут обслуживаться сервисным работником в браузере.
- Огромные преимущества в производительности
- Это прогрессивное усовершенствование
Но самое главное, это может означать, что мы очень близко подошли к созданию превосходного опыта для разработчиков! Astro вполне может стать первым фреймворком, способным предоставить нам опыт разработчика, подобный следующему:
/blog/[id].astro
:
---
import Header from '../src/components/Header.astro';
import Sidemenu from '../src/components/Sidemenu.astro';
import Footer from '../src/components/Footer.astro';
const { id } = Astro.params;
---
<html>
<Header/>
<Sidemenu/>
{fetch(`/blog/${id}.html`)}
<Footer/>
</html>
Разве это не потрясающе? Этот код мог бы выполняться как на сервере, так и в рабочем сервисе. Однако! Как бы здорово это ни было, мы еще не дошли до этого. В настоящее время Astro еще не поддерживает потоковые ответы, об этом мы поговорим чуть позже, а пока помечтайте со мной минутку.
В данном фрагменте кода произойдет следующее: При первом посещении сервер отображает эту страницу, как в примере из блога Джеффа. Затем устанавливается сервисный работник и может взять на себя управление запросами, что означает, что с этого момента точно такой же код может быть отображен сервисным работником в браузере и немедленно доставить ответы.
Более того, в этом примере <Header/>
и <Sidemenu/>
являются статическими компонентами и могут быть переданы немедленно. Обещание fetch
возвращает ответ, телом которого является… Вы угадали, это поток! Это означает, что браузер уже может начать рендеринг заголовка (который также может начать загрузку других активов), рендеринг бокового меню, а затем немедленно начать потоковую передачу результата fetch
в браузер.
Изоморфный рендеринг
Мы можем даже расширить эту схему:
---
import Header from '../src/components/Header.astro';
import Sidemenu from '../src/components/Sidemenu.astro';
import Footer from '../src/components/Footer.astro';
const { id } = Astro.params;
---
<html>
<Header/>
<Sidemenu/>
{fetch(`/blog/${id}.html`).catch(() => {
return caches?.match?.('/404.html') || fetch('/404.html');
})}
<Footer/>
</html>
Представьте, что мы посетили URL с несуществующим id
. Если у пользователя еще не установлен service worker, сервер будет:
- попытается получить
/blog/${id}.html
, что не удастся. - Запустит обратный вызов
catch
и попытается выполнитьcaches?.match?.('/404.html')
, к которому у нас нет доступа на сервере. - Поэтому вместо этого он вернется к
|| fetch('/404.html')
.
Однако, если у пользователя уже установлен service worker, он мог бы предварительно кэшировать '/404.html'
во время установки, и просто загрузить его мгновенно из кэша.
Возможно, вы даже можете представить себе некоторые помощники, такие как:
<Header/>
{cacheFirst(`/blog/${id}.html`)}
{staleWhileRevalidate(`/blog/${id}.html`)}
{networkFirst(`/blog/${id}.html`)}
<Footer/>
Недостатки
Еще не все готово
В настоящее время ответы Astro еще не транслируются. Однако Нейт, один из основных сопровождающих Astro, упомянул об этом:
Хорошая новость об Astro заключается в том, что потоковая передача была конечной целью с самого первого дня! Нам не нужны никакие изменения архитектуры для ее поддержки — компоненты Astro являются просто асинхронными итераторами. Мы в основном ждали стабилизации SSR API, прежде чем открывать потоковую передачу.
Рассмотрим следующий фрагмент кода из исходного кода Astro:
export async function render(htmlParts: TemplateStringsArray, ...expressions: any[]) {
return new AstroComponent(htmlParts, expressions);
}
Как выглядит AstroComponent
:
class AstroComponent {
constructor(htmlParts, expressions) {
this.htmlParts = htmlParts;
this.expressions = expressions;
}
get [Symbol.toStringTag]() {
return "AstroComponent";
}
*[Symbol.iterator]() {
const { htmlParts, expressions } = this;
for (let i = 0; i < htmlParts.length; i++) {
const html = htmlParts[i];
const expression = expressions[i];
yield markHTMLString(html);
yield _render(expression);
}
}
}
Как сказал Нейт, это просто асинхронный итератор. Это означает, что потенциально это может даже позволить использовать обещания и итераторы в выражениях Astro, например:
---
import Header from '../src/components/Header.astro';
function* renderLongList() {
yield "item 1";
yield "item 2";
}
---
<html>
<Header/>
{renderLongList()}
</html>
Или пример с fetch
, который мы видели ранее в этом посте:
<Header/>
<Sidemenu/>
{fetch(`/blog/${id}.html`)}
<Footer/>
В настоящее время ведется обсуждение в этом RFC на репозитории Astro. Если вы заинтересованы в таком будущем, пожалуйста, оставьте свой комментарий, чтобы выразить заинтересованность мейнтейнерам. Однако за это придется заплатить. Были и другие предложения, которые сделали бы невозможным потоковые ответы, например, постобработка HTML или концепция элемента <astro:head>
, где дочерний компонент может добавляться к head. Обе эти вещи несовместимы с потоковыми ответами. Хотя, возможно, эти возможности не обязательно должны быть взаимоисключающими; возможно, рендеры можно даже сделать настраиваемыми в Astro через astro.config.mjs
:
export default defineConfig({
ssr: {
output: 'stream'
}
});
Есть над чем подумать, но в любом случае, пожалуйста, загляните в обсуждение RFC и оставьте свои мысли или просто upvote/emoji!
Bundlesize
Другим недостатком является размер пакета. Следует признать, что пакет Astro при запуске в сервисном работнике… велик. Однако я еще не слишком много экспериментировал здесь, но кажется, что есть много возможностей для улучшения размера пакета.
Astro-service-worker
Хотя потоковые ответы в Astro могут быть еще далеки, я превратил свои эксперименты с service worker в интеграцию Astro, которую вы можете использовать уже сегодня: astro-service-worker
. Эта интеграция возьмет ваш проект Astro SSR и создаст для него сборку сервисного работника.
Приступить к работе очень просто, установите зависимость:
npm i -S astro-service-worker
И добавьте интеграцию в ваш astro.config.mjs
:
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify';
+import serviceWorker from 'astro-service-worker';
export default defineConfig({
adapters: netlify(),
integrations: [
+ serviceWorker()
]
});
Демо
Пример небольшого приложения, использующего astro-service-worker
, вы можете найти в этой демонстрации, а исходный код демонстрации вы можете найти здесь.
Сервер-первый, сервер-единственный, сервис-рабочий-первый, сервис-рабочий-единственный
При сервис-воркеризации приложений Astro необходимо помнить, что код Astro, который вы пишете в вашем Astro frontmatter, теперь должен работать и в браузере. Это означает, что вы не сможете использовать какие-либо зависимости commonjs или встроенные модули node, например, 'fs'
. Однако может случиться так, что вам понадобится код, предназначенный только для сервера, например, доступ к базе данных, или веб-крючки, или обратные вызовы перенаправления, или что-то еще. В этом случае вы можете исключить эти конечные точки из пучка рабочих служб вывода.
Это означает, что у вас может быть целая кодовая база с полным стеком: Server-first, server-only, service-worker-first и service-worker-only код в одном проекте. Кроме того, сервисный работник является полностью прогрессивным усовершенствованием. Если ваш пользователь использует браузер, который не поддерживает service worker, сервер все равно прекрасно отобразит ваше приложение.
Только для сети
Может случиться так, что вы захотите использовать некоторые конечные точки или страницы только для сервера, возможно, для создания соединений с базой данных, или другие вещи, которые зависят от встроенных модулей Nodejs, недоступных в браузере. В этом случае вы можете указать, какую страницу вы хотите исключить из пакета рабочих служб:
export default defineConfig({
integrations: [
serviceWorker({
networkOnly: ['/networkonly-page', '/db-endpoint', 'etc']
}),
]
});
Настройка логики Service Worker
Вы также можете расширить Service Worker и добавить свою собственную логику. Для этого вы можете использовать параметр swSrc
.
export default defineConfig({
integrations: [
serviceWorker({
swSrc: 'my-custom-sw.js',
}),
]
});
my-project/my-custom-sw.js
:
self.addEventListener('fetch', (e) => {
console.log('Custom logic!');
});
Комбинируйте с другими интеграциями
Вы можете даже объединить это с другими SSR интеграциями; если ваши компоненты поддерживают SSR, то они также должны поддерживать SWSR! Однако обратите внимание, что могут быть некоторые различия в традиционном серверном окружении и сервисном рабочем. Это означает, что могут быть дополнительные вещи, которые вам нужно будет подправить.
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify';
import customElements from 'custom-elements-ssr/astro.js';
import serviceWorker from './index.js';
export default defineConfig({
adapter: netlify(),
integrations: [
customElements(),
serviceWorker()
]
});