Skip to content

hellowac/yjs-zh-cn

 
 

Repository files navigation

Yjs

English

一个具有强大共享数据抽象的 CRDT 框架

Yjs 是一个 CRDT 实现,它将其内部 数据结构暴露为 共享类型。共享类型是常见的数据类型,如 MapArray,具有超能力:更改会自动分发给其他 对等方,并在没有合并冲突的情况下合并。

Yjs 是 网络无关的(p2p!),支持许多现有的 富文本 编辑器离线编辑版本快照撤销/重做共享光标。它可以支持无限数量的用户,并且非常适合处理大型文档。

👷‍♀️ 如果您正在寻找专业支持,请 考虑通过 GitHub Sponsors 支持该项目,签订“支持合同”。我会更快处理您的问题, 我们可以在定期视频会议中讨论问题和疑问。 否则,您可以在我们的社区 讨论论坛 找到帮助。

赞助

请在财务上为项目贡献——特别是如果您的公司依赖 Yjs。成为赞助商

专业支持

  • 与维护者的支持合同 - 通过对开源 Yjs 项目进行财务贡献,您可以直接从作者那里获得 专业支持。这包括每周视频通话的机会,以讨论您的具体挑战。
  • Synergy Codes - 专注于 为视觉应用开发实时协作编辑解决方案,Synergy Codes 专注于 互动图表、复杂图形、图表和各种数据可视化类型。他们的专业知识使开发人员能够构建 引人入胜和互动的视觉体验,利用 Yjs 的强大功能。请查看 他们在 视觉协作展示 中的工作。

谁在使用 Yjs

  • AFFiNE 本地优先、隐私优先的开源知识库。 🌟
  • Huly - 开源的一体化项目管理平台 🌟
  • Cargo 设计师和艺术家的网站构建工具 🌟
  • Gitbook 技术团队的知识管理 🌟
  • Evernote 笔记应用 🌟
  • Lessonspace 企业级虚拟教室和在线培训平台 🌟
  • Ellipsus - 讲故事等的协作写作应用。支持版本控制、变更归属和“责任归属”。为整个出版过程(包括销售)提供解决方案 ⭐
  • Dynaboard 协作构建 Web 应用。 ⭐
  • Relm 一个协作游戏世界,用于团队合作和社区。 ⭐
  • Room.sh 一款会议应用,集成协作绘图、编辑和编码工具。 ⭐
  • Nimbus Note 由 Nimbus Web 设计的笔记应用。 ⭐
  • Pluxbox RadioManager 一款基于 Web 的应用,用于协作组织广播。 ⭐
  • modyfi - Modyfi 是为多学科设计师构建的设计平台。设计、生成、动画等——无需在应用之间切换。 ⭐
  • Sana 具有 Yjs 支持的协作文本编辑的学习平台。
  • Serenity Notes 端到端加密的协作笔记应用。
  • PRSM 协作思维导图和系统可视化。 (来源)
  • Alldone 下一代项目管理和协作平台。
  • Living Spec 产品团队协作的现代方式。
  • Slidebeamer 演示应用。
  • BlockSurvey 端到端加密的表单/调查工具。
  • Skiff 私人去中心化工作空间。
  • JupyterLab 协作计算笔记本。
  • JupyterCad JupyterLab 的扩展,支持 3D FreeCAD 模型的协作编辑。
  • Hyperquery 用于共享分析、文档、电子表格和仪表板的协作数据工作空间。
  • Nosgestesclimat 法国碳足迹计算器具有基于 Yjs 的小组 P2P 模式。
  • oorja.io 可扩展协作应用的在线会议空间,端到端加密。
  • LegendKeeper 协作的活动策划和世界构建应用,适用于桌面 RPG。
  • IllumiDesk 使用 AI 构建课程和内容。
  • btw 开源 Medium 替代品。
  • AWS SageMaker 构建机器学习模型的工具。
  • linear 精简问题、项目和产品路线图。
  • btw - 个人网站构建器。
  • AWS SageMaker - 机器学习服务。
  • Arkiter - 实时面试软件。
  • Appflowy - 他们使用 Yrs。
  • Multi.app - 多人应用共享:在共享应用中指点、绘制和编辑,就像它们在您的计算机上一样。它们正在使用 Yrs。
  • AppMaster 无代码平台,用于创建可生产的应用程序并生成源代码。
  • Synthesia - 协作视频编辑器。
  • thinkdeli - 一款由 AI 驱动的快速简单笔记应用。
  • ourboard - 一款协作白板应用。
  • Ellie.ai - 数据产品设计与协作。
  • GoPeer - 协作辅导。
  • screen.garden - PKM 应用的协作后端。
  • NextCloud - 内容协作平台。
  • keystatic - 基于 git 的 CMS。
  • QDAcity - 协作定性数据分析平台。
  • Kanbert - 项目管理软件。
  • Eclipse Theia - 一款在浏览器中运行的云端和桌面 IDE。
  • ScienHub - 浏览器中的协作 LaTeX 编辑器。

目录

概述

此存储库包含一组可观察更改并可并发操作的共享类型。网络功能和双向绑定实现于单独的模块中。

绑定

名称 光标 绑定 演示
ProseMirror                                                   y-prosemirror 演示
Quill y-quill 演示
CodeMirror y-codemirror 演示
Monaco y-monaco 演示
Slate slate-yjs 演示
BlockSuite (native) 演示
Lexical (native) 演示
valtio valtio-yjs 演示
immer immer-yjs 演示
React react-yjs 演示
React / Vue / Svelte / MobX SyncedStore 演示
mobx-keystone mobx-keystone-yjs 演示

提供者(Providers)

设置客户端之间的通信、管理意识信息以及为离线使用存储共享数据相当麻烦。提供者为您管理所有这些,是您协作应用的完美起点。

这个提供者列表并不完整。请提交 PR 将您的提供者添加到此列表中!

连接提供者

y-websocket
一个包含简单 WebSocket 后端和连接该后端的 WebSocket 客户端的模块。 y-redisy-sweetypy-websocketHocuspocus(见下文)是 y-websocket 的替代后端。
y-webrtc
通过 WebRTC 在对等方之间传播文档更新。对等方通过信令服务器交换信令数据。公共可用的信令服务器可以使用。通过提供共享密钥,可以加密信令服务器上的通信,从而保持连接信息和共享文档的私密性。
@liveblocks/yjs
Liveblocks Yjs 提供完全托管的 WebSocket 基础设施和持久化数据存储,用于 Yjs 文档。无需配置或维护。它还具有 Yjs webhook 事件、用于读取和更新 Yjs 文档的 REST API,以及浏览器 DevTools 扩展。
y-sweet
一个独立的 yjs 服务器,具有 S3 或文件系统的持久性。他们还提供云服务
Hocuspocus
一个独立的可扩展 yjs 服务器,具有 sqlite 持久性、webhooks、身份验证等功能。
PartyKit
用于构建多人应用的云服务。
y-libp2p
使用 libp2p 通过 GossipSub 传播更新。还包括一个对等同步机制,以便追赶错过的更新。
y-dat
[进行中] 使用 multifeed 高效地写入文档更新到 dat 网络。每个客户端都有一个仅附加的 CRDT 本地更新日志(hypercore)。Multifeed 管理和同步 hypercores,而 y-dat 监听更改并将其应用于 Yjs 文档。
Matrix-CRDT
通过使用 MatrixProvider,将 Matrix 作为 Yjs 的现成后端。使用 Matrix 作为 Yjs 更新的传输和存储,这样您可以专注于构建客户端应用程序,而 Matrix 可以提供强大的功能,如身份验证、授权、联邦、托管(自托管或 SaaS)甚至端到端加密(E2EE)。
yrb-actioncable
Yjs 客户端的 ActionCable 伴侣。还有一个适合的 redis 扩展
ypy-websocket
使用 Python 编写的 WebSocket 后端。
Tinybase
用于本地优先应用的反应式数据存储。他们支持多个 CRDT 和不同的网络技术。
y-webxdc
用于在 webxdc 聊天应用 中共享数据的提供者。
secsync
一种通过中央服务转发端到端加密 CRDT 的架构。

持久性提供者

y-indexeddb
高效地将文档更新持久化到浏览器的 indexeddb 数据库。文档立即可用,只有差异需要通过网络提供者同步。
y-mongodb-provider
为使用 MongoDB 的服务器添加持久存储。可以与 y-websocket 提供者一起使用。
@toeverything/y-indexeddb
类似于 y-indexeddb,但支持子文档,并且完全支持 TypeScript。
y-fire
基于 Firestore 的 Yjs 数据库和连接提供者。
y-op-sqlite
使用 op-sqlite,在 React Native 应用中持久化 YJS 更新,这是 React Native 的最快 SQLite 库。
y-postgresql
为使用 PostgreSQL 的 Web 服务器提供持久存储,且与 y-websocket 兼容。

工具

端口

有多个 Yjs 兼容的其他编程语言的移植版本。

  • y-octo - 由 AFFiNE 提供的 Rust 实现
  • y-crdt - Rust 实现,具有多个语言绑定
  • ycs - .Net 兼容的 C# 实现。

入门

使用您喜欢的包管理器安装 Yjs 和提供者:

npm i yjs y-websocket

启动 y-websocket 服务器:

PORT=1234 node ./node_modules/y-websocket/bin/server.cjs

示例:观察类型

import * as Y from 'yjs';

const doc = new Y.Doc();
const yarray = doc.getArray('my-array')
yarray.observe(event => {
  console.log('yarray 被修改了')
})
// 每当本地或远程客户端修改 yarray 时,观察者都会被调用
yarray.insert(0, ['val']) // => "yarray 被修改了"

示例:嵌套类型

请记住,共享类型只是普通的数据类型。唯一的限制是共享类型在共享文档中必须只存在一次。

const ymap = doc.getMap('map')
const foodArray = new Y.Array()
foodArray.insert(0, ['apple', 'banana'])
ymap.set('food', foodArray)
ymap.get('food') === foodArray // => true
ymap.set('fruit', foodArray) // => 错误!foodArray 已经被定义

现在你了解了如何在共享文档上定义类型。接下来你可以跳转到 演示仓库 或继续阅读 API 文档。

示例:使用和组合提供者

任何 Yjs 提供者都可以相互组合。因此你可以通过不同的网络技术同步数据。

在大多数情况下,你希望将网络提供者(如 y-websocket 或 y-webrtc)与持久化提供者(浏览器中的 y-indexeddb)结合使用。持久化允许你更快地加载文档,并在离线时持久化创建的数据。

为了演示,我们将两个不同的网络提供者与一个持久化提供者结合使用。

import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'

const ydoc = new Y.Doc()

// 这允许你立即获取(缓存的)文档数据
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc)
indexeddbProvider.whenSynced.then(() => {
  console.log('从索引数据库加载的数据')
})

// 使用 y-webrtc 提供者同步客户端。
const webrtcProvider = new WebrtcProvider('count-demo', ydoc)

// 使用 y-websocket 提供者同步客户端
const websocketProvider = new WebsocketProvider(
  'wss://demos.yjs.dev', 'count-demo', ydoc
)

// 生成和计算总和的数字数组
const yarray = ydoc.getArray('count')

// 观察总和的变化
yarray.observe(event => {
  // 当数据变化时打印更新
  console.log('新总和: ' + yarray.toArray().reduce((a,b) => a + b))
})

// 将 1 加到总和中
yarray.push([1]) // => "新总和: 1"

API

import * as Y from 'yjs'

共享类型

Y.Array

一种可共享的类数组类型,支持在任何位置高效地插入/删除元素。内部使用链表的数组,在必要时进行拆分。

const yarray = new Y.Array()
Y.Array.from(Array<object|boolean|Array|string|number|null|Uint8Array|Y.Type>): Y.Array
基于现有内容创建 Y.Array 的替代工厂函数。
parent:Y.AbstractType|null
insert(index:number, content:Array<object|boolean|Array|string|number|null|Uint8Array|Y.Type>)
index 插入内容。注意,内容是元素的数组。 即 array.insert(0, [1]) 会在位置 0 插入 1。
push(Array<Object|boolean|Array|string|number|null|Uint8Array|Y.Type>)
unshift(Array<Object|boolean|Array|string|number|null|Uint8Array|Y.Type>)
delete(index:number, length:number)
get(index:number)
slice(start:number, end:number):Array<Object|boolean|Array|string|number|null|Uint8Array|Y.Type>
检索内容的范围
length:number
forEach(function(value:object|boolean|Array|string|number|null|Uint8Array|Y.Type, index:number, array: Y.Array))
map(function(T, number, YArray):M):Array<M>
clone(): Y.Array
将所有值克隆到一个新的 Y.Array 实例中。返回的类型可以包含到 Yjs 文档中。
toArray():Array<object|boolean|Array|string|number|null|Uint8Array|Y.Type>
将此 YArray 的内容复制到一个新数组中。
toJSON():Array<Object|boolean|Array|string|number|null>
将此 YArray 的内容复制到一个新数组中。它使用其 toJSON 方法将所有子类型转换为 JSON。
[Symbol.Iterator]
返回一个 YArray 迭代器,包含数组中每个索引的值。
for (let value of yarray) { .. }
observe(function(YArrayEvent, Transaction):void)
为此类型添加一个事件监听器,每次修改此类型时都会同步调用。若在事件监听器中修改了此类型,当前事件监听器返回后会再次调用该事件监听器。
unobserve(function(YArrayEvent, Transaction):void)
从此类型移除 observe 事件监听器。
observeDeep(function(Array<YEvent>, Transaction):void)
为此类型添加一个事件监听器,每次修改此类型或其任何子项时都会同步调用。若在事件监听器中修改了此类型,当前事件监听器返回后会再次调用该事件监听器。事件监听器接收由自身或任何子项创建的所有事件。
unobserveDeep(function(Array<YEvent>, Transaction):void)
从此类型移除 observeDeep 事件监听器。
Y.Map

一种可共享的 Map 类型。

const ymap = new Y.Map()
parent:Y.AbstractType|null
size: number
键/值对的总数。
get(key:string):object|boolean|string|number|null|Uint8Array|Y.Type
set(key:string, value:object|boolean|string|number|null|Uint8Array|Y.Type)
delete(key:string)
has(key:string):boolean
clear()
从此 YMap 中移除所有元素。
clone():Y.Map
将此类型克隆为一个新的 Yjs 类型。
toJSON():Object<string, Object|boolean|Array|string|number|null|Uint8Array>
将此 YMap 的 [key,value] 对复制到一个新对象。它使用其 toJSON 方法将所有子类型转换为 JSON。
forEach(function(value:object|boolean|Array|string|number|null|Uint8Array|Y.Type, key:string, map: Y.Map))
对每个键值对执行提供的函数一次。
[Symbol.Iterator]
返回一个 [key, value] 对的迭代器。
for (let [key, value] of ymap) { .. }
entries()
返回一个 [key, value] 对的迭代器。
values()
返回所有值的迭代器。
keys()
返回所有键的迭代器。
observe(function(YMapEvent, Transaction):void)
为此类型添加一个事件监听器,每次修改此类型时都会同步调用。若在事件监听器中修改了此类型,当前事件监听器返回后会再次调用该事件监听器。
unobserve(function(YMapEvent, Transaction):void)
从此类型移除 observe 事件监听器。
observeDeep(function(Array<YEvent>, Transaction):void)
为此类型添加一个事件监听器,每次修改此类型或其任何子项时都会同步调用。若在事件监听器中修改了此类型,当前事件监听器返回后会再次调用该事件监听器。事件监听器接收由自身或任何子项创建的所有事件。
unobserveDeep(function(Array<YEvent>, Transaction):void)
从此类型移除 observeDeep 事件监听器。
Y.Text

一种可共享的类型,专为文本的共享编辑而优化。它允许为文本中的范围分配属性。这使得实现丰富文本绑定成为可能。

该类型还可以转换为 delta 格式。类似地,YTextEvents 计算变化为增量。

const ytext = new Y.Text()
parent:Y.AbstractType|null
insert(index:number, content:string, [formattingAttributes:Object<string,string>])
index 插入字符串并为其分配格式属性。
ytext.insert(0, 'bold text', { bold: true })
delete(index:number, length:number)
format(index:number, length:number, formattingAttributes:Object<string,string>)
为文本中的范围分配格式属性
applyDelta(delta: Delta, opts:Object<string,any>)
参见 Quill Delta 可以设置选项以防止移除结尾的新行,默认值为 true。
ytext.applyDelta(delta, { sanitize: false })
length:number
toString():string
将此类型(不带格式选项)转换为字符串。
toJSON():string
参见 toString
toDelta():Delta
将此类型转换为 Quill Delta
observe(function(YTextEvent, Transaction):void)
为此类型添加一个事件监听器,每次修改此类型时都会同步调用。若在事件监听器中修改了此类型,当前事件监听器返回后会再次调用该事件监听器。
unobserve(function(YTextEvent, Transaction):void)
从此类型移除 observe 事件监听器。
observeDeep(function(Array<YEvent>, Transaction):void)
为此类型添加一个事件监听器,每次修改此类型或其任何子项时都会同步调用。若在事件监听器中修改了此类型,当前事件监听器返回后会再次调用该事件监听器。事件监听器接收由自身或任何子项创建的所有事件。
unobserveDeep(function(Array<YEvent>, Transaction):void)
从此类型移除 observeDeep 事件监听器。
Y.XmlFragment

一个包含 Y.XmlElements 数组的容器。

const yxml = new Y.XmlFragment()
parent:Y.AbstractType|null
firstChild:Y.XmlElement|Y.XmlText|null
insert(index:number, content:Array<Y.XmlElement|Y.XmlText>)
delete(index:number, length:number)
get(index:number)
slice(start:number, end:number):Array<Y.XmlElement|Y.XmlText>
检索内容范围
length:number
clone():Y.XmlFragment
将此类型克隆为一个新的 Yjs 类型。
toArray():Array<Y.XmlElement|Y.XmlText>
将子项复制到一个新的数组中。
toDOM():DocumentFragment
将此类型及所有子项转换为新的 DOM 元素。
toString():string
获取所有后代的 XML 序列化。
toJSON():string
参见 toString
createTreeWalker(filter: function(AbstractType<any>):boolean):Iterable
创建一个可迭代对象,以遍历子项。
observe(function(YXmlEvent, Transaction):void)
为此类型添加一个事件监听器,每次修改此类型时都会同步调用。若在事件监听器中修改了此类型,当前事件监听器返回后会再次调用该事件监听器。
unobserve(function(YXmlEvent, Transaction):void)
从此类型移除 observe 事件监听器。
observeDeep(function(Array<YEvent>, Transaction):void)
为此类型添加一个事件监听器,每次修改此类型或其任何子项时都会同步调用。若在事件监听器中修改了此类型,当前事件监听器返回后会再次调用该事件监听器。事件监听器接收由自身或任何子项创建的所有事件。
unobserveDeep(function(Array<YEvent>, Transaction):void)
从此类型移除 observeDeep 事件监听器。
Y.XmlElement

一个可共享的类型,表示一个 XML 元素。它具有 nodeName、属性和子项列表,但并不努力验证其内容或确保符合 XML 标准。

const yxml = new Y.XmlElement()
parent:Y.AbstractType|null
firstChild:Y.XmlElement|Y.XmlText|null
nextSibling:Y.XmlElement|Y.XmlText|null
prevSibling:Y.XmlElement|Y.XmlText|null
insert(index:number, content:Array<Y.XmlElement|Y.XmlText>)
delete(index:number, length:number)
get(index:number)
length:number
setAttribute(attributeName:string, attributeValue:string)
removeAttribute(attributeName:string)
getAttribute(attributeName:string):string
getAttributes():Object<string,string>
get(i:number):Y.XmlElement|Y.XmlText
检索第 i 个元素。
slice(start:number, end:number):Array<Y.XmlElement|Y.XmlText>
检索内容范围
clone():Y.XmlElement
将此类型克隆为一个新的 Yjs 类型。
toArray():Array<Y.XmlElement|Y.XmlText>
将子项复制到一个新的数组中。
toDOM():Element
将此类型及所有子项转换为一个新的 DOM 元素。
toString():string
获取所有后代的 XML 序列化。
toJSON():string
参见 toString
observe(function(YXmlEvent, Transaction):void)
为此类型添加一个事件监听器,每次修改此类型时都会同步调用。若在事件监听器中修改了此类型,当前事件监听器返回后会再次调用该事件监听器。
unobserve(function(YXmlEvent, Transaction):void)
从此类型移除 observe 事件监听器。
observeDeep(function(Array<YEvent>, Transaction):void)
为此类型添加一个事件监听器,每次修改此类型或其任何子项时都会同步调用。若在事件监听器中修改了此类型,当前事件监听器返回后会再次调用该事件监听器。事件监听器接收由自身或任何子项创建的所有事件。
unobserveDeep(function(Array<YEvent>, Transaction):void)
从此类型移除 observeDeep 事件监听器。

Y.Doc

const doc = new Y.Doc()
clientID
一个唯一的 ID,用于标识此客户端。(只读)
gc
是否在此文档实例上启用垃圾回收。设置 `doc.gc = false` 以禁用垃圾回收并能够恢复旧内容。有关 Yjs 中垃圾回收的更多信息,请参见 https://github.com/yjs/yjs#yjs-crdt-algorithm。
transact(function(Transaction):void [, origin:any])
每次对共享文档的更改都发生在一个事务中。观察者调用和 update 事件在每个事务后被调用。您应该将更改打包到一个单独的事务中,以减少事件调用的数量。即 doc.transact(() => { yarray.insert(..); ymap.set(..) }) 触发一个单一的更改事件。
您可以指定一个可选的 origin 参数,该参数存储在 transaction.originon('update', (update, origin) => ..) 中。
toJSON():any
已弃用:建议直接在共享类型上调用 toJSON。将整个文档转换为 js 对象,递归遍历每个 yjs 类型。不会记录未定义的类型(使用 ydoc.getType(..))。
get(string, Y.[TypeClass]):[Type]
定义一个共享类型。
getArray(string):Y.Array
定义一个共享的 Y.Array 类型。相当于 y.get(string, Y.Array)
getMap(string):Y.Map
定义一个共享的 Y.Map 类型。相当于 y.get(string, Y.Map)
getText(string):Y.Text
定义一个共享的 Y.Text 类型。相当于 y.get(string, Y.Text)
getXmlElement(string, string):Y.XmlElement
定义一个共享的 Y.XmlElement 类型。相当于 y.get(string, Y.XmlElement)
getXmlFragment(string):Y.XmlFragment
定义一个共享的 Y.XmlFragment 类型。相当于 y.get(string, Y.XmlFragment)
on(string, function)
在共享类型上注册一个事件监听器
off(string, function)
从共享类型中注销一个事件监听器

Y.Doc 事件

on('update', function(updateMessage:Uint8Array, origin:any, Y.Doc):void)
监听文档更新。文档更新必须传递给所有其他对等方。您可以以任何顺序和多次应用文档更新。使用 `updateV2` 接收 V2 事件。
on('beforeTransaction', function(Y.Transaction, Y.Doc):void)
在每个事务之前发出。
on('afterTransaction', function(Y.Transaction, Y.Doc):void)
在每个事务之后发出。
on('beforeAllTransactions', function(Y.Doc):void)
事务可以嵌套(例如,当一个事务中的事件调用另一个事务时)。在第一个事务之前发出。
on('afterAllTransactions', function(Y.Doc, Array<Y.Transaction>):void)
在最后一个事务清理后发出。

文档更新

对共享文档的更改被编码为 文档更新。文档更新是 交换律幂等 的。这意味着它们可以以任何顺序和多次应用。

示例:监听更新事件并在远程客户端上应用

const doc1 = new Y.Doc()
const doc2 = new Y.Doc()

doc1.on('update', update => {
  Y.applyUpdate(doc2, update)
})

doc2.on('update', update => {
  Y.applyUpdate(doc1, update)
})

// 所有更改也应用于另一个文档
doc1.getArray('myarray').insert(0, ['Hello doc2, you got this?'])
doc2.getArray('myarray').get(0) // => 'Hello doc2, you got this?'

Yjs 内部维护一个 状态向量,表示每个客户端期望的下一个时钟。在不同的解释中,它保存由每个客户端创建的结构数量。当两个客户端同步时,您可以选择交换完整的文档结构或仅发送状态向量以计算差异。

示例:通过交换完整文档结构同步两个客户端

const state1 = Y.encodeStateAsUpdate(ydoc1)
const state2 = Y.encodeStateAsUpdate(ydoc2)
Y.applyUpdate(ydoc1, state2)
Y.applyUpdate(ydoc2, state1)

示例:通过计算差异同步两个客户端

此示例演示如何通过仅使用远程客户端的状态向量计算差异,以最小的数据交换量同步两个客户端。使用状态向量同步客户端需要额外的往返,但可以节省大量带宽。

const stateVector1 = Y.encodeStateVector(ydoc1)
const stateVector2 = Y.encodeStateVector(ydoc2)
const diff1 = Y.encodeStateAsUpdate(ydoc1, stateVector2)
const diff2 = Y.encodeStateAsUpdate(ydoc2, stateVector1)
Y.applyUpdate(ydoc1, diff2)
Y.applyUpdate(ydoc2, diff1)

示例:在不加载 Y.Doc 的情况下同步客户端

可以在不将 Yjs 文档加载到内存中的情况下同步客户端并计算增量更新。Yjs 提供一个 API 以直接在二进制文档更新上计算差异。

// 将当前状态编码为二进制缓冲区
let currentState1 = Y.encodeStateAsUpdate(ydoc1)
let currentState2 = Y.encodeStateAsUpdate(ydoc2)
// 现在我们可以继续使用状态向量同步客户端,而不使用 Y.Doc
ydoc1.destroy()
ydoc2.destroy()

const stateVector1 = Y.encodeStateVectorFromUpdate(currentState1)
const stateVector2 = Y.encodeStateVectorFromUpdate(currentState2)
const diff1 = Y.diffUpdate(currentState1, stateVector2)
const diff2 = Y.diffUpdate(currentState2, stateVector1)

// 同步客户端
currentState1 = Y.mergeUpdates([currentState1, diff2])
currentState2 = Y.mergeUpdates([currentState2, diff1])

混淆更新

如果您的用户遇到奇怪的错误(例如,富文本编辑器抛出错误消息),您不必请求用户的完整文档。相反,他们可以在发送给您之前混淆文档(即,用无意义的生成内容替换内容)。请注意,某人可能仍会通过查看文档的一般结构来推断内容的类型。但这比请求原始文档要好得多。

混淆更新包含合并所需的所有与 CRDT 相关的数据。因此,合并混淆更新是安全的。

const ydoc = new Y.Doc()
// 进行一些更改..
ydoc.getText().insert(0, 'hello world')
const update = Y.encodeStateAsUpdate(ydoc)
// 以下更新包含混乱的数据
const obfuscatedUpdate = Y.obfuscateUpdate(update)
const ydoc2 = new Y.Doc()
Y.applyUpdate(ydoc2, obfuscatedUpdate)
ydoc2.getText().toString() // => "00000000000"

使用 V2 更新格式

Yjs 实现了两种更新格式。默认情况下,您使用的是 V1 更新格式。您可以选择使用 V2 更新格式,该格式提供了更好的压缩效果。并非所有提供者都使用它。不过,如果您正在构建自己的提供者,您已经可以使用它。所有以下函数都有后缀 "V2"。例如 Y.applyUpdateY.applyUpdateV2。此外,在监听更新时,您需要特别监听 V2 事件,例如 yDoc.on('updateV2', …)。我们还支持两种格式之间的转换函数:Y.convertUpdateFormatV1ToV2Y.convertUpdateFormatV2ToV1

更新 API

Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])
在共享文档上应用文档更新。您可以选择指定 transactionOrigin,该值将存储在 transaction.originydoc.on('update', (update, origin) => ..) 中。
Y.encodeStateAsUpdate(Y.Doc, [encodedTargetStateVector:Uint8Array]):Uint8Array
将文档状态编码为可以应用于远程文档的单个更新消息。可以选择指定目标状态向量,仅将差异写入更新消息。
Y.encodeStateVector(Y.Doc):Uint8Array
计算状态向量并将其编码为 Uint8Array。
Y.mergeUpdates(Array<Uint8Array>)
将多个文档更新合并为单个文档更新,同时删除重复信息。合并后的文档更新始终比单独的更新更小,因为采用了压缩编码。
Y.encodeStateVectorFromUpdate(Uint8Array): Uint8Array
从文档更新计算状态向量并将其编码为 Uint8Array。
Y.diffUpdate(update: Uint8Array, stateVector: Uint8Array): Uint8Array
将缺失的差异编码为另一个更新消息。此函数类似于 Y.encodeStateAsUpdate(ydoc, stateVector),但适用于更新。
convertUpdateFormatV1ToV2
将 V1 更新格式转换为 V2 更新格式。
convertUpdateFormatV2ToV1
将 V2 更新格式转换为 V1 更新格式。

相对位置

在处理协作文档时,我们经常需要处理位置。位置可以表示光标位置、选择范围,甚至将评论分配给一段文本。正常的索引位置(以整数表示)不方便使用,因为一旦远程更改操作文档,索引范围就会失效。相对位置为您提供了一个强大的 API 来表达位置。

相对位置固定于共享文档中的一个元素,并且不受远程更改的影响。即给定文档 "a|c",相对位置附加到 c。当远程用户通过在光标之前插入一个字符来修改文档时,光标将保持附加在字符 c 上。insert(1, 'x')("a|c") = "ax|c"。当相对位置设置在文档末尾时,它将保持附加在文档的末尾。

示例:转换为相对位置并返回

const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const pos = Y.createAbsolutePositionFromRelativePosition(relPos, doc)
pos.type === ytext // => true
pos.index === 2 // => true

示例:将相对位置发送给远程客户端(json)

const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const encodedRelPos = JSON.stringify(relPos)
// 将 encodedRelPos 发送给远程客户端..
const parsedRelPos = JSON.parse(encodedRelPos)
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
pos.type === remoteytext // => true
pos.index === 2 // => true

示例:将相对位置发送给远程客户端(Uint8Array)

const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
const encodedRelPos = Y.encodeRelativePosition(relPos)
// 将 encodedRelPos 发送给远程客户端..
const parsedRelPos = Y.decodeRelativePosition(encodedRelPos)
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
pos.type === remoteytext // => true
pos.index === 2 // => true
Y.createRelativePositionFromTypeIndex(type:Uint8Array|Y.Type, index: number [, assoc=0])
创建一个相对位置,固定于任何序列类型共享的第 i 个元素(如果 assoc >= 0)。默认情况下,该位置与指定索引位置之后的字符关联。如果 assoc < 0,则相对位置与指定索引位置之前的字符关联。
Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc): { type: Y.AbstractType, index: number, assoc: number } | null
从相对位置创建绝对位置。如果相对位置无法引用,或者类型已被删除,则结果为 null。
Y.encodeRelativePosition(RelativePosition):Uint8Array
将相对位置编码为 Uint8Array。二进制数据是文档更新的首选编码格式。如果您更喜欢 JSON 编码,可以简单地使用 JSON.stringify / JSON.parse 相对位置。
Y.decodeRelativePosition(Uint8Array):RelativePosition
将二进制编码的相对位置解码为 RelativePosition 对象。

Y.UndoManager

Yjs 附带一个用于选择性撤销/重做 Yjs 类型更改的撤销/重做管理器。这些更改可以选择性地限制在事务来源上。

const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext)

ytext.insert(0, 'abc')
undoManager.undo()
ytext.toString() // => ''
undoManager.redo()
ytext.toString() // => 'abc'
constructor(scope:Y.AbstractType|Array<Y.AbstractType> [, {captureTimeout:number,trackedOrigins:Set<any>,deleteFilter:function(item):boolean}])
接受单个类型作为范围或类型数组。
undo()
redo()
stopCapturing()
on('stack-item-added', { stackItem: { meta: Map<any,any> }, type: 'undo' | 'redo' })
注册一个事件,当一个 StackItem 被添加到撤销或重做栈时被调用。
on('stack-item-updated', { stackItem: { meta: Map<any,any> }, type: 'undo' | 'redo' })
注册一个事件,当现有的 StackItem 被更新时被调用。这在“捕获间隔”内发生两个更改时发生。
on('stack-item-popped', { stackItem: { meta: Map<any,any> }, type: 'undo' | 'redo' })
注册一个事件,当一个 StackItem 从撤销或重做栈中弹出时被调用。
on('stack-cleared', { undoStackCleared: boolean, redoStackCleared: boolean })
注册一个事件,当撤销和/或重做栈被清除时被调用。

示例:停止捕获

UndoManager 会合并在小于 options.captureTimeout 的时间间隔内创建的撤销栈项。调用 um.stopCapturing() 以便下一个栈项不会被合并。

// 未停止捕获
ytext.insert(0, 'a')
ytext.insert(1, 'b')
undoManager.undo()
ytext.toString() // => '' (注意 'ab' 被删除)
// 停止捕获
ytext.insert(0, 'a')
undoManager.stopCapturing()
ytext.insert(0, 'b')
undoManager.undo()
ytext.toString() // => 'a' (注意只有 'b' 被删除)

示例:指定跟踪来源

共享文档上的每个更改都有一个来源。如果未指定来源,则默认为 null。通过指定 trackedOrigins,您可以选择性地指定哪些更改应该被 UndoManager 跟踪。UndoManager 实例始终会被添加到 trackedOrigins 中。

class CustomBinding {}

const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext, {
  trackedOrigins: new Set([42, CustomBinding])
})

ytext.insert(0, 'abc')
undoManager.undo()
ytext.toString() // => 'abc' (未跟踪,因为来源为 `null`,并且不属于
                 //           `trackedTransactionOrigins`)
ytext.delete(0, 3) // 撤销更改

doc.transact(() => {
  ytext.insert(0, 'abc')
}, 42)
undoManager.undo()
ytext.toString() // => '' (被跟踪,因为来源是 `trackedTransactionorigins` 的一个实例)

doc.transact(() => {
  ytext.insert(0, 'abc')
}, 41)
undoManager.undo()
ytext.toString() // => 'abc' (未被跟踪,因为 41 不是 `trackedTransactionorigins` 的一个实例)
ytext.delete(0, 3) // 撤销更改

doc.transact(() => {
  ytext.insert(0, 'abc')
}, new CustomBinding())
undoManager.undo()
ytext.toString() // => '' (被跟踪,因为来源是 `CustomBinding`,并且
                 //        `CustomBinding` 在 `trackedTransactionorigins` 中)

示例:向 StackItems 添加额外信息

在撤销或重做之前的操作时,通常期望恢复额外的元信息,如光标位置或文档视图。您可以将元信息分配给撤销/重做栈项。

const ytext = doc.getText('text')
const undoManager = new Y.UndoManager(ytext, {
  trackedOrigins: new Set([42, CustomBinding])
})

undoManager.on('stack-item-added', event => {
  // 将当前光标位置保存到栈项中
  event.stackItem.meta.set('cursor-location', getRelativeCursorLocation())
})

undoManager.on('stack-item-popped', event => {
  // 恢复栈项中的当前光标位置
  restoreCursorLocation(event.stackItem.meta.get('cursor-location'))
})

Yjs CRDT 算法

无冲突复制数据类型(CRDT)用于协作编辑,是操作变换(OT)的替代方法。两者的简单区分在于,OT 尝试转换索引位置以确保收敛(所有客户端最终拥有相同内容),而 CRDT 则使用通常不涉及索引转换的数学模型,例如链表。OT 目前是文本共享编辑的事实标准。支持无中央真实来源(中央服务器)的共享编辑的 OT 方法在实际操作中需要过多的记录管理,因而不太可行。CRDT 更适合分布式系统,提供了额外的保证,确保文档可以与远程客户端同步,并且不需要中央真实来源。

Yjs 实现了该算法的修改版,详细信息可参考 这篇论文。这篇 文章 解释了 CRDT 模型的简单优化,并提供了有关 Yjs 性能特征的更多见解。关于具体实现的更多信息,请参见 INTERNALS.mdYjs 代码库的此指南

适合共享文本编辑的 CRDT 存在只能不断增长的缺陷。虽然存在不增长的 CRDT,但它们不具备对共享文本编辑有利的特性(如意图保留)。Yjs 实现了许多对原始算法的改进,减轻了文档仅增长的缺陷。我们无法在确保结构唯一顺序的同时回收已删除的结构(墓碑)。但我们可以 1. 将前面的结构合并为单个结构,以减少元信息的数量,2. 如果内容被删除,则可以从结构中删除内容,3. 如果我们不再关心结构的顺序,则可以回收墓碑(例如,如果父级被删除)。

示例:

  1. 如果用户按顺序插入元素,则结构将合并为一个单一结构。例如 text.insert(0, 'a'), text.insert(1, 'b'); 首先表示为两个结构 ([{id: {client, clock: 0}, content: 'a'}, {id: {client, clock: 1}, content: 'b'}]),然后合并为一个结构:[{id: {client, clock: 0}, content: 'ab'}]
  2. 当包含内容的结构(例如 ItemString)被删除时,该结构将被替换为不再包含内容的 ItemDeleted
  3. 当类型被删除时,所有子元素都会转换为 GC 结构。GC 结构仅表示结构的存在及其被删除。GC 结构可以与其他 GC 结构合并,只要它们的 id 相邻。

特别是在处理结构化内容时(例如在 ProseMirror 上进行共享编辑),这些改进在 基准测试 随机文档编辑时表现出良好的结果。在实践中,它们显示出更好的效果,因为用户通常按顺序编辑文本,从而生成可以轻松合并的结构。基准测试表明,即使在用户从右到左编辑文本的最坏情况下,Yjs 对于大型文档也能保持良好的性能。

状态向量

Yjs 能够在同步两个客户端时仅交换差异。我们使用 Lamport 时间戳来标识结构,并跟踪客户端创建它们的顺序。每个结构都有一个 struct.id = { client: number, clock: number},唯一标识一个结构。我们定义每个客户端的下一个预期 clock状态向量。该数据结构类似于 版本向量 数据结构。但我们使用状态向量仅用于描述本地文档的状态,以便计算远程客户端缺失的结构。我们不使用它来跟踪因果关系。

许可证和作者

Yjs 和所有相关项目均为 MIT 许可证

Yjs 基于我在 RWTH i5 学生时期的研究。现在我在业余时间从事 Yjs 的开发。

通过在 GitHub Sponsors 上捐赠或雇用 作为您的协作应用程序的承包商来支持该项目。

About

用于构建协作软件的共享数据类型

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • JavaScript 100.0%