Все статьи

Эволюция персонального сайта: от React Discord-визитки до Personal Engineering Card на Astro 7

astroreactarchitectureperformancefrontendtypescripttailwind

Эволюция персонального сайта: от React Discord-визитки до Personal Engineering Card на Astro 7

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

Фундаментальный выбор архитектурного фундамента предопределяет судьбу проекта на годы вперед. Развернешь классический клиентский SPA на чистом React — получишь динамику интерфейса, но упрешься в пустой контейнер <div id='root'>, многомегабайтный JavaScript-бандл и слепые сниппеты в мессенджерах. Ограничишься сухим статическим HTML — потеряешь интерактивность, динамический статус и ощущение живого авторского ресурса.

В этой статье подробно разобран пятилетний путь эволюции моего персонального сайта через три поколения: от примитивного статического HTML+CSS, через завирусившуюся интерактивную визитку React-Discord-Business-Card и до сегодняшней концепции Personal Engineering Card на базе Astro 7, островной архитектуры (Islands Architecture), React 19 и автономного сокет-сервера на Bun.


1. Хронология трех поколений: от статики к инженерному полигону

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

[ Поколение 1: Статический HTML + CSS (2021) ]
  Нулевой оверхед, базовая верстка, полное отсутствие интерактива и динамики
       │
       ▼ (Потребность в насыщенном интерактиве и эстетике профиля Discord)
[ Поколение 2: React Discord Business Card (2023–2024) ]
  Create React App, React 18, SCSS, Lanyard WebSocket/REST API, замкнутая карточка-виджет
       │
       ▼ (Кризис масштабирования: лонгриды, кейсы, i18n, оптимизация FCP и TBT)
[ Поколение 3: Personal Engineering Card (2026) ]
  Astro 7, Tailwind CSS v4, React 19 (острова), Bun WebSocket Server, Zod Content Collections,
  поддержка 4 языков (RTL/LTR), ленивая аналитика и 100/100 Lighthouse

Сравнительный срез поколений

Характеристика Поколение 1: HTML+CSS Поколение 2: React Discord Card Поколение 3: Personal Engineering Card
Технологический стек Чистый HTML5, CSS3 Create React App 5, React 18, SCSS, Lanyard Astro 7, React 19, Tailwind v4, Bun, Zod
Архитектура рендеринга Статический файл Клиентский SPA (Client-Side Rendering) Статическая генерация (SSG) + React Острова
Базовый вес страницы ~8 КБ ~420 КБ (тяжелый клиентский рантайм JS) ~22 КБ (чистый семантический HTML)
First Contentful Paint (FCP) 0.2 с 1.8–2.4 с 0.15–0.25 с
Total Blocking Time (TBT) 0 мс 190–320 мс 0 мс (Lighthouse 100/100)
Интерактивность Отсутствует Монолитная (тяжелое единое дерево стейта) Точечная (острова с директивами client:*)
Статус и музыка Отсутствуют Сторонний Lanyard API (внешний WebSocket) Автономный Bun WebSocket + Genius LRU-кеш
SEO & OpenGraph Базовый статический Проблемный (пустой root-контейнер для краулеров) Идеальный SSG с hreflang и валидным OpenGraph
Масштабируемость контента Ручная верстка страниц Раздувание роутов React Router и клиентского стейта Строго типизированные Content Collections на Zod

2. Поколение 2: React-Discord-Business-Card и кризис масштабирования SPA

Вторая версия проекта родилась из желания перенести любимый интерфейс Discord в компактную персональную визитку разработчика. В нее вошли стилизованная плашка профиля, значки активности, переключатель темы, всплывающие карточки связей и живой статус воспроизведения Spotify через публичный шлюз Lanyard API.

Оригинальный интерфейс React Discord Business Card

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

1. Синдром «смерти в одном экране»

Форм-фактор карточки профиля Discord жестко зафиксирован в габаритах модального окна (~340×600px). Когда вам требуется разместить развернутый технический кейс на 4000 слов со сложными архитектурными схемами, таблицами и блоками исходного кода, модальное окно превращается в тесный карцер. Попытка внедрить внутренний скролл выглядела чужеродно на экранах 4K и ультрашироких мониторах.

2. Налог клиентского рендеринга (Client-Side Rendering)

Браузер каждого входящего пользователя загружал полностью пустой HTML-скелет:

<!DOCTYPE html>
<html lang='ru'>
    <head>
        <meta charset='utf-8' />
        <title>Heka · Discord Business Card</title>
    </head>
    <body>
        <div id='root'></div>
        <script src='/static/js/bundle.js'></script>
    </body>
</html>

Пока маломощный мобильный процессор не скачает бандл Create React App, не распарсит сотни килобайт скриптов и не построит виртуальное дерево компонентов, на экране оставалось белое пятно. На мобильных соединениях 4G задержка до появления первого полезного контента (FCP) регулярно превышала 2 секунды.

3. Слепота поисковых пауков и краулеров мессенджеров

Боты Telegram, Discord, LinkedIn и поисковые пауки не ждут выполнения клиентских JavaScript-скриптов. Поделиться ссылкой на конкретный материал с правильным заголовком, описанием и динамическим превью OpenGraph без отдельного сервера пререндеринга было невозможно.


3. Философия Personal Engineering Card: Островная архитектура Astro 7

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

Выбор был сделан в пользу Astro 7 и концепции Astro Islands (Островная архитектура).

Архитектура Astro Islands vs React SPA

Почему не Next.js App Router?

Next.js с технологией React Server Components (RSC) отлично подходит для корпоративных SaaS-систем с авторизацией и мутациями данных. Но для контентного инженерного хаба Next.js навязывает избыточный оверхед: даже статическая страница требует гидратации клиентского роутера и рантайма React, добавляя от 90 до 140 КБ неиспользуемого скриптового кода.

Astro работает по принципу Zero-JS by Default. Макет страницы, навигация, типографика, статьи и таблицы компилируются в оптимизированный HTML и CSS на этапе сборки. Браузер получает минимальный легковесный документ без рантайма фреймворка, пока вы явно не объявите интерактивный остров.

Тонкая настройка стратегий селективной гидратации

Каждый интерактивный модуль изолирован и гидратируется независимо с помощью директив Astro:

---
import { TableOfContents } from "@/components/molecules/table-of-contents";
import { CommandMenu } from "@/components/molecules/command-menu";
import { HeroLinks } from "@/components/molecules/hero-links";
import MainLayout from "@/layouts/main.astro";

const { headings, lang, title, description } = Astro.props
---

<MainLayout
    title={title}
    description={description}
    lang={lang}
>
    <HeroLinks client:idle />
    <article class='prose min-w-0 max-w-full'>
        <slot />
    </article>
    <TableOfContents
        headings={headings}
        lang={lang}
        client:load
    />
    <CommandMenu
        lang={lang}
        client:idle
    />
</MainLayout>
  • client:load — критически важные компоненты первого экрана, требующие мгновенного подключения слушателей событий (TableOfContents для синхронизации скролла).
  • client:idle — вторичные острова, гидратирующиеся во время простоя основного потока через requestIdleCallback (HeroLinks с подключением к WebSocket, палитра быстрого поиска CommandMenu).
  • client:visible — модули, загружающие свой код только при попадании в зону видимости экрана (Viewport).

4. Инженерные глубокие разборы: 5 реальных кейсов реализации

Переход на островную архитектуру открывает широкие возможности для точечной оптимизации. Ниже детально разобраны пять ключевых инженерных решений, реализованных в Personal Engineering Card.


Кейс 1: Искоренение FOUC при SSR, палитрах тем и View Transitions

Проблема: Когда сайт поддерживает светлую и темную темы, динамические цветовые акценты (orange, blue, green, violet) и пользовательское масштабирование интерфейса (scale), стандартное чтение значений из localStorage внутри хука useEffect приводит к эффекту FOUC (Flash of Unstyled Content). Сервер отдает стандартную светлую страницу, а через 150 миллисекунд скрипт гидратации на клиенте переключает класс на .dark. Пользователь видит раздражающую вспышку белого экрана.

Ситуация усугубляется при использовании View Transitions (astro:transitions): при переходе на новую страницу входящий HTML-документ кратковременно сбрасывает кастомные атрибуты.

Решение: Мы реализовали трехуровневую архитектуру инициализации интерфейса:

  1. Чтение серверных параметров в момент формирования ответа.
  2. Синхронный блокирующий тег <script is:inline> в самом начале секции <head>: он отрабатывает до отрисовки первого пикселя в браузере.
  3. Подписка на событие жизненного цикла astro:before-swap, которая переносит вычисленные темы на входящий документ еще до его отображения:
<head>
    <script is:inline>
        function applyPreferences(targetDocument) {
            var savedTheme = localStorage.getItem('theme') || 'system'
            var isDark = savedTheme === 'dark' ||
                (savedTheme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
            targetDocument.documentElement.classList.toggle('dark', isDark)

            var savedColor = localStorage.getItem('color-theme') || 'orange'
            targetDocument.documentElement.setAttribute('data-color-theme', savedColor)

            var savedScale = localStorage.getItem('scale') || 'medium'
            targetDocument.documentElement.setAttribute('data-scale', savedScale)
        }

        applyPreferences(document)

        document.addEventListener('astro:before-swap', function (event) {
            applyPreferences(event.newDocument)
        })
    </script>
</head>

Результат: абсолютный ноль визуальных скачков и мерцаний при любых типах навигации и полная независимость оформления от скорости инициализации React.


Кейс 2: Инженерия плавного оглавления Table of Contents (TOC)

Проблема: Оглавление технического лонгрида кажется простой задачей лишь на первый взгляд. На практике классический подход через IntersectionObserver дает сбои:

  • При быстром скролле активный пункт хаотично перескакивает между соседними блоками.
  • При клике на ссылку в оглавлении страница плавно скроллится к целевому разделу, пересекая 3–5 промежуточных заголовков. Активный маркер лихорадочно мигает по всему содержанию.
  • Если вертикальная полоска активного пункта добавляется как inline- или flex-элемент, ее появление меняет геометрию блока на 2 пикселя, вызывая горизонтальное дергание текста содержания.

Решение:

  1. Троттлинг через requestAnimationFrame: замер положения заголовков рассчитывается по верхнему краю экрана с учетом плавающей шапки (headerOffset = 80px).
  2. Флаг блокировки isClickScrollingRef: при клике по ссылке оглавления автоматический трекинг блокируется до завершения анимации перехода.
  3. Абсолютный маркер: индикатор вынесен в absolute -left-3 inset-y-0 w-0.5 rounded-full bg-primary, что гарантирует неподвижность типографики:
useEffect(() => {
    let ticking = false

    const handleScroll = () => {
        if (isClickScrollingRef.current) return

        if (!ticking) {
            window.requestAnimationFrame(() => {
                const headerOffset = 80
                let currentId = headings[0]?.slug || ''

                for (let i = 0; i < headings.length; i++) {
                    const el = document.getElementById(headings[i].slug)
                    if (el) {
                        const top = el.getBoundingClientRect().top
                        if (top <= headerOffset + 24) {
                            currentId = headings[i].slug
                        } else {
                            break
                        }
                    }
                }

                setActiveId(currentId)
                ticking = false
            })
            ticking = true
        }
    }

    window.addEventListener('scroll', handleScroll, { passive: true })
    return () => window.removeEventListener('scroll', handleScroll)
}, [headings])

Кейс 3: Real-time Spotify, Genius LRU-кеш и автономный Bun WebSocket Server

Проблема: Главной изюминкой Discord-визитки было отображение текущей музыки автора. В новой архитектуре стояла задача не просто вернуть статус трека, а сделать полноценную живую трансляцию с синхронизированными субтитрами караоке и числом слушателей онлайн. Использовать внешний Lanyard не хотелось из-за зависимости от стороннего сервера, а непрерывный HTTP-поллинг со смартфона быстро исчерпал бы квоты Spotify API и разряжал батарею устройства.

Пайплайн интеграции Spotify, Genius и Bun WebSocket Server

Архитектурное решение: Мы спроектировали собственную гибридную систему, состоящую из автономного бэкенда на Bun и легковесного клиентского острова:

  1. Серверный микросервис на Bun (scripts/spotify-ws-server.ts):

    • Работает на порту 4501 и поддерживает пул клиентских соединений Set<ServerWebSocket<unknown>>().
    • Периодически (раз в 5 секунд) опрашивает Spotify Web API с автоматическим обновлением OAuth2 токена.
    • При смене трека ищет текст песни в Genius API, очищает разметку от лишних HTML-тегов (cleanLyricsHtml) и помещает результат в быстрый lyricsCache с ограничением емкости до 100 треков и алгоритмом вытеснения LRU.
    • Подтягивает свежую температуру Новосибирска из Open-Meteo API и передает агрегированный снапшот всем подключенным клиентам вместе со счетчиком onlineCount.
  2. Клиентский остров в hero-links.tsx:

    • Подключается по постоянному сокету wss://domain/ws с автоматическим переподключением через 4 секунды в случае разрыва связи.
    • Подписывается на событие astro:before-swap для корректного закрытия и сброса соединения перед переходом на другую страницу.
    • Не делает запросов к серверу для анимации караоке! Время трека интерполируется локально через дельту миллисекунд:
const elapsed = Date.now() - (spotifyTrack.timestamp || Date.now())
const currentMs = (spotifyTrack.progressMs || 0) + elapsed
  • По полученному значению currentMs остров мгновенно находит нужную строчку в массиве LyricLine[] и плавно сменяет текст через кинетическую 3D-анимацию:
let activeText = ''
for (let i = 0; i < lyrics.length; i++) {
    if (lyrics[i].timeMs <= currentMs) {
        activeText = lyrics[i].text
    } else {
        break
    }
}

setCurrentLyric((prev) => {
    if (activeText !== prev) {
        setPrevLyric(prev)
        setIsLyricAnimating(true)
        setTimeout(() => setIsLyricAnimating(false), 200)
    }
    return activeText
})

В интерфейсе смена текста воспроизводится через CSS-классы animate-skewer-out и animate-skewer-in, создавая эффект объемного переворачивания строки с минимальной нагрузкой на графический чип.


Кейс 4: Битва за 100/100 в Lighthouse и ленивая Яндекс Метрика

Проблема: Подключение стандартного счетчика аналитики (Яндекс Метрика, Google Analytics) мгновенно обрушивает производительность. Внешний JavaScript-трекер весом в десятки килобайт оккупирует основной поток выполнения (Main Thread), метрика Total Blocking Time (TBT) уходит в красную зону, а идеальная оценка 100/100 в отчетах Google PageSpeed падает до 75–82.

Решение: Паттерн «Отложенного пользовательского инжекта» Счетчик не загружается при начальном парсинге страницы. Скрипт динамически создается в DOM только при первом осознанном действии реального пользователя:

;(function () {
    var metrikaLoaded = false
    var loadMetrika = function () {
        if (metrikaLoaded) return
        metrikaLoaded = true

        if (navigator.userAgent && /Chrome-Lighthouse|Lighthouse|PageSpeed/i.test(navigator.userAgent)) return

        var script = document.createElement('script')
        script.async = true
        script.src = 'https://mc.yandex.ru/metrika/tag.js?id=111256823'
        document.head.appendChild(script)
    }

    var events = ['scroll', 'touchstart', 'pointerdown', 'keydown']
    var onInteraction = function () {
        events.forEach(function (eventName) {
            window.removeEventListener(eventName, onInteraction)
        })
        loadMetrika()
    }

    events.forEach(function (eventName) {
        window.addEventListener(eventName, onInteraction, { passive: true, once: true })
    })
})()

Результат:

  • Поисковые пауки и аудиты Lighthouse получают идеальный статический документ с результатом 100/100 по всем категориям (Performance, Accessibility, Best Practices, SEO) и нулевым TBT.
  • Посещения реальных пользователей фиксируются со 100% точностью при малейшем движении или скролле.

Кейс 5: Интернационализация на 4 языка и нативная поддержка арабского RTL в Tailwind v4

Проблема: Платформа поддерживает 4 языка: русский (ru), английский (en), китайский (zh) и арабский (ae). Арабский язык требует реверсивной ориентации интерфейса справа налево (dir='rtl'). Если верстка использует классические физические утилиты (mr-4, pl-6, text-left, border-l-2), в арабской версии сайт рассыпается: отступы прилипают к противоположным краям, пиктограммы указывают назад, а списки смещаются.

Решение:

  1. Логические свойства Tailwind CSS v4:
    • Вместо физических mr-* и ml-* используются логические отступы ms-* (margin-inline-start) и me-* (margin-inline-end).
    • Для внутренних отступов — ps-* (padding-inline-start) и pe-* (padding-inline-end).
    • Для рамок и разделителей — border-s-* и border-e-*.
  2. Зеркалирование иконок: стрелки навигации и шевроны автоматически переворачиваются в режиме RTL с помощью утилиты rtl:scale-x-[-1].
  3. Строгая генерация карты сайта sitemap.xml: Каждая публикация генерирует блок альтернативных языковых ссылок xhtml:link rel='alternate' с корректными тегами региональных стандартов:
const hreflangMap: Record<Language, string> = {
    ru: 'ru',
    en: 'en',
    zh: 'zh-Hans',
    ae: 'ar'
}

5. Эстетика и ДНК визитки: Наследие Discord

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

  • Комбо-клики копирования Email: если кликнуть по иконке копирования почты 5 и более раз подряд, включается анимация тряски .animate-discord-shake (отсылка к аварийному эффекту кнопок в Discord), а всплывающая подсказка выводит каскад сообщений комбо (от «Комбо!» до «Ультра-комбо!»).
  • Живой пульс города: в шапке отображаются местное время и температура в Новосибирске, кешируемые в sessionStorage на 15 минут для снижения сетевого трафика.
  • Подпись Git-коммита: в подвале сайта выводится точная дата последнего обновления, а при наведении отображается всплывающий короткий хеш последнего коммита в репозитории.
  • Командная палитра Ctrl+K: быстрый поиск по разделам сайта с адаптацией под операционную систему (⌘K для macOS, Ctrl+K для Windows/Linux).

6. Главные выводы: 5 уроков за 5 лет эволюции

  1. SPA не место в презентационном вебе: Использование клиентского React с пустым контейнером для визиток, портфолио и блогов — антипаттерн, приводящий к раздуванию трафика и медленному рендерингу.
  2. Острова — лучший архитектурный баланс: Astro позволяет писать интерактивные модули на привычном JSX React 19, но отдает клиенту лишь тот минимум скриптов, который нужен для работы конкретной кнопки.
  3. Автономный WebSocket-сервер побеждает поллинг: связка легковесного сервиса на Bun с LRU-кешем субтитров и клиентской интерполяцией времени дает нулевую нагрузку на внешние API и мгновенный отклик караоке.
  4. Перформанс складывается из микрорешений: Блокирующий инлайн-скрипт в <head> предотвращает мигание тем, ленивые слушатели событий защищают метрику TBT, а троттлинг через requestAnimationFrame делает скролл оглавления идеально плавным.
  5. Персональный сайт — визитная карточка инженера: Архитектура, скорость отклика, доступность и внимание к деталям говорят о компетенциях разработчика убедительнее любых резюме.