Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

探索使用Compose HTML实现服务端渲染

#compose html#服务端渲染#kotlin#jvm#全栈开发

服务端渲染的 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,按名称调用,参数以无类型字符串传入:

$ markup
<!-- fragments/card.html -->
<div th:fragment="card(title, count)" class="card">
	<h3 th:text="${title}">Title</h3>
	<span th:text="${count}">0</span>
</div>
<!-- usage -->
<div th:replace="~{fragments/card :: card(title='Cart', count=${cartCount})}"></div>
<div th:replace="~{fragments/card :: card(title='Wishlist', count=${wishlistCount})}"></div>

count 重命名为 itemCount,所有调用点仍然能编译,直到运行时才出错。编译器根本不知道 card 或其参数的存在。

同样的组件在 Compose 中是一个带类型的函数:

$ kotlin
@Composable
fun Card(title: String, count: Int) {
	Div({ classes("card") }) {
		H3 { Text(title) }
		Span { Text(count.toString()) }
	}
}
// usage
Card(title = "Cart", count = cartCount)
Card(title = "Wishlist", count = wishlistCount)

在这里重命名 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 目标,这说起来容易做起来难。需要有 renderToStringrenderToBytes 函数,在 JVM 上执行一次组合,并将生成的树序列化为字符串。

$ kotlin
fun renderToString(content: @Composable DOMScope<DomElement>.() -> Unit): String

val html: String = renderToString {
    Div({ classes("card") }) {
        Text("Hello")
        Span({ classes("title") }) {
            Text("World")
        }
    }
}
// html == """<div class="card">Hello<span class="title">World</span></div>"""

它只组合一次,让初始组合稳定下来,遍历生成的树,并直接序列化为 HTML 字符串:不需要浏览器,也不需要 DOM。

这会有一些限制。可能只有一次渲染过程,意味着状态变化不会触发重组,也不会有任何副作用,本质上与 JS 中的 SSR 非常相似。事件监听器应当被接受,但不会生效;在服务端绑定浏览器事件没有意义。

这可能已经足以用 Compose 构建基础的、完全服务端渲染的页面。下面是一个完整的 Spring Boot 待办事项应用:

$ kotlin
@Controller
class TodoController(private val todoService: TodoService) {

    @GetMapping("/todos")
    @ResponseBody
    fun todoView(): String = renderToString {
        TodoView(todoService)
    }

    @PostMapping("/todos")
    fun addTodo(createTodoDto: CreateTodoDto): String {
        todoService.addTodo(createTodoDto.title)
        return "redirect:/todos"
    }

    @PostMapping("/complete/{id}")
    fun completeTodo(@PathVariable id: Long): String {
        todoService.completeTodo(id)
        return "redirect:/todos"
    }
}

data class CreateTodoDto(val title: String)

@Composable
fun TodoView(todoService: TodoService) {
    AddTodo()
    TodoList(todoService)
}

@Composable
fun AddTodo() {
    Form(
        attrs = {
            action("/todos")
            method(FormMethod.Post)
        }
    ) {
        TextInput(
            attrs = {
                placeholder("Add todo")
                name("title")
            }
        )
        Button(
            attrs = {
                type(ButtonType.Submit)
            }
        ) {
            Text("Add")
        }
    }
}

@Composable
fun TodoList(todoService: TodoService) {
    val todos by produceState(initialValue = emptyList<Todo>(), todoService) {
        value = todoService.getTodos()
    }
    Ul {
        todos.forEach { todo ->
            Li {
                Form(
                    attrs = {
                        action("/complete/${todo.id}")
                        method(FormMethod.Post)
                    }
                ) {
                    Text(todo.title)
                    Button(
                        attrs = {
                            type(ButtonType.Submit)
                        }
                    ) {
                        Text("Complete")
                    }
                }
            }
        }
    }
}

这里每一次交互都是真实的 HTTP 表单提交和整页重定向:完全没有客户端 JS,与经典的 Thymeleaf 风格 SSR 相同,只是完全用 Compose 编写。

到那时,Spring 和 Ktor 等框架就可以开始进行集成实验,并发现缺失的集成点。这也是创建新库(例如组件库)的第一个合理时机。

接下来彻底偏离现实,进入纯粹猜想:这样一个集成在 Spring 中可能看起来像这样:

$ kotlin
@ComposePage("/todos")
@Composable
fun TodosPage(todoService: TodoService) {
    AddTodo()
    TodoList(todoService)
}

@ComposeAction("/todos", method = PostMapping::class)
fun addTodo(
    @RequestBody createTodoDto: CreateTodoDto,
    todoService: TodoService
) {
    todoService.addTodo(createTodoDto.title)
}

思路是:一个假想的 Spring 集成可以直接把 @Composable 函数变成带路由的页面,无需手动调用 renderToString,没有控制器样板代码,也不需要包裹 HTML 外壳。Spring 会像现在一样负责请求映射和依赖注入;Compose HTML 只是作为渲染目标,取代 View/模板

或者对于 Ktor:

$ kotlin
routing {
    composable("/todos") {
        TodoView(todoService)
    }

    post("/todos") {
        val params = call.receiveParameters()
        todoService.addTodo(params["title"]!!)
        call.respondRedirect("/todos")
    }
}

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 的回合早该到来了。