初版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实现渲染?
