yjs如何接入,历史记录如何保存增量和恢复指定版本
说明
不论你是vue、react、或其他框架,均可接入yjs。查找了很多资料都不具有完整对yjs历史记录的说明和使用说明,本文主要是在editablejs的yjs-websocket对服务端处理yjs历史记录和恢复指定版本做流程性说明,开发完整产品如果需要对历史记录过多进行优化,需要将历史记录前n个记录进行二进制合并,减少表格数据
yjs服务端模块,直接使用
直接拷贝editablejs的yjs-websocket模块全部ts代码,其他依赖
npm install @editablejs/models @editablejs/yjs-protocols @editablejs/yjs-transform yjs mongodb lodash.debounce lib0 ws
安装好后package.json配置为
{
****
"scripts": {
"build": "tsc",
"dev": "nodemon --watch node_modules --watch src ./src/index.ts"
},
"devDependencies": {
"@types/lodash.debounce": "^4.0.9",
"@types/ws": "^8.5.10",
"cpx": "^1.5.0",
"nodemon": "2.0.20",
"rimraf": "^5.0.7",
"ts-node": "10.9.1",
"typescript": "4.9.5"
},
"license": "MIT",
"dependencies": {
"@editablejs/models": "^1.0.0",
"@editablejs/yjs-protocols": "^1.0.0",
"@editablejs/yjs-transform": "^1.0.0",
"http": "^0.0.1-security",
"lib0": "0.2.52",
"lodash.debounce": "^4.0.8",
"mongodb": "^5.0.1",
"ws": "^8.17.1",
"y-leveldb": "^0.1.2",
"yjs": "13.5.46"
},
***
}
yarn dev / npm run dev 调试
yarn build / npm run build 打包
开始改代码记录历史(以mongodb为例)
// server/persistence.ts中找到 ydoc.on('update', update => {}),改为
// 记录历史记录前n秒的doc,历史表为文档表docName加上一个后缀 ‘-history’
let _historyDoc: Y.Doc = await ldb!.getYDoc(docName + '-history');
// 与初始化一样,判断为空,就初始化表
const content2 = _historyDoc.get(contentField, Y.XmlText) as Y.XmlText
const updateContent2 = _historyDoc.get(contentField, Y.XmlText) as Y.XmlText
//如果修改了间隔5s记录一次历史记录
const DEFAULT_HISTORY_INTERVAL = 5000
// 记录上次修改更新时间
let _historyTime = Date.now();
ydoc.on('update', update => {
const nowTime = Date.now();
if (!options.readOnly && nowTime - _historyTime > DEFAULT_HISTORY_INTERVAL) {
// 差量保存,超过5s记录历史,此处为增量记录,记录全量文件太大浪费
// 历史的状态向量
const stateVector = Y.encodeStateVector(_historyDoc)
// 和最新记录做差量,得到差量的 UInt8Array
const diff = Y.encodeStateAsUpdate(ydoc, stateVector)
// 时间重置最新,以便等下次修改后5s再次记录
_historyTime = nowTime;
//如果差量大于0,写入数据库,并将上次记录更新到最新版本
if (diff.length > 0) {
// 保存到数据库 historyUpdate是新增的方法,和storeUpdate差不多,
// 只是记录历史到新表docName+'-history‘,保存的数据结构为docName表加一个字段time
ldb!.historyUpdate(docName, diff);
// 将上次记录更新到最新版本
Y.applyUpdate(_historyDoc, diff);
}
}
ldb!.storeUpdate(docName, update)
})
历史记录好后,前端获取历史记录恢复
// 前端获取docName+'-history'表中数据即可,主要是value的差量二进制数据
// 前端获取到数据结构,只需要clock >= 0的记录
{
time: number, // 时间戳
value: UInt8Array, // 差量二进制
clock: number, // 表中数据,必须按顺序来,具体干嘛用的,去看代码,每个记录更新都会+1
// 其他数据,根据需要自己定义获取
}
前端获取的的历史记录肯定是这个数组,这是二进制该如和现实成完整的编辑内容呢?直接上代码
仿照slate或editable直接写个editor
const createEditor = (options: HistoryRecordEditorOptions): HistoryRecordEditor => {
const ydoc = new Y.Doc();
const editor: HistoryRecordEditor = {
loading: false,
id: options.articleId, // 编辑文件id
wss: options.wss, // 就是yjs的wss
ydoc,
isFinished: false,
sharedRoot: ydoc.get('content', Y.XmlText) as Y.XmlText,
size: options.size || 10,
page: 0,
token: options.token, // 根据需要自己去设计,此处不做说明
url: options.url, // 获取历史记录的url, get方法获取历史表中数据,这个可以直接在yjs里面写代码,会node的人都会,不会就去学
destroy() {
editor.ydoc.destroy();
},
getMore: async () => {
if (editor.loading || editor.isFinished) [];
editor.loading = true;
// getHistory就是分页获取历史记录,获取到后直接yjs转换为需要的内容,看下面定义
return getHistory(editor).then(result => {
if (result.length < editor.size) {
editor.isFinished = true;
}
editor.loading = false;
return result;
}).catch(() => {
editor.loading = false;
return [];
})
},
// buffer 为历史记录里面的UInt8Array数组,前clock+1个,看恢复到那个clock
recover: async (buffers: Uint8Array[]) => {
// 恢复历史使用
if (!editor.wss || !editor.token) return;
// bufferToOps直接将前clock+1个buffer(历史记录里面的UInt8Array数组)
const adds = await bufferToOps(buffers);
const yjsEditor = await getYjsEditor(editor.wss, editor.id, editor.token);
const ops: Operation[] = []
// 有标题,不敢直接全部删除,全部删除editablejs会出错
for (let i = 1; i < yjsEditor.children.length; ++i) {
ops.push({
type: 'remove_node',
node: yjsEditor.children[i],
path: [1],
})
}
for (let i = adds.length - 1; i >= 1; --i) {
ops.push({
type: 'insert_node',
node: adds[i],
path: [1]
})
}
ops.push({
type: 'remove_node',
node: yjsEditor.children[0],
path: [0],
})
ops.push({
type: 'insert_node',
node: adds[0],
path: [0],
})
ops.forEach(op => {
yjsEditor.apply(op);
})
//调用完后恢复成功
}
}
return editor;
}
// 获取历史记录
const getHistory = async (editor: HistoryRecordEditor) => {
return documentRequest(editor.url || '', 'GET', {
id: editor.id,
page: editor.page,
size: editor.size
}, { token: editor.token || 'testtoken' }).then((data: { buffer: number[], time: number, clock: number }[]) => {
editor.page++;
if (data.length === 0) {
return [];
}
const arr = data.map(item => ({ buffer: new Uint8Array(item.buffer), time: item.time, clock: item.clock }));
// 历史列表显示的关键,会将buffer转换为显示的文本
return bufferArrayToOps(editor, arr).then(result => {
// 转换好后写入到store,store自己去看zustand的使用
const store = getHistoryRecordStore(editor);
const records = store.getState().records
store.setState({
records: records.concat(result)
})
return result;
})
})
//下面是bufferArrayToOps 的方法解析,其实就是如何将前clock+1数据转换为显示内容,定义的yjs-event.ts文件
import * as Y from 'yjs'
import { Node, Operation, createEditor } from '@editablejs/models'
import {
deltaInsertToEditorNode,
} from '@editablejs/yjs-transform'
import { HistoryRecordEditor, HistoryRecordInfo } from './interface'
import { WebsocketProvider } from '@editablejs/yjs-websocket'
import { YjsEditor, withYjs } from '@editablejs/plugin-yjs'
import { Editable, withEditable } from '@editablejs/editor'
function applyDelta(delta: any): Operation[] {
const ops: Operation[] = []
// Apply changes in reverse order to avoid path changes.
const changes = delta.reverse()
for (const change of changes) {
if ('insert' in change) {
ops.push({
type: 'insert_node',
path: [0],
node: deltaInsertToEditorNode(change),
})
}
}
return ops
}
export function translateYTextEvent(
event: Y.YTextEvent,
): Operation[] {
const { target } = event
const delta = event.delta
if (!(target instanceof Y.XmlText)) {
throw new Error('Unexpected target node type')
}
const ops: Operation[] = []
if (delta.length > 0) {
ops.push(...applyDelta(delta))
}
return ops
}
function translateYjsEvent(
event: Y.YEvent<Y.XmlText>,
) {
if (event instanceof Y.YTextEvent) {
return translateYTextEvent(event)
}
throw new Error('Unexpected Y event type')
}
export const applyYjsEvents = (events: Y.YEvent<Y.XmlText>[]) => {
const ops = events.reduceRight<any[]>((_, event) => {
return translateYjsEvent(event)
}, [])
return ops;
}
export const bufferArrayToOps = async (editor: HistoryRecordEditor, updates: { buffer: Uint8Array; time: number, clock: number }[]): Promise<HistoryRecordInfo[]> => {
return new Promise(resolve => {
const ydoc = editor.ydoc;
const allHistory: any[] = []
let len = updates.length;
const onObserveDeep = (events: Y.YEvent<Y.XmlText>[]) => {
const ops = applyYjsEvents(events);
len--;
// console.log('ssss----', editor.sharedRoot.toString())
allHistory.push({
content: ops.map(op => Node.string(op.node)).join(''),
time: updates[updates.length - len - 1].time,
buffer: updates[updates.length - len - 1].buffer,
ops: [...ops],
clock: updates[updates.length - len - 1].clock,
})
// if (ops.length > 0) {
// allHistory.push({
// content: ops.map(op => Node.string(op.node)).join(''),
// time: updates[updates.length - len - 1].time,
// ops: [...ops]
// })
// }
if (len === 0) {
editor.sharedRoot.unobserveDeep(onObserveDeep)
resolve(allHistory);
}
}
editor.sharedRoot.observeDeep(onObserveDeep)
for (let i = 0; i < updates.length; i++) {
Y.applyUpdate(ydoc, updates[i].buffer)
}
})
}
export const testToOps = (updates: { buffer: Uint8Array }[]) => {
console.log('-test--events-222--', updates)
const doc = new Y.Doc();
const sharedRoot = doc.get('content', Y.XmlText) as Y.XmlText;
let len = updates.length;
let allOps: any[] = [];
sharedRoot.observeDeep(events => {
const ops = applyYjsEvents(events);
allOps = allOps.concat(ops);
console.log('--test--events--11--', ops.length);
len--;
if (len === 0) {
console.log('--test--events----', allOps);
}
})
for (let i = 0; i < updates.length; i++) {
Y.applyUpdate(doc, updates[i].buffer)
}
}
export const bufferToOps = (buffers: Uint8Array[]): Promise<Node[]> => {
return new Promise(resolve => {
const ydoc = new Y.Doc()
ydoc.get('content', Y.XmlText).observeDeep((events) => {
const ops = applyYjsEvents(events);
resolve(ops.map(op => op.node).reverse());
})
ydoc.transact(() => {
for (let i = 0; i < buffers.length; i++) {
Y.applyUpdate(ydoc, buffers[i])
}
})
})
}
// 恢复需要链接yjs,然后删除存在的,同时把需要恢复历史在添加进去就可以了,这样的好处是,支持协同,不要问为什么,直接这样做,不然你绝对一堆问题
export const getYjsEditor = async (wss: string, id: string, token: string): Promise<Editable> => {
return new Promise((resolve) => {
const ydoc = new Y.Doc({ gc: false });
const provider = new WebsocketProvider(wss, id, ydoc, {
connect: false,
params: {
token: token
}
})
const sharedType = ydoc.get('content', Y.XmlText) as Y.XmlText
const editor = withYjs(withEditable(createEditor()), sharedType, { autoConnect: false });
editor.on('change', () => {
if (editor.children.length > 0) {
resolve(editor)
setTimeout(() => {
// 成功后需要丢掉这个链接
provider.disconnect();
}, 200);
}
})
provider.on('status', (event: any) => {
if (event.status === 'connected') {
YjsEditor.connect(editor)
}
})
provider.connect();
})
}
}