Реалізація Islands Architecture для веб-застосунку
Islands Architecture — архітектурний паттерн, де сторінка складається зі статичного HTML-океану з ізольованими «островами» інтерактивності. Кожний остров — незалежний компонент з власним JS, який гідрується окремо, не знаючи про сусідні острови.
Концепцію описав Джейсон Міллер (Preact) у 2020 році. Сьогодні це основа Astro, Marko та частина підходу Qwik.
Архітектурна модель
Статичний HTML (сервер, нулевий JS):
┌─────────────────────────────────────┐
│ Header │ ← HTML
│ Logo | Nav links | ... │
├─────────────────────────────────────┤
│ Hero section │ ← HTML
│ H1, зображення, CTA │
├──────────────┬──────────────────────┤
│ Article text │ 🏝️ Island: │ ← JS тільки для острова
│ (HTML) │ TableOfContents.tsx │
│ │ (sticky, highlight) │
├──────────────┴──────────────────────┤
│ 🏝️ Island: CommentSection.tsx │ ← JS тільки для острова
│ (React, завантажується при скролі) │
├─────────────────────────────────────┤
│ Footer │ ← HTML
└─────────────────────────────────────┘
Результат: JS грузиться тільки для 2 островів, не для всієї сторінки
Реалізація в Astro
Astro — перший фреймворк з нативною підтримкою Islands:
---
// src/pages/blog/[slug].astro
import type { GetStaticPaths } from 'astro';
import { getCollection } from 'astro:content';
// Серверні компоненти — .astro файли без JS на клієнті
import BaseLayout from '@/layouts/BaseLayout.astro';
import ArticleHero from '@/components/ArticleHero.astro';
import Prose from '@/components/Prose.astro';
// Острови — React/Vue/Svelte компоненти з директивами
import TableOfContents from '@/islands/TableOfContents.tsx';
import CommentSection from '@/islands/CommentSection.tsx';
import ShareButtons from '@/islands/ShareButtons.svelte';
import NewsletterSignup from '@/islands/NewsletterSignup.vue';
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await getCollection('blog', p => !p.data.draft);
return posts.map(post => ({ params: { slug: post.slug }, props: { post } }));
};
const { post } = Astro.props;
const { Content, headings } = await post.render();
---
<BaseLayout title={post.data.title} description={post.data.description}>
<ArticleHero post={post} />
<div class="article-layout">
<!-- Остров 1: інтерактивне оглавління -->
<!-- Завантажується тільки коли браузер проставає -->
<TableOfContents headings={headings} client:idle />
<article>
<Prose>
<Content />
</Prose>
</article>
</div>
<!-- Остров 2: кнопки шарингу -->
<!-- Завантажується тільки при появленні у viewport -->
<ShareButtons
url={Astro.url.href}
title={post.data.title}
client:visible
/>
<!-- Остров 3: коментарі -->
<!-- Завантажується тільки при появленні у viewport, затримка 500ms -->
<CommentSection
articleId={post.id}
client:visible={{ rootMargin: '0px 0px 200px 0px' }}
/>
<!-- Остров 4: підписка на розсилку -->
<!-- Завантажується негайно — в fold -->
<NewsletterSignup client:load />
</BaseLayout>
Комунікація між островами
Острови ізольовані — у них немає спільного React контексту. Для комунікації використовуйте:
Nano Stores (рекомендується для Astro):
// src/stores/cart.ts
import { atom, computed } from 'nanostores';
import { persistentAtom } from '@nanostores/persistent';
export const cartItems = persistentAtom<CartItem[]>('cart', [], {
encode: JSON.stringify,
decode: JSON.parse,
});
export const cartCount = computed(cartItems, items => items.length);
export const cartTotal = computed(cartItems, items =>
items.reduce((sum, item) => sum + item.price * item.qty, 0)
);
export function addToCart(product: Product) {
const items = cartItems.get();
const existing = items.find(i => i.id === product.id);
if (existing) {
cartItems.set(items.map(i => i.id === product.id ? { ...i, qty: i.qty + 1 } : i));
} else {
cartItems.set([...items, { ...product, qty: 1 }]);
}
}
// islands/CartIcon.tsx — React остров
import { useStore } from '@nanostores/react';
import { cartCount } from '@/stores/cart';
export function CartIcon() {
const count = useStore(cartCount);
return (
<a href="/cart" className="relative">
<ShoppingCartIcon />
{count > 0 && <span className="badge">{count}</span>}
</a>
);
}
<!-- islands/AddToCartButton.svelte — Svelte остров -->
<script>
import { addToCart } from '@/stores/cart';
export let product;
let loading = false;
async function handleAdd() {
loading = true;
addToCart(product);
loading = false;
}
</script>
<button on:click={handleAdd} disabled={loading}>
{loading ? 'Додаємо...' : 'У корзину'}
</button>
Обидва острови (React та Svelte) працюють з одним сховищем. Змінення в одному — негайно відображається в іншому.
Нативні браузерні події:
// Універсальна шина подій через CustomEvent
export function emit<T>(event: string, detail: T) {
window.dispatchEvent(new CustomEvent(event, { detail, bubbles: true }));
}
export function on<T>(event: string, handler: (detail: T) => void) {
const wrapped = (e: CustomEvent<T>) => handler(e.detail);
window.addEventListener(event, wrapped as EventListener);
return () => window.removeEventListener(event, wrapped as EventListener);
}
// В острові 1
emit('product:added', { id: product.id, name: product.name });
// В острові 2
useEffect(() => {
return on<{ id: string; name: string }>('product:added', ({ name }) => {
showToast(`${name} додано у корзину`);
});
}, []);
Рендеринг островів на кількох фреймворках
Astro підтримує кілька рендерер-плагінів одночасно:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import vue from '@astrojs/vue';
import svelte from '@astrojs/svelte';
import preact from '@astrojs/preact';
export default defineConfig({
integrations: [
react(), // Для існуючих React-компонентів
vue(), // Для Vue-компонентів з бібліотеки
svelte(), // Для легких інтерактивних віджетів
preact({ compat: true }), // Preact як замінник React для легких островів
],
});
На практиці: різні фреймворки для різних цілей в межах одного сайту без конфліктів.
Продуктивність: цифри
Вимірювання на реальних проектах після міграції на Islands Architecture:
| Метрика | До (Full React SSR) | Після (Astro Islands) |
|---|---|---|
| Initial JS | 180–250 KB | 15–40 KB |
| TTI | 3.2s | 0.8s |
| TBT | 480ms | 60ms |
| Lighthouse Performance | 62 | 96 |
Розброс залежить від кількості та складності островів.
Обмеження паттерну
Islands Architecture не підходить для:
- SPA-застосунків — потрібно єдине стан між багатьма компонентами
- Дашбордів — занадто багато інтерактивності, острови теряють ізоляцію
- Сторінок, де все інтерактивно — мінімальна користь
Ідеальний випадок: контентний сайт з кількома інтерактивними блоками на сторінці.
Строки реалізації
- Тиждень 1–2: аудит існуючого сайту, виявлення інтерактивних компонентів, налаштування Astro + рендерер-плагіни
- Тиждень 3: перенос статичних сторінок, розбивка на острови з директивами гідрації
- Тиждень 4: комунікація між островами через nano stores, тестування ізоляції
- Тиждень 5: вимірювання Core Web Vitals, порівняння з baseline, оптимізація директив
- Тиждень 6: розгортання (Cloudflare Pages / Netlify), Lighthouse CI, фінальна документація







