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

很久以前我做了 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 走了三步:

  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 感兴趣,欢迎试试:

sh 复制代码
npm create gemkit@latest my-app
相关推荐
cidy_981 小时前
深度拆解任务工作台架构:左右分栏、队列管理、实时刷新全方案
前端
Linye1 小时前
告别“PRD 与原型两张皮”!我们把可交互原生组件直接嵌进了需求文档,还能双向回写 Markdown
前端
deli0071 小时前
RSA 为什么公开公钥也解不开?2 个质数拆开实测一遍
前端
ModyQyW1 小时前
vite-plugin-uni-pages 0.6:统一扫描规则,开发期少重启
前端·uni-app
门思科技1 小时前
把 ThinkLink 接进 Home Assistant 与 Node-RED:一条 MQTT 转发器能走通的路
前端
没事写点啥1 小时前
WeKnora 回答溯源
前端·后端·ai编程
SL_staff2 小时前
破除执行黑箱:用目标地图、资源热力图与健康度看板构建可下钻的执行事实链
java·开源·全栈
LuHang2 小时前
Vue3 大屏项目 MQTT 实战:机场与无人机 OSD 实时数据总线
前端
2401_885885042 小时前
芬兰语音通知接口怎样对接?语音提醒接入流程
android·前端·python·php·语音识别