Устали от SPА? Время для динамичных тусовок на островах

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0

Авторство: hackeryaroslav

Источник: xss.is


В веб-разработки мы наблюдаем интересный феномен: всё больше компаний пересматривают свой подход к построению веб-приложений, отходя от классических Single Page Applications (SPA) в пользу более гибких и эффективных решений. Давайте глубоко погрузимся в три ключевых тренда, которые формируют будущее веб-разработки.

Почему компании снова выбирают MPA?

Компании вроде GitHub и Shopify уже начали переходить к мультистраничным приложениям. Что интересно, современные MPA значительно отличаются от тех, что мы видели в начале 2000-х. Ранее они были громоздкими, медленно загружались и не давали хорошего пользовательского опыта. Теперь же благодаря новым технологиям они вернули себе актуальность, сохранив простоту архитектуры, но повысив производительность и гибкость.

Что не так с SPA?

Архитектура SPA долгое время была популярной благодаря своей интерактивности и возможности без перезагрузки загружать контент. Но у неё есть и свои слабые места:
  • Большой начальный бандл: Вся логика приложения загружается сразу, что увеличивает время первого рендера.
  • Проблемы с SEO: Поисковым системам сложно индексировать контент, так как он загружается динамически.
  • Зависимость от JavaScript: Приложение не работает, если у пользователя отключен JS.
Эти проблемы побудили компании искать альтернативные подходы — и улучшенные MPA стали ответом на вызовы.

Преимущества современных MPA

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

1. Производительность первой загрузки

  • Меньший размер начального JavaScript-бандла: Загружается только необходимое для конкретной страницы.
  • Быстрая первая отрисовка (FCP): Контент появляется на экране быстрее.

2. SEO и доступность

  • Мгновенная индексация контента: Поисковые роботы видят страницу в том виде, в каком её видит пользователь.
  • Семантическая разметка: Приложение сразу поддерживает HTML5 и улучшает доступность.

3. Надёжность и предсказуемое поведение

  • Работа без JavaScript: Если клиент отключил JavaScript, страницы всё равно загружаются.
  • Меньше точек отказа: Каждая страница автономна и не зависит от сложной клиентской логики.

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

JavaScript: Скопировать в буфер обмена
Код:
// server.js
const express = require('express');
const turbolinks = require('turbolinks-express');
const app = express();

// Добавляем поддержку Turbolinks для плавной навигации
app.use(turbolinks());

// Настраиваем кэширование и оптимизацию
app.use(express.static('public', {
maxAge: '1d',
etag: true,
lastModified: true
}));

// Роутинг с поддержкой частичной загрузки контента
app.get('/products', async (req, res) => {
const products = await db.getProducts();

if (req.headers['turbolinks-referrer']) {
// Если запрос через Turbolinks, возвращаем только контент
res.render('products/partial', { products });
} else {
// Иначе возвращаем полную страницу
res.render('products/index', { products });
}
});

// Прогрессивное улучшение с помощью JavaScript
app.get('/api/products/search', async (req, res) => {
const results = await db.searchProducts(req.query.term);
res.json(results);
});


А теперь посмотрим на клиентскую часть:

JavaScript: Скопировать в буфер обмена
Код:
document.addEventListener('turbolinks:load', () => {
  // Инициализация интерактивных элементов
  initializeSearchForm();
  initializeLazyLoading();
});

function initializeSearchForm() {
  const searchForm = document.querySelector('.search-form');
  if (!searchForm) return;

  searchForm.addEventListener('input', async (e) => {
    const searchTerm = e.target.value;
    if (searchTerm.length < 3) return;

    try {
      const response = await fetch(`/api/products/search?term=${searchTerm}`);
      const results = await response.json();
      updateSearchResults(results);
    } catch (error) {
      console.error('Ошибка поиска:', error);
    }
  });
}

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

Оптимизация производительности: ключевые приёмы

Чтобы извлечь максимум из MPA, разработчики применяют несколько важных техник оптимизации:

1. Предварительная загрузка ресурсов

Ускоряет загрузку, заранее подгружая важные файлы:

HTML: Скопировать в буфер обмена
Код:
<head>
<link rel="preload" href="/assets/critical.css" as="style">
<link rel="prefetch" href="/assets/next-page.js">
<link rel="preconnect" href="https://api.example.com">
</head>

2. Частичная гидратация компонентов

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

JavaScript: Скопировать в буфер обмена
Код:
class DynamicComponent extends HTMLElement {
connectedCallback() {
if (this.dataset.hydrated) return;

// Загружаем JavaScript только для этого компонента
import('./components/dynamic-component.js')
.then(module => {
module.initialize(this);
this.dataset.hydrated = 'true';
});
}
}

customElements.define('dynamic-component', DynamicComponent);


Islands Architecture: подход к гидратации

Концепция и преимущества

Islands Architecture, предложенная Джейсоном Миллером, представляет собой подход к построению веб-приложений, где интерактивные компоненты ("острова") существуют в море статического HTML. Этот подход особенно эффективен для контент-ориентированных сайтов с отдельными интерактивными элементами.

Рассмотрим пример реализации с использованием современных инструментов:

JavaScript: Скопировать в буфер обмена
Код:
// islands/ShoppingCart.js
export class ShoppingCart extends HTMLElement {
constructor() {
super();
this.items = new Map();
}

async connectedCallback() {
// Загружаем только необходимые компоненты
const { createCart } = await import('./cart-logic.js');

this.innerHTML = `
<div class="cart-container">
<button class="cart-toggle">Корзина (<span>0</span>)</button>
<div class="cart-details" hidden>
<ul class="cart-items"></ul>
<div class="cart-total">Итого: <span>0</span> ₽</div>
</div>
</div>
`;

createCart(this);
}

// Методы для работы с корзиной
addItem(product) {
// Реализация добавления товара
}

updateTotal() {
// Обновление общей суммы
}
}

customElements.define('shopping-cart', ShoppingCart);


Оптимизация Islands Architecture

Для максимальной эффективности Islands Architecture важно правильно организовать загрузку и инициализацию компонентов:


JavaScript: Скопировать в буфер обмена
Код:
// island-loader.js
const islands = new Set();

// Наблюдаем за появлением островов в DOM
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const island = entry.target;
if (!islands.has(island)) {
hydrateIsland(island);
islands.add(island);
}
}
});
});

async function hydrateIsland(island) {
const componentName = island.dataset.component;
try {
const module = await import(`/islands/${componentName}.js`);
module.default(island);
} catch (error) {
console.error(`Failed to hydrate island ${componentName}:`, error);
}
}

// Находим и начинаем наблюдение за островами
document.querySelectorAll('[data-island]').forEach(island => {
observer.observe(island);
});

Inertia.js и смешанная архитектура: Лучшее из двух миров

Inertia.js представляет собой подход, который позволяет создавать монолитные приложения, сохраняя при этом удобство разработки SPA. Давайте рассмотрим, как это работает на практике.

Базовая настройка Inertia.js

Давайте рассмотрим базовую настройку Inertia.js на примере простого приложения с использованием Laravel и Vue 3. Начнем с конфигурации клиентской части.
В файле app.js мы создаем Inertia приложение и указываем, как обрабатывать страницы:

JavaScript: Скопировать в буфер обмена
Код:
// app.js
import { createInertiaApp } from '@inertiajs/inertia-vue3'
import { createApp, h } from 'vue3'

createInertiaApp({
resolve: name => require(`./Pages/${name}`),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
})

Здесь мы используем функцию createInertiaApp, которая инкапсулирует все необходимые настройки для работы с Inertia.

Серверная часть на Laravel

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

PHP: Скопировать в буфер обмена
Код:
// routes/web.php
Route::get('/users', function () {
return Inertia::render('Users/Index', [
'users' => User::paginate(10)->through(fn($user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
])
]);
});

Эта конфигурация позволяет нам отправлять данные пользователей непосредственно в компонент Vue.

Клиентский компонент на Vue

Теперь создадим компонент Vue для отображения списка пользователей. В файле Pages/Users/Index.vue мы реализуем интерфейс:

JavaScript: Скопировать в буфер обмена
Код:
<!-- Pages/Users/Index.vue -->
<template>
<div class="users-container">
<h1>Пользователи</h1>

<div class="search-box">
<input
type="text"
v-model="search"
@input="debouncedSearch"
placeholder="Поиск пользователей..."
>
</div>

<div class="users-list">
<div v-for="user in users.data" :key="user.id" class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<button @click="editUser(user)">Редактировать</button>
</div>
</div>

<pagination :links="users.links" />
</div>
</template>

<script>
import { ref, onMounted } from 'vue'
import { usePage, router } from '@inertiajs/inertia-vue3'
import debounce from 'lodash/debounce'

export default {
props: {
users: Object,
},

setup(props) {
const search = ref('')

const debouncedSearch = debounce(() => {
router.get('/users', { search: search.value }, {
preserveState: true,
preserveScroll: true,
})
}, 300)

function editUser(user) {
router.get(`/users/${user.id}/edit`)
}

return {
search,
debouncedSearch,
editUser,
}
}
}
</script>

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

Продвинутые техники с Inertia.js

  1. Управление состоянием и кэширование:
Рассмотрим, как мы можем кэшировать пользователей для оптимизации производительности:

JavaScript: Скопировать в буфер обмена
Код:
// store/users.js
import { defineStore } from 'pinia'

export const useUsersStore = defineStore('users', {
state: () => ({
cachedUsers: new Map(),
lastFetch: null,
}),

actions: {
async fetchUser(id) {
// Проверяем кэш
if (this.cachedUsers.has(id)) {
const cached = this.cachedUsers.get(id)
if (Date.now() - cached.timestamp < 5 * 60 * 1000) {
return cached.data
}
}

// Если нет в кэше или устарел, загружаем
const response = await router.get(`/api/users/${id}`)
const userData = response.data

this.cachedUsers.set(id, {
data: userData,
timestamp: Date.now()
})

return userData
}
}
})

  1. Здесь мы используем Pinia для управления состоянием и кэшируем пользователей, чтобы снизить нагрузку на сервер.

    Оптимизация производительности

    Важно также обеспечить, чтобы данные, отправляемые пользователю, кэшировались правильно. Для этого мы создаем middleware:
JavaScript: Скопировать в буфер обмена
Код:
// middleware/cacheControl.js
export default function cacheControl({ response }) {
if (process.env.NODE_ENV === 'production') {
response.header('Cache-Control', 'public, max-age=31536000, immutable');
}
}

// Использование в компоненте
export default {
middleware: ['cacheControl'],
// ...
}

Интеграция с внешними сервисами

Создадим универсальный адаптер для работы с внешними API:

JavaScript: Скопировать в буфер обмена
Код:
// services/apiAdapter.js
export class ApiAdapter {
constructor(baseURL, options = {}) {
this.baseURL = baseURL;
this.options = {
timeout: 5000,
retries: 3,
...options
};
}

async request(endpoint, method = 'GET', data = null) {
let attempts = 0;

while (attempts < this.options.retries) {
try {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',
...this.options.headers
},
body: data ? JSON.stringify(data) : null,
signal: AbortSignal.timeout(this.options.timeout)
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

return await response.json();
} catch (error) {
attempts++;
if (attempts === this.options.retries) {
throw error;
}
// Экспоненциальная задержка перед повторной попыткой
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempts) * 100)
);
}
}
}
}

Продвинутая реализация Modern MPA

1.1 Система маршрутизации с поддержкой частичной загрузки

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

JavaScript: Скопировать в буфер обмена
Код:
// router/index.js
class Router {
  constructor(options = {}) {
    this.routes = new Map();
    this.middlewares = [];
    this.options = {
      cacheTimeout: 5 * 60 * 1000, // 5 минут
      prefetch: true,
      ...options
    };
   
    this.pageCache = new Map();
    this.setupEventListeners();
  }

  use(middleware) {
    this.middlewares.push(middleware);
    return this;
  }

  route(path, handler) {
    this.routes.set(path, handler);
    return this;
  }

  async handleRequest(path, context = {}) {
    // Проверяем кэш
    const cachedPage = this.pageCache.get(path);
    if (cachedPage && Date.now() - cachedPage.timestamp < this.options.cacheTimeout) {
      return cachedPage.content;
    }

    // Выполняем промежуточное ПО
    for (const middleware of this.middlewares) {
      await middleware(context);
    }

    const handler = this.routes.get(path) || this.routes.get('*');
    if (!handler) {
      throw new Error(`No handler for path: ${path}`);
    }

    const content = await handler(context);
   
    // Кэшируем результат
    this.pageCache.set(path, {
      content,
      timestamp: Date.now()
    });

    return content;
  }

  setupEventListeners() {
    window.addEventListener('popstate', async (e) => {
      const path = window.location.pathname;
      const content = await this.handleRequest(path, {
        historyAction: 'pop',
        state: e.state
      });
      this.updatePage(content);
    });

    if (this.options.prefetch) {
      this.setupPrefetching();
    }
  }

  setupPrefetching() {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const link = entry.target;
          const path = link.getAttribute('href');
          if (path && !this.pageCache.has(path)) {
            this.prefetchPage(path);
          }
        }
      });
    });

    // Наблюдаем за всеми ссылками на странице
    document.querySelectorAll('a[href^="/"]').forEach(link => {
      observer.observe(link);
    });
  }

  async prefetchPage(path) {
    try {
      const content = await this.handleRequest(path, { prefetch: true });
      this.pageCache.set(path, {
        content,
        timestamp: Date.now()
      });
    } catch (error) {
      console.warn(`Failed to prefetch ${path}:`, error);
    }
  }

  updatePage(content) {
    const parser = new DOMParser();
    const newDoc = parser.parseFromString(content, 'text/html');
   
    // Обновляем только изменившиеся части
    const oldMain = document.querySelector('main');
    const newMain = newDoc.querySelector('main');
    if (oldMain && newMain) {
      oldMain.replaceWith(newMain);
    }

    // Обновляем заголовок
    document.title = newDoc.title;

    // Обновляем мета-теги
    this.updateMetaTags(newDoc);

    // Перезагружаем скрипты
    this.reloadScripts(newDoc);
  }

  updateMetaTags(newDoc) {
    const oldMetas = Array.from(document.getElementsByTagName('meta'));
    const newMetas = Array.from(newDoc.getElementsByTagName('meta'));

    oldMetas.forEach(oldMeta => {
      const newMeta = newMetas.find(meta =>
        meta.getAttribute('name') === oldMeta.getAttribute('name') ||
        meta.getAttribute('property') === oldMeta.getAttribute('property')
      );
     
      if (!newMeta) {
        oldMeta.remove();
      } else if (newMeta.getAttribute('content') !== oldMeta.getAttribute('content')) {
        oldMeta.setAttribute('content', newMeta.getAttribute('content'));
      }
    });
  }

  reloadScripts(newDoc) {
    const scripts = Array.from(newDoc.getElementsByTagName('script'))
      .filter(script => !script.getAttribute('async'));

    const loadScript = (script) => {
      return new Promise((resolve, reject) => {
        const newScript = document.createElement('script');
        Array.from(script.attributes).forEach(attr => {
          newScript.setAttribute(attr.name, attr.value);
        });
        newScript.textContent = script.textContent;
        newScript.onload = resolve;
        newScript.onerror = reject;
        document.body.appendChild(newScript);
      });
    };

    return scripts.reduce((promise, script) => {
      return promise.then(() => loadScript(script));
    }, Promise.resolve());
  }
}

Как это работает​

  1. Кэширование страниц: Мы используем Map для хранения кэшированных страниц, что позволяет значительно сократить время загрузки при повторных переходах по тем же маршрутам.
  2. Middleware: Поддержка промежуточного ПО позволяет добавлять функциональность, такую как аутентификация или логирование, перед выполнением основного обработчика маршрута.
  3. Переходы: При нажатии на ссылку или использовании кнопки "назад" браузера вызывается метод handleRequest, который обрабатывает текущий путь, загружая контент либо из кэша, либо с сервера.
  4. Частичная загрузка: Вместо перезагрузки всей страницы система обновляет только необходимые элементы (например, содержимое тега <main>), что значительно ускоряет взаимодействие.
  5. Предварительная загрузка: С помощью IntersectionObserver приложение может предзагружать страницы, когда пользователь наводит курсор на ссылки, обеспечивая мгновенный доступ к контенту.

1.2 Система компонентов для MPA

Легковесная система компонентов без полной гидратации

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

JavaScript: Скопировать в буфер обмена
Код:
// components/BaseComponent.js
export class BaseComponent extends HTMLElement {
  constructor() {
    super();
    this.state = new Proxy({}, {
      set: (target, property, value) => {
        const oldValue = target[property];
        target[property] = value;
        this.stateChanged(property, oldValue, value);
        return true;
      }
    });
  }

  setState(newState) {
    Object.entries(newState).forEach(([key, value]) => {
      this.state[key] = value;
    });
  }

  stateChanged(property, oldValue, newValue) {
    if (oldValue !== newValue) {
      this.render();
    }
  }

  render() {
    // Переопределяется в наследниках
  }

  dispatch(eventName, detail = {}) {
    this.dispatchEvent(new CustomEvent(eventName, {
      detail,
      bubbles: true,
      composed: true
    }));
  }

  // Вспомогательные методы для работы с атрибутами
  static get observedAttributes() {
    return [];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
      this[name] = newValue;
    }
  }
}

// Пример использования компонента
export class SearchComponent extends BaseComponent {
  static get observedAttributes() {
    return ['endpoint', 'placeholder'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.state = {
      results: [],
      loading: false,
      error: null
    };
  }

  connectedCallback() {
    this.render();
    this.setupEventListeners();
  }

  setupEventListeners() {
    const input = this.shadowRoot.querySelector('input');
    input.addEventListener('input', this.debounce(this.handleSearch.bind(this), 300));
  }

  async handleSearch(event) {
    const query = event.target.value;
    if (query.length < 2) {
      this.setState({ results: [], error: null });
      return;
    }

    this.setState({ loading: true, error: null });

    try {
      const response = await fetch(`${this.endpoint}?q=${encodeURIComponent(query)}`);
      if (!response.ok) throw new Error('Search failed');
     
      const results = await response.json();
      this.setState({ results, loading: false });
    } catch (error) {
      this.setState({ error: error.message, loading: false });
    }
  }

  debounce(func, wait) {
    let timeout;
    return function executedFunction(...args) {
      const later = () => {
        clearTimeout(timeout);
        func(...args);
      };
      clearTimeout(timeout);
      timeout = setTimeout(later, wait);
    };
  }

  render() {
    const { results, loading, error } = this.state;
   
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          position: relative;
        }
        .search-container {
          position: relative;
        }
        input {
          width: 100%;
          padding: 8px;
          border: 1px solid #ddd;
          border-radius: 4px;
        }
        .results {
          position: absolute;
          top: 100%;
          left: 0;
          right: 0;
          background: white;
          border: 1px solid #ddd;
          border-radius: 4px;
          margin-top: 4px;
          max-height: 300px;
          overflow-y: auto;
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .result-item {
          padding: 8px;
          cursor: pointer;
        }
        .result-item:hover {
          background: #f5f5f5;
        }
        .loading {
          padding: 8px;
          text-align: center;
          color: #666;
        }
        .error {
          padding: 8px;
          color: red;
        }
      </style>
     
      <div class="search-container">
        <input type="search"
               placeholder="${this.placeholder || 'Search...'}"
               aria-label="Search">
       
        ${loading ? `
          <div class="results">
            <div class="loading">Searching...</div>
          </div>
        ` : ''}
       
        ${error ? `
          <div class="results">
            <div class="error">${error}</div>
          </div>
        ` : ''}
       
        ${!loading && !error && results.length > 0 ? `
          <div class="results">
            ${results.map(result => `
              <div class="result-item" data-id="${result.id}">
                ${result.title}
              </div>
            `).join('')}
          </div>
        ` : ''}
      </div>
    `;

    // Добавляем обработчики для результатов поиска
    if (results.length > 0) {
      this.shadowRoot.querySelectorAll('.result-item').forEach(item => {
        item.addEventListener('click', () => {
          this.dispatch('result-selected', {
            id: item.dataset.id,
            title: item.textContent.trim()
          });
        });
      });
    }
  }
}

customElements.define('search-component', SearchComponent);

Как это работает​

  1. Наследование от BaseComponent: Компоненты, такие как SearchComponent, наследуют функциональность BaseComponent, включая управление состоянием и механизм события.
  2. Управление состоянием: Используя прокси-объект, мы можем автоматически отслеживать изменения состояния и вызывать render, когда данные обновляются.
  3. Отложенная обработка ввода: Функция debounce предотвращает избыточные запросы к API при вводе данных пользователем.
  4. Изоляция стилей и логики: Shadow DOM позволяет изолировать стили компонента, предотвращая их конфликт с другими стилями на странице.
  5. Взаимодействие с API: Компонент выполняет запросы к API для получения результатов поиска, обрабатывая как успешные ответы, так и ошибки.

2. Продвинутая реализация Islands Architecture

2.1 Система управления островами

В этом разделе мы разработаем IslandManager, который будет управлять этими островами, используя IntersectionObserver для отслеживания их видимости.

Реализация IslandManager

JavaScript: Скопировать в буфер обмена
Код:
// islands/IslandManager.js
export class IslandManager {
  constructor(options = {}) {
    this.options = {
      threshold: 0.1,
      rootMargin: '50px',
      ...options
    };
   
    this.islands = new Map();
    this.hydrationQueue = new Map();
    this.observer = this.createObserver();
  }

  createObserver() {
    return new IntersectionObserver(
      (entries) => this.handleIntersection(entries),
      this.options
    );
  }

  async handleIntersection(entries) {
    const visibleIslands = entries
      .filter(entry => entry.isIntersecting)
      .map(entry => entry.target);

    if (visibleIslands.length === 0) return;

    // Приоритизируем острова на основе их позиции на странице
    const prioritizedIslands = this.prioritizeIslands(visibleIslands);
   
    // Последовательно гидратируем острова
    for (const island of prioritizedIslands) {
      await this.hydrateIsland(island);
    }
  }

  prioritizeIslands(islands) {
    return islands.sort((a, b) => {
      const aRect = a.getBoundingClientRect();
      const bRect = b.getBoundingClientRect();
      return aRect.top - bRect.top;
    });
  }

  async hydrateIsland(island) {
    const id = island.dataset.islandId;
    if (this.islands.has(id)) return;

    const type = island.dataset.islandType;
    if (!type) {
      console.warn(`Island ${id} has no type specified`);
      return;
    }

    try {
      const module = await this.loadIslandModule(type);
      await this.initializeIsland(island, module);
      this.islands.set(id, true);
    } catch (error) {
      console.error(`Failed to hydrate island ${id}:`, error);
    }
  }

  async loadIslandModule(type) {
    // Кэшируем промисы загрузки модулей
    if (!this.hydrationQueue.has(type)) {
      this.hydrationQueue.set(type, import(`./types/${type}.js`));
    }
    return this.hydrationQueue.get(type);
  }

  async initializeIsland(island, module) {
    const props = this.parseIslandProps(island);
   
    // Создаём песочницу для острова
    const sandbox = this.createIslandSandbox(island);
   
    // Инициализируем остров в песочнице
    await module.default(island, props, sandbox);
  }

  parseIslandProps(island) {
    try {
      return JSON.parse(island.dataset.islandProps || '{}');
    } catch (error) {
      console.warn(`Invalid props for island ${island.dataset.islandId}:`, error);
      return {};
    }
  }

  createIslandSandbox(island) {
    const sandbox = {
      dispatch: (eventName, detail) => {
        island.dispatchEvent(new CustomEvent(eventName, {
          detail,
          bubbles: true,
          composed: true
        }));
      },
      // Добавляем другие безопасные API
    };

    return new Proxy(sandbox, {
      get(target, prop) {
        if (prop in target) {
          return target[prop];
        }
        throw new Error(`Access to '${prop}' is not allowed in island sandbox`);
      }
    });
  }

  observe(element) {
    if (element.dataset.islandId) {
      this.observer.observe(element);
    } else {
      element.querySelectorAll('[data-island-id]').forEach(island => {
        this.observer.observe(island);
      });
    }
  }
 
  disconnect() {
    this.observer.disconnect();
    this.islands.clear();
    this.hydrationQueue.clear();
  }
}

Основные функции​

  1. Отслеживание видимости: Используя IntersectionObserver, IslandManager отслеживает, когда острова становятся видимыми на экране.
  2. Приоритизация: Острова, которые видны на экране, сортируются по их позиции, чтобы гарантировать, что сначала будут загружены наиболее важные элементы.
  3. Гидратация островов: Для каждого видимого острова происходит его гидратация, что включает в себя загрузку необходимых модулей и инициализацию компонентов.
  4. Песочница для безопасного взаимодействия: Создается песочница, которая позволяет компонентам безопасно взаимодействовать друг с другом и с окружением.
  5. Кэширование модулей: Модули для островов кэшируются, чтобы избежать повторной загрузки одного и того же ресурса при взаимодействии с несколькими экземплярами одного типа острова.
Далее представлен пример реализации острова с интерактивной картой, который будет загружаться и инициализироваться только по мере необходимости.


JavaScript: Скопировать в буфер обмена
Код:
// islands/types/MapIsland.js
export default async function initializeMapIsland(element, props, sandbox) {
  // Загружаем карту только когда она действительно нужна
  const { createMap } = await import('./map-library.js');

  const state = new Proxy({
    markers: props.initialMarkers || [],
    center: props.initialCenter || { lat: 0, lng: 0 },
    zoom: props.initialZoom || 10
  }, {
    set(target, prop, value) {
      target[prop] = value;
      render();
      return true;
    }
  });

  const map = await createMap(element, {
    center: state.center,
    zoom: state.zoom,
    markers: state.markers
  });

  function render() {
    map.setView(state.center, state.zoom);
    map.updateMarkers(state.markers);
  }

  // Обработка событий
  map.on('moveend', () => {
    const center = map.getCenter();
    const zoom = map.getZoom();
    sandbox.dispatch('map-moved', { center, zoom });
  });

  map.on('click', (event) => {
    const { lat, lng } = event.latlng;
    sandbox.dispatch('map-clicked', { lat, lng });
  });

  // Экспортируем API для внешнего взаимодействия
  element.mapApi = {
    addMarker(marker) {
      state.markers = [...state.markers, marker];
    },
    removeMarker(markerId) {
      state.markers = state.markers.filter(m => m.id !== markerId);
    },
    setCenter(center) {
      state.center = center;
    },
    setZoom(zoom) {
      state.zoom = zoom;
    }
  };
}



2.2 Продвинутая система межостровной коммуникации


Давайте буду объяснять по частям код.

Структура IslandBus


JavaScript: Скопировать в буфер обмена
Код:
// islands/IslandBus.js
export class IslandBus {
constructor() {
this.subscribers = new Map();
this.messageQueue = new Map();
}

subscribe(islandId, eventType, callback) {
if (!this.subscribers.has(eventType)) {
this.subscribers.set(eventType, new Map());
}

const eventSubscribers = this.subscribers.get(eventType);
if (!eventSubscribers.has(islandId)) {
eventSubscribers.set(islandId, new Set());
}

eventSubscribers.get(islandId).add(callback);

// Проверяем, есть ли отложенные сообщения
if (this.messageQueue.has(eventType)) {
const messages = this.messageQueue.get(eventType);
messages.forEach(message => {
if (message.target === islandId || message.target === '*') {
callback(message.data);
}
});
// Очищаем обработанные сообщения
this.messageQueue.set(eventType,
messages.filter(m => m.target !== islandId && m.target !== '*')
);
}
}

В этом коде класс IslandBus управляет подписками на события и очередями сообщений. Метод subscribe добавляет обработчик для конкретного острова и типа события, а также проверяет наличие отложенных сообщений, которые могут быть переданы новым подписчикам.

Публикация сообщений

Метод publish отправляет сообщения всем подписчикам, а также сохраняет сообщения в очередь для будущих подписчиков.

JavaScript: Скопировать в буфер обмена
Код:
publish(sourceIslandId, eventType, data, target = '*') {
// Если есть активные подписчики, отправляем сообщение немедленно
if (this.subscribers.has(eventType)) {
const eventSubscribers = this.subscribers.get(eventType);
eventSubscribers.forEach((callbacks, islandId) => {
if (islandId === target || target === '*') {
callbacks.forEach(callback => callback(data, sourceIslandId));
}
});
}

// Сохраняем сообщение в очередь для будущих подписчиков
if (!this.messageQueue.has(eventType)) {
this.messageQueue.set(eventType, []);
}
this.messageQueue.get(eventType).push({
data,
target,
timestamp: Date.now()
});

// Очищаем старые сообщения
this.cleanupMessageQueue();
}

Очистка очереди сообщений

Очередь сообщений очищается с помощью метода cleanupMessageQueue, который удаляет старые сообщения.

JavaScript: Скопировать в буфер обмена
Код:
cleanupMessageQueue(maxAge = 5000) { // 5 секунд
const now = Date.now();
this.messageQueue.forEach((messages, eventType) => {
const filteredMessages = messages.filter(
message => now - message.timestamp < maxAge
);
if (filteredMessages.length === 0) {
this.messageQueue.delete(eventType);
} else {
this.messageQueue.set(eventType, filteredMessages);
}
});
}
}

Пример использования IslandBus

Теперь рассмотрим, как использовать IslandBus для коммуникации между островами. Мы создадим два острова: CartIsland и ProductIsland.

CartIsland


JavaScript: Скопировать в буфер обмена
Код:
// islands/types/CartIsland.js
export default async function initializeCartIsland(element, props, sandbox) {
const bus = window.islandBus; // глобальный экземпляр IslandBus

const state = {
items: props.initialItems || [],
total: 0
};

function updateTotal() {
state.total = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
render();
}

function render() {
element.innerHTML = `
<div class="cart">
<h3>Корзина (${state.items.length})</h3>
<div class="cart-items">
${state.items.map(item => `
<div class="cart-item" data-id="${item.id}">
<span>${item.name}</span>
<span>${item.quantity} x ${item.price}₽</span>
<button class="remove-item">✕</button>
</div>
`).join('')}
</div>
<div class="cart-total">
Итого: ${state.total}₽
</div>
</div>
`;

// Добавляем обработчики событий
element.querySelectorAll('.remove-item').forEach(button => {
const itemId = button.closest('.cart-item').dataset.id;
button.addEventListener('click', () => {
removeItem(itemId);
});
});
}

function addItem(item) {
const existingItem = state.items.find(i => i.id === item.id);
if (existingItem) {
existingItem.quantity += 1;
} else {
state.items.push({ ...item, quantity: 1 });
}
updateTotal();
bus.publish('cart', 'cart:updated', {
itemCount: state.items.length,
total: state.total
});
}

function removeItem(itemId) {
state.items = state.items.filter(item => item.id !== itemId);
updateTotal();
bus.publish('cart', 'cart:updated', {
itemCount: state.items.length,
total: state.total
});
}

// Подписываемся на события добавления товаров
bus.subscribe('cart', 'product:added-to-cart', addItem);

// Начальная отрисовка
render();

// Очистка при удалении острова
return () => {
bus.unsubscribe('cart', 'product:added-to-cart', addItem);
};
}

CartIsland​

  1. Инициализация: При инициализации CartIsland создается локальное состояние, включающее товары и общую стоимость.
  2. Функция updateTotal: Эта функция пересчитывает общую стоимость всех товаров в корзине. Она использует метод reduce для суммирования цен с учетом их количества и вызывает функцию render для обновления пользовательского интерфейса.
  3. Функция render: Отвечает за отрисовку содержимого корзины в элементе DOM. Она создает HTML-код для отображения товаров и общей стоимости.
  4. Функция addItem: Добавляет товар в корзину. Если товар уже существует, увеличивает его количество. После этого она вызывает updateTotal и отправляет сообщение о том, что корзина обновилась.
  5. Функция removeItem: Удаляет товар из корзины по его идентификатору и обновляет общую стоимость.
  6. Подписка на события: CartIsland подписывается на событие product:added-to-cart, чтобы автоматически обновлять корзину при добавлении новых товаров с помощью ProductIsland.
  7. Очистка: Возвращается функция для отписки от события при удалении острова, предотвращая утечки памяти.

ProductIsland


JavaScript: Скопировать в буфер обмена
Код:
// islands/types/ProductIsland.js
export default async function initializeProductIsland(element, props, sandbox) {
const bus = window.islandBus;

const state = {
product: props.product,
loading: false
};

function render() {
element.innerHTML = `
<div class="product-card">
<h3>${state.product.name}</h3>
<p>${state.product.description}</p>
<div class="price">${state.product.price}₽</div>
<button class="add-to-cart" ${state.loading ? 'disabled' : ''}>
${state.loading ? 'Добавление...' : 'В корзину'}
</button>
</div>
`;

element.querySelector('.add-to-cart').addEventListener('click', async () => {
state.loading = true;
render();

try {
// Имитация запроса к API
await new Promise(resolve => setTimeout(resolve, 500));

bus.publish('product', 'product:added-to-cart', state.product);

// Показываем уведомление об успехе
sandbox.dispatch('notification', {
type: 'success',
message: 'Товар добавлен в корзину'
});
} catch (error) {
sandbox.dispatch('notification', {
type: 'error',
message: 'Не удалось добавить товар'
});
} finally {
state.loading = false;
render();
}
});
}

// Начальная отрисовка
render();
}

ProductIsland​

  1. Инициализация: ProductIsland получает информацию о товаре через props и устанавливает состояние загрузки.
  2. Функция render: Отвечает за отрисовку карточки товара. Она включает информацию о товаре и кнопку для добавления в корзину. Кнопка блокируется во время загрузки.
  3. Обработчик события: На кнопку "В корзину" устанавливается обработчик события, который имитирует задержку, затем публикует сообщение о добавлении товара в корзину с помощью IslandBus.
  4. Уведомления: После успешного добавления или при ошибке показывается уведомление через sandbox.dispatch.
  5. Начальная отрисовка: Вызывается функция render, чтобы отобразить товар при инициализации.


2.3 Оптимизация производительности островов


Структура IslandOptimizer

JavaScript: Скопировать в буфер обмена
Код:
// islands/IslandOptimizer.js
export class IslandOptimizer {
constructor(options = {}) {
this.options = {
maxConcurrentHydrations: 3,
hydrationTimeout: 2000,
...options
};

this.hydrationQueue = [];
this.activeHydrations = new Set();
this.completedHydrations = new Set();
}

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

Планирование гидратации

Метод scheduleHydration добавляет остров в очередь на гидратацию, рассчитывая приоритет.
JavaScript: Скопировать в буфер обмена
Код:
async scheduleHydration(island) {
return new Promise((resolve, reject) => {
const hydrationTask = {
island,
resolve,
reject,
priority: this.calculatePriority(island),
timestamp: Date.now()
};

this.hydrationQueue.push(hydrationTask);
this.processQueue();
});
}

Расчет приоритета

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

JavaScript: Скопировать в буфер обмена
Код:
calculatePriority(island) {
const rect = island.getBoundingClientRect();
const viewportHeight = window.innerHeight;

// Приоритет выше для островов в области видимости
if (rect.top < viewportHeight) {
return 1 - (rect.top / viewportHeight);
}
return 0;
}

Обработка очереди

Метод processQueue управляет очередью задач и запускает гидратацию для островов в зависимости от приоритета и доступных ресурсов.


JavaScript: Скопировать в буфер обмена
Код:
async processQueue() {
if (this.activeHydrations.size >= this.options.maxConcurrentHydrations) {
return;
}

// Сортируем очередь по приоритету
this.hydrationQueue.sort((a, b) => b.priority - a.priority);

while (this.hydrationQueue.length > 0 &&
this.activeHydrations.size < this.options.maxConcurrentHydrations) {
const task = this.hydrationQueue.shift();

if (this.completedHydrations.has(task.island)) {
task.resolve();
continue;
}

this.activeHydrations.add(task.island);

try {
await Promise.race([
this.hydrateIsland(task.island),
this.createTimeout(task.island)
]);

this.completedHydrations.add(task.island);
task.resolve();
} catch (error) {
task.reject(error);
} finally {
this.activeHydrations.delete(task.island);
this.processQueue();
}
}
}

Таймаут для гидратации

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

JavaScript: Скопировать в буфер обмена
Код:
createTimeout(island) {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Hydration timeout for island ${island.dataset.islandId}`));
}, this.options.hydrationTimeout);
});
}

Гидратация острова

Метод hydrateIsland отвечает за динамическую загрузку и инициализацию острова, а также за измерение времени, необходимого для завершения процесса.

JavaScript: Скопировать в буфер обмена
Код:
async hydrateIsland(island) {
const type = island.dataset.islandType;
const module = await import(`./types/${type}.js`);

// Измеряем производительность
const start = performance.now();

try {
await module.default(island);

const duration = performance.now() - start;
this.reportPerformance(island, duration);
} catch (error) {
console.error(`Failed to hydrate island ${island.dataset.islandId}:`, error);
throw error;
}
}

Отчет о производительности

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

JavaScript: Скопировать в буфер обмена
Код:
reportPerformance(island, duration) {
// Отправляем метрики производительности
if (window.performance && window.performance.mark) {
window.performance.mark(`island-hydration-${island.dataset.islandId}`);

window.performance.measure(
`island-hydration-duration-${island.dataset.islandId}`,
{
duration,
detail: {
type: island.dataset.islandType,
size: island.getBoundingClientRect()
}
}
);
}
}
}

Заключение

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