Skip to main content
Блогқа оралу
SolidJSSolidStartPerformanceSSGCode Splitting

SolidStart-та SSG-ды жоғалтпай блог мазмұнын сұраныс бойынша жүктеу

Блогың, сірә, әр посттың негізгі мәтінін бір ғана bundle ішінде жеткізеді — менікі 2,88 МБ-қа дейін өсіп, тек тақырыптар қажет тізім бетінде де жүктелетін. Міне, мен әр посттың мәтінін сұраныс бойынша жүктелетін бөлек chunk-қа қалай бөлдім, әрі әр бет SEO үшін серверде толық рендерленген күйінде қалды.

Жарияланды 2026 ж. 5 тамыз8 мин оқу

Мазмұнды типтелген кодта ұстау ыңғайлы — үйінді әрбір келушінің жүктейтінін ісіндіретіндей үлкейгенше. Міне, мен SolidStart-та 2,88 МБ блогты бір де бір серверде рендерленген беттен бас тартпай, әр постқа бөлек chunk-тарға қалай бөлдім.

Бәрін билейтін бір chunk

Менің блог тізілімім (registry) айқын нәрсені істейтін: әр постты статикалық импорттап, оларды массивке салып, бірнеше көмекші функция экспорттайтын.

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

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

Әрбір BlogPost өзінің толық мәтінін — ContentBlock[] — 13 тілдің бәрі үшін алып жүретін. Статикалық импорттар бандлердің осының бәрін бір chunk-қа тартатынын білдіреді. Нәтижесі:

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

Ол 2,88 МБ блогтың әрбір бетінде жеткізілетін — тек тақырыптар мен сипаттамалардың тізімі рендерленетін индекс бетін қоса. Әрбір келуші біреуін оқу үшін 19 посттың бәрінің мәтінін 13 тілде жүктейтін.

Бөлу: метадеректер bundle-де, мәтін бөлек

Шешім — тізімге керегін (арзан) жеке постқа керегінен (қымбат) ажырату. Әр пост файлы екеу болды.

Жеңіл жартысы — <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() болғандықтан, бандлер әр посттың мәтініне жеке chunk береді, ол тек біреу оны шақырғанда ғана жүктеледі.

Тізілім: синхронды қысқаша дерек, асинхронды мазмұн

Енді тізілім тек жеңіл жартыларды импорттайды және екі түрлі қатынасты ұсынады — синхронды метадеректер, асинхронды мәтін:

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 қысқаша деректен, мәтін createAsync-тен

Пост маршруты екі жылдамдықпен рендерленеді. Тақырып (header), атау, тегтер және әрбір <meta>/JSON-LD тегі қысқаша деректен келеді — синхронды, әрдайым бар. Мәтін createAsync-тен келеді, ол chunk-ты жүктейді:

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 ме?

Міне, адамдарды тоқтататын сұрақ: Suspense fallback-і бар ғой — олай болса Google менің мазмұнымның орнына қаңқаны (skeleton) индекстемей ме?

Жоқ. Әрі себебін түсінген жөн, өйткені бұл — бүкіл көтеруші арқалық.

Suspense өз fallback-ін тек асинхронды күту күйінде тұрғанда ғана көрсетеді — ал күту деректер әлі дайын емес жерде ғана болады. Алдын ала рендерлеу (prerender) кезінде бұл ешқашан олай емес:

  • Серверде SolidStart рендерлеу алдында ресурсты күтеді. HTML жасалған сәтте content() әлдеқашан шешілген (resolved), сондықтан fallback тармағы ешқашан таңдалмайды. Статикалық файл толық мақаланы қамтиды.
  • Содан кейін SolidStart шешілген мәнді гидратация payload-ына сериялайды. Тікелей кіргенде клиент ресурсты сол payload-тан тікелей жалғастырады — ол getPostContent-ты қайта іске қоспайды, сондықтан мазмұн chunk-ын тіпті жүктемейді.

Мұны build-ке қарап дәлелдеуге болады. Алдын ала рендерленген постқа grep жаса:

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

Нөл қаңқа. Толық мәтін. Мазмұн chunk-ы тіпті сілтенбеген — мәтін әрі DOM-да, әрі ~20 КБ серияланған гидратация скриптінде жүреді.

Қаңқа — бұл спиннер. Сервер ешқашан спиннер бермейді — ол деректер дайын болғанша жауапты ұстап тұрады да, содан кейін дайын бетті береді. Краулерлер дайын бетті алады.

Екі жол, әрі тек біреуі ғана асинхронды

Бөлу SSG-ды әлсіретпейді; ол оның үстіне code-splitting қабатын қосады. Екі бөлек жол бар, ал жалқау chunk тек біреуі үшін ғана маңызды:

КімНе аладыМазмұн chunk-ы?
Google / тікелей кіру / F5Толық алдын ала рендерленген HTML, серияланған payload-тан гидратацияланадыЖүктелмейді
Қосымша ішінде шертіп жүрген келуші (SPA навигация)Маршрут клиентте рендерленеді, createAsync import()-ты іске қосадыСұраныс бойынша жүктеледі

Асинхронды құнды тек әлдеқашан жүктелген қосымшаның ішінде жүріп, беттер арасында ауысатын адам төлейді — яғни дәл 108 КБ bundle-ден ұтатын адам.

Асинхрондыны көрінбейтін ету

Сол жалғыз SPA-навигация жолы үшін екі қозғалыс тігісті жояды.

Қаңқаны постқа шақтап өлшемде. Қаңқа көрсетілген сәтте мәтін жүктелмеген (мәні де осы), сондықтан синхронды қолда бар жалғыз ұзындық сигналы — readingTime. Минутына бір абзац тобын рендерле, сонда қысқа пост аз, ұзыны көбірек орын алады — төменгі колонтитул (footer) секіруін тоқтатады:

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

Ниет бойынша алдын ала жүкте. Меңзер немесе пернетақта карточкаға түскен сол сәтте chunk-ты жылыт, сонда шерту суық импорттың орнына ұшып бара жатқан (немесе кэштелген) импортпен кездеседі:

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

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

«Әр slug-қа бір promise» кэші алдын ала жүктеу мен одан кейінгі шертудің бір import()-ты бөлісуін қамтамасыз етеді, сондықтан chunk ешқашан екі рет жүктелмейді. Алдын ала жүктеумен қаңқа әдеттегі жағдай емес, сирек кэш-мисс (баяу желі, 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)

Индекс пен пост емес әр бет енді 2,88 МБ орнына 108 КБ жеткізеді. Постты тікелей аш — толық серверде рендерленген HTML аласың, әрі мазмұн chunk-ы мүлде сұралмайды. Қосымша ішінде оған өт — оның мәтіні жүктеледі, әдетте әлдеқашан алдын ала жүктелген.

Түйін

  • Мазмұнды әр көріністің шынымен қажетіне қарай бөл: тізімдерге метадеректер, постқа оның мәтіні керек. Индексті 19 мақала үшін төлеуге мәжбүрлеме.
  • Типтелген loadContent() артындағы динамикалық import() — әр постқа жеке chunk беру үшін керектің бәрі осы.
  • Жалқау жүктеу мен SSG қайшы келмейді. createAsync серверде шешіледі және гидратация payload-ына серияланады, сондықтан алдын ала рендерленген HTML толық күйінде қалады, ал fallback ешқашан жеткізілмейді.
  • Қалған тігіс — клиенттік навигация — SEO емес, UX мәселесі: readingTime-ға шақталған қаңқа мен ниет бойынша алдын ала жүктеу оны жоғалтады.