W3C

Web 中文兴趣组会议

2022年9月6日

题目:内容变更的可描述化在现代编辑器中的实践

讲者:陈佳伟 (字节跳动)[演示文稿]

现场纪要

陈佳伟:

大家好,我是来自飞书文档的陈佳伟,今天和大家分享的是 内容变更的可描述化在现代编辑器中的实践。

我会从以下方面谈谈:首先什么是内容变更的描述;其次是编辑器领域怎么抽象一套完备的变更描述;第三是通过在线编辑器领域一些具体应用场景,探讨怎么通过变更描述提供解决实际问题;最后是落地到规范的一些建议。

先看看什么是内容变更的描述。简单来讲,我们对内容进行修改的时候,怎么去描述修改,像刚才烈锦同学提到的operation就是一种变更描述。

目前对于内容变更的描述有两种形式,一种是基于状态的描述,一种是基于操作的描述。前者强调状态变成什么样子,比如从A状态变成B状态,得到的是全量的状态,可能是变更前的状态A或者是变更后的状态B。后者是强调状态怎么样变化,比如对状态执行了什么样的操作,可以是增、删、改之类的,这个描述得到的是增量的表达。

目前,基于状态的描述会有哪些实际的例子。比如前端使用比较多的react,我们想对一个状态进行变更的时候,一般都要setstate得到new State。

另一种方式是基于操作的描述,比如redux,它抽象了一个状态变更的函数 -- reducer,通过 action 的 Type 去匹配到具体的逻辑,这个逻辑就会更改对应的State。这个 action 的描述就更倾向于一种基于操作的描述。但由于它的描述表达还不够完备,所以在time Travel的时候还是需要存储快照。

在编辑器领域,通常会抽象一套更完备的表达 -- Operation。比如现在我需要从Hello字符变成一个Hello world,在etherpad描述就是用这样一段简洁的方式描述变更。翻译一下,它的意思就是说原来的字符长度为6,新增之后,它的长度会增加6,后面会从6开始,走了5步,它会删除感叹号,再插入 ' world!'。

简单来讲,我们会把这个变更的描述抽象成为:第一是 where,改变哪里的数据;第二是 how,如何改变,是删除还是新增;第三是 what,即内容改变成什么。具备这三个要素,它就是拥有完整表达能力的变更描述。

接下来我会用OP简称Operation。

接下来是具体领域的应用场景,接下来如何用OP解决这些问题。

第一个是操作的聚合。通常用户会快速连续的输入一段内容,如果每输入一个字就发一个请求,比如发请求做保存或者做协同,对性能肯定不那么友好。一般来讲,我们都需要在一段时间内做一些聚合,再发出去,比如1秒内做一个聚合再发,就不会说1秒内连续输入N个字符就发了N个请求。

还有一种就是聚合的一个场景,当我们做一个连续输入的时候,做Undo,希望能够在一段时间内的连续输入做一次整体的Undo,比如这个场景,快速输了hello再输入world,undo时应该是整个 world 一起 undo,而不是一个字一个。

如果是比较慢的输入,一个字一个字慢慢输入,撤销的时候也应该是一个字符、一个字符的撤销,而不是整体都撤销了。也就是说在聚合这个场景下,在真实的业务场景里有各种各样的要求,也就是说我们需要一个比较灵活,可以对操作形成聚合的能力。

基于OP可以实现Compose或者Merge的接口,只要满足以下条件就可以实现刚才提到的变更聚合,比如OP A描述的是新增操作一个A字符,B是操作一个B字符,我们实现compose的接口,聚合后能得到 'AB' 或者 'BA'。(opA.compose(opB) => 'AB')。

另一个场景就是协同,举个最简单协同的例子:就是客户端的A产生了一个变更 opA,就是在本地插入一个A字符,客户端B产生变更产生一个op B,插入B字符。它们通过协同发出去,比如像A,A收到B的变更之后,聚合得到的结果是AB;B收到A的变更之后,通过聚合得到的是BA,两者聚合之后的结果是不一致的,就是冲突了。

怎么解决这个问题呢?基于OT上实现Transform接口,只要这个Transform能满足条件:A.transform(B, true) === B.transform(A, false),就可以得到一个转换后的变更,和原有的op进行聚合,得到的结果都是BA或者得到的结果都是AB。

两种都可以,取决要以谁为优先,比如可以以哪个op先到服务器(假设有一个中心化的服务器),以先到为优先或者后到为优先都可以,以具体的业务为准。这样就可以实现客户端的协同,保证最终一致性。

另一个场景是undo/redo。现在先看一个最基础的undo的能力,在文档里插入一个A字符,我们希望把这个A undo掉,只要对这个op实现一个invert的接口,求出它操作的反操作,比如现在是insert 'A',反操作就是 delete 1。求出一个反操作之后,两个操作只要满足: opA.compose(opA.invert()) === empty,即聚合之后它们的作用相互抵消了,只要满足这样的条件,op就具备了invert的能力,这个invert就可以实现undo能力。比如我们可以维护一个undo栈,执行undo的时候,就可以对应取出invert,compose在文档里,最后得到的结果就是应用了逆操作后的结果。这就是通过op实现基本undo、redo的能力。

一般图片上传有一个loading,我们看一个动画:现在要上传一张图片,图片比较大,有一个loadind的过程,现在loading结束后我们进行undo,它应该是回到上传前的状态,而不是回到loading的状态。

其实,在这里最简单做法就是把插入loading的操作和插入图片的操作聚合起来,因为上传图片的过程不是阻塞的,所以是可以进行其他操作,比如说loading的过程又输入了文字。所以loading和替换成真实图片这两个操作是不连续的。

这时候,问题就变成了怎么把两个非连续的变更做一个聚合。连续变更可以直接做聚合,但非连续直接聚合是会有问题的。这里做了一些推导,但是时间原因,这里就不仔细展开了。

就是说在这里面连续的聚合可以很方便的聚合,但是,如果是非常连续的聚合,那直接做compose之后,结果是有歧异的。所以,我们会用刚才的Transform来解决这个问题。通过转换,得到新的op。所以我们不仅用Transform解决协同问题,同时可以用Transform实现非连续变更的聚合能力。

这样的话,我们就可以在undo里实现更复杂的需求。

比如语法纠错,我们自己实现了语法纠错能力,中间要发请求去AI service做语法校验,最后返回的结果说'help'写错了,我们就要加下划线,提醒用户纠错。如果做undo,就不应该单独把下划线undo掉,而是应该把它和具体的help这个字聚合在一起,才能做整体的undo。其实和图片loading是类似的问题,都可以用刚才提到的Transform或者是undo的 merge能力实现。

另一个例子是历史记录。飞书文档里可以看到任何历史上变更的记录,可以跳到具体的版本,看到当时对版本的修改,甚至可以回退到对应的版本。本质上它是持久化的undo/redo能力。可以通过存储这些变更,在需要的时候应用这些变更或者把变更可视化,就可以实现历史记录的能力。

通过变更描述的能力,还可以探索实现更高性能的更新机制。在react的机制下,更新流程我们通过一个render函数,传入全量的state,最后算出一个全量的描述,通过对全量描述的diff,最后得到一个patch。它是基于全量变更的过程,所以它的diff在某些场景下比较耗性能,因为它需要对全量进行对比,以及需要跑很多render的过程。

如果我们已经有了一个增量的描述,就可以有一个更高效的策略:既然在model层已经知道了具体变更了哪些数据,通过f函数,我们又获得了model和view的映射关系,其实是可以直接得出视图层的patch。这样就可以得到更高效的更新策略。

最后探讨一下如何和现有的规范结合,就是如何把op和现有的规范结合,提供原生更通用的能力。

第一个是editContext,其实它解决了传统contenteditable的输入问题,像刚才有同学提到的那些输入导致的问题,比如view和model不统一,或者输入破坏了视图等等问题。现在的做法是自己实现隐藏的输入框,让它不要直接破坏视图。 editContext很好的解决了这些输入问题。它支持对文字变更的描述,但属性之类的没有支持。

这会导致原生的undo能力就无法感知到,比如划词评论,划词评论实现的时候是对选中字段文字加一个评论属性去描述它,词上面有评论,具体渲染的时候再根据属性做高亮或者其他处理。如果添加评论属性的操作没法被上下文感知到,就会导致操作没办法被undo进行感知,除非自己生成一个undo,不然就没有办法undo评论(假设这个评论需要undo)。

所以,加入editContext提供一个Operation的表达,这样我们就可以很方便的拓展表达能力,很方便的支持对属性变更的描述,那整个编辑上下文都可以感知到完整的内容变化。

举个例子(代码展示),在editContext有一个uptate的接口,支持传入一个Operation,这个Operation可以实现这些能力,对具体文档内的内容做一个修改。

包括text update Event也可以知道我做了哪些变更。

刚才评论里提到的问题,可以通过一个Operation表达在文字上加一个comment的属性,代表我对5个字符添加一个评论,这个东西最终通过update接口传给editContext之后就会应用到文字里。那么此时其他模块比如undo,就可以感知到这个Operation,当我做undo的时候,就可以把这个操作撤销。

另一个提案是基于op的undo API(代码展示)

它可以具备最基本的undo/redo接口,最核心的点是merge接口: undo API的栈维护的是一些op,它可以基于op提供一个merge的接口,这样我们就可以实现刚才提到的那些在undo做聚合以及非连续变更的聚合等等场景。

今天的分享就到这里,谢谢大家!


返回[会议总结页面]获取其他话题的会议纪要。

若您对上述内容有任何疑问或需进一步协助,请联系:会议主办方 W3C 北航总部 <team-beihang-events@w3.org>。