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

Проект привлек внимание сообщества и собрал звезды на 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 (Островная архитектура).
Почему не 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-документ кратковременно сбрасывает кастомные атрибуты.
Решение: Мы реализовали трехуровневую архитектуру инициализации интерфейса:
- Чтение серверных параметров в момент формирования ответа.
- Синхронный блокирующий тег
<script is:inline>в самом начале секции<head>: он отрабатывает до отрисовки первого пикселя в браузере. - Подписка на событие жизненного цикла
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 пикселя, вызывая горизонтальное дергание текста содержания.
Решение:
- Троттлинг через
requestAnimationFrame: замер положения заголовков рассчитывается по верхнему краю экрана с учетом плавающей шапки (headerOffset = 80px). - Флаг блокировки
isClickScrollingRef: при клике по ссылке оглавления автоматический трекинг блокируется до завершения анимации перехода. - Абсолютный маркер: индикатор вынесен в
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 и разряжал батарею устройства.
Архитектурное решение: Мы спроектировали собственную гибридную систему, состоящую из автономного бэкенда на Bun и легковесного клиентского острова:
-
Серверный микросервис на 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.
- Работает на порту
-
Клиентский остров в
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), в арабской версии сайт рассыпается: отступы прилипают к противоположным краям, пиктограммы указывают назад, а списки смещаются.
Решение:
- Логические свойства 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-*.
- Вместо физических
- Зеркалирование иконок: стрелки навигации и шевроны автоматически переворачиваются в режиме RTL с помощью утилиты
rtl:scale-x-[-1]. - Строгая генерация карты сайта
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 лет эволюции
- SPA не место в презентационном вебе: Использование клиентского React с пустым контейнером для визиток, портфолио и блогов — антипаттерн, приводящий к раздуванию трафика и медленному рендерингу.
- Острова — лучший архитектурный баланс: Astro позволяет писать интерактивные модули на привычном JSX React 19, но отдает клиенту лишь тот минимум скриптов, который нужен для работы конкретной кнопки.
- Автономный WebSocket-сервер побеждает поллинг: связка легковесного сервиса на Bun с LRU-кешем субтитров и клиентской интерполяцией времени дает нулевую нагрузку на внешние API и мгновенный отклик караоке.
- Перформанс складывается из микрорешений: Блокирующий инлайн-скрипт в
<head>предотвращает мигание тем, ленивые слушатели событий защищают метрику TBT, а троттлинг черезrequestAnimationFrameделает скролл оглавления идеально плавным. - Персональный сайт — визитная карточка инженера: Архитектура, скорость отклика, доступность и внимание к деталям говорят о компетенциях разработчика убедительнее любых резюме.