很久以前我做了 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 返回一个异步迭代器,异步元素之前的内容先发出去,互不依赖的异步元素并行等待:
js
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 的配置和插件都可以继续用。
sh
npm create gemkit@latest my-app
一个文件就是一个页面
ts
// 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 很像:
bash
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 里,它不会被打包到客户端(客户端误导入会直接构建失败):
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') };
}
ts
// 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 的体验一致:
ts
// 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() |
只输出标签 | 正常渲染,模块里可以放只能在浏览器运行的代码 |
ts
@customElement('app-like-button')
@lazy({ on: 'interaction' })
class AppLikeButtonElement extends GemElement {}
@lazy 默认在第一次交互时加载,触发加载的事件(click、pointerdown 等)会在加载完成后重新派发到原来的目标上,第一次点击不会丢。点赞按钮、评论框这类很少用的部分就不必占首屏的 JS。
@server() 也可以标记方法或字段:在服务端渲染时执行,执行后的状态随元素输出到页面,客户端水合时直接恢复,并跳过第一次执行,不会重复请求数据:
ts
@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 请求数 | |
|---|---|---|---|
| gemkit | 25.9 KB | 25.9 KB | 2 |
| SvelteKit | 44.0 KB | 43.3 KB | 23 |
| Nuxt | 85.8 KB | 85.5 KB | 15 |
| Next.js | 103.5 KB | 126.5 KB | 8 |
Lighthouse 首页(移动端模拟节流)
| 分数 | FCP | LCP | TBT | |
|---|---|---|---|---|
| gemkit | 100 | 906 ms | 1361 ms | 0 |
| SvelteKit | 100 | 1285 ms | 1671 ms | 0 |
| Nuxt | 99 | 1656 ms | 1806 ms | 0 |
| Next.js | 96 | 908 ms | 2022 ms | 4 |
SSR 吞吐(autocannon,20 连接,5 轮中位数)
| 首页 req/s | 文章页 req/s | |
|---|---|---|
| gemkit | 1382 | 1377 |
| SvelteKit | 1437 | 1388 |
| Nuxt | 1042 | 1169 |
| Next.js | 317 | 359 |
其他
| 客户端导航(首页→文章) | 源码行数 | 官方 e2e 通过 | |
|---|---|---|---|
| gemkit | 53 ms | 968 | 84 / 91 |
| SvelteKit | 78 ms | 1143 | 6 / 93 |
| Nuxt | 95 ms | 711 | 86 / 91 |
| Next.js | 86 ms | 2712 | 60 / 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 走了三步:
- jsdom 原型:跑通序列化(Declarative Shadow DOM)和不重建 DOM 的水合,证明 WebComponents 做 SSR 可行
- gem-ssr:去掉 jsdom,字符串渲染 + 请求隔离 + 流式输出
- Gemkit :在 gem-ssr 之上借鉴 Next.js / SvelteKit,提供文件路由、布局、
load/actions、服务端元素、SSG/ISR 和 i18n
结果是一个基于 Web 标准、首屏 JS 只有 ~26 KB、性能和 SvelteKit 同一档的应用框架。如果你对 WebComponents 感兴趣,欢迎试试:
sh
npm create gemkit@latest my-app
- GitHub:github.com/mantou132/g...
- Gemkit 文档:gemkit.gemjs.org/zh
- Gem 文档:gemjs.org