从纯 CSR 到 SSR:用 WebComponents 做一个 Next.js 式框架 Gemkit

2 阅读9分钟

很久以前我做了 Gem:一个基于 WebComponents 的 UI 组件和应用开发库。用它写元素、写应用都很顺手,但它一直有个硬伤——只有客户端渲染。连自带的文档站生成器 GemBook 也是纯 CSR,搜索引擎拿到的是一个几乎空白的 HTML,SEO 一直不理想。

这篇文章讲我怎么一步步把 Gem 带到服务端:先用 jsdom 做出能序列化和水合的 gem-ssr 原型,再迁移掉 jsdom、加上请求隔离,最后借鉴 Next.js 和 SvelteKit 的设计做出应用框架 Gemkit,并用 RealWorld(Medium 克隆)和 SvelteKit、Nuxt、Next.js 做了一次横向对比。

一、为什么 WebComponents 的 SSR 难

React、Vue 的组件是框架自己的抽象,服务端只要把虚拟 DOM 拼成字符串就行。WebComponents 不一样,它天生依赖浏览器:

  • 元素是 class extends HTMLElement,要有 customElements、HTMLElement、document……
  • 样式和内容放在 Shadow DOM 里,HTML 字符串本来表达不了 Shadow Root
  • Gem 的模板基于 lit-html,渲染结果是真实 DOM 节点,不是虚拟 DOM

好在 Declarative Shadow DOM 已经被所有主流浏览器支持,<template shadowrootmode="open"> 让 Shadow DOM 可以直接写在 HTML 里。剩下的问题就是:怎么在 Node.js 里把 Gem 元素跑起来。

二、gem-ssr 原型:用 jsdom 跑通序列化 + 水合

最直接的思路:既然 Gem 需要 DOM,那就给它一个 DOM。gem-ssr 的第一版用 jsdom 模拟浏览器环境,先把 SSR 最核心的部分做通:

1. 序列化。 在服务端创建元素、渲染,把结果连同 Shadow DOM 一起序列化成 HTML,Shadow DOM 输出成 Declarative Shadow DOM,浏览器拿到 HTML 就能直接显示。

2. 水合。 服务端输出中为每个模板的绑定写入注释标记,客户端元素第一次渲染时用这些标记把绑定连接到已有 DOM 上——只绑定事件、设置属性,不重建 DOM。模板还会带上摘要(digest),客户端发现模板不一致时会告警并只重渲染那一段。

到这一步,“服务端渲染 + 客户端无缝接管”已经跑通,证明 WebComponents 做 SSR 是可行的。但 jsdom 方案离生产还差得远:

  • jsdom 很重,每个请求都要走一遍完整的 DOM 实现,吞吐上不去
  • 全局的 document、location、history 只有一份,并发请求会相互污染

三、gem-ssr:去掉 jsdom,请求隔离

接下来把 gem-ssr 往生产可用的方向迁移:

1. 去掉 jsdom,直接渲染成字符串。 只提供 Gem 需要的最小 DOM,模板在服务端直接编译成字符串输出,序列化和水合的协议保持不变。

2. 请求隔离。 基于 AsyncLocalStorage,store、history、location、i18n 语言都是请求级别的,并发请求互不影响。

3. 流式输出。 render 返回一个异步迭代器,异步元素之前的内容先发出去,互不依赖的异步元素并行等待:

import { render } from '@mantou/gem-ssr';

const stream = ReadableStream.from(render(html`<app-root></app-root>`, { url: req.url }));

到这一步,gem-ssr 已经可以放进真实的服务器里用了。但要做一个真正的应用,路由、数据加载、表单、部署这些事情还得每个项目自己拼——这就是 Gemkit 要解决的。

四、Gemkit:Gem 的应用框架

Gemkit 是基于 Rsbuild 的应用框架,设计上大量借鉴了 Next.js 和 SvelteKit:文件路由、布局、load、form actions、SSR/SSG/ISR。它本身就是一个 Rsbuild 插件,Rsbuild 的配置和插件都可以继续用。

npm create gemkit@latest my-app

一个文件就是一个页面

// src/pages/index.ts
@page('Home')
export default class HomePage extends GemElement {
  @template()
  #content = () => html`<h1>Hello Gemkit</h1>`;
}

不需要 @customElement,标签名由文件路径生成;Gem 和常用的 Gemkit API 都是自动导入的。为了 Gemkit,unplugin-gem 还加了 ssr 选项:由 swc 插件根据文件路径和变量名给模块级 createStore 加上 key,客户端和服务端构建得到的 key 一致。服务端渲染时更新过的 store 会按 key 输出到页面,客户端创建同 key 的 store 时直接使用这些值,首屏和 HTML 一致。同时它还会从客户端构建中移除 Gem 的 SSR 代码。这个页面会在构建时预渲染成 dist/web/index.html,并在客户端水合。

目录结构和 SvelteKit 很像:

src/
  app.ts               全局样式、store
  app.server.ts        handle:每个服务端请求前执行
  elements/            app-* 标签自动导入
  pages/
    _layout.ts         根布局,<gemkit-outlet> 渲染子路由
    _error.ts          错误页面 / 404
    index.ts           /
    blog/
      _layout.ts       嵌套布局,在 /blog/* 之间导航时保留
      [slug].ts        /blog/:slug
      [slug].server.ts 只在服务端运行的 load / actions
      [...rest].ts     /blog/*
  locales/
    en.json            默认语言
    zh.json            /zh/*

数据加载:同构 load + 服务端模块

页面的 load 是同构的,服务端渲染和客户端导航都会执行;只能在服务端运行的代码放在同名的 *.server.ts 里,它不会被打包到客户端(客户端误导入会直接构建失败):

// src/pages/blog/[slug].server.ts
import { readFile } from 'node:fs/promises';

export async function load({ params }) {
  return { markdown: await readFile(`content/${params.slug}.md`, 'utf8') };
}
// src/pages/blog/[slug].ts
@page<{ slug: string }, { markdown: string }>({
  load: ({ data }) => parseMarkdown(data.markdown),
  title: ({ data }) => data.title,
})
export default class PostPage extends GemElement {
  @property data?: { html: string };

  @template()
  #content = () => html`<article .innerHTML=${this.data?.html}></article>`;
}
  • 首屏:数据以 JSON 输出在 HTML 中,水合时直接使用,不再请求
  • 客户端导航:请求 <页面路径>/__data.json,包含页面和所有布局的服务端数据
  • 预渲染:数据文件写在页面旁边,静态托管也能客户端导航

感知请求:cookie、跳转、表单

*.server.ts 的 load 可以读请求和 cookie、一行跳转,actions 处理表单提交——和 SvelteKit 的体验一致:

// src/pages/article/[slug].server.ts(RealWorld 中的真实代码)
export async function load(ctx) {
  const [{ article }, { comments }] = await Promise.all([
    api(ctx, `articles/${ctx.params.slug}`),
    api(ctx, `articles/${ctx.params.slug}/comments`),
  ]);
  // markdown 在服务端转换,marked 和 sanitize-html 不会进客户端包
  return { article: { ...article, body: sanitizeHtml(await marked(article.body)) }, comments };
}

export const actions = {
  deleteArticle: async (ctx) => {
    await api(ctx, `articles/${ctx.params.slug}`, 'DELETE');
    redirect(303, '/');
  },
};

API 路由就是导出 GET、POST 的普通 *.server.ts,并且有类型化的 $gemkit/api 客户端。

服务端元素:岛屿架构

这是 Gemkit 和 WebComponents 结合得最有意思的地方。同一个页面里可以混合三种元素:

装饰器服务端客户端
@server()渲染不加载代码、不水合,代码和依赖都不进客户端包
@lazy({ on })渲染先显示服务端 HTML,交互 / 可见 / 空闲时才加载代码并水合
@client()只输出标签正常渲染,模块里可以放只能在浏览器运行的代码
@customElement('app-like-button')
@lazy({ on: 'interaction' })
class AppLikeButtonElement extends GemElement {}

@lazy 默认在第一次交互时加载,触发加载的事件(click、pointerdown 等)会在加载完成后重新派发到原来的目标上,第一次点击不会丢。点赞按钮、评论框这类很少用的部分就不必占首屏的 JS。

@server() 也可以标记方法或字段:在服务端渲染时执行,执行后的状态随元素输出到页面,客户端水合时直接恢复,并跳过第一次执行,不会重复请求数据:

@customElement('app-comments')
class AppCommentsElement extends GemElement {
  #state = createState({ comments: [] as Comment[] });

  // 服务端:输出前等待执行完成,状态随元素输出到页面
  // 客户端:首屏直接恢复状态并跳过;客户端导航时在 mounted 中执行
  @server()
  @mounted()
  #load = async () => this.#state({ comments: await fetchComments() });

  @template()
  #content = () => html`${this.#state.comments.map((c) => html`<p>${c.text}</p>`)}`;
}

渲染模式:SSR、SSG、ISR

  • 默认所有页面都在构建时预渲染(SSG),可以部署到任意静态托管
  • 读取了 cookie / 请求的页面导出 prerender = false,由 gemkit start 按请求渲染
  • 导出 revalidate = 60 开启 ISR:第一次请求时渲染并缓存,过期后先返回旧文件、后台重新渲染

另外还有一些细节:链接悬停时预取模块、数据和服务端元素 HTML;前进后退直接恢复历史缓存,不发请求,并正确恢复滚动位置;src/locales 自动生成带语言前缀的路由。

GemBook 也已经迁移到 Gemkit,文档站终于有了完整的 SSR/SSG,Gemkit 自己的文档 gemkit.gemjs.org 就是用它生成的。

五、实战:RealWorld 横评

为了验证 Gemkit 是不是“能用”,我用它实现了 RealWorld(Medium 克隆,社区标准的“真实应用”示例),和 SvelteKit、Nuxt、Next.js 的实现接同一个本地后端、同一份种子数据和主题 CSS 做对比。

环境:Apple M2 / 16 GB / Node v25.8.1,Lighthouse 取 5 次中位数。

客户端 JS(gzip)

首页文章页首页 JS 请求数
gemkit25.9 KB25.9 KB2
SvelteKit44.0 KB43.3 KB23
Nuxt85.8 KB85.5 KB15
Next.js103.5 KB126.5 KB8

Lighthouse 首页(移动端模拟节流)

分数FCPLCPTBT
gemkit100906 ms1361 ms0
SvelteKit1001285 ms1671 ms0
Nuxt991656 ms1806 ms0
Next.js96908 ms2022 ms4

SSR 吞吐(autocannon,20 连接,5 轮中位数)

首页 req/s文章页 req/s
gemkit13821377
SvelteKit14371388
Nuxt10421169
Next.js317359

其他

客户端导航(首页→文章)源码行数官方 e2e 通过
gemkit53 ms96884 / 91
SvelteKit78 ms11436 / 93
Nuxt95 ms71186 / 91
Next.js86 ms271260 / 91

简单解读:

  • JS 体积:gemkit 首页约为 SvelteKit 的 59%、Nuxt 的 30%、Next 的 25%。WebComponents 本身就是浏览器能力,框架运行时很小。
  • Lighthouse:gemkit 和 SvelteKit 都是满分,gemkit 的 FCP 快约 400 ms。
  • SSR 吞吐:gemkit 和 SvelteKit 基本持平(SvelteKit 略高 1–4%),比 Nuxt 高 18–33%,是 Next 的约 4 倍。两者都已经接近共享后端的上限,压测时 gemkit 约 44% 的时间在等后端。
  • e2e 剩下的失败主要是测试环境问题(外部图片超时、后端只返回热门标签等);SvelteKit 和 Next 的社区实现早于这套 e2e 约定,路由不一致,通过率低不代表功能缺失。

写 RealWorld 的过程本身也是一次“吃狗粮”:发现并修复了 load 拿不到请求、只改 query 不重新 load、子元素先于父元素水合导致 mismatch 等问题;CPU profiling 发现 每次路由匹配都新建 URLPattern(改为缓存,不含参数的路径直接比较字符串)、多余的 TextEncoderStream 等开销,修完后首页吞吐从 661 提高到约 1380 req/s。

测试代码、复现步骤和原始数据都在仓库里,欢迎挑刺。

六、总结

从“只能 CSR”到一个完整的 SSR 框架,Gem 走了三步:

  1. jsdom 原型:跑通序列化(Declarative Shadow DOM)和不重建 DOM 的水合,证明 WebComponents 做 SSR 可行
  2. gem-ssr:去掉 jsdom,字符串渲染 + 请求隔离 + 流式输出
  3. Gemkit:在 gem-ssr 之上借鉴 Next.js / SvelteKit,提供文件路由、布局、load/actions、服务端元素、SSG/ISR 和 i18n

结果是一个基于 Web 标准、首屏 JS 只有 ~26 KB、性能和 SvelteKit 同一档的应用框架。如果你对 WebComponents 感兴趣,欢迎试试:

npm create gemkit@latest my-app