基于 Yjs + Vue 自研表格实现多用户协同编辑的关键难点与解决方案

初版PHA-X采用handsontable实现HAZOP分析功能,由于后期实现多人协同加之handsontable不提供富文本编辑器的问题,且市面上没有开源或免费的表格系统使用,故自研基于Yjs + Vue 表格实现。

在构建类似 Excel / Notion 的协同表格系统时,单纯的数据渲染并不复杂,真正的难点在于:

  • 多用户实时协同(CRDT 一致性)
  • 表格结构复杂(合并单元格)
  • 编辑体验(IME 输入法、光标行为)
  • 状态管理(undo / redo)
  • 性能优化(对于复杂表格,渲染性能必须优化)

本文基于 Vue3 + Yjs 的实际项目经验,梳理几个核心难点及解决思路。

1.合并单元格

在之前基于handsontable的开发中,我采用以下方式记录合并单元格信息

{ row: 2, col: 3, rowspan: 4, colspan: 1 }

遇到的问题:

多人同时操作:

  • 用户 A 合并 A1
  • 用户 B 删除 A2

merge 区域失效

问题分析:row / col 是不稳定标识在 CRDT 系统中,插入 / 删除会改变 index

解决方案:在yjs中,使用 rowId / colId 替代 index,避免直接使用行号、列号导致合并单元格数据失效

mergeCell 重构

示例,实际并不是基于uuid实现的,而是rowidx/colidx
{
  startRowId: 'r-uuid-1',
  startColId: 'c-uuid-2',
  rowspan: 4,
  colspan: 1
}

2.IME 输入法问题

遇到的问题:选择单元格后,直接输入内容,IME没有输入对象导致首字母被直接输入到富文本编辑器

问题分析:浏览器 IME 工作机制:keydown → input → compositionstart

解决方案:始终存在一个可聚焦的输入节点,让 IME 有“输入目标”在表格初始化时,设置一个隐藏的<textarea>,该区域随着用户选中的单元格移动,同时设置该<textarea>的z-index: -1 系统可见用户不可见

开发过程中的问题:需要注意<textarea>的位置需要根据用户当前选中单元格进行调整,对于合并单元格/多行单元格,位置计算逻辑重点关注,避免IME遮挡单元格

3.多用户撤销/重做问题

在单用户表格中,撤销 / 重做通常是简单的,只需要操作 → 入栈 → undo → 回退

但在 Yjs 多人协同环境中,会出现以下问题:

3.1撤销了不属于自己的操作

遇到的问题:A编辑(1,1)=100 B编辑(1,1)=200 A点击撤销,(1,1)变为100

3.2操作顺序被打乱

遇到的问题:A 操作1 → B 操作2 → A 操作3,B撤销时,将A的操作3撤销了

3.3结构操作与内容操作不同步

遇到的问题:插入行 + mergeCells + 修改单元格,撤销后导致表格结构错乱

解决方案:为每个用户维护独立撤销栈(Undo Stack)并设置基于客户端的Undo/RedoManager,并在所有操作中标记 origin:

doc.transact(() => { insertRow(…) setMergeCells(…) }, clientId)

4.多人光标与选区同步

基本结构

awareness.setLocalState({ 
user: { name: 'User A', color: '#ff0000' }, cursor: { rowId: 'r-1', colId: 'c-2' }, selection: { start: { rowId: 'r-1', colId: 'c-2' }, end: { rowId: 'r-3', colId: 'c-4' } } })

多人光标

function updateCursor(rowId, colId) { const state = awareness.getLocalState() || {} awareness.setLocalState({ ...state, cursor: { rowId, colId } }) }

变化同步

awareness.on('change', () => { const states = awareness.getStates() states.forEach((state, clientId) => { if (clientId === awareness.clientID) return renderRemoteCursor(clientId, state.cursor) }) })

5.数据拉取与初始化同步问题

问题描述:用户1进入 → 从数据库加载数据 → 初始化 Yjs → 开始编辑 → 同步到协同服务器

用户2进入→再次从数据库加载→初始化 Yjs→覆盖掉用户1的数据

解决方案:用户首次进入初始化Yjs Room → 从数据库加载 → 之后所有用户 → 从 Yjs Room获取

6.框选单元格性能优化

问题描述:在框选单元格时,移动鼠标导致CPU占用率100%

问题分析:原onDocMouseMove方法中,未设置节流机制,导致每一个移动事件(尤其是高DPI鼠标)中,每秒执行数百次DOM遍历,同时在YJS中进行广播,导致卡顿

问题片段:

function onDocMouseMove(e: MouseEvent) {
  if (!isDragging) return
  const pos = getCellIndicesFromEvent(e)
  if (!pos) return
  const range: RangeSelection = {
    startRowIdx: dragStartRowIdx,
    startColIdx: dragStartColIdx,
    endRowIdx: pos.rowIdx,
    endColIdx: pos.colIdx,
  }
  const expandedRange = expandRangeByMerges(range, positionalMergeCells.value)
  if (expandedRange && (expandedRange.startRowIdx !== expandedRange.endRowIdx || expandedRange.startColIdx !== expandedRange.endColIdx)) {
    draggedRangeThisPointer = true
    setSelectionRange(expandedRange)
  } else {
    setSelectionRange(null)
  }
}

function onDocMouseUp(_e: MouseEvent) {
  if (draggedRangeThisPointer) {
    suppressNextCellClick = true
  }
  isDragging = false
}

解决方法:使用requestAnimationFrame(RAF)进行节流,同时新增processDragMouseMove方法,在鼠标移动时,判断当前Position的lastDragRowIdx / lastDragColIdx是否变化,未变化则跳过

7.渲染性能优化(未实现)

问题描述:每个cell使用v-for循环加全量DOM渲染绑定过多逻辑,在cell过多时产生严重的性能瓶颈

解决方案:使用虚拟滚动(visibleRows)?或者绕开VUE实现渲染?

留下评论

您的邮箱地址不会被公开。 必填项已用 * 标注