首页
/
新闻
/
SPA 与 Hypermedia 之争:一个协作式看板与 PLANKA 的实测对比
SPA 与 Hypermedia 之争:一个协作式看板与 PLANKA 的实测对比
摘要
zwei und eins gmbh 用单个 Go 二进制文件、SQLite 和 Datastar 构建了一个完全由服务端渲染的实时协作看板,并在限速手机上与 PLANKA Community 2.2.1(React/Redux/Sails.js)进行了基准测试:首次渲染为 58 kB、0.6 秒,对比 2.6 MB、15.4 秒;卡片移动经服务端确认的时间为 252 毫秒,对比 712 毫秒。
<p><a href="https://lobste.rs/s/5evbvq/spa_vs_hypermedia_collaborative_kanban">评论</a></p>
查看缓存全文
缓存时间:
2026/10/08 12:29
# SPA vs. Hypermedia:用协同看板对比 PLANKA | zwei und eins gmbh
来源:https://zweiundeins.gmbh/en/blog/spa-vs-hypermedia-a-collaborative-kanban-board-measured-against-planka
20261007-kanban-hero.jpg:200 张卡片的看板,一张卡片正在被拖动
## SPA vs. Hypermedia:用协同看板对比 PLANKA
我们构建了一个看板,多人可以同时拖动卡片,页面完全由服务端渲染,并在一台运行缓慢的手机上与 PLANKA 进行了测量。
Michael Bolli·08.10.2026
[](https://linkedin.com/in/mbolli)
测量于 2026 年 10 月 7 日与 8 日 · Playwright、Lighthouse 13.5、Chromium 154 · 查看源码与原始数据 →(https://github.com/zweiundeins/kanban)
我们构建了一个 Trello 风格的看板,多人可以同时拖动卡片,页面完全由服务端渲染:一个 Go 二进制文件、一个 SQLite 文件,浏览器端使用 [Datastar](https://data-star.dev/)。我们将其与 [PLANKA](https://planka.app/) Community 2.2.1 进行了对比——这是一款流行的自托管看板应用,使用 React、Redux、Sails.js 和 PostgreSQL 构建。两者的看板内容完全相同,测量者使用一台降速的手机,网络往返延迟为 150 ms。
我们的看板传输 **58 kB,0.6 秒后即显示在屏幕上**;PLANKA 传输 2.6 MB,15.4 秒后才显示看板。一张卡片被放下后,**252 ms 内出现在新位置,服务端已经接受**;PLANKA 在 481 ms 后显示移动结果,712 ms 后收到服务端确认。我们的看板从不显示服务端尚未接受的移动。PLANKA 在我们测量的一项操作上更快:添加评论。
## 为什么选择看板
我们的 [AI 聊天对比](https://zweiundeins.gmbh/en/blog/spa-vs-hypermedia-real-world-performance-under-load) 留下了一个未解的质疑:服务端渲染的 HTML 也许适用于表单和聊天,但一个包含拖放、多人同时编辑同一屏幕的应用,就需要单页应用(SPA)。看板正是这样的应用。PLANKA 是一个用单页方式构建的真实产品,在 GitHub 上约有 12,600 个 star。
## 两个应用
两个应用都实现了我们对比的功能:登录;添加、重命名、排序和归档列表;通过拖动或键盘添加、重命名、移动和归档卡片;卡片详情视图,包含 Markdown 描述、标签、截止日期和评论;以及实时协作——看板上的每个人都能看到每一次改动和当前还有谁在看。
PLANKA 的功能要多得多:卡片成员、搜索与筛选、其他看板视图、活动历史、编辑评论、删除卡片、计时器、附件、任务清单、自定义字段、通知、webhook、REST API 以及双因素登录。我们只比较了两个应用都具备的功能,而 PLANKA 的其他功能也体现在它的包体积和内存占用上。
| | 我们的实现 | PLANKA Community 2.2.1 |
| --- | --- | --- |
| 服务端 | Go,单个二进制文件 | Node.js + Sails.js、socket.io |
| 数据库 | SQLite,单个文件 | PostgreSQL 16 |
| 浏览器 | Datastar 1.0.4、[Starbase](https://starbase.zweiundeins.gmbh/) 组件 | React 18、Redux、redux-saga、react-beautiful-dnd |
| 实时更新 | server-sent events、HTML | WebSocket、JSON 事件 |
## 看板如何保持同步
1. **浏览器** 卡片被放下。页面发送一个带操作 id 的 POST 请求。
2. **写入方** 一个 goroutine 独占唯一的写连接,在一个事务中提交排队的命令。请求收到一个空的 204 响应。
3. **渲染** 看板最多每 50 ms 渲染一次,对所有正在查看的人只渲染一次。
4. **每个打开的页面** 每个页面的流收到新的 HTML,经 Brotli 压缩后由 Datastar 变换(morph)进页面。
一次操作,从放下卡片到看板上每一块屏幕。
我们看板上的每一个动作都是一个 POST 请求,它修改数据库,并以空的 `204` 作答。每个打开的页面维持一条 server-sent events 流,每次改动之后,服务端把看板以 HTML 的形式通过该流发下来。没有变化的卡片来自按内容索引的缓存;连接较慢时会收到最新的帧,而不是排队的一串旧帧。一帧只携带发生变化的列表,而 Brotli 跨帧压缩,所以移动一张卡片后的一帧在线路上的开销不到 1 kB。Brotli 的窗口属于单个连接,因此每条流各自压缩自己的帧;帧的其他部分只计算一次,同时最多只有一半的处理器核心在压缩,所以一帧突发不会阻塞任何操作。
所有写操作都经过那个持有写连接的 goroutine。事务提交期间,新命令排队,下一个事务把它们全部取走,每条命令各占一个保存点(savepoint),因此一条被拒绝的命令只会撤销它自己的改动。“在你的页面渲染之后,有没有人移动过这张卡片”的检查就在这条命令内部完成,整个网络请求期间不持有任何锁。这个设计参考了 [Anders Murphy 关于 SQLite 在争用条件下测量的结果](https://andersmurphy.com/2025/12/02/100000-tps-over-a-billion-rows-the-unreasonable-effectiveness-of-sqlite.html)。SQLite 以 `synchronous=FULL` 运行,所以每次提交都要等待磁盘。
被放下的卡片会停留在服务端最后一次放置的位置,并显示为待处理状态(变暗,带一个加载指示);直到显示它新位置的那一帧到达,那一帧同时也清除待处理外观。与此同时,看板在卡片原本被放下的位置打开一个空隙,并在那里显示一张虚线卡片副本:这是这个人做了什么,而不是服务端做了什么。如果服务端拒绝了这次移动,卡片保持不动,页面会说明原因:“Anna 在此期间把‘修复登录’移到了‘完成’。”每个动作都携带一个 id,因此浏览器在网络错误后重发的请求只会被应用一次。
## 我们如何测量
- **相同的数据。** 我们的种子数据创建了四个人和两个看板。被测量的那个看板“Release 2.0”有 8 个列表、200 张卡片和 268 条评论。我们将其导出,通过 API 加载到全新安装的 PLANKA 中,标签、截止日期和评论作者一并保留。所有测试结束后,两个数据库中仍然保有相同的列表、卡片和评论;200 张卡片中有 2 张处于不同的位置,因为当卡片高度不同时,放置动作会落点略有差异。
- **同一台机器。** 两个应用、数据库和浏览器都运行在同一台机器上(Intel i5-13500)。PLANKA 使用其官方 Docker 镜像运行,Compose 文件基于官方文件修改而来。
- **相同的慢速网络。** 每个应用前面都放了一个小型 TCP 代理,为每个数据包(包括我们流上的包和 PLANKA 的 WebSocket 上的包)增加 150 ms 往返延迟,并将带宽限制为下行 1.6 Mbit/s、上行 750 kbit/s。Chrome 自带的网络限速只在请求开始时引入延迟,在我们最初的测试中,已建立连接上后来到达的数据没有被延迟。
- **一台降速的手机。** Chrome 将被测量者的 CPU 降速四倍。另一个人用正常 CPU 查看同一个看板。
- **冷加载** 指已登录用户的首次访问:一个只有会话 cookie 的全新浏览器配置文件,没有缓存,也没有已建立的连接。
- **鼠标拖动。** 每次移动都把卡片向右拖动一个列表。PLANKA 的键盘拖动在我们的自动化测试中无法移动卡片,因此两个应用都用鼠标测量。
- **各运行三次**,每次 20 次移动。移动、打开卡片和评论的操作将所有运行的样本汇总,因此每个应用的 p95 基于 60 次移动;加载数据取各次运行的中位数。Lighthouse 每种设备形态运行三次,均为已登录状态、空缓存,我们保留中位数那次运行的结果。
## 加载看板
降速的手机在 200 张卡片的看板冷加载期间显示的内容:Chrome 在每个时刻最后绘制的一帧。这些帧来自一次开启 tracing 的独立测试,它会让页面略微变慢;下面的计时数据来自未开启 tracing 的测试。
**200 张卡片看板的冷加载**
| | 我们的实现 | PLANKA | 差异 |
| --- | --- | --- | --- |
| 传输量 | **58 kB** | 2,576 kB | 少 44 倍 |
| 传输的 JavaScript | **40 kB** | 2,226 kB | 少 56 倍 |
| 看板上屏(LCP) | **0.6 s** | 15.4 s | 提前 27 倍 |
| 所有脚本就位(load 事件) | **0.6 s** | 12.5 s | 提前 20 倍 |
| 布局偏移(CLS) | **0** | 0.02 | 无 |
PLANKA 在请求看板之前先下载整个应用,所以在慢速连接上,15.4 秒中的大部分时间都花在了 2.2 MB 的 JavaScript 上。我们的服务端在第一个响应中就把看板以 HTML 的形式发出来,样式表一到浏览器就开始绘制。我们的 JavaScript 在那之后加载,五个文件一波完成:Datastar,以及每个 Starbase 组件一个文件。它们加载完成后拖动即可工作,在这个连接条件下是 0.6 秒。
**我们的实现**
**PLANKA**
点击链接两秒后,在降速的手机上。拖动滑块进行比较。
### Lighthouse
| 指标 | 我们(桌面) | PLANKA(桌面) | 我们(移动) | PLANKA(移动) |
| --- | --- | --- | --- | --- |
| 性能评分 | **100** | 69 | **98** | 33 |
| FCP | **0.3 s** | 2.4 s | **1.4 s** | 14.0 s |
| LCP | **0.3 s** | 2.6 s | **1.4 s** | 14.9 s |
| 总阻塞时间 | **0 ms** | 140 ms | **160 ms** | 990 ms |
Lighthouse 自身会模拟慢速网络和慢速手机(移动端:150 ms 往返、1.6 Mbit/s、4 倍 CPU),并维护自己的请求重叠模型,所以它的移动端数字与我们的实测值不同。每个组件一个文件的情况下,它为我们的看板在移动端统计出 160 ms 的总阻塞时间;如果同样的组件被拆成 17 个独立模块,它统计出的是 80 ms。
## 移动一张卡片
**我们的实现**
253 ms
**PLANKA**
712 ms
0 200 400 600 800 ms
- 卡片停留在原处并显示为待处理;一张虚线副本标出它要去的位置
- PLANKA 的放置动画
- 显示在新的列表中,服务端尚未接受
- 处于新位置且已被服务端接受
从放下卡片到移动被确认,降速手机上 60 次拖动的中位数。
| 指针拖动,向右一个列表 | 我们的实现 | PLANKA | 差异 |
| --- | --- | --- | --- |
| 卡片显示在新列表中 | **252 ms** | 481 ms | 提前 1.9 倍 |
| 服务端确认移动 | **253 ms** | 712 ms | 提前 2.8 倍 |
| 移动确认,p95 | **276 ms** | 756 ms | 提前 2.7 倍 |
| 另一个人看到这次移动 | **199 ms** | 656 ms | 提前 3.3 倍 |
两个应用对“已显示”的含义不同。两者都会立刻标出目标位置:PLANKA 的列表在拖动过程中打开一个空隙,放置后卡片飘入其中;我们的看板打开一个空隙,并在那里显示一张虚线卡片副本。PLANKA 在浏览器中移动卡片本身,随后再通知服务端;它的卡片在 481 ms 后出现在列表里,也就是其放置动画结束之时,而服务端的确认在 712 ms 才到达。如果服务端拒绝这次移动,卡片已经被显示在了它不在的位置上。我们的看板只在服务端的帧把卡片带到时才移动它,时间是 252 ms,同一帧也清除了待处理外观。PLANKA 只在放置动画结束之后才发送移动请求,这也是另一个人更晚看到它的原因。
**我们的实现**
**PLANKA**
放下卡片 150 ms 后,在降速的手机上。我们的:卡片要去的位置有空隙和虚线副本,卡片本身在原列表中仍显示为暗淡。PLANKA:卡片仍在飘向空隙。
## 添加评论
| 添加评论(p50) | 我们的实现 | PLANKA | 差异 |
| --- | --- | --- | --- |
| 评论显示 | 302 ms | **90 ms** | PLANKA 快 3.4 倍 |
| 服务端确认评论 | 302 ms | **229 ms** | PLANKA 快 1.3 倍 |
PLANKA 在这里更快。它立即显示一条新评论,早于服务端收到它,并在 229 ms 后通过已建立的 WebSocket 收到确认。我们的看板发送一个 HTTP 请求,随卡片详情视图的下一帧显示这条评论——降速的 CPU 需要执行这次变换——耗时 302 ms。
## 浏览器中的响应性与内存
会话进行一段时间之后:
| | 我们的实现 | PLANKA | 差异 |
| --- | --- | --- | --- |
| INP(Interaction to Next Paint) | **64 ms** | 256 ms | 短 4 倍 |
| JavaScript 堆 | **4.4 MB** | 45.0 MB | 少 10 倍 |
| DOM 元素 | **1,843** | 3,295 | 少 1.8 倍 |
| 打开一张卡片 | **258 ms** | 318 ms | 快 1.2 倍 |
## 同一看板上的许多人
我们还用模拟用户加载我们的看板,每个用户拥有一条真实的压缩流,按固定时间表在 200 张卡片的看板上移动随机卡片。该工具测量一个人等待的时间:从发送一次移动,到结果到达他自己那条流上。服务端和负载工具运行在同一台机器的不同核心上;数字是三次 30 秒测试的中位数。
**50 个人,每人每秒移动一次**
- 每秒移动次数:487
- 因数据过期被拒绝:1,499 次中的 17 次
- 请求被应答(204),p95:12 ms
- 结果到达个人的流,p95:75 ms
- 每帧在线路上传输的字节数:510
- 服务端 CPU:1.7 个核心
- 服务端内存:266 MB
**200 个人,每人每秒移动四次**
- 每秒移动次数:88
- 因数据过期被拒绝:23,919 次中的 4,422 次
- 请求被应答(204),p95:17 ms
- 结果到达个人的流,p95:98 ms
- 每帧在线路上传输的字节数:992
- 服务端 CPU:5.1 个核心
- 服务端内存:1.1 GB
“因数据过期被拒绝”是指两个人在同一时刻移动了同一张卡片,第二个人被告知是谁把它移到了哪里。
数据库并不是服务端耗时所在。渲染看板大约每帧耗时 2 ms,对所有人只渲染一次。在每秒 800 次移动时,每一帧都会改变全部八个列表,约 140 kB 的 HTML,而这 200 条流每条都用各自的 Brotli 窗口去压缩它:这占去了五个核心中的大部分。压缩器的状态也占去了大部分内存,每条繁忙的流约 2.2 MB,而 Go 的垃圾回收器大致会再翻一倍。安静看板上的 200 个人需要 150 MB。
### 只有写入方
去掉 HTTP 和流之后,当 200 个人同时移动时,我们的写入方每秒能处理约 10,000 次移动;当一个人独自移动时,则是每秒 240 到 300 次。[Anders Murphy 用相同的设计测量出](https://andersmurphy.com/2025/12/02/100000-tps-over-a-billion-rows-the-unreasonable-effectiveness-of-sqlite.html) 每秒 98,163 次事务。差异可以用三点解释:
- 我们看板上的一次移动大约执行十五条 SQL 语句:检查操作 id 和成员关系、两个保存点、读取卡片及其列表、查找位置、执行更新、更新看板版本并记录操作。而他的转账事务只执行两条更新。
- 纯 Go 的 SQLite 驱动每次调用都会重新准备每条语句。
- 在他的 MacBook 上,除非开启 `PRAGMA fullfsync`(他的配置中没有列出),SQLite 的 fsync 会在驱动器写完缓存之前返回。而在我们的 Linux 机器上,一次提交要等待磁盘 3 到 5 ms,这就是一个人独自移动时的上限。
看板远远达不到这个上限:200 个人每 250 ms 各移动一张卡片,合计每秒 800 次移动。
## 服务端运行了什么
**安装体积**
我们的实现:一个 13.5 MB 的二进制文件
PLANKA:两个镜像,673 MB
**空闲时内存**
我们的实现:28 MB
PLANKA:200 MB
**磁盘上相同的看板数据**
我们的实现:143 kB
PLANKA:10.3 MB
**进程数**
我们的实现:1
PLANKA:18
基准测试结束后处于空闲状态时。每一对都按相同比例绘制。
| 基准测试结束后的空闲状态 | 我们的实现 | PLANKA |
| --- | --- | --- |
| 运行内容 | **systemd 下的一个 13.5 MB 二进制文件** | 两个容器:PLANKA(379 MB 镜像)和 PostgreSQL 16(294 MB 镜像) |
| 进程数 | **1** | 18 |
| 内存 | **28 MB** | 200 MB |
| 磁盘上相同的看板数据 | **143 kB 的 SQLite 文件** | 10.3 MB 的 PostgreSQL 数据库 |
| 依赖 | **12 个 Go 模块,编译进二进制** | 83 个客户端包和 37 个服务端包,外加它们的依赖 |
一个空的 PostgreSQL 数据库本身就占 7.3 MB,PLANKA 的数据目录整体占 65 MB,其中大部分是预写日志(WAL)段文件和模板数据库。如上所述,我们的服务端内存随打开的流而增长。
## 让 200 张卡片的看板在手机上流畅
我们第一版看板每帧在降速的 CPU 上要占用约 120 ms 的主线程时间,而一帧期间按下的每个按键都要等待,这让 INP 达到 248 ms。三处改动把每帧降到了约 55 ms:
- 一帧只携带发生变化的列表;其余列表以空占位形式发送,Datastar 的 morph 会跳过它们,因为两份副本都带有 `data-ignore-morph`。
- 一帧修补的是看板组件内部的列表,而不是组件元素本身。仅这一处改动就把一帧从约 100 ms 降到约 55 ms。
- 屏幕外的列表和卡片带有 `content-visibility: auto`,因此浏览器不会为手机显示不到的列表进行布局或绘制。
使用 Datastar 1.0.4 的默认重试设置时,服务端干净地关闭一条流(例如部署期间)不会被重新打开,页面会默默停止更新。我们的看板以 `retry: 'always'` 打开流,一个看门狗会替换变得静默的连接,并用一条横幅提示页面可能已过期。
## 局限
- 两个应用都和浏览器运行在同一台机器上,因此都不承担与真实数据中心之间的物理距离成本。代理为两者引入了相同的延迟。
- 我们只比较了两个应用共有的功能。PLANKA 的功能多得多,其中一部分重量是为…
相似文章
Hacker News Top
KanBots 是一款开源看板桌面应用,它在每个卡片上并行运行 AI 代理(Claude Code 或 Codex),并分别位于独立的 git 工作树中,支持自动化功能开发、任务管理,并提供实时更新和决策提示。
Lobsters Hottest
本文介绍了用于编译时 CSS-in-JS 引擎的基准测试框架,在像素相同的 React 应用上比较了 Bamboo、StyleX 和 Panda CSS,其中 Bamboo 在交付字节数和构建时间等性能指标上领先。
Reddit r/AI_Agents
一家初创公司描述了如何使用 Microsoft Planner 的 Kanban 看板,集中追踪四名人类员工和多个 AI 智能体(包括 Claude Code 和 Codex)的工作,从而彻底改变了公司的运营和生产力。作者认为,随着 AI 智能体承担越来越多的实际工作,人机协同共享的协作工具正在成为基础设施级别的标配。
X AI KOLs Timeline
Gr8r Studio 是一个用原生 JavaScript ES 模块编写的项目管理 Web 应用,提供看板、列表、表格、日历、时间线等多种视图,零运行时依赖,基于 Vite 构建,克隆后即可运行。
Hacker News Top
Show HN:Proa 是一个 Rust Web 框架,具有快速服务端渲染、多目标渲染(Markdown/HTML 宏)、Shadcn 风格的类型化 Rust 组件库,以及基准测试声称渲染速度显著快于 React。