Skip to main content
返回博客
SolidJSSolidStartPerformanceSSGCode Splitting

在 SolidStart 中懒加载博客内容而不失去 SSG

你的博客很可能把每篇文章的正文都打进同一个 bundle——我的已经涨到 2.88 MB,连只需要标题的列表页也要下载它。下面是我如何把每篇文章的正文拆成按需加载的懒加载 chunk,同时让每个页面仍然完整地服务端渲染以保证 SEO。

发布于 2026年8月5日8 分钟阅读

把内容放在带类型的代码里很方便——直到这堆东西大到会撑爆每位访客的下载量。下面是我如何在 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——只保留元数据,外加一个导入重的那一半的函数:

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

文章路由以两种速度渲染。页头、标题、标签,以及每个 <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 定尺寸的骨架屏,加上凭意图的预加载,就能让它消失。