最近天天在家待业,终于有时间把 Textbus 的最新成果发出来了。官网地址:
Textbus 是一个与视图无关、高性能的协作文档框架,主要解决编辑器,如富文本、画板、PPT 等大型复杂前端项目数据建模和无感多人协作等核心问题。
从 4.0 开始,Textbus 把内置的视图层实现从内核中剥离了,并开发了 Viewfly.org 作为其视图层第一方实现(也支持 Vue 和 React),这直接抹平了在开发复杂交互编辑器和普通 web 应用在技术上的差异。你可以自己封装层组件,也可以直接在编辑器内使用开源组件库去做视图层的交互。没有特殊语法,也不需要打任何标记,这在以前是很难想象的。
不过,解决了性能、无感协作、视图层开发自由等技术问题之后,还有一个问题亟待攻克。那就是在大型文档中的主从文档、异步加载等问题!
一般来说,当一个文档足够大,我们自然而然就想到了,能不能拆成几份,然后按需加载,这样既解决了性能问题,又可以节约不必要的请求,减少用户的等待时间等等。但要实现一个设计完备、逻辑上自洽抽象确不是一个容易的事。
它面临如下几个问题:
- 异步组件(组件为 Textbus 的建模抽象,而非前端所说的组件)怎么定义,它何时加载,怎么卸载。
- 异步组件和同步组件之间怎么交互,怎么能不受限制的应用到文档的任意位置。
- 历史记录怎么管理,多文档比文档复杂,且历史记录前进和后退是同步的。
- 怎么让异步组件和同步组件一样,无感知的协作功能。
主要问题就包含上面几条,还有其它一些小问题就不一一列举了,开发过富文本的小伙伴应该知道,富文本里很多功能都是牵一发动全身。需要注意的是,上面的问题都是协作情况下才变得更难以解决的,非协作编辑,客户端只维护本地状态,同步组件也完全可以做到异步加载功能,且更简单,也更可控。
下面我分别就上面几个问题来向大家介绍 Textbus 提供的方案。
异步组件的抽象
做过架构设计的小伙伴应该对这句话深有同感,即增加新功能不要去破坏原有实现,最好是只做增量。
我们在 Textbus 原有的 Component 和 Slot 类上,继承了两个子类,分别为 AsnycComponent 和 AsyncSlot 类,由于继承关系,它天然就与原有的数据结构无缝融合,我只需要在内核层面,对需要处理其异步特性的地方添加对应逻辑即可。而其中子类新增的内容就是定义一个加载机制,让开发者可以根据自已业务的需要,手动触发加载时机,Textbus 框架内部只需要做好相关的加载响应即可。
以 AsyncComponent 为例:
export declare abstract class AsyncComponent<M extends Metadata = Metadata, T extends State = State> extends Component<T> {
metadata: M;
constructor(state: T, metadata: M);
loader: AsyncModelLoader;
toJSON(): AsyncComponentLiteral<State>;
}
我们在原 Component 的基础上增加了 Metadata 元数据,用于保存异步组件的一些额外信息,如子文档 ID、摘要等。也增加了一个 loader 属性对象,用于触发组件内容的加载,当开发者在业务中调用了 component.loader.load() 方法时,Textbus 会自动调用编辑初始化时传入的加载器并完成文档的加载。
加载器的定义如下:
/**
* 子文档加载器
*/
export declare abstract class SubModelLoader {
/**
* 通过插槽获取已加载的文档
* @param slot
*/
abstract getLoadedModelBySlot(slot: AsyncSlot): YDoc | null;
/**
* 通过组件获取已加载的文档
* @param component
*/
abstract getLoadedModelByComponent(component: AsyncComponent): YDoc | null;
/**
* 当本地新增异步子插槽时调用
* @param slot
*/
abstract createSubModelBySlot(slot: AsyncSlot): Promise<YDoc>;
/**
* 当本地新增异步子组件时调用
* @param component
*/
abstract createSubModelByComponent(component: AsyncComponent): Promise<YDoc>;
/**
* 当远程异步子插槽同步到本地时调用
* @param slot
*/
abstract loadSubModelBySlot(slot: AsyncSlot): Promise<YDoc>;
/**
* 当远程异步子组件同步到本地时调用
* @param component
*/
abstract loadSubModelByComponent(component: AsyncComponent): Promise<YDoc>;
}
大家可以注意到,加载器方法返回值都与 YDoc 有关,这里不得不提到一个现代文档协作库yjs,它基本可以说是最新协作文档的事实标准了。Textbus 也不例外,我们的协作能力完全构建在 yjs 这上。当然,你也可以完全自定义协作能力的实现,不过较为复杂,不建议这么做。
从上面的加载器不难看出,我们定义了异步文档最核心的几个功能,即:
- 获取现有文档——解决历史回退或重做同步的问题
- 加载已有文档——异步获取子文档的能力
- 创建异步文档——解决新增子文档的能力
我们实现了加载器的抽象方法后,当我们实例化编辑器时,我们就可以在文档中无感的使用子文档了。
import { SubModelLoader, MultipleDocumentCollaborateModule } from '@textbus/collaborate'
import { Textbus } from '@textbus/core'
class YourLoader extends SubModelLoader {
....
}
const textbus = new Textbus({
modules: [
new MultipleDocumentCollaborateModule({
// 配置子文档加载器
subModelLoader: new YourLoader()
})
]
})
异步和同步模型怎么交互
在常规情况下,异步编辑总是很复杂的,这个问题导致我构思实现异步文档的抽象时,曾一度陷入停滞,但在我曾经就职于某公司带领团队做多维表格时,让我对这个问题有了更深刻的理解。
多维表格是典型的数据量庞大,性能要求高,还要尽量做到实时性。里面涉及大量的数据引用关系、表依赖关系,多视图多表联动。这些表之和数据之间,不可能一次全部加载,否则当数据量过大时,在数据没有完全加载完成之前,用户不能做任何操作。
一个真正的多维表格,其实就是一个在线智能数据库。肯定不是几万条或几十万条数据。往往上百万行甚至千万行。除了数据以外,还有其它很多与多维表业务形态相关的配置数据,这个数据量是非常庞大的。
由于协作层采用 yjs,但 YDoc 是一份完整的文档数据,不可以从中截断拆分。虽然 yjs 官方有 SubDocuments 相关的方案,但实践下来,还是有很多问题,不能满足需求。
所以多维表格要做拆分,并且不是简单的主从文档两级结构,而是库-> 表->多视图模型->行四层结构。其定义如下:
- 库——用于保存多维表格的表基础信息
- 表——用于保存列的表的列详细信息和行基础信息
- 视图模型——用于保存一张数据表的不同视图有详细信息,如表格视图、看板视图、日历视图还有表单视图等
- 行——用于保存行的具体内容
这么复杂的结构和业务,真正实践下来,我发现,其实,当数据没有加载到本地时,用户本身就不会与这个不存在的文档实体发生任何交互,而当数据已经加载到本地了,发生交互时,则更像是同步数据模型。
说这么多,其实更像是我得出结构的一个过程,出于程序员一惯的思维,总是想把架构各方面设计的逻辑自洽、推论严谨。但实际的工作中,暂时并没有遇到我原来以为那个非常复杂的问题,算是不解而解。
所以,在 4.0 版本一直处于实验性质的异步组件模型,经过了一年多的应用,现在才正式放出来。
异步组件的历史记录管理
凭单编辑器的历史记录管理都不是一个简单的事,常见的实现有两种,一种是记录快照,一种是记录变更。
记录快照很简单,当文档内容发生改变时,记录一下整个文档的快照,当用户回退或重做时,找到对应的快照,重新应用即可。缺点也很明显,性能差,内存占用高。
命令式则刚好相反,当文档内容发生改变时,只记录这一次改变发生变化的内容和位置,并生成正向操作的记录和反向操作的记录,当用户回退或重做时,找到记录的位置,并应用当时的正向操作命令或反向操作命令。好处是,内存占用低,性能好,但实现成本高,非常复杂。
Textbus 的历史记录实现是记录变更,也就是命令式。当数据模型发生改变时,记录对应的变更信息并储存起来,当用户回退或前进时,再应用即可。这在非协作模式是非常好的实现,但在协作场景中,简单的监听数据模型变更就变得不可行,因为文档的变更不只是你一个人,而是多人协作的结果。你不能在回退时,把另一个人编辑的内容给回退了。
这就要提到 yjs 官方提供的 UndoManager,专门用于管理协作文档的历史记录功能,它能精准的记录哪一部分是哪一个用户更改的,当操作历史记录时,只会操作用户自己产生的变更,而不会影响到其它人。
Textbus 对 UndoManager 作了二次封装,你在使用 Textbus 开发中,不会见到它。但主从文档或者说多级文档结构,仅仅使用 UndoManager 提供的功能就不够用了。
前面提到 yjs 的 YDoc 是整个文档,不能拆分,UndoManager 的基础也基于此。所以 yjs 作者还提供了一个用于管理多文档的历史记录库,YMultiDocUndoManager。但提供的功能也非常基础,且业务中遇到的问题这个库也不能解决,所以,我重新创建了一个新的实现方式。
简单来说,一个 UndoManager 只能记录一个 YDoc 的变更,那如果我给每一个 YDoc 都实例化一个 UndoManager,当文档产生变更时,我只需要记录是哪些 UndoManager 实例产生了历史记录,然后把这些实例存起来,当用户操作回退或重做时,我只需要找对对应的实例,并去调用其 redo、undo 方法即可。
经过实际的测试,这种设计算是曲线救国,效果非常不错,逻辑简单。我们不用深入了解 yjs 的数据结构和变更记录,又能使用上 yjs 的能力,做到协作情况下的多文档历史记录功能。
无感知协作的处理
最后就是 Textbus 一始既往的无感知封装了。
使用过 Textbus 的小伙伴应该都知道,当我们需要文档支持协作,只需要在 Textbus 实例化时,添加 Collaborate 模块即可,实际的组件开发、插件、工具等,根本见不到协作文档的影子。也就是说,我们不添加 Collaborate 模块,就是一个普通的富文本编辑器,添加了,就自动支持协作了。
这一点,Textbus 在协作模块内也做了对应的处理,我们使用了观察者模式,可以精准的知道,哪个位置发生了改变,哪个子文档需要被加载。你在开发文档编辑器的时候,只需要按照 Textbus 的文档,按正常流程编辑代码即可。
Textbus 使用案例
最后,简单介绍一些 Textbus 的使用案例。
万兴脑图云笔记:万兴脑图(原亿图脑图)多平台思维导图软件,让您的创意破茧而出
可牛办公 AIPPT:AI PPT - 1 分钟生成专业 PPT
我正在开发的一款在线 PPT 编辑器,支持 AI 生成,未上线,有合作兴趣的小伙伴,欢迎联系
最后 XNote 也更新了,添加了段落拖拽排序, AI 续写、翻译、优化内容等功能。欢迎大家试用,演示地址:textbus.io/playground。
如果你觉得 Textbus 还可以,那就到 github 上帮我点个 star 吧。