文本领域的在线协作引擎------OT 算法的原理与应用
前言
之所以想写这篇文章,是因为之前在使用飞书跟腾讯文档做旅游攻略的时候,跟小伙伴们一起编辑一篇文档,其中的协同功能给我留下了很深的印象,也一直很好奇他们是怎么实现的,所以本文其实就为了回答一个问题:如何实现文本编辑器的在线协同功能?
协同的第一关:冲突解决
为了解决这个问题,我们先模拟一个场景,现在有一篇文档,它的版本是 V0,内容是 1234, 我们记作:
ini
// 格式:版本号=内容
V0=1234
此时有两个人:Alice 与 Bob,两个人都是从 V0 开始编辑文档:Alice 先给文章的最后加一个字母 a,并保存为 V0' 版本、Bob 随后给文章的最后加一个字母 b,并保存为 V0'' 版本,此时我们总共有了 3 个版本的文档:
ini
V0=1234
V0'=1234a
V0''=1234b
我们知道,所有在线协同文档最后肯定都是要合并为一个相同的版本的,那么上述场景中的 V0' 与 V0'' 应该怎么合并呢?
有趣的是,这个合并的结果并不存在标准答案,而是取决于我们使用的策略,而策略的选择则取决于我们想要达成的效果
下面我举几个策略的例子来解释一下上面那句话
Last write win(LWW)
这个策略的目的是只保留最后一个到达的数据
在上述的例子里,文档的内容会变成:
ini
V1=1234b
具体的操作流程可以简化为:
- 首先 Alice 的 V0' 版本被传输至服务器
- 随后 Bob 的 V0'' 版本被传输至服务器
- 服务器判断两个版本都是基于 V0,所以他会永远选择更新的版本(也就是最后到达的版本,换言之,时间戳最大的版本)
- 服务器把 V0'' 当成 V1,并将其下发到 Alice 与 Bob 的客户端上
当然,此时 Alice 会蒙蔽的看着自己屏幕的那个新增的 b 并开始怀疑自己是不是需要换一个新电脑了🥹
所以 LWW 策略其实并不适用于我们现在讲的富文本编辑器的场景
事实上,LWW 策略主要是用于分布式的键值对存储数据库的(比如 Cassandra 或 Redis)
篇幅所限,这里只简单说下为什么(有兴趣的朋友可以自行了解):根据分布式系统的 CAP 理论,LWW 策略可以在牺牲部分场景的一致性的情况下,保证系统有效性与分区容错性
Three way merge
这个策略有一个更加通俗易懂的叫法:git 的合并算法
这个策略主要目的是:让文档的最后结果完全符合用户的意图
为了完全符合这个意图,甚至会在必要的时候,强制让用户介入来决策合并的结果
为了快速理解这个策略的原理,我们来举一个简单的例子,假设有个文档的 V0 版本是:
csharp
hi,
my name is xxx
然后此时 Alice 对这个文档进行了更改,并将其与 V0 合并,我们把这个版本称为 V1:
csharp
hello,
my name is Alice
此时 Bob 在 Alice 提交 V1 之前就拉取了 V0,并将其进行了修改,我们把这个版本称为 V1':
bash
hi,
my name is Bob,
have a nice day;)
此时,Bob 想要把当前文档进行提交与合并,但在此时他会发现合并报错了:
markdown
hi,
<<<<<<< HEAD
my name is Alice
=======
my name is Bob
>>>>>>> Bob
我们可以发现两点:
- 第一行的 hi 被 Alice 成功更改为 hello 了
- 第二行的变更导致了一个冲突,而这个冲突需要 Bob 人工干预来解决
为什么呢?
在这个例子里,我们可以把文档的提交看成一颗版本树:
vbnet
V0
| \
V1 V1'
| /
V2
我们的例子发生在当 V1 要与 V1' 合并时,此时系统并不只会考虑要合并的两个版本,它会把两者的最近公共祖先版本(在这个例子中时 V0)也纳入考虑
当要合并第一行的时候,系统判断出,V1' 的第一行没有进行变更,实际上是 V0 版本的内容,而 V1 版本的第一行内容比 V0 版本的第一行新,所以此时系统自动保留了 Alice 修改的第一行
当要合并第二行的时候,系统发现两个版本都基于 V0 版本更新了,但是系统并不能判断哪一个版本更新(因为 V0 已经是两者的最近公共祖先了)所以此时为了保留完整的用户意图,系统将合并后内容的选择权交给了用户
回到我们的主题,这个策略能用于富文本编辑器的在线协同吗?
答案是肯定的,但是有一个约束,那就是这个策略并不能用于多人频繁提交的场景,否则会发现解冲突的成本会随着合并数量的提升而指数级增加
Operational Transformation
紧接着就是本篇文档的主人公了
OT 算法的核心思想在于:在不打断用户创作且尽可能保留用户的意图的情况下提供最终一致性
其中主要有三个重点:
- 不打断用户创作:意味着不会出现像 3-way merge 那样需要用户自己来解决冲突的情况
- 尽可能保留用户意图:意味着这个算法并不能保证合并的结果能够完整保留用户意图
- 提供最终一致性:意味着经过这个策略合并的文档最后将会完全一致
想要理解 OT 算法的基本原理,可以从它的名字入手,其中有两个关键问题:
- 什么是 operation?
- 为什么要 transform?
Operation
要解答第一个问题,首先需要一点想象力,想象我们现在写一个支持协同的编辑器
我们其中一个用户有一篇非常巨大的文档(假设文件大小有 100 MB)
此时用户给其中一句话加了一个句号,我们该怎么表达这个变更呢?
把修改后的整篇文档全量上传?这肯定是不行的,响应速度先不说,这个服务器的流量成本就先爆表了
只将变更的部分上传?好像可以,但我们要怎么描述这个变更呢?
arduino
{type: 'insert', position: 20, content: '.'}
这个好像可行🧐
那我们把它封装成一个方法吧
js
function insert(position, content) {
return {type: 'insert', position: 20, content: '.'}
}
这个 insert,就是一个 operation
当我们的服务器接收到这个 operation 之后,再将它应用到实际的文档中,我们就完成了一次文档的变更
看起来很美好?但是其实这个模式还是有问题的,问题就出在它不允许多个人同时对这篇文章进行修改(也就是协同)
我们还是举一个例子,一开始文档的内容是 12345
此时有两个我们熟悉的用户正在同时编辑这个文档:Alice 执行了 insert(3, 'A')
;Bob 执行了 insert(5, 'B')
此时,Alice 本地编辑器的内容是:123A45
、Bob 本地编辑器的内容是 12345B
紧接着,双方都接收到来自对方的 operation:Alice 本地编辑器的内容更新为 123A4B5
、Bob 本地编辑器的内容更新为 123A45B
可以看到双方的编辑器此时的内容不一致了,为什么呢?
我们稍微倒带一下,看看 insert(5, 'B')
这个操作的两次执行有什么不同
第一次是在 Bob 的编辑器中,执行前的内容是 12345
第二次是在 Alice 的编辑器中,执行前的内容是 123A45
可以看到,对不同的内容(后文称 context)执行同一个操作,自然无法得到相同结果
要解决这个问题,我们要不改变 context,要不改变 operation,但是 context 是在用户的编辑器中的,自然无法随意改变,那么留给我们的就只剩下一条路了,对 operation 进行转变(transform)
Transformation
现在我们知道了为什么要对 operation 进行 transform,接下来我们看看要怎么转变
接续上面的例子,我们将 Alice 在本地执行的 insert(3, 'A')
操作称之为 Oa、将 Bob 在本地执行的 insert(5, 'B')
操作称之为 Ob
当 Alice 与 Bob 编辑器在本地第一次执行完各自 Oa 与 Ob 的操作时候,因为 context 的不同,我们需要对双方后来接收到的操作各自进行一次 transform:
scss
Ob' = OT(Oa, Ob)
Oa' = OT(Ob, Oa)
进行这次 transform 的目的是完成下方的闭环,得到最终一致的内容 T:
scss
S(原始数据)
Oa / \ Ob
/ \
\ /
Ob' \ / Oa'
T(最终数据)
那么这个 OT 方法要怎么实现呢?针对上述例子,我们可以写出一个最基础的版本:
js
function OT(preOp, curOp) {
if(preOp.type === 'insert' && curOp.type === 'insert' && preOp.position <= curOp.position) {
return {
type: 'insert',
position: curOp.position + preOp.content.length,
content: curOp.content
}
}
return curOp
}
对于 Ob' 来说,我们只需要将原来 Ob 的位置往后移就可以了,所以 Ob' 最后会被转换成 insert(6, 'B')
对于 Oa' 来说,我们无需任何变换,直接返回原来的操作 Oa 就行
所以最后对于 Alice 的编辑器,会先经历 insert(3, 'A')
,再经历 insert(6, 'B')
,最后的内容是 123A45B
对于 Bob 的编辑器,会先经历 insert(5, 'B')
,再经历 insert(3, 'A')
,最后的内容是 123A45B
两者达成了最终一致性
Ops
上面我们描述了两个用户,各自有一个操作的协同情况,现在我们要讨论一下各自有多个操作产生的情况下 OT 是如何保持最终一致性的
让我们继续回到上述例子的两个主人公:Alice 跟 Bob
我们先来模拟一个 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 × 1 2 \times 1 </math>2×1 的情况:
- Alice 在本地执行了两个操作:Oa1、Oa2
- Bob 在本地执行了一个操作:Ob
要完成最终一致性的闭环,Alice 跟 Bob 的编辑器需要执行下方的菱形操作:
scss
S(原始数据)
Oa1 / \ Ob
/ \
/ \ Ob' /
Oa2 / \ / Oa1'
\ /
Ob'' \ / Oa2'
T(最终数据)
在实际的操作中,编辑器只需要关注菱形最外层(左边与右边)的操作就好
站在 Alice 的编辑器的角度来说,它只需要按照顺序执行:Oa1, Oa2, Ob''
站在 Bob 编辑器的角度来说,它只需要按顺序执行:Ob, Oa1', Oa2'
其中:
scss
Oa1'= OT(Ob, Oa1)
Oa2'= OT(Oa1', Oa2)
Ob''= OT(Oa2, OT(Oa1, Ob))
为了方便读者理解,我们会用文字站在 Bob 的编辑器的视角重新梳理一次 OT 的流程:
- 为了响应 Bob 的更改,编辑器首先应用了 Ob 的操作
- 编辑器收到了 Oa1 的操作,由于当前 context 与 Oa1 的时候不同了,所以编辑器需要对 Oa1 操作进行转换:
OT(Ob, Oa1) => Oa1'
- 编辑器应用了转换后的操作 Oa1'
- 编辑器收到了 Oa2 的操作,由于当前 context 与 Oa2 的时候不同了,所以编辑器需要对 Oa2 操作进行转换:
OT(Oa1', Oa2) => Oa2'
- 编辑器应用了转换后的操作 Oa2'
既然各位已经了解了 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 × 1 2 \times 1 </math>2×1 的 OT 过程,那么我们再加一点点难度,我们来看看 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 × 2 2 \times 2 </math>2×2 的情况
假设文本的状态可以用 S(x, y)
表示,其中 x,y 分别表示此时客户端 A 与 B 的状态,初始情况下的文档使用 S(0, 0)
表示
同时 A(x, y)
与 B(x, y)
则表示客户端 A、B 在文本 S(x, y)
状态下的操作,于是我们得到了:
scss
// A 在文档 x,y 的情况做了一个操作,所以 x+1 了
S(x,y) o A(x,y) = S(x+1,y)
// B 在文档 x,y 的情况做了一个操作,所以 y+1 了
S(x,y) o B(x,y) = S(x,y+1)
此时,我们假设客户端 A 与 B 各自分别进行了两次操作,于是此时系统的状态变成:
scss
S(0,0) → A(0,0) → S(1,0) → A(1,0) → S(2,0)
↓
B(0,0)
↓
S(0,1)
↓
B(0,1)
↓
S(0,2)
其中客户端 A 中看到的是 S(2, 0)
、客户端 B 中看到的是 S(0, 2)
客户端各自应用完本地的操作后,会把本地的操作推送出去,而当客户端各自接收到新操作的推送后,会将新的操作变更后应用到本地,于是我们的系统状态就变成了:
scss
S(0,0) → A(0,0) → S(1,0) → A(1,0) → S(2,0)
↓ ↓
B(0,0) B(2,0)
↓ ↓
S(0,1) S(2,1)
↓ ↓
B(0,1) B(2,1)
↓ ↓
S(0,2) → A(0,2) → S(1,2) → A(1,2) → S(2,2)
于是客户端 A 的状态从 S(2, 0)
更新为了 S(2, 2)
;客户端 B 的状态从 S(0, 2)
更新为了 S(2, 2)
,两者回归一致了
需要注意的是,右上角的 S(2, 0)
应用的第一次 B 操作不是 B(0, 0)
而是 B(2, 0)
,因为当前 context 已经不同了,所以需要对 B(0, 0)
进行 transform
同理,最后一行对 A 的操作也都是经过了 transform 的结果
上面的系统状态图描述的是当本地两个操作都应用完了才收到其他客户端的推送,那如果是只应用了一个操作后就收到了另一个客户端的操作推送,最后才执行本地的最后一个操作(也就是 A => B => A 或者 B => A => B)会怎样呢?
我们补充一下我们的系统状态图:
scss
S(0,0) → A(0,0) → S(1,0)
↓ ↓
B(0,0) B(1,0)
↓ ↓
S(0,1) → A(0,1) → S(1,1) → A(1,1) → S(2,1)
↓ ↓
B(1,1) B(2,1)
↓ ↓
S(1,2) → A(1,2) → S(2,2)
可以看到,不管是什么情况,我们的系统状态最后都能够被收敛到 S(2, 2)
这个状态
完整的系统状态图如下:
scss
S(0,0) → A(0,0) → S(1,0) → A(1,0) → S(2,0)
↓ ↓ ↓
B(0,0) B(1,0) B(2,0)
↓ ↓ ↓
S(0,1) → A(0,1) → S(1,1) → A(1,1) → S(2,1)
↓ ↓ ↓
B(0,1) B(1,1) B(2,1)
↓ ↓ ↓
S(0,2) → A(0,2) → S(1,2) → A(1,2) → S(2,2)
其实这个图不过就是我们之前的菱形图旋转了 90 度后拓展了一层的结果
同理, <math xmlns="http://www.w3.org/1998/Math/MathML"> n × n n \times n </math>n×n 的情况也只不过是将这个状态图的行或列不断拓展罢了,最后都是会收敛成唯一状态的
协同的第二关:中央服务器
通过上文的学习,我们知道了 OT 是如何在两个客户端之间保证数据最终一致性的,同时也知道了,一个操作能否被直接应用取决于当前的上下文
在上个章节中,我们为了简化理解,做出了一个看似很直觉的假设:所有客户端的初始状态都是 S(0, 0)
这个假设看似非常的直觉,但在真实的场景中,却可能因为种种网络原因(丢包、延迟,甚至离线)无法保证这一点
一旦我们无法保证所有的客户端都有一致的初始(历史)状态,我们就没办法保证协同的最终一致性
所以,我们需要一个中央服务器来帮助我们统一所有客户端的初始状态
通过引入中央服务器,我们解决了统一所有客户端初始状态的问题:服务器会为每个初次连接的客户端下发文章当下的全量内容
但是随着系统角色的增加,我们又引入了新的问题:OT 该在什么时候干活呢?
服务器只检测冲突,客户端负责解决冲突
让我们假设一个场景,我们有两个客户端 Alice 与 Bob,以及一台 Server
在 T=0 的时候,Alice 与 Bob 分别从 Server 处取得了文档,三方的初始状态都是 S(0, 0)
在 T=1 的时候,Alice 率先编辑了一个操作并发送给了 Server,此时三方的状态如下:
css
Server: S(0, 0) => A(0, 0) => S(1, 0) // 无冲突,直接应用
Alice: S(1, 0)
Bob: S(0, 0)
在 T=2 的时候,Bob 也编辑了一个操作,并马上发送给了 Server:
css
Server: S(1, 0) => B(0, 0) => X // 检测到了冲突,需要将该操作打回
Alice: S(1, 0)
Bob: S(0, 1)
此时的 Server 因为已经应用了 Alice 的操作,所以当前上下文发生了改变,无法直接应用 Bob 的操作,所以此时 Server 选择将 Alice 的操作返回给 Bob 让他自己解决完冲突之后再发起协同
T=3 的时候,Alice 发来了最新的操作 A(1, 0)
,Server 基于当前状态 S(2, 0)
检测无冲突,直接应用
css
Server: S(1, 0) => A(1, 0) => S(2, 0)
Alice: S(2, 0)
Bob: OT(A(0, 0), B(0, 0))=B(1, 0) // Bob 根据 Alice 的操作将原来的操作 transform 为 B(1, 0)
T=4 的时候,Bob 将新的操作 B(1, 0)
发送给 Server:
css
Server: S(2, 0) => B(1, 0) => X // 因为 Alice 发来的操作,又冲突了
Alice: S(2, 0)
Bob: S(1, 1)
于是 Server 又把 Bob 的操作打回,Bob 又开始解决冲突。。。。
可以发现,这个方法有几个致命缺点:
- 打回率高,操作容易滞留本地,落库时间不可控
- 客户端需要大量资源来计算冲突,最后甚至会阻塞用户编辑
综上所述,这个思路能支撑的协同人数与同时操作的数量非常有限,是个不可用的架构
服务端负责解决冲突,客户端负责提交与应用
上述我们探讨了将工作交给客户端的方案,很自然的,我们也可以来讨论一下当我们把工作都交给服务端会发生什么事情
回到刚刚的例子,我们模拟的系统中依然还有三个角色:Alice, Bob, Server
在 T=0 的时候,Alice 与 Bob 分别从 Server 处取得了文档,三方的初始状态都是 S(0, 0)
在 T=1 的时候,Alice 率先编辑了一个操作并发送给了 Server,此时三方的状态依然如下:
css
Server: S(0, 0) => A(0, 0) => S(1, 0) // 无冲突,直接应用
Alice: S(1, 0)
Bob: S(0, 0)
在 T=2 的时候,Bob 也编辑了一个操作,并马上发送给了 Server:
css
Server: S(1, 0) => B(0, 0) => X // 检测到了冲突
Alice: S(1, 0)
Bob: S(0, 1)
与上述不同的是,这次我们让 Server 自己来解决冲突,首先 Server 会根据 Alice 的操作对 Bob 的操作进行 OT,然后会将 transform 后的操作应用到自身的文档中,最后,Server 会将不同的操作分别发送给 Alice 与 Bob:
- 对于 Alice 的客户端,Server 需要发送 OT 后的 Bob 的操作
- 对于 Bob 的客户端,Server 需要发送 Alice 的操作
css
Server: S(1, 0) => OT(A(0, 0), B(0, 0)) => B(1, 0) => S(1, 1)
Alice: S(1, 0) => B(1, 0) => S(1, 1)
Bob: S(0, 1) => OT(B(0, 0), A(0, 0)) => A(0, 1) => S(1, 1) // 这里 Bob 对 Server 下发的 Alice 的操作进行了 OT
可以看到,这个方法也可以达成最终一致性,但是它依然存在两个问题:
- 如果 Server 在进行完 OT 但是还没下发操作的时候,又收到来自 Alice 的操作,会需要重新进行 OT 计算,如果这种现象频繁发生将会导致 Server 存在大量计算资源被浪费
- 在 Server 侧,针对每一个当前活跃的客户端,都需要维护一个状态向量来保存需要发送的操作,如果有 n 个客户,就需要维护一个 n 维向量,OT 计算的复杂度会不可控的增长
所以,我们还需要进一步改进这个策略
限制客户端提交,并让 Server 与 Client 分别处理各自冲突
我门在上一个策略中指出了两个问题,现在我们来分别分析下他们出现的原因与解法
对于第一个问题,其根本原因在于:用户提交的操作太多,而且全部积压在了服务端
前者来源于用户的正常行为,我们没法改变,但是操作积压的问题我们可以做出一些调整
我们可以将操作积压的压力转移到客户端上,换言之,我们可以限制客户端的提交
对于第二个问题,其根本原因在于:Server 需要记录每个 Client 当前的上下文
为了解决这个问题,我们需要引入一个版本号的概念
我们继续以一个例子来说明
我们依然有 Server、Alice、Bob 三个角色
假设在初始状态的时候,Alice 与 Bob 两个客户端分别产生了一个操作 A(0) 与 B(0),而整个系统流转的过程如下:
scss
S(0) // 原始数据
A(0)/ \ B(0)
/ \
Sa(1) Sb(1) // 两个客户端本地暂存的版本,因为没有提交,所以还未真正落库
\ /
B(1) \ / A(1) // B(1) 表示 B(0) 经过 OT 的操作;A(1) 表示 A(0) 经过 OT 的操作
S(2) // 最终落库数据
为了使得上述的流传成立,我们设计了详细的数据流转的方案如下:

可以看到,最后三方的状态都被归一化为了 S(2)
,而且我们不需要 Server 维护一个多维向量,服务端与客户端的计算压力也被成功分摊掉了,事实上,这个已经非常接近我们真实的应用场景了
想要体验这个系统的,可以去 operational-transformation.github.io/index.html 自行体验
协同第三关:真实场景应用
通过上文,我们了解了 OT 算法的原理,但在实际应用中,我们仍然会面临很多问题
受限于篇幅与本人的精力,本文没办法把所有的问题都一一提出讨论,对具体代码实现感兴趣的朋友可以去看看:ot.js 这个仓库的具体实现,下面我会选一些比较有趣的地方带大家过一下
客户端的状态
OT 通常会需要 Server 与 Client 互相搭配,在 ot.js 中,这两个部分的代码分别在 lib/client
与 lib/server
由于 server 的代码相对比较简单,我们重点来看下 client 的部份
client 把自己的状态分成了三种:
Synchronized
:表示本地已经完成了所有操作的提交与确认(ACK)AwaitingConfirm
:表示本地有一个操作已提交,还在等待 Server 确认AwaitingWithBuffer
:表示在AwaitingConfirm
的基础上,在本地还产生了其他未提交的操作
此外,Client 还定义了 8 种方法,这些方法在不同的状态下有不同的具体实现:
setState
:切换状态,一般通过下面的其他方法来调用applyClient
:当用户操作了文档的时候调用applyServer
:当 client 接收到 server 丢过来的操作的时候调用serverAck
:当 client 提交的操作得到了 server 的确认后调用serverReconnect
:用于当客户端断线的时候,重新提交操作,一般是在之前的操作没有被确认或者还没来的及提交的时候使用transformSelection
:处理用户光标的移动(如果 server 丢过来的操作影响到了我们的光标,我们需要调用中合格方法进行调整)sendOperation
:client 提交操作使用applyOperation
:应用操作,一般由applyClient
或applyServer
调用
只要我们分别实现这 8*3 个方法,我们就完成了一个基础的 OT 框架的搭建(当然别忘了 Server 的部份)
Transform
有了基础框架之后,我们还需要定义操作并实现操作之间的转换
在 ot.js 中,这部份代码在 lib/text-operation
其中操作的部份,它只实现了最基础的三个操作:
- insert:插入
- delete:删除
- retain:移动光标位置
比较有意思的是,除了这三个基础操作,它还实现了一个 compose
方法用来组合操作
compose 方法可以把两个操作组合成一个操作,并且满足 apply(apply(S, A), B) = apply(S, compose(A, B))
这个操作一般用于 AwaitingWithBuffer
这个状态,将等待确认的时候用户的多个操作组合成一个操作
操作转换的方法在 transform
方法中,因为是基于 op 来写的,所以看起来有些不太方便,想要了解基础思路的话推荐先看看 lib/simple-text-operation
中的 transform
方法,这里使用 insert
与 delete
方法来表达,更加清晰易懂
transform
这个方法是整个 OT 中最关键的部份,它实现的好坏直接决定了整个系统的性能
transform
还是跟应用定义的 op 是紧密相连的,而我们应用所需要的 op 的数量与复杂度一般取决于我们应用用户的需求,所以如何平衡用户需求与 transform
性能,是协同文档所需要面临的一个权衡
Undo/Redo
作为一个编辑器,撤回跟取消撤回也是用户非常高频使用的一个操作,所以我们在实现协同的时候也需要考虑这一点
在一个协同编辑器中,文档状态的变更有可能有两种情况:本地用户操作、远程协同操作
对于 Undo 操作来说,我们只需要(也只能)关注本地用户的操作(因为没有任何一个人想要自己辛苦编辑的内容被别人一个不小心就顺手撤回了)
所以一个非常直觉的想法就是:我们本地保留一个栈来记录本地应用的操作,当用户触发 Undo 的时候,我们只需要从栈顶拿取操作撤回就好
问题是:怎么撤回?
如果我们在一个文档的最后插入一段话,假设是 123
,那么当我们撤回的时候,我们期望的效果是删除 123
其中插入 123
我们可以把它当成是正操作;删除 123
就是一个反操作
我们只需要定义出我们所有操作的反操作,然后在应用正操作的时候将反操作推进 Undo 栈,在用户触发 Undo 的时候应用 Undo 栈顶的操作就可以完美实现 Undo 了
那么 Redo 怎么做呢?
只要我们在 Undo 的时候,把 Undo 栈顶操作的反操作推进 Redo 栈,在用户触发 Redo 的时候应用 Redo 栈顶的操作就可以了
如何判断是否可以进行 Undo/Redo 操作呢?
只需要分别判断 Undo 栈与 Redo 栈是否为空就可以了
生成反操作的代码在 lib/text-operation
的 invert
方法
维护 Undo/Redo 栈以及应用 Undo/Redo 的代码在 lib/undo-manager
写在最后
看到这里,相信大家对如何实现一个编辑器的协同功能有了初步的认识,当然因为个人精力问题(偷懒😆)还有很多问题没办法全部写出来(比如光标选区问题、client 与 server 如何连接等等等等)
要实现一个协同系统从来就不是一件简单的事情,也不可能有一开始就完美的系统,都是一点点的发现问题并解决,这也是编程有趣的地方
最后,其实 OT 并不仅限于文本领域的协同,如果我们把文本换成 json,然后用 json 描述表格/画布/ppt...... 理论上我们可以用 OT 给任何可以用特定文本格式描述的应用实现协同功能,很酷吧😎