基础概念

Align
2023-08-14
8 min

基础概念

感知信息(Awareness)

感知信息(用户状态)指的是多人在线协作编辑下其他编辑者的在线状态和信息,例如他人的光标信息,用户标识颜色。这些统称为感知信息, yjs专门提供了一个对象用来存储这些信息。

question: 为什么用单独的字段存储这些信息?

官方文档的解释是这些信息并不属于文档信息的一部分,更像是一种辅助多人编辑的时效性信息,未来也不会持久化到数据,所以作为一个单独的对象信息是合理的, 在线协作编辑下,这些信息往往在用户离线或者离开编辑页面后就失去意义了。

以下是官方提供的一段实例代码:

// 所有的连接都提供了感知信息

const awareness = provider.awareness

// 你可以添加事件监听函数来监听用户信息的变化

awareness.on('change', changes => {

//当用户信息变化时打印出所有的用户状态信息

console.log(Array.from(awareness.getStates().values()))
})

//通过修改user字段可以自定义自己的状态信息

awareness.setLocalStateField('user', {

// 定义用户名

name: 'Align',

// 定义自己在其他人页面上的展示颜色

color: '#ffb61e' // 注意为十六进制的颜色
})

目前感知信息(Awareness)字段还没有规范化,可以自定义一些字段,不过需要注意现有的支持yjs协作的编辑器通常会使用cursor和user字段来记录光标位置和用户信息,这些编辑器都接收Awareness实例来获取光标信息,例如quill:

const binding = new QuillBinding(ytext, quill, provider.awareness)

离线支持(Offline Support)

yjs提供了专门的库用来支持离线下操作。y-indexeddb用于将文档数据同步到浏览器的indexDB数据库中,官网使用示例如下:

import { IndexeddbPersistence } from 'y-indexeddb'

const ydoc = new Y.Doc()
const roomName = 'my-room-name'
const persistence = new IndexeddbPersistence(roomName, ydoc)

// The persistence provider works similarly to the network providers:

// const network = new WebrtcProvider(roomName)

通过监听实例的async事件可以在本地数据加载时执行相关代码:

persistence.once('synced', () => { console.log('initial content loaded') })

另外官方提到使用y-indexeddb的优点是当服务器或其他分布式方有数据丢失时yjs也能依靠本地数据库中的存储在下次更新中自动修复。其实就是相当于本地缓存 的意思,看样子官方还是推荐使用y-indexeddb作为本地缓存的。

使用y-indexeddb构建离线应用程序

通过将应用程序的数据托管到y-indexeddb可以轻易的构建一个离线的网页应用程序或网站。

不过y-indexeddb只充当数据库的作用,模拟服务器端的controller和server层需要使用浏览器提供的Server Worker API来实现。 不过我没有使用过这个API,这里只附上其在MDN中的文档地址

共享类型(Shared Types)

文档是一堆序列化的数据结构,你可以轻易的设想出一篇文档的数据对象可能具有的结构和属性,例如一篇简单的word文档,最顶层document Object下可能具有页眉(header field),内容(content field),页脚(footer field)等等。现在我们有了一个文档对象可以对这个文档对象进行编辑(增删改查),并通过服务器分发同步到其他客户端实现同步,但是当冲突和并发发生的时候文档数据可不会自己处理这些情况,如何保证在复杂和高频的增删修改下文档数据的最终一致性,这正是yjs做的事情。

但并不是把文件对象光秃秃的扔给yjs实例他就能处理了,yjs的分布式冲突处理能力是基于自身提供的共享数据类型来实现的,我们需要将普通的文档对象转换为yjs提供的共享数据对象,这样yjs才能正确处理并解决数据冲突。

以下是官网提供的文档示例:

import * as Y from 'yjs'

//新建一个文档对象

const ydoc = new Y.Doc()

// 在该文档下创建一个数组

const yarray = ydoc.getArray('my array')

// 对这个数组添加监听

yarray.observe(event => {

// 数据变化时打印出数据

console.log('delta:', event.changes.delta)
})

// 共享类型的使用也不是完全没有限制的,有一些规则需要遵守,我们通过下面的一些实例来展示:

// 向数组添加或删除元素
yarray.insert(0, ['some content']) // => delta: [{ insert: ['some content'] }]

// 注意上述方法中插入的内容应该被数组包裹

yarray.toArray() // => ['some content']

// 我们可以插入任何json格式的数据,当然Uint8Arrays也是可以的.

yarray.insert(0, [1, { bool: true }, new Uint8Array([1,2,3])]) // => delta: [{ insert: [1, { bool: true }, Uint8Array([1,2,3])] }]

yarray.toArray() // => [1, { bool: true }, Uint8Array([1,2,3]), 'some content']

// 你甚至可以插入共享类型以实现嵌套

const subArray = new Y.Array()

yarray.insert(0, [subArray]) // => delta: [{ insert: [subArray] }]

// 请注意,当你向被插入的subArray共享对象中插入数据时不会触发yarray的监听(没有类似冒泡的机制)

subArray.insert(0, ['nope']) // [observer not called]

// 要实现监听需要在subArray中绑定监听事件即可

subArray.observe(event => {  })

// 或者直接在yarray上绑定深层监听

yarray.observeDeep(events => { console.log('All deep events: ', events) })

subArray.insert(0, ['this works']) // => All deep events: [..]

// 注意不要把一个共享类型插入到不同的地方,共享类型应该只存在于一个地方

yarray.insert(0, [subArray]) // Throws exception!

除了yarray,yjs还提供了其他共享数据类型

注意事项

  1. 最重要的,也是刚刚提到的,不要移动已经插入的共享数据类型。
  2. 不要直接修改插入到共享对象中的对象,yjs考虑到性能方面不会克隆插入的数据,直接修改数据会产生无法同步的数据变化。

事务(Transactions)

在yjs中,所有的数据修改都应该通过发起事务(Transactions)来实现。当你调用方法修改数组对象时(e.g. yarray.insert(..)),实际上yjs内部隐式的创建了事务来执行修改。 以下示例展示了如何显示的创建事务并修改数据:

const ydoc = new Y.Doc()
const ymap = ydoc.getMap('favorites')

// 设置初始值
ymap.set('food', 'pizza')

// 每次事务完成后都会触发监听
ymap.observe(event => {
console.log('changes', event.changes.keys)
})

ydoc.transact(() => {
ymap.set('food', 'pencake')
ymap.set('number', 31)
}) // => changes: Map({ number: { action: 'added' }, food: { action: 'updated', oldValue: 'pizza' } })

每次事务的修改都会触发事件的监听,所以最好尽可能的合并修改到一个事务中以减少不需要的监听触发。

yjs事件的触发遵循以下顺序:

  • ydoc.on('beforeTransaction', event => { .. }) 事务执行前调用
  • 事务已触发
  • ydoc.on('beforeObserverCalls', event => {})事务执行后但是还没开始触发监听
  • ytype.observe(event => { .. }) - 监听器被调用
  • ytype.observeDeep(event => { .. }) - 深层监听器被调用
  • ydoc.on('afterTransaction', event => {}) - - 事务执行完成后
  • ydoc.on('update', update => { .. }) - - 其他客户端修改触发的更新事件

可以看到其他客户端的修改会触发update事件,所以尽量合并事件减少事务次数是必要的。