在 SolidStart 中懒加载博客内容而不失去 SSG
你的博客很可能把每篇文章的正文都打进同一个 bundle——我的已经涨到 2.88 MB,连只需要标题的列表页也要下载它。下面是我如何把每篇文章的正文拆成按需加载的懒加载 chunk,同时让每个页面仍然完整地服务端渲染以保证 SEO。
把内容放在带类型的代码里很方便——直到这堆东西大到会撑爆每位访客的下载量。下面是我如何在 SolidStart 中把一个 2.88 MB 的博客按文章拆成一个个 chunk,而不放弃任何一个服务端渲染的页面。
一个 chunk 统治一切
我的博客注册表做了最直白的事:静态导入每一篇文章,塞进一个数组,再导出几个辅助函数。
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 MB 会在每一个博客页面上下发——包括列表页,而列表页只渲染一串标题和描述。每位访客都要下载全部 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
文章路由以两种速度渲染。页头、标题、标签,以及每个 <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 岂不是会索引骨架屏而不是我的内容?
不会。而且这个原因值得弄懂,因为它是整根承重梁。
Suspense 只在异步处于 pending 时才显示它的 fallback——而 pending 只发生在数据尚未就绪的地方。在预渲染期间,这永远不会发生:
- 在服务端,SolidStart 会在渲染之前等待该资源。当 HTML 生成时,
content()已经解析完毕,所以 fallback 分支永远不会被走到。静态文件里就是完整的文章。 - 接着 SolidStart 会把解析后的值序列化进 hydration payload。直接访问时,客户端直接从这个 payload 恢复该资源——它不会重新执行
getPostContent,因此连内容 chunk 都不会去下载。
你可以对着构建产物验证。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 KB 的序列化 hydration 脚本里。
骨架屏就是个加载动画。服务端从不下发加载动画——它会攥着响应直到数据就绪,然后下发成品页面。爬虫拿到的就是成品页面。
两条路径,只有一条是异步的
拆分并不削弱 SSG;它只是在其之上加了一层 code-splitting。存在两条不同的路径,而懒加载 chunk 只对其中一条有意义:
| 谁 | 拿到什么 | 内容 chunk? |
|---|---|---|
| Google / 直接访问 / F5 | 完整的预渲染 HTML,从序列化 payload 进行 hydration | 不下载 |
| 在应用内点击浏览的访客(SPA 导航) | 路由在客户端渲染,createAsync 触发 import() | 按需下载 |
异步的代价只由那些已经进入已加载应用、在页面间导航的人承担——而这恰恰正是从 108 KB 的 bundle 中受益的人。
让异步隐于无形
对那唯一一条 SPA 导航路径,两处小改动就能抹平接缝。
让骨架屏与文章相称。骨架屏出现时正文还没加载(这正是重点),所以你唯一能同步拿到的长度信号就是 readingTime。每分钟渲染一组段落,让短文章占位少、长文章占位多——页脚就不再跳动:
<For each={Array.from({ length: Math.min(Math.max(minutes, 3), 12) })}>
{() => <ParagraphGroup />}
</For>凭意图预加载。在指针或键盘落到卡片上的那一刻就把 chunk 预热,好让点击迎面遇上一个正在飞行中(或已缓存)的 import,而不是一个冷的:
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)列表页以及每个非文章页现在只下发 108 KB,而不是 2.88 MB。直接打开一篇文章,你得到的是完整的服务端渲染 HTML,内容 chunk 根本不会被请求。在应用内导航过去时,它的正文才会加载进来——通常已经预加载好了。
要点
- 按每个视图真正需要的东西来拆分内容:列表要的是元数据,文章要的是正文。别让列表页为 19 篇文章买单。
- 一个藏在带类型的
loadContent()背后的动态import(),就足以给每篇文章一个独立的 chunk。 - 懒加载和 SSG 并不冲突。
createAsync在服务端解析并序列化进 hydration payload,所以预渲染的 HTML 依旧完整,fallback 永远不会被下发。 - 剩下的那道接缝——客户端导航——是 UX,不是 SEO:一个按
readingTime定尺寸的骨架屏,加上凭意图的预加载,就能让它消失。