SolidStart-та SSG-ды жоғалтпай блог мазмұнын сұраныс бойынша жүктеу
Блогың, сірә, әр посттың негізгі мәтінін бір ғана bundle ішінде жеткізеді — менікі 2,88 МБ-қа дейін өсіп, тек тақырыптар қажет тізім бетінде де жүктелетін. Міне, мен әр посттың мәтінін сұраныс бойынша жүктелетін бөлек chunk-қа қалай бөлдім, әрі әр бет SEO үшін серверде толық рендерленген күйінде қалды.
Мазмұнды типтелген кодта ұстау ыңғайлы — үйінді әрбір келушінің жүктейтінін ісіндіретіндей үлкейгенше. Міне, мен 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 — тек метадеректерді және ауыр жартысын импорттайтын функцияны сақтайды:
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 — мәтінді ұстайды, әрі оны ешнәрсе статикалық импорттамайды:
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-ға шақталған қаңқа мен ниет бойынша алдын ала жүктеу оны жоғалтады.