yjs 文档协作、历史文档恢复

说明

不论你是vue、react、或其他框架,均可接入yjs。查找了很多资料都不具有完整对yjs历史记录的说明和使用说明,本文主要是在editablejs的yjs-websocket对服务端处理yjs历史记录和恢复指定版本做流程性说明,开发完整产品如果需要对历史记录过多进行优化,需要将历史记录前n个记录进行二进制合并,减少表格数据

不想看使用,直接去获取代码 前端yjs服务

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();
    })
  }
}
  • 14
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值