对前端来说开发一个在线文档需要啥技术?

前言

  • 最基础的文本编辑功能(哦?好像textarea就可以完成,那如果是富文本呢?)我们需要一个文档模型来描述文档;
  • 富文本编辑器,提供富文本的编辑和渲染能力;
  • 协同功能,不同的用户对同一份文档的编辑需要保持大家看到的都是一样的;
  • 协同网络模型,保证服务器和客户端之间的文档模型一致;

名词解释

OT:一种解决协同问题的算法;

让客户满意是我们工作的目标,不断超越客户的期望值来自于我们对这个行业的热爱。我们立志把好的技术通过有效、简单的方式提供给客户,将通过不懈努力成为客户在信息化领域值得信任、有价值的长期合作伙伴,公司提供的服务项目有:域名申请、网络空间、营销软件、网站建设、宜都网站维护、网站推广。

OP:operation的简称,在OT中指的是一次操作;

etherpad: 一个实现文档协同功能的开源库;

easysync: etherpad中实现文档协同的核心算法,是OT算法的一种,主要用来处理文本协同;

ot-json:ot算法的一种,顾名思义,是主要用来处理结构化数据;

Changeset: 一种描述文档更改的数据格式,用来表示整个文档的一次修改;

ClientVars  表示一篇文档的初始化数据,一般由连续的changeset组合而成;

符号解释

​|​​ :移动光标;

​·​​:叠加;

正文

OT算法

什么是OT算法呢?我们先从头说起,如果要实现一个多人共同编辑文档的功能,我们最简单暴力的做法是啥?

编辑锁

顾名思义,假如A在编辑文档,服务端直接将这个文档加锁,B如果在这个时候也加入了编辑,由于锁的存在,B的编辑直接被丢弃。可以看出,这种编辑锁的实现方式非常粗暴,体验极其糟糕,当然了,在很多公司(比如我们的某死对头公司)的一些wiki系统就是用这种实现方式,由于这种实现方式比较简单,而且体验很糟糕(内容丢失
& 无法实时),我们这里就不做讨论了。

Linux中的diff-patch

Linux中有两个命令:diff和patch;如果我们能在JS中实现这套算法,那么多人协同编辑可以这样做:

  1. 用户打开文档后和服务端建立长链接,保存文档副本;
  2. 用户编辑的时候如果有停顿(比如3s),则将现有的文档和副本进行diff对比,将结果传给服务端,更新副本;
  3. 服务端更新文档,将diff结果通过长链接通知到其它用户,其它用户使用patch方法更新本地的文档;

我们来测试下:

# 本地文档
$ echo '复仇者联盟
钢铁侠
美国队长' > test-local.txt
# 生成用户A编辑后的文档
$ echo '复仇者联盟
钢铁侠
绿巨人' > test-userA.txt
# diff两个文档
$ diff test-local.txt test-userA.txt > diff-test.patch
# 查看diff-test.patch内容
$ cat diff-test.patch
3c3
< 美国队长
---
> 绿巨人

从diff-test.patch内容可以看出,已经找出了两个文档不同的地方,然后我们再模拟下用户B的行为:

# 生成用户B编辑的文档
$ echo '复仇者联盟
黑寡妇
美国队长' > test-userB.txt
# patch方法更新文档
$ patch test-userB.txt < diff-test.patch
# 查看test-userB.txt内容
$ cat test-userB.txt
复仇者联盟
黑寡妇
绿巨人

可以看到,用户B文档的第三行已经更新为了用户A修改后的“绿巨人”。但这种实现方式有个问题,因为他是基于行来进行对比的,就会导致很容易出现冲突,比如:

# 生成文件1
$ echo '复仇者联盟' > local.txt
# 生成文件2
$ echo '复仇者联盟钢铁侠' > userA.txt
# diff对比
$ diff local.txt userA.txt > diff.patch

查看diff.patch内容:

1c1
< 复仇者联盟
---
> 复仇者联盟钢铁侠

这就意味着如果两个人同时修改同一行,那必然就会产生冲突,我们测试下:

# 生成文件3
$ echo '复仇者联盟美国队长' > userB.txt
# patch
$ patch userB.txt < diff.patch

以上我们发现,假如原始文档是“复仇者联盟”,用户A修改为“复仇者联盟钢铁侠”,将diff结果传给服务端,服务端传给用户B,而用户B只是将文档改为了“复仇者联盟美国队长”,直觉上我们可以看出,这两处是不冲突的,完全可以合并成“复仇者联盟钢铁侠美国队长”,但实际上的patch结果却是这样的:

$ cat userB.txt.rej
***************
*** 1
- 复仇者联盟
--- 1 -----
+ 复仇者联盟钢铁侠

因此这种基于行的算法还是比较粗糙,体验上比编辑锁虽然好了一点,但实际弊端还是比较大,既然基于行的实现无法满足需求,那有木有可能去基于字符进行diff呢?

diff-patch算法

diff-match-patch[1]是另一种diff-patch算法的实现,它是基于字符去进行diff的,这里不介绍该算法的细节了,它的算法在这:diff-match-patch JS实现源码[2]。我们直接测试下它的效果

// 示例1
const localText = '复仇者联盟';
const userAText = '复仇者联盟钢铁侠';
const userBText = '复仇者联盟美国队长';

// 结果为:复仇者联盟钢铁侠美国队长
// 示例2
const localText = '复仇者联盟';
const userAText = '复仇者联盟美国队长';
const userBText = '复仇者联盟钢铁侠';

// 结果为:复仇者联盟钢铁侠美国队长
// 示例3
const localText = '复仇者联盟';
const userAText = '复仇者联盟 美国队长';
const userBText = '复仇者联盟 钢铁侠';

// 结果为:复仇者联盟 美国队长 钢铁侠

如上示例已经解决了Linux的diff-patch基于行diff的弊端,但仍然存在问题,如上的示例1和示例2如果没有符号分割,那么结果是一样的。

const localText = '复仇者 Iron Man';
const userAText = 'Iron Man 钢铁侠';
const userBText = '复仇者 Caption';
// 结果为:Caption

原始文档为“复仇者 Iron Man”,用户A修改为了“Iron Man 钢铁侠”,用户B修改为了“复仇者 Caption”,直觉上其实可以合并为“Caption 钢铁侠”,但实际上却修改为了“Caption ”(注意Caption后面有个空格,钢铁侠没了),

也就是说diff-match-patch存在丢字符的情况,这个富文本格式的文档中会是致命的问题,比如丢失了某个 > 可能整个文档都会乱掉,那么有木有既解决了行匹配冲突问题又解决了丢字符问题的解决方案呢?答案就是本文的重点——OT算法

operation transformation

示例

ot.js[3]是针对纯文本的一种JS实现,我们看下它的实现效果,针对同样的示例:

const str = '复仇者 Iron Man';
const operation0 = new ot.TextOperation().delete('复仇者 ').retain(8).insert(' 钢铁侠');
const operation1 = new ot.TextOperation().retain(4).delete('Iron Man').insert('Captain');

const op = ot.TextOperation.transform(operation0, operation1);
// 结果:Captain 钢铁侠

可以看到这正是符合我们预期的结果。

原理

看了很多讲OT的文档,基本每一篇都很长,云山雾罩,但其实它的核心原理很简单。在OT中,我们将文档的操作分为三个类型,通过组合这三个原子操作完成对整个文档的编辑工作:

  • insert(插入字符);
  • delete(删除字符)
  • retain(保持n个字符,也就是移动光标);

注: 实际上diff-match-patch算法也将操作分为三类:insert,delete,equal(不变的字符),insert、delete和OT中含义类似,equal是指对比diff过程中那些没有改变的字符,diff-match-patch会给这些不同类型的字符打标,后面patch的时候再根据不同类型的字符做对应的逻辑处理。

insert

|

复仇者联盟|

如上|代表的是光标的位置,从上到下模拟用户操作的行为,以上操作使用ot.js来描述:

const str = '';
const operation = new ot.TextOperation().insert('复仇者联盟');
const result = operation.apply(str);
console.log(result); // 复仇者联盟

op创建时会有一个虚拟光标位于字符的开头,在一个op结束时,光标一定要在字符串的末尾,其中insert会自动移动光标位置,因此我们这里不需要手动去移动光标;

retain

|复仇者联盟

复仇者联盟|

复仇者联盟钢铁侠|

如上过程用ot.js来描述:

const str = '复仇者联盟';
const operation = new ot.TextOperation().retain(5).insert('钢铁侠');
const result = operation.apply(str);
console.log(result);// 复仇者联盟钢铁侠
delete

|复仇者联盟钢铁侠

复仇者联盟|钢铁侠

复仇者联盟|

如上过程用ot.js描述:

const str = '复仇者联盟钢铁侠';
const operation = new ot.TextOperation().retain(5).delete('钢铁侠');
const result = operation.apply(str);
console.log(result);// 复仇者联盟

删除字符时可以输入字符,也可以输入字符数,实际上源码中是直接取的​​'钢铁侠'.length​​ 因此对于delete中字符串而言,只要长度正确就可以达到目的,上面代码改成​​delete('123')​​不会有任何影响。

transform

前面的代码我们看到过ot.js的这个方法,正是这个方法实现了diff-match-patch的丢失字符的问题,而transform正是OT中的核心方法。我们先不罗列他的源码,先看几个例子:

示例1

原始文档内容(空白文档):|

用户A编辑后的文档内容:钢铁侠

用户B编辑后的文档内容:雷神

对应代码实现:

const str = ' ';
const operation0 = new ot.TextOperation().insert('钢铁侠');
const operation1 = new ot.TextOperation().insert('雷神');
const op = ot.TextOperation.transform(operation0, operation1);
console.log('transform后op操作:', op[0].toString(), ' | ', op[1].toString());
// transform后op操作:insert '钢铁侠', retain 2 | retain 3, insert '雷神'
console.log('transform后操作后的字符串:', op[0].apply(operation1.apply(str)), ' | ', op[1].apply(operation0.apply(str)));
// transform后操作后的字符串: 钢铁侠雷神 | 钢铁侠雷神

最终结果是“钢铁侠雷神”;

transform的操作过程:

循环次数

op1

op2

operation1prime

operation2prime

1

3

2

insert('钢铁侠')

retain(3)

2

undefined

2

retain(2)

insert('雷神')

示例2

原始文档:复仇者联盟

用户A:复仇者钢铁侠联盟

用户B:复仇者联盟美国队长

对应代码实现:

const str = '复仇者联盟';
const operation0 = new ot.TextOperation().retain(3).insert('钢铁侠').retain(2);
const operation1 = new ot.TextOperation().retain(5).insert('美国队长');
const op = ot.TextOperation.transform(operation0, operation1);
console.log('transform后op操作:', op[0].toString(), ' | ', op[1].toString());
// transform后op操作:retain 3, insert '钢铁侠', retain 6 | retain 8, insert '美国队长'
console.log('transform后操作后的字符串:', op[0].apply(operation1.apply(str)), ' | ', op[1].apply(operation0.apply(str)));
// transform后操作后的字符串: 复仇者钢铁侠联盟美国队长 | 复仇者钢铁侠联盟美国队长

最终结果是“复仇者钢铁侠联盟美国队长”;

transform的操作过程:

循环次数

op1

op2

operation1prime

operation2prime

1

3

5

retain(3)

retain(3)

2

'钢铁侠'

2

insert('钢铁侠')

retain(3)

3

2

2

retain(2)

retain(2)

4

undefined

'美国队长'

retain(4)

insert('美国队长')

示例3

原始文档:复仇者联盟钢铁侠美国队长

用户A:复仇者联盟钢铁侠

用户B:复仇者联盟美国队长

对应代码实现:

const str = '复仇者联盟钢铁侠美国队长';
const operation0 = new ot.TextOperation().retain(5).delete('钢铁侠').retain(4);
const operation1 = new ot.TextOperation().retain(8).delete('美国队长');
const op = ot.TextOperation.transform(operation0, operation1);
console.log('transform后op操作:', op[0].toString(), ' | ', op[1].toString());
// transform后op操作:retain 5, delete 3 | retain 5, delete 4
console.log('transform后操作后的字符串:', op[0].apply(operation1.apply(str)), ' | ', op[1].apply(operation0.apply(str)));
// transform后操作后的字符串: 复仇者联盟 | 复仇者联盟

最终结果是“复仇者联盟”;

操作过程:

循环次数

op1

op2

operation1prime

operation2prime

1

5

8

retain(5)

retain(5)

2

-3

3

delete(3)

-

3

4

-4

-

delete(4)

最终结果是“复仇者联盟”;

示例4

原始文档:复仇者联盟钢铁侠美国队长'

用户A:复仇者联盟

用户B:复仇者联盟美国队长

对应代码实现:

const str = '复仇者联盟钢铁侠美国队长';
const operation0 = new ot.TextOperation().retain(5).delete('钢铁侠美国队长');
const operation1 = new ot.TextOperation().retain(5).delete('钢铁侠').retain(4);
const op = ot.TextOperation.transform(operation0, operation1);
console.log('transform后op操作:', op[0].toString(), ' | ', op[1].toString());
//transform后op操作:retain 5, delete 4 | retain 5
console.log('transform后操作后的字符串:', op[0].apply(operation1.apply(str)), ' | ', op[1].apply(operation0.apply(str)));
// transform后操作后的字符串: 复仇者联盟 | 复仇者联盟

最终结果是“复仇者联盟”;

操作过程:

循环次数

op1

op2

operation1prime

operation2prime

1

5

5

retain(5)

retain(5)

2

-7

-3

-

-

3

-4

4

delete(4)

-

ot.js中transform的源码如下:

TextOperation.transform = function (operation1, operation2) {
// ...
var operation1prime = new TextOperation();
var operation2prime = new TextOperation();
var ops1 = operation1.ops, ops2 = operation2.ops;
var i1 = 0, i2 = 0;
var op1 = ops1[i1++], op2 = ops2[i2++];
while (true) {
//...
// 对应示例1第一次循环的操作逻辑
if (isInsert(op1)) {
operation1prime.insert(op1);
operation2prime.retain(op1.length);
op1 = ops1[i1++];
continue;
}
// 对应示例1第二次循环的操作逻辑
if (isInsert(op2)) {
operation1prime.retain(op2.length);
operation2prime.insert(op2);
op2 = ops2[i2++];
continue;
}
// ...

var minl;
// 对应示例2循环
if (isRetain(op1) && isRetain(op2)) {
if (op1 > op2) {
minl = op2;
op1 = op1 - op2;
op2 = ops2[i2++];
// 对应示例2第三次循环的操作逻辑
} else if (op1 === op2) {
minl = op2;
op1 = ops1[i1++];
op2 = ops2[i2++];
// 对应示例2的第一次循环操作逻辑
} else {
minl = op1;
op2 = op2 - op1;
op1 = ops1[i1++];
}
operation1prime.retain(minl);
operation2prime.retain(minl);
// 对应示例4的第二次循环
} else if (isDelete(op1) && isDelete(op2)) {
if (-op1 > -op2) {
op1 = op1 - op2;
op2 = ops2[i2++];
} else if (op1 === op2) {
op1 = ops1[i1++];
op2 = ops2[i2++];
} else {
op2 = op2 - op1;
op1 = ops1[i1++];
}
// 示例3的第二次循环
} else if (isDelete(op1) && isRetain(op2)) {
if (-op1 > op2) {
minl = op2;
op1 = op1 + op2;
op2 = ops2[i2++];
} else if (-op1 === op2) {
minl = op2;
op1 = ops1[i1++];
op2 = ops2[i2++];
} else {
minl = -op1;
op2 = op2 + op1;
op1 = ops1[i1++];
}
operation1prime['delete'](minl "'delete'");
// 示例3的第三次循环
} else if (isRetain(op1) && isDelete(op2)) {
if (op1 > -op2) {
minl = -op2;
op1 = op1 + op2;
op2 = ops2[i2++];
} else if (op1 === -op2) {
minl = op1;
op1 = ops1[i1++];
op2 = ops2[i2++];
} else {
minl = op1;
op2 = op2 + op1;
op1 = ops1[i1++];
}
op

当前标题:对前端来说开发一个在线文档需要啥技术?
当前URL:http://www.mswzjz.cn/qtweb/news7/20457.html

攀枝花网站建设、攀枝花网站运维推广公司-贝锐智能,是专注品牌与效果的网络营销公司;服务项目有等

广告

声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 贝锐智能