探索使用Compose HTML实现服务端渲染
服务端渲染的 Web 开发领域正在发生一些变化。React 推出了服务端组件。HTMX 让“超媒体”重新流行起来。Phoenix LiveView 证明了服务端可以在完全没有客户端框架的情况下推送交互式 UI 更新。似乎每个生态都在重新发现“服务端作为 UI 渲染场所”这件事,唯独一个例外:JVM。如果 Compose——这个已经覆盖 Android、桌面端和 iOS 的 UI 工具包——也尝试一下服务端渲染 HTML,会怎样?
愿景很简单:让后端开发者能够用类型安全、可复用的 Compose 组件(真正的 Kotlin,具备自动补全、重构和编译器检查)来构建服务端渲染的 UI,而不是基于字符串的模板。没有独立的模板语言,也不需要与后端一起维护一套独立的 UI 代码库。这篇博客旨在探索如何实现这一愿景,属于探索性内容,而非官方承诺。
现在每个主流 JS 框架都有自己的 SSR 方案:React 有 Next,Vue 有 Nuxt,Svelte 有 SvelteKit。而且不只是 JS 生态。C#、Rust,甚至 Elixir 这样的函数式语言,都有创新方案来构建不依赖模板引擎的全栈应用。它们将状态和渲染打包成可复用组件,直接写在代码中,就像 Compose 在其他地方已经做到的那样。
目前 JVM 在这场竞赛中还没有入局。JVM 上并不缺少 SSR 库,但它们大多需要某种模板语言,而且没有任何东西接近 JS 开发者心目中的“组件”。
但已经有一个久经考验、能够为 JVM 填补这一空白的框架,只是它从未真正以服务端为目标。Compose Multiplatform 允许我们一次编写业务逻辑和用户界面,并在 Android、iOS、桌面端和 Web 之间共享。它下一步只需要跨入服务端。
Compose Multiplatform 已经支持 Web,但并非你想要的那种方式:它直接渲染到 canvas 中,这让移动平台和浏览器之间可以共享 UI 代码,代价是 SEO、加载时间和可访问性。
一种用 Compose 渲染 HTML 的方式已经存在,而且比 Compose for Web 更早:Compose HTML。它使用 Compose 运行时在 Kotlin 中构建 SPA,并通过 Kotlin/JS 编译器将其编译为 JS。增加一个 JVM 目标,它就能做 SSR。渲染直接发生在 Kotlin 中:真正的组件、真正的类型,没有模板语言。
被 Thymeleaf/JSP 困住、或者为了构建全栈应用不得不去用独立 JS 框架的 JVM 开发者,将不必离开原有平台:类型安全、可复用的 Compose 组件取代了过去由模板语言处理的工作。Kotlin 与 Java 的互操作性意味着它也能嵌入大型遗留 Java 应用。
以一个基本可复用卡片组件为例。在 Thymeleaf 中,它是一个定义在独立文件里的 fragment,按名称调用,参数以无类型字符串传入:
把 count 重命名为 itemCount,所有调用点仍然能编译,直到运行时才出错。编译器根本不知道 card 或其参数的存在。
同样的组件在 Compose 中是一个带类型的函数:
在这里重命名 count,所有调用点要么会随 IDE 自动更新,要么编译失败。在期望 Int 的地方传入 String,会直接产生编译错误,而不是运行时意外。
目前 Compose HTML 只有 JS 目标,因此只能在浏览器中使用;还没有办法做 SSR。但这并不意味着 Kotlin Web 开发生态停滞不前。
有 Kobweb,一个基于 Compose HTML 构建、开箱即用的框架。它不提供 SSR,但支持静态站点导出/预渲染以帮助 SEO。还有 Kilua,它并不基于 Compose HTML,而是直接基于 Compose Runtime 实现 SSR 和 CSR,利用 JS 或 Wasm,并为 Ktor、Spring Boot 等提供集成。此外还有 Summon,支持 SSR 和水合(hydration)。
已经有一个不大但活跃的社区在用 Compose 为 Web 构建应用。为 Compose HTML 增加 SSR 能力,将为 Kobweb、Kilua 和 Summon 提供一个共享基础,而不是三种各自为政的方案,也会让 Spring Boot、Ktor 等框架有充分理由在服务端集成它。
这个领域并非完全无人涉足,但自此之后的一切都纯粹是探索性的。
Compose HTML 在服务端可能会是什么样子
第一步是为 Compose HTML 增加 JVM 目标,这说起来容易做起来难。需要有 renderToString 和 renderToBytes 函数,在 JVM 上执行一次组合,并将生成的树序列化为字符串。
它只组合一次,让初始组合稳定下来,遍历生成的树,并直接序列化为 HTML 字符串:不需要浏览器,也不需要 DOM。
这会有一些限制。可能只有一次渲染过程,意味着状态变化不会触发重组,也不会有任何副作用,本质上与 JS 中的 SSR 非常相似。事件监听器应当被接受,但不会生效;在服务端绑定浏览器事件没有意义。
这可能已经足以用 Compose 构建基础的、完全服务端渲染的页面。下面是一个完整的 Spring Boot 待办事项应用:
这里每一次交互都是真实的 HTTP 表单提交和整页重定向:完全没有客户端 JS,与经典的 Thymeleaf 风格 SSR 相同,只是完全用 Compose 编写。
到那时,Spring 和 Ktor 等框架就可以开始进行集成实验,并发现缺失的集成点。这也是创建新库(例如组件库)的第一个合理时机。
接下来彻底偏离现实,进入纯粹猜想:这样一个集成在 Spring 中可能看起来像这样:
思路是:一个假想的 Spring 集成可以直接把 @Composable 函数变成带路由的页面,无需手动调用 renderToString,没有控制器样板代码,也不需要包裹 HTML 外壳。Spring 会像现在一样负责请求映射和依赖注入;Compose HTML 只是作为渲染目标,取代 View/模板。
或者对于 Ktor:
composable(path) { } 可以是 Ktor 添加的一个薄封装:在内部调用 renderToString,并以 HTML 内容类型响应,从而让路由体变成 @Composable lambda,而不是字符串模板或手动调用 call.respondText。
值得重申:这些只是说明性的草图,不是已规划的 API,也不是路线图。
接下来自然要问的是水合与状态同步,但这里并没有答案:一个已经在服务端渲染过的 composable,如何在浏览器中恢复交互性?客户端和服务端是否需要就状态达成一致?回答这一问题,也将打开在客户端和服务端之间共享 UI 代码的大门——同一个组件分别针对浏览器和服务端各编译一次——并让完全用 Kotlin 构建的交互式全栈 Web 应用成为可能。
让我们明确一下范围:目标不是把 Compose HTML 扩展成一个功能完备、开箱即用的框架。相反,愿景与 React 类似:保持核心小巧,让上层框架去构建集成点,只是将这一思路应用到一个跨平台库而非单一平台库上。框架集成和生态库都位于核心之外。这与 Compose Multiplatform 的其他部分形成鲜明对比:后者为 Material3 组件、状态管理等诸多内容提供了官方库。Compose HTML 将需要依靠 Kotlin 社区和生态来弄清楚真正需要哪些集成点、它的未来会是什么样子,而不是从内部指定方向。
我们已经在与 Kobweb、Kilua 和 Summon 的框架维护者沟通,听取他们的看法;同时也在与 Spring 团队沟通,对方表示一旦 Compose HTML 添加 JVM 目标,就有兴趣进行实验。
如果你想交流技术、对上述内容提出异议,或者只想看看后续发展,欢迎加入 Kotlinlang Slack(在此获取邀请:https://kotl.in/slack)以及 #compose-ssr 频道。
其他每个生态都已经在服务端出手尝试过了。Kotlin 的回合早该到来了。