Skip to content

Yjs细节补充

1. 基本使用#

在 Yjs 中,Y.Doc 是一切的起点。

可以把它理解成一份“协同文档”的载体,就像是前端里的一个全局 store,每个 Yjs 的数据结构都挂载在它之上。你可以创建多个 Y.Doc,Y.Doc 可以实例化多个,每实例化一个就开了一个副本,类似于新加入了一个客户端。只要你通过网络把它们“连起来”,这些副本就能自动合并。

在 Yjs 中,我们如果要协同编辑字符串,应该用 Y.Text,这个结构在底层会自动记录每一次插入和删除,并转化为一种可合并的 CRDT 操作。

const doc = new Y.Doc() // 创建一个 Yjs 的副本,相当于一个新的客户端
const text = doc.getText('myText') // 创建一个共享文本
// 参数是对共享文本的一个标识(key),如果是第一次使用,会创建一个全新的共享文本
// 后续调用的时候,会取出对应 key 的共享文本
// 因为支持传入 key,因此在同一个文档中可以标记不同的共享数据片段
// doc.getText("title"); // 共享文档标题
// doc.getText("body"); // 共享正文
text.insert(0, 'Hello Yjs') // 插入文本,第一个参数是插入文本的位置,第二个参数是要插入的文本内容
console.log(text.toString()) // 获取当前的共享文本的内容

下面是一个基本的示例:

import * as Y from 'yjs'
const doc1 = new Y.Doc() // 创建第一个副本,相当于一个客户端A
const doc2 = new Y.Doc() // 创建第二个副本,相当于一个客户端B
const text1 = doc1.getText('text') // 创建一个文本类型的共享数据
const text2 = doc2.getText('text') // 这里就会和第一个副本的文本类型共享数据进行同步
// 监听第二个副本的变化
text2.observe(() => {
console.log(`doc2收到更新:${text2.toString()}`)
})
text1.insert(0, 'Hello ') // 在第一个副本插入数据
console.log(`doc1插入数据:${text1.toString()}`) // 输出:Hello
console.log(`doc2当前的数据:${text2.toString()}`) // 输出:Hello
// 思考:如果共享给 doc2
// 该方法用于创建一个更新
const update = Y.encodeStateAsUpdate(doc1)
Y.applyUpdate(doc2, update) // 应用更新

课堂练习

  1. 在文本中插入一段文字,编码后应用到另一个文档上。
  2. 修改为删除操作,观察同步结果。
import * as Y from 'yjs'
const doc1 = new Y.Doc() // 创建第一个副本,相当于一个客户端A
const doc2 = new Y.Doc() // 创建第二个副本,相当于一个客户端B
const text1 = doc1.getText('text') // 创建一个文本类型的共享数据
const text2 = doc2.getText('text') // 这里就会和第一个副本的文本类型共享数据进行同步
// 监听两个副本的变化
text1.observe(() => {
console.log(`doc1收到更新:${text1.toString()}`)
})
text2.observe(() => {
console.log(`doc2收到更新:${text2.toString()}`)
})
text1.insert(0, 'Hello ') // 在第一个副本插入数据
console.log(`doc1插入数据:${text1.toString()}`) // 输出:Hello
console.log(`doc2当前的数据:${text2.toString()}`) // 输出:Hello
// 思考:如果共享给 doc2
// 该方法用于创建一个更新
const update = Y.encodeStateAsUpdate(doc1)
Y.applyUpdate(doc2, update) // 应用更新
// 接下来进行删除操作
text2.delete(0, 2) // 第一个参数代表删除的起始位置,第二个参数代表删除的长度
console.log(`doc1当前的数据:${text1.toString()}`) // Hello
console.log(`doc2当前的数据:${text2.toString()}`) // 输出:llo
// 接下来我们需要将这个变化同步给副本1
const update2 = Y.encodeStateAsUpdate(doc2)
Y.applyUpdate(doc1, update2) // 应用更新

2. 结构化数据#

在实际应用中,我们面对的数据远不止文本,更多的是结构化内容:

如果说 Y.Text 是“字符串的协同容器”,那接下来的 Y.MapY.Array,就是协同世界里的“对象”和“数组”。

1. Y.Map

Yjs 中的 Y.Map 是动态对象协同结构,就像 JavaScript 中的对象 {},它支持动态增删键、嵌套结构、事件监听,而且具备协同特性:

我们来看一个最基本的例子:

import * as Y from 'yjs'
const doc = new Y.Doc() // 创建第一个副本,相当于一个客户端A
const profile = doc.getMap('profile') // 这里的profile就是一个共享Map类型的数据
profile.set('name', 'Alice') // 设置一个属性
profile.set('age', 30) // 设置另一个属性
profile.set('address', '123 Main St') // 设置第三个属性
console.log('profile:', profile.toJSON()) // 输出当前的profile对象
const doc2 = new Y.Doc() // 创建第二个副本,相当于一个客户端B
const profile2 = doc2.getMap('profile') // 这里的profile就是一个共享Map类型的数据
console.log('同步之前的profile2:', profile2.toJSON()) // 输出当前的profile对象
// 接下来我们要把第一个副本的profile数据同步到第二个副本
const update = Y.encodeStateAsUpdate(doc)
Y.applyUpdate(doc2, update) // 将第一个副本的更新应用到第二个副本
console.log('同步之后的profile2:', profile2.toJSON()) // 输出当前的profile对象

从语法上看,几乎和普通对象没差,但它是 可共享、可监听、可同步 的。

2. Y.Array

Y.Array 是一个动态协同数组结构,我们可以把它理解成带有版本合并机制的 JavaScript 数组。

import * as Y from 'yjs'
const doc = new Y.Doc() // 创建第一个副本,相当于一个客户端A
const list = doc.getArray('list')
console.log(list.toArray()) // []
list.push(['hello', 'world']) // 向列表中添加元素
console.log(list.toArray()) // ["hello", "world"]

总结一下:不同的数据类型,在获取内容的时候方式不一样

你可以用它做评论区、任务列表、协同表格,甚至 JSON 树结构。Y.Map、Y.Array 可以嵌套使用,例如:

import * as Y from 'yjs'
const doc = new Y.Doc() // 创建第一个副本,相当于一个客户端A
const users = doc.getMap('users') // 创建了一个Map类型的Yjs对象
const user1 = new Y.Map()
const tag = new Y.Array()
tag.push(['tag1', 'tag2'])
user1.set('tags', tag)
// 接下来再将这个user1添加到users中
users.set('user1', user1) // 将user1添加到users中
users.set('name', 'Lucy')
console.log(users.toJSON()) // { name: 'Lucy', user1: { tags: [ 'tag1', 'tag2' ] } }

这就像是把一个 JSON 对象“协同化”了,任何一个内部字段的修改都会被正确同步和合并。


-EOF-