Skip to main content
Назад к блогу
SolidJSSolidStartPerformanceSSGCode Splitting

Ленивая загрузка контента блога в SolidStart без потери SSG

Твой блог, скорее всего, отдаёт тело каждого поста в одном бандле — мой разросся до 2,88 МБ и качался даже на странице-списке, где нужны только заголовки. Вот как я вынес прозу каждого поста в ленивый чанк, который грузится по требованию, сохранив полный серверный рендер каждой страницы для SEO.

Опубликовано 5 августа 2026 г.8 мин чтения

Держать контент в типизированном коде удобно — пока куча не разрастётся настолько, что раздувает то, что качает каждый посетитель. Вот как я разбил блог на 2,88 МБ на чанки-по-посту в SolidStart, не пожертвовав ни одной серверно отрендеренной страницей.

Один чанк, чтобы править всеми

Мой реестр блога делал очевидное: статически импортировал каждый пост, складывал их в массив, экспортировал пару хелперов.

import { postA } from "./posts/a";
import { postB } from "./posts/b";
// …19 of these

const allPosts: BlogPost[] = [postA, postB, /* … */];

Каждый BlogPost нёс всё своё тело — ContentBlock[] — для всех 13 локалей. Статические импорты означают, что бандлер затягивает всё это в один чанк. Результат:

blog-C81oI990.js   2,880 kB │ gzip: 942 kB

Эти 2,88 МБ отдавались на каждой странице блога — включая список, где рендерится лишь перечень заголовков и описаний. Каждый посетитель качал прозу всех 19 постов на 13 языках, чтобы прочитать один.

Разбиение: метаданные в бандле, проза — отдельно

Решение — отделить то, что нужно списку (дёшево), от того, что нужно отдельному посту (дорого). Каждый файл поста стал двумя.

Лёгкая половина — <slug>.ts — держит только метаданные плюс функцию, импортирующую тяжёлую половину:

posts/<slug>.ts
import type { BlogMeta } from "../types";

export const fitText: BlogMeta = {
  slug: "fit-text-to-container-pure-css",
  date: "2026-07-24",
  readingTime: 11,
  tags: ["CSS", "Container Queries", "Typography"],
  translations: {
    en: { title: "Fit text to its container…", description: "…" },
    // …12 more locales, title + description only
  },
  loadContent: () => import("./fit-text-to-container-pure-css.content"),
};

Тяжёлая половина — <slug>.content.ts — содержит прозу, и ничто не импортирует её статически:

posts/<slug>.content.ts
import type { ContentBlock } from "../types";
import type { Language } from "~/i18n/languages";

export const content: Record<Language, ContentBlock[]> = {
  en: [ /* the whole article, as blocks */ ],
  // …12 more locales
};

Тип BlogMeta — это контракт, который их склеивает:

export interface BlogMeta {
  slug: string;
  date: string;
  readingTime: number;
  tags: string[];
  translations: Record<Language, { title: string; description: string }>;
  loadContent: () => Promise<{ content: Record<Language, ContentBlock[]> }>;
}

Поскольку loadContent — это динамический import(), бандлер даёт телу каждого поста собственный чанк, который загружается только когда его кто-то вызовет.

Реестр: синхронные summary, асинхронный контент

Теперь реестр импортирует только лёгкие половины и даёт два вида доступа — синхронные метаданные и асинхронное тело:

export function getPostSummary(slug: string, lang: Language) {
  const meta = metas.find((p) => p.slug === slug);
  return meta && { ...meta, localized: meta.translations[lang] };
}

export async function getPostContent(slug: string, lang: Language) {
  const meta = metas.find((p) => p.slug === slug);
  if (!meta) return undefined;
  const mod = await meta.loadContent();      // ← the lazy chunk loads here
  return mod.content[lang] ?? mod.content.en;
}

Страница-список и карточки вызывают getPostSummary/getAllPosts и никогда не трогают тело. Только страница поста обращается к getPostContent.

Страница: SEO из summary, тело — из createAsync

Роут поста рендерится на двух скоростях. Хедер, заголовок, теги и каждый тег <meta>/JSON-LD берутся из summary — синхронно, они есть всегда. Тело приходит из createAsync, который грузит чанк:

const post = () => getPostSummary(params.slug, lang());
const content = createAsync(() => getPostContent(params.slug, lang()));

return (
  <Show when={post()} keyed>
    {(p) => (
      <article>
        <PageSeo customTitle={p.localized.title} /* …sync SEO… */ />
        <header>{/* title, tags, date — sync */}</header>

        <Suspense fallback={<Skeleton minutes={p.readingTime} />}>
          <Show when={content()}>
            {(blocks) => <BlogPostRenderer content={blocks()} />}
          </Show>
        </Suspense>
      </article>
    )}
  </Show>
);

Место, где все ошибаются: это всё ещё SSG?

Вот вопрос, который останавливает людей: там же есть fallback у Suspense — так разве Google не проиндексирует скелетон вместо моего контента?

Нет. И причину стоит понять, потому что это вся несущая балка.

Suspense показывает свой fallback только пока async в состоянии ожидания — а ожидание возникает лишь там, где данных ещё нет. При пререндере это не так никогда:

  • На сервере SolidStart дожидается ресурса перед рендером. К моменту, когда HTML сгенерирован, content() уже зарезолвлен, так что ветка fallback не срабатывает никогда. Статический файл содержит полную статью.
  • Затем SolidStart сериализует зарезолвленное значение в payload гидратации. При прямом заходе клиент восстанавливает ресурс прямо из этого payload — он не запускает getPostContent заново и потому даже не качает content-чанк.

Это можно доказать на собранном выводе. Грепни один пререндер-пост:

skeleton markers (aria-busy, animate-pulse, "Loading"):  0
article code blocks (<pre>/<code>):                     11
reference to the .content chunk in the HTML:             none

Ноль скелетона. Полное тело. Content-чанк даже не залинкован — проза едет и в DOM, и в ~20 КБ сериализованного скрипта гидратации.

Скелетон — это спиннер. Сервер никогда не отдаёт спиннер: он держит ответ, пока данные не готовы, и отдаёт завершённую страницу. Краулеры получают завершённую страницу.

Два пути, и async лишь в одном

Разбиение не ослабляет SSG; оно добавляет слой code-splitting поверх него. Есть два разных пути, и ленивый чанк важен лишь для одного:

КтоЧто получаетContent-чанк?
Google / прямой заход / F5Полный пререндер-HTML, гидратация из сериализованного payloadНе качается
Посетитель, кликающий внутри приложения (SPA-навигация)Роут рендерится на клиенте, createAsync запускает import()Качается по требованию

За async платит лишь тот, кто уже внутри загруженного приложения и переходит между страницами — то есть именно тот, кто выигрывает от бандла на 108 КБ.

Делаем async незаметным

Для этого единственного SPA-навигационного пути шов убирают два штриха.

Масштабируй скелетон под пост. В момент показа скелетона проза ещё не загружена (в этом и суть), так что единственный сигнал длины, доступный синхронно, — это readingTime. Рендери одну группу-абзац на минуту, чтобы короткий пост резервировал мало, а длинный — больше; футер перестаёт прыгать:

<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
  {() => <ParagraphGroup />}
</For>

Прелоад по намерению. Прогрей чанк в тот же миг, когда курсор или клавиатура попадают на карточку, чтобы клик встретил импорт в полёте (или из кеша), а не холодный:

const preload = () => preloadPostContent(props.post.slug);

<A href={href} onMouseEnter={preload} onFocus={preload} onTouchStart={preload}>

Кеш «один промис на slug» заставляет прелоад и следующий клик делить один и тот же import(), так что чанк никогда не качается дважды. С прелоадом скелетон становится запасным вариантом для редкого кеш-мисса (медленная сеть, тап без hover), а не обычным случаем.

Результаты

                    before        after
main blog chunk     2,880 kB      108 kB      (-96%)
post body           in the chunk  19 lazy chunks, ~100-280 kB each
prerendered pages   247           247         (unchanged)

Список и каждая не-постовая страница теперь отдают 108 КБ вместо 2,88 МБ. Открой пост напрямую — получишь полностью серверно отрендеренный HTML, и content-чанк вообще не запрашивается. Перейди в него внутри приложения — его тело подгрузится, обычно уже прелоаднутое.

Выводы

  • Разбивай контент по тому, что реально нужно каждому вью: спискам — метаданные, посту — его тело. Не заставляй список платить за 19 статей.
  • Динамический import() за типизированным loadContent() — это всё, что нужно, чтобы дать каждому посту собственный чанк.
  • Ленивая загрузка и SSG не конфликтуют. createAsync резолвится на сервере и сериализуется в payload гидратации, так что пререндер-HTML остаётся полным, а fallback никогда в него не попадает.
  • Оставшийся шов — клиентская навигация — это UX, а не SEO: скелетон размером под readingTime и прелоад по намерению убирают его.