会议网站建设方案,辛集seo网站优化价格,建教育网站需要多少钱,番禺网站开发系统初探富文本之CRDT协同实例
在前边初探富文本之CRDT协同算法一文中我们探讨了为什么需要协同、分布式的最终一致性理论、偏序集与半格的概念、为什么需要有偏序关系、如何通过数据结构避免冲突、分布式系统如何进行同步调度等等#xff0c;这些属于完成协同所需要了解的基础知…初探富文本之CRDT协同实例
在前边初探富文本之CRDT协同算法一文中我们探讨了为什么需要协同、分布式的最终一致性理论、偏序集与半格的概念、为什么需要有偏序关系、如何通过数据结构避免冲突、分布式系统如何进行同步调度等等这些属于完成协同所需要了解的基础知识实际上当前有很多成熟的协同实现例如automerge、yjs等等本文就是关注于以yjs为CRDT协同框架来实现协同的实例。
描述
接入协同框架实际上并不是一件简单的事情当然相对于接入OT协同而言接入CRDT协同已经是比较简单的了因为我们只需要聚焦于数据结构的使用就好而不需要对变换有过多的关注。当前我们更加关注的是Op-based CRDT本文所说的CRDT也是特指的Op-based CRDT毕竟State-baed CRDT需要将全量数据进行传输每次都要完整传输状态来完成同步让它比较难变成通用的解决方案。因此与OT算法一样我们依然需要Operation在富文本领域最经典的Operation有quill的delta模型通过retain、insert、delete三个操作完成整篇文档的描述与操作还有slate的JSON模型通过insert_text、split_node、remove_text等等操作来完成整篇文档的描述与操作。假如此时是OT的话接下来我们就要聊到变换Transformation了但是使用CRDT算法的情况下我们的关注点变了我们需要做的是关注于如何将我们现在的数据结构转换为CRDT框架的数据结构比如通过框架提供的Array、Map、Text等类型构建我们自己的JSON数据并且我们的Op也需要映射到对框架提供的数据结构进行的操作这样框架便可以帮我们进行协同当框架完成协同之后把框架的数据结构的改变返回此时我们需要再将这部分改变映射到我们自己的Op然后我们只需要在本地应用这些远程同步并在本地转换的Op就可以做到协同了。
上边这个数据转换听起来是不是有点耳熟在前边初探富文本之OT协同实例中我们介绍了json0我们也提到了一个可行的操作我们让变换Transformation这部分让json0去做我们需要关注的是从我们自己定义的数据结构转换到json0在json0进行变换操作之后我们同样地将Op转换后应用到我们本地的数据就好。虽然原理是完全不同的但是我们在已有成熟框架的情况下似乎并不需要关注这点我们更侧重于使用实际上在使用起来是很像的。此时假设我们有一个自研的思维导图功能需要实现协同而保存的数据结构都是自定义的没有直接可以调用的实现方案我们就需要进行转换适配那么如果使用OT的话并且借助json0做变换那么我们需要做的是把Op转换为json0的Op发送的数据也将会是这个json0的Op那么如果直接使用CRDT的话我们更像是通过框架定义的数据结构将Op应用到数据结构上发送的数据是框架定义的数据类似于将Op应用到数据结构上了其他的操作都由框架给予完整的支持了。实际上通过框架提供的例子后接入CRDT协同就主要是理解并且实现的问题了这样就有一个大体的实现方向了而不是毫无头绪不知道应该从哪里开始做协同。另外还是那个宗旨合适的才是最好的要考虑到实现的成本问题没有必要硬套数据结构的实现OT有OT的优点CRDT有CRDT的优点CRDT这类方法相比OT还比较年轻还是在不断发展过程中的实际上有些问题例如内存占用、速度等问题最近几年才被比较好的解决ShareDB作者在关注CRDT不断发展的过程中也说了CRDTs are the future。此外从技术上讲CRDT类型是OT类型的子集也就是说CRDT实际上是不需要转换函数的OT类型因此任何可以处理这些OT类型的东西也应该能够使用CRDT。
或许上边的一些概念可能一时间让人难以理解所以下面的Counter与Quill两个实例就是介绍了如何使用yjs实现协同究竟如何通过数据结构完成协同的接入工作当然具体的API调用还是还是需要看yjs的文档本文只涉及到最基本的协同操作所有的代码都在https://github.com/WindrunnerMax/Collab中注意这是个pnpm的workspace monorepo项目要注意使用pnpm安装依赖。
Counter
首先我们运行一个基础的协同实例Counter实现的主要功能是在多个客户端可以1的情况下我们可以维护同一份计数器总数该实例的地址是https://github.com/WindrunnerMax/Collab/tree/master/packages/crdt-counter首先简单看一下目录结构(tree --dirsfirst -I node_modules):
crdt-counter
├── public
│ ├── favicon.ico
│ └── index.html
├── server
│ └── index.ts
├── src
│ ├── client.ts
│ ├── counter.tsx
│ └── index.tsx
├── babel.config.js
├── package.json
├── rollup.config.js
├── rollup.server.js
└── tsconfig.json先简略说明下各个文件夹和文件的作用public存储了静态资源文件在客户端打包时将会把内容移动到build文件夹server文件夹中存储了CRDT服务端的实现在运行时同样会编译为js文件放置于build文件夹下src文件夹是客户端的代码主要是视图与CRDT客户端的实现babel.config.js是babel的配置信息rollup.config.js是打包客户端的配置文件rollup.server.js是打包服务端的配置文件package.json与tsconfig.json大家都懂就不赘述了。
在前边CRDT协同算法实现一文中我们经常提到的就是无需中央服务器的分布式协同那么在这个例子中我们就来实现一个peer-to-peer的实例。yjs提供了一个y-webrtc的信令服务器甚至还有公共的信令服务器可以用当然可能因为网络的关系这个公共的信令服务器在国内不是很适用。在继续完成协同之前我们还需要了解一下WebRTC以及信令的相关概念。
WebRTC是一种实时通信技术重点在于可以点对点即P2P通信其允许浏览器和应用程序直接在互联网上传输音频、视频和数据流无需使用中间服务器进行中转。WebRTC利用浏览器内置的标准API和协议来提供这些功能并且支持多种编解码器和平台WebRTC可以用于开发各种实时通信应用例如在线会议、远程协作、实时广播、在线游戏和IoT应用等。但是在多级NAT网络环境下P2P连接可能会受到限制简单来说就是一台设备无法直接发现另一台设备自然也就没有办法进行P2P通信这时需要使用特殊的技术来绕过NAT并建立P2P连接。
NAT Network Address Translation网络地址转换是一种在IP网络中广泛使用的技术主要是将一个IP地址转换为另一个IP地址具体来说其工作原理是将一个私有IP地址(如在家庭网络或企业内部网络中使用的地址)映射到一个公共IP地址(如互联网上的IP地址)。当一个设备从私有网络向公共网络发送数据包时NAT设备会将源IP地址从私有地址转换为公共地址并且在返回数据包时将目标IP地址从公共地址转换为私有地址。NAT可以通过多种方式实现例如静态NAT、动态NAT和端口地址转换PAT等静态NAT将一个私有IP地址映射到一个公共IP地址而动态NAT则动态地为每个私有地址分配一个公共地址PAT是一种特殊的动态NAT在将私有IP地址转换为公共IP地址时还会将源端口号或目标端口号转换为不同的端口号以支持多个设备使用同一个公共IP地址。NAT最初是为了解决IPv4地址空间的短缺而设计的后来也为提高网络安全性并简化网络管理提供了基础。
在互联网上大多数设备都是通过路由器或防火墙连接到网络的这些设备通常使用网络地址转换NAT将内部IP地址映射到一个公共的IP地址上这个公共IP地址可以被其他设备用来访问但是这些设备内部的IP地址是隐藏的其他的设备不能直接通过它们的内部IP地址建立P2P连接。因此直接进行P2P连接可能会受到网络地址转换NAT的限制导致连接无法建立。为了解决这个问题需要使用一些技术来绕过NAT并建立P2P连接。另外P2P连接也需要一些控制和协调机制以确保连接的可靠性和安全性。
信令可以用来解决多级NAT环境下的P2P连接问题当两个设备尝试建立P2P连接时可以使用信令服务器来交换网络信息例如IP地址、端口和协议类型等以便设备之间可以彼此发现并建立连接。当然信令服务器并不是绕过NAT的唯一解决方案STUN、TURN和ICE等技术也可以帮助解决这个问题。信令服务器的主要作用是协调不同设备之间的连接以确保设备可以正确地发现和通信。在实际应用中通常需要同时使用多种技术和工具来解决多级NAT环境下的P2P连接问题。
那么回到WebRTC我们即使是使用了P2P的技术但是不可避免的需要一个信令服务器来交换WebRTC会话描述和控制信息。当然这些信息不包括实际通信的数据流本身而是用于描述和控制这些流的方式和参数这些数据流本身是通过对等连接在两个浏览器之间直接传输的。主要数据流的通信不经过中央服务器这就使得WebRTC有着低延迟和高带宽等优点但是同样的因为每个对等点相互连接不适合单个文档上的大量协作者。
接下来我们要进行数据结构的设计目前在yjs中是没有Y.Number这个数据结构的也就是说yjs没有自增自减的操作这点就与前边OT实例不一样了所以在这里我们需要设计数据结构。网络是不可靠的我们不能够在本地模拟1的操作就是说本地先取得值然后进行1操作之后再把值推到其他的客户端上这样的设计虽然在本地测试应该是可行的但是由于网络不可靠我们不能保证本地取值的时候获得的是最新的值所以这个方案是不可靠的。
那么我们思考几种方案来实现这一点有一种可行的方案是类似于我们之前介绍的CRDT数据结构我们可以构造一个集合Y.Array当我们点1的时候就向集合中push一个新的值这样再取和的时候直接取集合长度即可。
Y.Array: [] 1 [1] 1 [1, 1] ...
Counter: [1, 1].size N另一种方案是使用Y.Map来完成当用户加入我们的P2P组的时候我们通过其身份信息为其分配一个id然后这个id只记录与自增自己的值也就是说当某个客户端点击1的时候操作的只有其id对应的数而不能影响组网内其他的用户的值。
Y.Map: {} 1 {id: 1} 1 {id: 2} ...
Counter: Object.values({id: 2}).reduce((a, b) a b) N在这里我们使用的是Y.Map的方案毕竟如果是Y.Array的话占用资源会是比较大的当然因为实例中并没有身份信息每次进入的时候都是会随机分配id的当然这不会影响到我们的Counter。此外还有比较重要的一点是因为我们是直接进行P2P通信的当所有的设备都离线的时候由于没有设计实际的数据存储机制所以数据会丢失这点也是需要注意的。
接下来我们看看代码的实现首先我们来看看服务端这里主要实现是调用了一下y-webrtc-signaling来启动一个信令服务器这是y-webrtc给予的开箱即用的功能也可以基于这些内容进行改写不过因为是信令服务器除非有着很高的稳定性、定制化等要求否则直接当作开箱即用的信令服务器就好。后边主要是使用了express启动了一个静态资源服务器因为直接在浏览器打开文件的file协议有很多的安全限制所以需要一个HTTP Server。
import { exec } from child_process;
import express from express;// https://github.com/yjs/y-webrtc/blob/master/bin/server.js
exec(PORT3001 npx y-webrtc-signaling, (err, stdout, stderr) { // 调用y-webrtc-signalingconsole.log(stdout, stderr);
});const app express(); // 实例化express
app.use(express.static(build)); // 客户端打包过后的静态资源路径
app.listen(3000);
console.log(Listening on http://localhost:3000);在客户端方面主要是定义了一个定义了一个共用的链接通过id来加入我们的P2P组并且还有密码的保护这里需要链接的信令服务器也就是上边启动的y-webrtc的3001端口的信令服务。之后我们通过observe定义的Y.Map数据结构的变化来执行回调在这里实际上就是将回调过后的整个Map数据传回回调函数然后在视图层进行Counter的计算这里还有一个transaction.origin判断是为了防止我们本地的调用触发回调。最后我们定义了一个increase函数在这里我们通过transact作为事务来执行set操作因为我们之前的设计只会处理我们当前客户端对应的id的那个值本地的值是可信的直接自增即可transact最后一个参数也就是上边提到了的transaction.origin可以用来判断事件的来源。
import { Doc, Map as YMap } from yjs;
import { WebrtcProvider } from y-webrtc;const getRandomId () Math.floor(Math.random() * 10000).toString();
export type ClientCallback (record: Recordstring, number) void;class Connection {private doc: Doc;private map: YMapnumber;public id: string getRandomId(); // 当前客户端生成的唯一idpublic counter 0; // 当前客户端的初始值constructor() {const doc new Doc();new WebrtcProvider(crdt-example, doc, { // P2P组名称 // Y.Doc实例password: room-password, // P2P组密码signaling: [ws://localhost:3001], // 信令服务器});const yMapDoc doc.getMapnumber(counter); // 获取数据结构this.doc doc;this.map yMapDoc;}bind(cb: ClientCallback) {this.map.observe(event { // 监听数据结构变化 // 如果是多层嵌套需要observeDeepif (event.transaction.origin ! this) { // 防止本地修改时触发const record [...this.map.entries()].reduce( // 获取Y.Map定义中的所有数据(cur, [key, value]) ({ ...cur, [key]: value }),{} as Recordstring, number);cb(record); // 执行回调}});}public increase() {this.doc.transact(() { // 事务this.map.set(this.id, this.counter); // 自增本地id对应的值}, this); // 来源}
}export default new Connection();Quill
在运行富文本的实例Quill之前我们不妨先来简单讨论一下是如何在富文本上应用的CRDT在前文CRDT协同算法中主要讨论的是分布式与CRDT的原理并没有涉及具体的富文本该如何设计数据结构那么在这里我们简单讨论下yjs在富文本上应用CRDT的设计。看之前描述那一节的时候我们可能会产生一些有趣的想法或许我们可以这么来做可以通过底层来实现OT之后在上层封装一层数据结构供外部使用的方式从而对外看起来像是CRDT。当然原理上是不会这么做的因为这样失去了拥抱CRDT的意义可能会有部分借鉴实现的思路但是不会直接这么做的。
首先我们可以回忆一下CRDT在集合这个数据结构上的设计我们主要考虑到了集合的添加和删除如何完整的保证交换律、结合律、幂等律那么现在在富文本的实现上我们不仅需要考虑到插入和删除需要考虑到顺序的问题并且我们还需要保证CCI即最终一致性、因果一致性、意图一致性当然还需要考虑到Undo/Redo、光标同步等相关的问题。
那么我们首先来看看如何保证插入数据的顺序对于OT而言是通过索引得知用户要操作的位置并且通过变换来确保最终一致性那么CRDT是不需要这么做的上边也提到过完全靠OT的话可能就失去了拥抱CRDT的意义那么如何确保要插入的位置正确呢CRDT不靠索引的话就需要靠数据结构来完成这点我们可以通过相对位置来完成例如我们目前有AB字符串此时在中间插入了C字符那么这个字符就需要被标记为在A之后在B之前那么很显然我们需要为每个字符都分配唯一的id否则我们是无法做到这一点的当然这块实际上还有优化空间在这里就先不谈这点那么由此我们通过相对位置保证了插入的顺序。
接下来我们再看看删除的问题在前文的Observed-Remove Set集合数据结构中我们是可以真正的进行删除操作的而在这里由于我们是通过相对位置来实现完整的顺序所以实际上我们是不能够真正地将我们标记的Item进行删除的Item可以理解为插入的字符也就是所谓的软删除。举个例子目前我们有AB字符串其中一个客户端删除了B另一个客户端同时在A与B之间增加了C那么此时这两个Op同步到了第三个客户端那么假如增加了C这个操作先到并且执行了再删除了B那么没有问题可是假设我们先删除了B再增加了C那么这个C我们就不能够找到他要插入的位置因为B已经被删除了我们是要在A与B之间去插入C的那么这样这个操作就无法执行下去了由此这样其实就导致了操作不满足交换律那么这就不能真的作为CRDT的数据结构设计了。其实我们可能会想为什么需要两个位置来保证插入的字符位置完全可以用B的左侧或者A的右侧来完成实际上思考一下这是同样的问题多个客户端来操作的话假如一个删除了A另一个删除了B那么便无论如何也找不到插入的位置了这是不满足交换律和结合律的操作就不能作为CRDT的实现了。因此为了冲突的解决yjs并没有真正的删除Item而是采用了标记的形式即删除的Item会被加入一个deleted标记那么不删除会造成一个明显的问题空间的占用会无限增长因此yjs引入了墓碑机制当确认了内容不会再被干涉之后将对象的内容替换为空的墓碑对象。
上边也提到了冲突的问题很明显在设计上是存在冲突的问题的因为CRDT实际上并不是完全为了协同编辑的场景而专门设计的其主要是为了解决分布式场景中的一致性问题所以在应用到协同编辑的场景中不可避免地会出现冲突的问题实际上这个冲突主要是为了集合顺序的引入而导致的要是不关心顺序那么自然就不会出现冲突问题了。那么为了使数据能够满足三律在前文我们引入了一个偏序的概念但是在协同编辑设计中使用偏序不能够保证数据同步的正确性和一致性因为其无法处理一些关键的冲突情况举一个简单的例子假设我们此时有AB字符串如果一个客户端在AB中加入了C另一个加入了D那么究竟谁在前呢所以我们需要引入全序的方法即任意两个Item都是可以比较的。那么很明显的如果我们为每个Item附加上时间戳的元信息便可以引入全序了但是实际上由于不同的客户端可能具有不同的时钟偏差网络延迟和时钟不同步等问题也可能导致时间戳不可靠。那么相比之下逻辑时钟或者逻辑时间戳可以使用更简单和可靠的方式来维护事件的顺序:
每次发生本地事件时clock clocl 1。每次接收到远程事件时clock max(clock, remoteClock) 1。
看起来依旧会有发生冲突的可能那么我们可以再引入一个客户端的唯一id也就是clientID。这种机制看似简单但实际上使我们获得了数学上性质良好的全序结构这意味着我们可以在任意两个Item之间对比获得逻辑上的先后关系这对保证CRDT算法的正确性相当重要。此外通过这种方式我们也可以保证因果一致性假如此时我们有两个操作a、b如果有因果关系那么a.clock一定大于b.clock这样的得到的顺序一定是满足因果关系的当然如果没有因果关系就可以取任意的顺序执行了。举个例子我们有三个客户端A、B、C以及字符串SEA在SE中间添加了a字符此时这个操作同步到了BB将a字符给删除了假设此时C先收到了B的删除操作因为这个操作依赖于A的操作需要进行因果依赖关系的检查这个操作的逻辑时钟和位移大于C本地文档中已经应用的操作的逻辑时钟和位移需要等待先前的操作被应用后再应用这个操作当然这并不是在yjs中的实现因为yjs不会存在真正的删除操作并且在删除操作的时候实际上并不会导致时钟的增加只是增加一个标记上边这个例子其实可以换个说法两个相同的插入操作因为我们是相对位置所以后一个插入操作是依赖前一个插入操作的因此就需要因果检查其实这也是件有意思的事情当收到在同一个位置编辑的不同客户端操作时候如果时钟相同就是冲突操作不相同就是因果关系。
那么由此我们通过CRDT数据结构与算法设计解决了最终一致性和因果一致性对于意图一致性的问题当不存在冲突的时候我们是能够保证意图的即插入文档的Item的顺序在冲突的时候我们实际上会比较clientID决定究竟谁在前在后其实实际上无论谁在前还是在后都可以认为是一种乌龙我们在冲突的时候只保证最终一致性对于意图一致性则需要做额外的设计才可以实现在这里就不做过多探讨了。实际上yjs还有大量的设计与优化操作以及基于YATA的冲突解决算法等比如通过双向链表来保存文档结构顺序通过Map为每个客户端保存的扁平的 Item数组优化本地插入的速度而设计的缓存机制(链表的查找O(N)与跟随光标的位置缓存)倾向于State-based的删除Undo/Redo光标同步压缩数据网络传输等等还是很值得研究的。
我们再回到富文本的实例Quill中实现的主要功能是在quill富文本编辑器中接入协同并支持编辑光标的同步该实例的地址是https://github.com/WindrunnerMax/Collab/tree/master/packages/crdt-quill首先简单看一下目录结构(tree --dirsfirst -I node_modules):
crdt-quill
├── public
│ └── favicon.ico
├── server
│ └── index.ts
├── src
│ ├── client.ts
│ ├── index.css
│ ├── index.ts
│ └── quill.ts
├── package.json
├── rollup.config.js
├── rollup.server.js
└── tsconfig.json依旧简略说明下各个文件夹和文件的作用public存储了静态资源文件在客户端打包时将会把内容移动到build文件夹server文件夹中存储了CRDT服务端的实现在运行时同样会编译为js文件放置于build文件夹下src文件夹是客户端的代码主要是视图与CRDT客户端的实现rollup.config.js是打包客户端的配置文件rollup.server.js是打包服务端的配置文件package.json与tsconfig.json大家都懂就不赘述了。
quill的数据结构并不是JSON而是DeltaDelta是通过retain、insert、delete三个操作完成整篇文档的描述与操作我们试想一下描述一段字符串的操作需要什么是不是通过这三种操作就能够完全覆盖了所以通过Delta来描述文本增删改是完全可行的而且12年quill的开源可以说是富文本发展的一个里程碑于是yjs是直接原生支持Delta数据结构的。
接下来我们看看来看看服务端这里主要实现是调用了一下y-websocket来启动一个websocket服务器这是y-websocket给予的开箱即用的功能也可以基于这些内容进行改写yjs还提供了y-mongodb-provider等服务端服务可以使用。后边主要是使用了express启动了一个静态资源服务器因为直接在浏览器打开文件的file协议有很多的安全限制所以需要一个HTTP Server。
import { exec } from child_process;
import express from express;// https://github.com/yjs/y-websocket/blob/master/bin/server.js
exec(PORT3001 npx y-websocket, (err, stdout, stderr) { // 调用y-websocketconsole.log(stdout, stderr);
});const app express(); // 实例化express
app.use(express.static(build)); // 客户端打包过后的静态资源路径
app.use(express.static(node_modules/quill/dist)); // quill静态资源路径
app.listen(3000);
console.log(Listening on http://localhost:3000);在客户端方面主要是定义了一个定义了一个共用的链接通过crdt-quill作为RoomName进入组这里需要链接的websocket服务器也就是上边启动的y-websocket的3001端口的服务。之后我们定义了顶层的数据结构为YText数据结构的变化来执行回调并且将一些信息暴露了出去doc就是这需要使用的yjs实例type是我们定义的顶层数据结构awareness意为感知只要是用来完成实时数据同步在这里是用来同步光标选区。
import { Doc, Text as YText } from yjs;
import { WebsocketProvider } from y-websocket;class Connection {public doc: Doc; // yjs实例public type: YText; // 顶层数据结构private connection: WebsocketProvider; // WebSocket链接public awareness: WebsocketProvider[awareness]; // 数据实时同步constructor() {const doc new Doc(); // 实例化const provider new WebsocketProvider(ws://localhost:3001, crdt-quill, doc); // 链接WebSocket服务器provider.on(status, (e: { status: string }) {console.log(WebSocket, e.status); // 链接状态});this.doc doc; // yjs实例this.type doc.getText(quill); // 获取顶层数据结构this.connection provider; // 链接this.awareness provider.awareness; // 数据实时同步}reconnect() {this.connection.connect(); // 重连}disconnect() {this.connection.disconnect(); // 断线}
}export default new Connection();在客户端主要分为了两部分分别是实例化quill的实例以及quill与yjs客户端通信的实现。在quill的实现中主要是将quill实例化注册光标的插件随机生成id的方法通过id获取随机颜色的方法以及光标同步的位置转换。在quill与yjs客户端通信的实现中主要是完成了对于quill与doc的事件监听主要是远程数据变更的回调本地数据变化的回调光标同步事件感知的回调。
import Quill from quill;
import QuillCursors from quill-cursors;
import tinyColor from tinycolor2;
import { Awareness } from y-protocols/awareness.js;
import {Doc,Text as YText,createAbsolutePositionFromRelativePosition,createRelativePositionFromJSON,
} from yjs;
export type { Sources } from quill;Quill.register(modules/cursors, QuillCursors); // 注册光标插件export default new Quill(#editor, { // 实例化quilltheme: snow,modules: { cursors: true },
});const COLOR_MAP: Recordstring, string {}; // id colorexport const getRandomId () Math.floor(Math.random() * 10000).toString(); // 随机生成用户idexport const getCursorColor (id: string) { // 根据id获取颜色COLOR_MAP[id] COLOR_MAP[id] || tinyColor.random().toHexString();return COLOR_MAP[id];
};export const updateCursor (cursor: QuillCursors,state: Awareness[states] extends Mapnumber, infer I ? I : never,clientId: number,doc: Doc,type: YText
) {try {// 从Awareness中取得状态if (state state.cursor clientId ! doc.clientID) {const user state.user || {};const color user.color || #aaa;const name user.name || User: ${clientId};// 根据clientId创建光标cursor.createCursor(clientId.toString(), name, color);// 相对位置转换为绝对位置 // 选区为focus --- anchorconst focus createAbsolutePositionFromRelativePosition(createRelativePositionFromJSON(state.cursor.focus),doc);const anchor createAbsolutePositionFromRelativePosition(createRelativePositionFromJSON(state.cursor.anchor),doc);if (focus anchor focus.type type) {// 移动光标位置cursor.moveCursor(clientId.toString(), {index: focus.index,length: anchor.index - focus.index,});}} else {// 根据clientId移除光标cursor.removeCursor(clientId.toString());}} catch (err) {console.error(err);}
};import ./index.css;
import quill, { getRandomId, updateCursor, Sources, getCursorColor } from ./quill;
import client from ./client;
import Delta from quill-delta;
import QuillCursors from quill-cursors;
import { compareRelativePositions, createRelativePositionFromTypeIndex } from yjs;const userId getRandomId(); // 本地客户端的id 或者使用awareness.clientID
const doc client.doc; // yjs实例
const type client.type; // 顶层类型
const cursors quill.getModule(cursors) as QuillCursors; // quill光标模块
const awareness client.awareness; // 实时通信感知模块// 设置当前客户端的信息 State的数据结构类似于Recordstring, unknown
awareness.setLocalStateField(user, {name: User: userId,color: getCursorColor(userId),
});// 页面显示的用户信息
const userNode document.getElementById(user) as HTMLInputElement;
userNode (userNode.value User: userId);type.observe(event {// 来源信息 // 本地UpdateContents不应该再触发ApplyDeltaif (event.transaction.origin ! userId) {const delta event.delta;quill.updateContents(new Delta(delta), api); // 应用远程数据, 来源}
});quill.on(editor-change, (_: string, delta: Delta, state: Delta, origin: Sources) {if (delta delta.ops) {// 来源信息 // 本地ApplyDelta不应该再触发UpdateContentsif (origin ! api) {doc.transact(() {type.applyDelta(delta.ops); // 应用Ops到yjs}, userId); // 来源}}const sel quill.getSelection(); // 选区const aw awareness.getLocalState(); // 实时通信状态数据if (sel null) { // 失去焦点if (awareness.getLocalState() ! null) {awareness.setLocalStateField(cursor, null); // 清除选区状态}} else {// 卷对位置转换为相对位置 // 选区为focus --- anchorconst focus createRelativePositionFromTypeIndex(type, sel.index);const anchor createRelativePositionFromTypeIndex(type, sel.index sel.length);if (!aw ||!aw.cursor ||!compareRelativePositions(focus, aw.cursor.focus) ||!compareRelativePositions(anchor, aw.cursor.anchor)) {// 选区位置发生变化 设置位置信息awareness.setLocalStateField(cursor, { focus, anchor });}}// 更新所有光标状态到本地awareness.getStates().forEach((aw, clientId) {updateCursor(cursors, aw, clientId, doc, type);});
});// 初始化更新所有远程光标状态到本地
awareness.getStates().forEach((state, clientId) {updateCursor(cursors, state, clientId, doc, type);
});
// 监听远程状态变化的回调
awareness.on(change,({ added, removed, updated }: { added: number[]; removed: number[]; updated: number[] }) {const states awareness.getStates();added.forEach(id {const state states.get(id);state updateCursor(cursors, state, id, doc, type);});updated.forEach(id {const state states.get(id);state updateCursor(cursors, state, id, doc, type);});removed.forEach(id {cursors.removeCursor(id.toString());});}
);每日一题
https://github.com/WindrunnerMax/EveryDay参考
https://docs.yjs.dev/
https://github.com/yjs/yjs
https://github.com/automerge/automerge
https://zhuanlan.zhihu.com/p/425265438
https://zhuanlan.zhihu.com/p/452980520
https://josephg.com/blog/crdts-go-brrr/
https://www.npmjs.com/package/quill-delta
https://josephg.com/blog/crdts-are-the-future/
https://github.com/yjs/yjs/blob/main/INTERNALS.md
https://cloud.tencent.com/developer/article/2081651