Quill 是一款时下非常热门的富文本编辑器,它拥有非常强大的扩展能力,可以让开发者根据自己的需要编写插件,使编辑器支持的内容类型更加丰富。它之所以能够拥有这么强大的扩展能力,一方面是因为它的架构和 api 设计从一开始就充分考虑了扩展的需求,另一方面就是它底层采用了一种表达能力很强的的数据存储模型—— quill-delta

quill-delta 是一个 ot 算法的实现,所谓 ot 算法是指 Operational Transformation,这个算法主要是用来解决数据协同编辑的问题,但因为算法本身并不针对特定类型的数据格式,所以其适应性很强,因此基于此算法实现的 quill-delta 表达能力也很不错。关于 ot 算法如果要展开来讲内容太多了,本文不打算涉及,大家感兴趣可以去搜索一下。

但是,quill-delta 可能更多的还是考虑简单文档类型数据的使用场景,所以它的数据基本上都是线性的,对树状结构的数据支持能力很弱,这也给他带来了很多使用场景上的限制。为了解决这个问题,我对 quill-delta 做了一些改造和扩展,于是就有了 quill-delta-enhanced。本文将介绍 quill-delta-enhanced 的设计思路,以及实现过程中面临的问题及其解决方案。

quill-delta 的问题

quill-delta 的优点是简单易于理解,花上几分钟看看 api 文档你就能理解它的工作方式。但简单也有简单的问题,就是表达能力不够强,比如,如何表示一个表格?在文档中嵌入表格其实也算是非常常见的需求,一个表格可以有若干行,每行又可以有若干个单元格,单元格还可以合并,每个单元格里面的内容也会非常不一样,甚至有的编辑器还提供了表格嵌套功能,就是一个表格中嵌套另一个表格。对于这样复杂嵌套的数据,quill-dlta 就显得有些力不从心了,所以 Quill 一直没有支持插入表格的操作。

如何增强 quill-delta 的表达能力

那么如何提升 quill-delta 的表达能力呢?我想到的一个思路是,嵌套 delta —— 既然表格的内容在直观上是嵌套的,那么为什么不把 delta 也设计成可以嵌套的样子呢?

事实上 quill-delta 本身并不仅仅支持 string 类型的数据,还支持 number 类型和 object 类型的数据,比如:

// string
new Delta().insert('hello world')
// number
new Delta().insert(3, {attr: 'number attributes'})
// object
new Delta().insert({name: 'tiger'})

很明显,既然 quill-delta 支持 object 类型的数据,就肯定也可以支持插入 delta 类型的数据,毕竟 delta 本身就是一种 object,所以,我们可以插入这样的数据:

// embed delta
new Delta().insert(new Delta().insert('embed'))

当然,这里面还有些细节需要处理,比如 compose、invert 和 transform 方法都需要一些变化才能适应嵌入 delta 的做法,具体内容大家可以看源代码。

嵌套 delta 如何 diff

在 quill-delta 中 diff 方法可以非常快速地比较两条 delta 之间的差异,比如:

var a = new Delta().insert('hello world')
var b = new Delta().insert('hi word') a.diff(b)
// new Delta().retain(1).insert('i').delete(4).retain(4).delete(1)

如果你看过 quill-delta 的代码,就知道 quill-delta 是用了一个叫做 fast-diff 的库来实现 diff 算法的。fast-diff 这个库只能用来处理 string 类型数据的 diff,那 quill-delta 是怎么用它来处理同时包含 string、number、object 三种类型的数据呢?还是看源码:

const NULL_CHARACTER = String.fromCharCode(0); // Placeholder char for embed in diff()

diff(other: Delta, cursor ?: number | diff.CursorInfo): Delta {
// .... const strings = [this, other].map(delta => {
return delta
.map(op => {
if (op.insert != null) {
return typeof op.insert === 'string' ? op.insert : NULL_CHARACTER;
}
const prep = delta === other ? 'on' : 'with';
throw new Error('diff() called ' + prep + ' non-document');
})
.join('');
}); // ....
}

quill-delta 在调用 fast-diff 之前把所有的 insert 操作全部转成了字符串,对于插入的 number 类型的数据和 object 类型的数据,都转成了一个特殊字符,然后和 string 类型的数据拼在一起,就成了一个大字符串,然后给 fast-diff 处理。但是,这里有一个很明显的问题,如果两个 delta 中分别插入了两个不同的 object,由于这里 diff 的时候全都转成同一个特殊字符了,那么 fast-diff 就无法找出这两个 object 的不同,所以 quill-delta 在拿到 fast-diff 的处理结果之后又做了进一步处理:

const diffResult = diff(strings[0], strings[1], cursor);

diffResult.forEach(component => {
//....
switch (component[0]) {
// ....
case diff.EQUAL:
// ....
if (equal(thisOp.insert, otherOp.insert)) {
retDelta.retain(
opLength,
AttributeMap.diff(thisOp.attributes, otherOp.attributes),
);
} else {
retDelta.push(otherOp).delete(opLength);
}
break;
}
// ....
})

如上所示,对于 fast-diff 认为是相同的内容,quill-delta 又做了一次 equal 操作来确认两条内容是不是真的相同,如果是真的相同就 retain,如果不是就粗暴的删除之前的内容插入新的内容。这样显然不能得到一个相对合理的 diff 结果,比如:

var a = new Delta().insert(3)
var b = new Delta().insert(2).insert(3) a.diff(b)
// new Delta().insert(2).delete(1).insert(3)

对于 number 类型的数据来说,直接删除原来的内容在插入新的内容似乎影响也不太大,但如果插入的是一个复杂的 object 数据或者 delta,这种操作就显得太低效了,所以在 delta 支持嵌套后,必须对 diff 算法做出改进。

改进 quill-delta 的 diff 算法

delta 中插入的如果都是 string 类型,那么整个 delta 可以近似看做是一个线性的数据结构,可当我们让 delta 可以嵌套另一个 delta 的时候,delta 就会变成一个树形结构,这会给我们的 diff 算法带来非常大的挑战。

传统的 tree diff 算法时间复杂度是 O(n^3),就是说,对于一个 1000 个节点的树,diff 一次需要进行 10 亿轮运算,这样的开销显然是不可接受的。但这个问题让我想到了一个非常非常非常著名的库:react。react 中用对 virtual dom 进行 diff,来生成针对 dom 的最小操作,达到尽量不操作 dom 的目的从而提升性能。我们知道 virtual dom 其实也是一种复杂的树结构数据,这种树的复杂程度一般要比 delta 的结构更复杂,那么 react 是怎么实现高效 diff 的呢?大家可以自行搜索一下,网上讲解 react 中 diff 算法的文章多如牛毛。我这里只提一下 react 对 diff 算法的优化其实是一种策略优化,就是通过限制场景来优化性能,主要是四点:分级比较、类型判断、 shouldComponentUpdate 优化、key 优化。通过这四点优化,react 将 tree diff 的时间复杂度从 O(n^3) 降低到了 O(n),效果非常显著。那么这四个优化策略是不是可以用在 delta 的 diff 算法上呢?我觉得至少其中两点是可以的。

首先,分级比较。就是说我们认为把一颗子树从一个节点下挪到另外一个节点下,这并不是一个常见的操作,diff 仅仅在同级别的元素之间进行,而不去考虑子树在节点之间挪动的场景,这样就大大简化了计算的复杂程度。举个例子:

var a = new Delta()
.insert(new Delta().insert('a').insert(1))
.insert(new Delta().insert('b'))
var b = new Delta()
.insert(new Delta().insert('a'))
.insert(new Delta().insert('b').insert(1))

这两条 delta 在做 diff 的时候,我们并不会去看 b 中的第二个子 delta 下的 insert(1) 操作是不是在 a 中的某个子 delta 中存在过,而仅仅将 b 中的第一、二两条子 delta 分别和 a 中的第一、二两条子 delta 做内容上的对比。

其次,key 优化。react 中对于同级别且同类型的元素会用一个该层级下唯一的 key 来标记元素的唯一性,通过这个 key 来判断哪个元素是新增的,哪个元素是之前就存在的。比如:

var a = new Delta()
.insert(new Delta().insert('a'))
var b = new Delta()
.insert(new Delta().insert('b'))
.insert(new Delta().insert('a'))

上面这两条 delta 如果不用 key 来标记唯一性的话,很可能 diff 出来的结果会是这样:

new Delta()
.retain(new Delta().insert('b').delete(1))
.insert(new Delta().insert('a'))

因为没有 key 来标记 a 中已经存在的子 delta,在 diff 的时候,程序只能拿 b 中的第一条子 delta 和 a 中的子 delta 对比,于是得到了结果中的 retain 操作。而如果我们能给子 delta 添加一个唯一的 key 来标记它,diff 的时候就可以通过 key 很容易地判断出哪些 delta 是之前就存在的。从而得到正确的结果:

new Delta()
.insert(new Delta().insert('b'))

修改思路

谈了优化策略,我们再来讲讲具体如何实现。还是只讲思路,具体修改的代码大家自己看 git 上的提交记录吧。

首先,fast-diff 是一个非常好用的 diff 算法库,我希望能尽量复用他,但是子 delta 添加 key 之后,显然就没法像之前那样把 delta 里的 op 都转成纯 string 类型的数据了,这就要求 fast-diff 能支持混合类型的 diff,比如

['abc', 1, 'defgh', 2, 'xyz']

上面的数据代表了一条 delta ,其中有 5 段内容分别是 abc、一条 key 为 1 的子 delta、defgh、一条 key 为 2 的子 delta,最后是 xyz。好在对 fast-diff 的修改其实并没有很复杂,大家可以想象一下,fast-diff 虽然是用来处理 string 类型的数据的,但其实 string 类型和 array 是非常类似的,我们可以把 string 想象成一个 char 类型的 array(一般C 语言里面就是这么来存储字符串的),所以修改起来并不算很复杂,大家可以参考这个提交:enhance diff to support diff sequence of characters and numbers

在 fast-diff 支持了这种字符串和数字类型数据混标的 array 之后,quill-delta 自身的 diff 逻辑修改就相对简单了,主要是要给 insert 接口添加 number 类型的 key 参数,以及处理 fast-diff 的结果的时候需要一点点特殊处理,参考这个提交:Improve diff algorithm, support efficient diff of nested delta

其他

quill-delta-enhanced 对还做了一些其他的改动,比如 insert 和 retain 操作支持的数据类型有稍作修改,原有的 number 类型的数据的含义也有些许变化,具体变化的内容大家可以在代码仓库的 README 文档中查看。

总结

quill-delta 是一个非常简洁实用的 ot 算法库,对于简单文档内容来说,他的表达能力已经很不错了,不过加入了嵌套 delta 的能力之后,其表达能力进一步增强。虽然嵌套 delta 让有些逻辑的复杂度大大增加,但其实冷静分析还是可以找到一些性价比很高的解决办法的。

如需转载,请注明转自:https://www.cnblogs.com/silenttiger/p/12704594.html

欢迎关注我的微信公众号:老虎的小窝

Quil-delta-enhanced 简介的更多相关文章

  1. Git简介

    Git从入门到学会 Git简介 Git是什么? Git和SVN一样都是一种高效的管理代码的系统. Git是目前世界上最先进的分布式版本控制系统(没有之一). 创建版本库 什么是版本库呢?版本库又名仓库 ...

  2. LevelDB库简介

    LevelDB库简介 一.LevelDB入门 LevelDB是Google开源的持久化KV单机数据库,具有很高的随机写,顺序读/写性能,但是随机读的性能很一般,也就是说,LevelDB很适合应用在查询 ...

  3. ISO 14229 简介 转载

    作者:autogeek 原文链接:http://www.cnblogs.com/autogeek/p/4458591.html 前言 由于工作中经常用到ISO-14229,因此决定对该协议做个总体介绍 ...

  4. git的简介,安装以及使用

    1git的简介 Git是什么? Git是目前世界上最先进的分布式版本控制系统(没有之一). Git有什么特点?简单来说就是:高端大气上档次! 2Linus一直痛恨的CVS及SVN都是集中式的版本控制系 ...

  5. JS内存泄漏 和Chrome 内存分析工具简介(摘)

    原文地址:http://web.jobbole.com/88463/ JavaScript 中 4 种常见的内存泄露陷阱   原文:Sebastián Peyrott 译文:伯乐在线专栏作者 - AR ...

  6. (转).NET Memory Profiler 使用简介

    1         简介 .Net Memory Profiler(以下简称Profiler):专门针对于.NET程序,功能最全的内存分析工具,最大的特点是具有内存动态分析(Automatic Mem ...

  7. Computer Science Theory for the Information Age-4: 一些机器学习算法的简介

    一些机器学习算法的简介 本节开始,介绍<Computer Science Theory for the Information Age>一书中第六章(这里先暂时跳过第三章),主要涉及学习以 ...

  8. 【转】CppUnit使用简介

    以下内容来自:http://www.cnblogs.com/wishma/archive/2008/08/01/1258370.html 1. 简介 CppUnit 是个基于 LGPL 的开源项目,最 ...

  9. 从 man 指令起步(info简介)

    前言 小生认为一切指令的学习首先要从帮助入手,深入了解它的功能,即使是在实际项目中我们都离不开它的帮助.因为我们不一定能够记住全部指令的全部的相关功能,因此,查看指令的帮助是我们的不二选择. 正文 下 ...

  10. InfiniBand技术简介

    InfiniBand技术简介   转载请在文首保留原文出处:EMC中文支持论坛https://community.emc.com/go/chinese 介绍         随着CPU和通讯处理速度的 ...

随机推荐

  1. 0318 guava并发工具

    并发是一个难题,但是可以通过使用强力简单的抽象来显著的简化,为了简化问题,guava扩展了Future接口,即 ListenableFuture (可以监听的Future).我强烈建议你在你的所有代码 ...

  2. 何为内存模型(JMM)?

    前言 任何一门语言都有其语言规范,从逻辑上我们可划分为语法规范和语义规范,语法规范则是描述了如何通过相关语法编写可执行的程序,而语义规范则是指通过语法编写的程序所构造出的具体含义.语言只要具备存储(比 ...

  3. react 给选中的li添加样式(转载)

    路:使用事件委托,关键:获取到的index必须转为数字,因为它是字符串 handleClick = (e) => { const nodeName = e.target.nodeName.toU ...

  4. IDEA启动项目报错:Cannot open URL.Please check this URL is correct

    IDEA启动项目报错:Cannot open URL.Please check this URL is correct 问题:IDEA启动SSM项目,使用的Tomcat,报错 Cannot open ...

  5. IOptions、IOptionsMonitor以及IOptionsSnapshot

    背景 ASP.NET Core引入了Options模式,使用类来表示相关的设置组.简单的来说,就是用强类型的类来表达配置项,这带来了很多好处.初学者会发现这个框架有3个主要的面向消费者的接口:IOpt ...

  6. IdentityServer4实现.Net Core API接口权限认证(快速入门)

    什么是IdentityServer4 官方解释:IdentityServer4是基于ASP.NET Core实现的认证和授权框架,是对OpenID Connect和OAuth 2.0协议的实现. 通俗 ...

  7. zookeeper基础学习-简介

    1.zookeeper的使命 zookeeper可以在分布式系统的协作多个任务(一个任务是指一个包含多个进程的任务),这个任务可以是为了协作或者是为了管理竞争. 协作:多个进程需要一同处理某些事情,一 ...

  8. angular的性能分析 -随记

    $watch 的实现原理和性能分析 只有双向绑定的 scope 才会被加入$watch队列,或者手动绑定$watch的$scope 所有放在 $scope 中的变量或函数都被加入到了$watch队列当 ...

  9. 使用SQL修改字段类型

    修改字段类型步骤: 1.首先需要检查字段约束 2.删除字段约束 3.修改字段类型 4.加上字段约束 --不加这个条件,库中所有默认约束都可以看到 SELECT  a.name AS DFName , ...

  10. JavaScript----简介及基础语法

    ##JavaScript *概念:一门客户端脚本语言 *运行在客户端浏览器中的.每一个浏览器都有JavaScript的解析引擎. *脚本语言:不需要编译,直接就可以被浏览器解析执行. *功能: *可以 ...