跳转到内容

xi-editor — Rope + CRDT 驱动的实验性编辑器

是什么

xi-editor 是 Google 工程师 Raph Levien 于 2016 年发起的实验性代码编辑器,后端用 Rust 编写,前端通过 JSON-RPC(一种用 JSON 格式发送指令、接收结果的进程间通信协议,类似两台程序之间互发短信)与后端异步通信。

日常类比:普通编辑器像一个厨师——接单、备菜、上菜全靠一个人,客人一多就排队卡顿。xi-editor 把”备菜”(语法分析、自动完成)交给独立的后厨(插件进程),厨师只管接单和上菜,永远不会因为后厨忙而让客人等待。

这个”前后端分离”的架构本身并不新鲜,但 xi-editor 把它推到了极致:用 Rope 数据结构 + Monoid Homomorphism 让文本操作全部降到 O(log n),用 CRDT(无冲突复制数据类型) 解决异步插件的并发编辑合并问题。这两个设计决策的深度远超编辑器本身,以”rope-science 系列”技术文档的形式流传至今,直接影响了后来的 Zed 和 Lapce。

为什么重要

不理解 xi-editor,下面这些事都没法解释:

  • 为什么 Zed / Lapce 能在”100 万行文件”里滚动不卡——它们继承了 xi 的 rope + 增量摘要思路
  • 为什么”异步语法高亮”不等同于”启一个线程”——插件拿到的是旧快照,如何与主线程合并才是难题,xi 用 CRDT 给出了答案
  • 为什么 B-tree 在文本编辑场景比数组快——rope 就是特化为文本的 B-tree,xi 的 rope-science 把原因解释得最清楚
  • 为什么 OT(Operational Transform)被 Google Wave 团队的工程师形容为”实现太痛苦”——xi 的文档用对比说明了 CRDT 在编辑器并发场景的简洁性

核心要点

xi-editor 的技术贡献可以拆成三个相互支撑的层:

  1. Rope + Monoid:文本编辑的 MapReduce

    普通编辑器用数组存文本,“最长行宽”这类统计需要遍历整个文件。xi 把文本存在 B-tree 形态的 Rope 里,每个节点缓存一份”摘要”(如:这棵子树有几行、最长行有多宽)。这里有个数学概念叫 Monoid Homomorphism(不要被名字吓到:Monoid 是”能合并”的结构,Homomorphism 是”合并规则一致”的保证——意思是父节点的摘要可以由子节点摘要合并而来,不用重算整棵树)。类比:超市仓库按货架分区,每个区有块小板子写着本区库存总量,统计全仓库只要把各区板子加一遍——这就是 Monoid 合并。编辑时只更新受影响节点的摘要,沿路往根冒泡,整体 O(log n)——Raph Levien 把这个模式称为”MapReduce for Text”,因为 map(每节点单独算摘要)+ reduce(父节点合并子节点摘要)就是标准 MapReduce 结构。

  2. CRDT:让插件像异步 actor 一样工作

    插件收到的是编辑器某个时刻的文本快照,插件算完要回写修改时,主 buffer 可能已经被用户改过了。传统方案是加锁(插件跑时用户不能打字)或用 OT 变换坐标(实现极复杂)。xi 借用 CRDT 里的”tombstone”思路:删除操作不真删,只打标记,所有并发操作都是单调往前推进的状态变更,因此天然可以以任意顺序合并。类比:两个编辑同时改一份稿子,各自记录”我加了什么""我删了什么”,事后两份记录合并只需对照——谁也没有覆盖对方的历史。

  3. JSON-RPC 前后端分离:前端可以是任何语言

    xi 的后端暴露一套 JSON-RPC 协议,前端可以是 macOS 原生 AppKit、GTK、Electron,甚至终端——理论上任何能收发 JSON 的程序都能做 xi 的 UI。这个设计让 xi 在很短时间内涌现出十几个第三方前端,也因为协议多次重构而让这些前端频繁断供,成为项目存档的重要原因之一。

实践案例

案例 1:理解 Rope 的增量摘要——用 Python 模拟”行宽 MapReduce”

# 用二叉树模拟 Rope 节点,每个节点缓存"最大行宽"摘要
class RopeNode:
def __init__(self, text="", left=None, right=None):
self.text = text # 叶节点存文字
self.left = left
self.right = right
self.max_width = self._compute()
def _compute(self):
if self.text: # 叶节点:直接算(map 步)
return max((len(l) for l in self.text.split("\n")), default=0)
# 内部节点:合并左右摘要(reduce 步 = Monoid 合并)
lw = self.left.max_width if self.left else 0
rw = self.right.max_width if self.right else 0
return max(lw, rw)
# 构建一棵简单的 rope:左子 "hello\nworld",右子 "foo"
leaf1 = RopeNode("hello\nworld") # max_width = 5
leaf2 = RopeNode("foo") # max_width = 3
root = RopeNode(left=leaf1, right=leaf2)
print(root.max_width) # 5,正确!
# 编辑时只重算受影响路径,不扫全文

逐部分解释:

  • _compute 里的 max(lw, rw) 就是 Monoid 的”合并”操作——满足结合律,父节点能从子节点推算
  • 真实的 xi rope 用更复杂的三元组(行首长、行尾长、最大行宽),但结构完全相同
  • 每次编辑只需从叶到根沿路更新,树高 O(log n),不触碰其他分支

案例 2:对比 CRDT 与 OT 在并发插入的合并逻辑

# CRDT tombstone 模型的极简示例(字符级,用字典模拟)
buffer = {
0: {"id": 0, "char": "h", "deleted": False},
1: {"id": 1, "char": "i", "deleted": False},
}
# 用户操作:在 id=1 后插入 "!"
op_user = {"after_id": 1, "char": "!", "new_id": 2}
# 插件操作(基于旧快照):删除 id=1 的字符
op_plugin = {"delete_id": 1}
# CRDT 合并:两个操作可以任意顺序应用,结果一致
def apply(buf, op):
if "char" in op: # 插入
buf[op["new_id"]] = {"id": op["new_id"], "char": op["char"], "deleted": False}
if "delete_id" in op: # 删除 = 打 tombstone,不真删
buf[op["delete_id"]]["deleted"] = True
apply(buffer, op_user)
apply(buffer, op_plugin)
# 最终渲染:过滤 deleted=True 的字符
result = "".join(v["char"] for v in sorted(buffer.values(), key=lambda x: x["id"])
if not v["deleted"])
print(result) # "h!" ——"i" 被删除,"!" 被插入,顺序正确

与 OT 的对比:OT 需要变换插件的坐标(“i” 在第 2 个字符位置,但用户插入了”!”后坐标变了);CRDT 用唯一 ID 指向字符,永远不需要坐标变换。

案例 3:追踪 xi-editor 在 Zed 中的继承

xi-editor 原始设计 Zed 的对应决策
─────────────────────────────────────────────────────────────
Rope + Monoid 摘要 直接沿用 xi 的 Rope 设计理念
CRDT-based buffer Zed 的 TextBuffer 继续使用 tombstone 合并
JSON-RPC 前后端分离 Zed 内化后端,用 GPUI 框架渲染 UI(放弃协议隔离)
插件在独立进程里跑 Zed Extension 仍然进程隔离,沿袭 xi 理念
macOS 优先 Zed 同样 macOS 优先,后来扩展 Linux

Zed 的第一个公开贡献者就包括 Raph Levien,两个项目在人员和设计思路上都有直接传承。你可以在 zed-industries/zedcrates/rope 目录找到 xi rope 的直系后代。

踩过的坑

  1. JSON-RPC 序列化开销:在高频输入(持续打字)时,每次击键触发一次 JSON 序列化 + 进程间通信,实际延迟比预期高,在低端硬件上会感知到轻微滞后。

  2. 协议不稳定导致前端生态碎片:xi 的前后端协议在 2017–2019 年多次重构,十几个社区前端频繁跟不上后端变更,最终大多停止维护,形成”协议活、前端死”的尴尬局面。

  3. CRDT tombstone 未完整 GC:删除操作留下的 tombstone 在长时间编辑后积累,xi 的 GC 策略设计了但没有完整实现,导致内存占用随编辑时长缓慢上升。

  4. 单核心贡献者风险:项目深度依赖 Raph Levien 一人推动架构决策,2020 年他转移精力到 Xilem(Rust GUI 框架)和 Druid,xi-editor 随即进入维护停滞,2021 年正式存档。

适用 vs 不适用场景

适用

  • 学习 rope 数据结构和增量计算的最佳文档来源(rope-science 系列 12 篇)
  • 理解编辑器并发架构的参考实现(CRDT + 异步插件)
  • 研究 Zed / Lapce 的设计根源
  • Rust 项目学习 B-tree 风格的数据结构组合方式

不适用

  • 日常开发使用(项目已存档,无活跃维护;替代:直接用 zedhelix
  • 需要稳定插件生态的场景(插件 API 已冻结在存档时的状态;替代:Zed 的 Extension 机制继承了 xi 的进程隔离理念但 API 更稳定)
  • 对延迟极度敏感的嵌入式环境(JSON-RPC 进程间通信本身有开销下限;替代:考虑 Ropey crate 或 gap buffer 方案)

历史小故事(可跳过)

  • 2016 年 4–5 月:Raph Levien 在 Google 内部写了一系列帖子,题为”rope science”,探索用 Monoid Homomorphism 把各种文本统计计算压到 O(log n)。这些帖子成为 xi-editor 的技术基石,后来公开发布在 xi-editor.io/docs。

  • 2016 年下半年:xi-editor 开源,以 macOS 原生 Cocoa 前端发布,在技术社区获得大量关注,被称为”认真对待性能的编辑器”。

  • 2017–2019 年:社区涌现十余个第三方前端(GTK、Electron、终端版),但随着协议重构,多数前端相继断供。项目尝试稳定 plugin API,努力多次却未果。

  • 2019 年:Raph Levien 在博客承认 xi-editor 的架构野心超过了实际可交付能力,宣布暂停新功能开发,转入纯维护模式。

  • 2020–2021 年:Levien 与团队转战 Zed(初期私有,2024 年开源),xi-editor 于 2021 年正式存档。rope-science 系列文档和相关代码理念继续以研究材料形式被后继项目引用。

  • 2022 年至今:Zed 开源后,xi-editor 的技术遗产通过 Zed 和 Lapce 在编辑器领域持续生长。

学到什么

  1. 数据结构选型影响整个架构:rope 不只是”更快的字符串”,它让增量计算、并发合并、语法高亮都能沿着树的路径局部化,整体架构的复杂度因此大幅降低。

  2. CRDT 的适用范围比”分布式数据库”更广:xi 证明了 CRDT 在单机编辑器的”异步插件 vs 主线程”这种轻量并发场景同样有效,且实现复杂度远低于 OT。

  3. 协议稳定性是生态的前提:技术领先但协议频繁重构,会让外部贡献者的投入持续归零。xi-editor 的生态失败是一个关于”接口契约”的经典反面教材。

  4. 实验项目的最大价值有时是文档:xi-editor 作为可用软件已死,但 rope-science 系列文章作为”凝固的知识”依然在影响新一代编辑器的设计者。

延伸阅读

关联

  • zed —— Zed 直接继承 xi 的 rope 实现和 CRDT buffer 设计
  • lapce —— Lapce 同样受 xi-editor rope-science 思路影响
  • helix —— 另一个 Rust 编辑器,走了不同的路线(kakoune 交互模型)
  • crdt-shapiro-2011 —— xi-editor CRDT 方案引用的核心理论综述
  • crdt-json-2017 —— CRDT 在结构化文档上的推广
  • neovim —— 同时代的编辑器,选择改造 Vim 而非重写,两种路线的对比
  • atom —— Electron 编辑器的代表,xi 作为其反面——“原生才能保证延迟”

反向链接

  • atom —— Atom — 已归档的 Web 编辑器先驱
  • crdt-shapiro-2011 —— CRDT — 让多副本各改各的,最终自动合一
  • crdt-sss-2011 —— CRDT 形式定义 — SSS 2011 八页浓缩版
  • textmate —— TextMate — macOS 经典编辑器,语法格式影响了所有人