1. 从一张“只能填指定格子”的表格说起
第一次接触 univer 是在一个内部数据填报系统里。业务方的需求听起来特别简单:给用户一张表格,只允许他们填写其中几列,其他列要么是公式自动算出来的,要么是系统预置的只读数据,用户碰都不能碰。我一开始想的是用现成的表格组件加一层校验,结果发现要么性能撑不住几千行,要么样式和 Excel 差太远,业务方天天吐槽“这表格怎么这么难用”。
后来翻到 univer 这个项目,它的定位是“一个开源的表格与文档协作引擎”,核心能力是把电子表格、文档、幻灯片这些办公套件的能力做成可嵌入的 SDK。热词里提到的“univer 支持用户定义表格,然后让用户去填写一些单元格,其他的单元格用户无法修改”,正好就是我那个场景的解法。它不是简单地给你一个渲染好的表格,而是把整个表格的模型、渲染、交互、权限都拆成了可编程的模块,你可以精确控制每一个单元格能不能编辑、能不能选中、能不能看到。
这篇文章我打算把 univer 从架构到实操完整拆一遍。适合谁看?如果你正在做在线表格、数据填报、报表配置、低代码平台里的表格模块,或者单纯想了解一个现代 Canvas 表格引擎是怎么设计的,那这篇应该能省你不少踩坑时间。我会重点讲清楚三件事:univer 的插件架构为什么这么设计、怎么用它的权限模型实现“部分单元格可编辑”、以及在实际项目里怎么把它和 Node.js 服务端配合起来做数据持久化。
2. univer 的整体设计与插件架构拆解
2.1 为什么它不叫“表格组件”而叫“表格引擎”
市面上大部分表格组件,比如 AG Grid、Handsontable,本质上是“渲染 + 事件”的封装。你给它数据,它画出来,你监听它的编辑事件,然后自己处理业务逻辑。这种模式在简单场景下很舒服,但一旦遇到复杂需求,比如“某些单元格根据另一张表的数据动态决定是否可编辑”,你就得在事件回调里写一堆判断,代码很快就乱了。
univer 的思路不一样。它把表格拆成了几个核心层:数据模型层(Model)、渲染层(Render)、交互层(Interaction)、命令层(Command)、插件层(Plugin)。你操作的不是一个黑盒组件,而是一个可以被你扩展的运行时。举个例子,你想让某一列只能填数字,在普通组件里你可能要写onCellEdit回调去校验;在 univer 里,你可以写一个插件,注册一个命令拦截器,在命令执行前就把非法输入挡掉,而且这个拦截器对所有入口生效——键盘输入、粘贴、拖拽填充,一个都跑不掉。
这种设计带来的直接好处是一致性。我踩过的一个坑是:用某表格组件时,键盘输入做了校验,但用户从 Excel 粘贴进来就绕过了,因为粘贴走的是另一套 API。univer 的命令层统一了所有修改数据的入口,你只要在命令层做一次拦截,所有路径都被覆盖。
2.2 插件架构到底解决了什么问题
univer 的插件架构不是那种“为了显得高级而插件化”的设计。它的核心插件包括:SheetPlugin(表格核心)、FormulaPlugin(公式计算)、RenderPlugin(Canvas 渲染)、UI plugin(工具栏、右键菜单)、PermissionPlugin(权限控制)。每个插件都可以独立加载或替换。
为什么这么拆?因为不同场景对表格的需求差异极大。比如一个纯展示的报表,你不需要公式插件,也不需要编辑权限插件,加载它们只会增加包体积和初始化时间。而一个数据填报系统,你可能需要权限插件但不需要公式插件。插件化让你按需组合,而不是被迫接受一个全量包。
更关键的是,插件之间通过事件总线和命令系统通信,而不是直接互相引用。这意味着你可以写一个自定义插件,监听BeforeCommandExecute事件,在SetRangeValuesCommand执行前检查目标单元格是否在允许编辑的范围内。这个自定义插件不需要修改 univer 的任何源码,也不需要理解渲染层是怎么工作的。
2.3 Canvas 渲染的取舍与代价
univer 用 Canvas 而不是 DOM 来渲染表格。这个选择在热词里也被反复提到,因为 Canvas 绘图是它的核心技术点之一。Canvas 的好处很直接:几万行数据滚动时,DOM 方案会创建大量节点,浏览器直接卡死;Canvas 只画可视区域内的单元格,性能稳定得多。
但 Canvas 也有代价。DOM 表格天然支持文本选择、无障碍访问、浏览器自带的查找功能,Canvas 全都要自己实现。univer 在这块做了不少工作,比如自己实现了文本选区、剪贴板、滚动条,但如果你要做深度定制,比如给单元格加一个复杂的下拉组件,就得用“浮层 DOM”的方式,在 Canvas 上方叠加一个绝对定位的 DOM 元素。这个模式在 univer 的 UI 插件里很常见,工具栏、右键菜单、公式输入框都是这么做的。
我的经验是:如果你的表格行数经常超过 1000 行,或者需要频繁重绘,Canvas 方案的优势非常明显;但如果你的表格只有几十行,而且需要大量自定义单元格组件,DOM 方案可能更省事。univer 适合前者,后者用普通组件反而更快。
3. 核心细节解析:权限模型与可编辑单元格的实现
3.1 univer 的权限控制到底控制了什么
很多人以为“权限”就是“能不能编辑”,但在实际业务里,权限至少分三层:可见性(这个单元格能不能被看到)、可选中性(能不能被点击选中)、可编辑性(能不能修改值)。univer 的权限模型把这三层都覆盖了。
它的核心是一个PermissionService,你可以注册一个权限判断函数,接收单元格的位置信息(sheetId、row、column),返回一个权限对象。这个对象里可以指定readable、selectable、editable三个布尔值。比如你要实现“A 列到 C 列可编辑,D 列只读但可见,E 列完全隐藏”,就是在这个函数里根据列索引返回不同的权限组合。
这里有个细节值得注意:权限判断是同步的,而且会被频繁调用。因为每次渲染、每次点击、每次命令执行前都要问一遍“这个单元格能不能干某事”。如果你的权限函数里做了网络请求或者复杂计算,表格会直接卡住。正确的做法是在初始化时把权限规则加载到内存,权限函数只做内存查询。
3.2 实现“部分单元格可编辑”的完整思路
回到热词里的那个需求:用户定义表格,指定哪些单元格可填,其他不可改。用 univer 实现的话,大致分四步。
第一步,定义表格结构。你需要告诉 univer 这个表格有多少行、多少列、表头是什么。这一步通过Workbook的sheet配置完成,可以理解为创建一个空的电子表格。
第二步,设置初始数据。对于只读的单元格,你可以预置数据;对于可编辑的单元格,可以留空或者给一个默认值。univer 的数据模型是稀疏的,你不需要为每个单元格都设置值,只设置有内容的即可。
第三步,注册权限规则。这是核心。你需要根据业务规则,判断每个单元格的editable属性。比如“第 0 行是表头,不可编辑;第 1 到 5 列可编辑;第 6 列是公式列,不可编辑”。这个规则可以写成一个函数,输入行列索引,输出权限对象。
第四步,拦截编辑命令。虽然权限服务会阻止大部分编辑操作,但为了保险,最好再注册一个命令拦截器,在SetRangeValuesCommand执行前再检查一次。双重保险的原因是:权限服务主要影响 UI 层的交互(比如双击不进入编辑态),但如果有代码直接调用命令 API,权限服务可能不会拦截。命令拦截器是最后一道防线。
3.3 公式列与只读列的联动处理
实际业务里,只读列往往不是静态的,而是根据可编辑列的值动态计算的。比如“总价 = 单价 × 数量”,单价和数量可编辑,总价只读。univer 的公式插件支持这种场景,你可以在总价列设置公式=B2*C2,然后通过权限规则把总价列设为不可编辑。
但这里有个坑:公式计算是异步的,而且可能触发连锁更新。如果用户修改了单价,总价会重新计算,这个计算过程会触发数据变更事件。如果你的权限规则里依赖了总价的值(比如“总价超过 1000 时锁定数量列”),就要小心循环触发。我的做法是把这类依赖逻辑放在命令执行后的回调里,而不是权限判断函数里,避免在渲染过程中触发副作用。
另一个坑是粘贴操作。用户从 Excel 复制一片区域粘贴进来,如果目标区域里混有可编辑和不可编辑的单元格,univer 默认会整体拒绝还是部分接受?实测下来,它会在命令层做校验,如果任何一个目标单元格不可编辑,整个粘贴操作会被拒绝。这个行为在大多数场景下是合理的,但如果你希望“只粘贴可编辑的部分”,就需要自己写一个自定义命令来拆分粘贴区域。
4. 实操过程:从零搭建一个可填报表格
4.1 环境准备与依赖安装
univer 是一个前端 SDK,但它的构建和开发流程依赖 Node.js 生态。热词里大量出现 Node.js 安装相关的内容,说明很多人在第一步就卡住了。我建议用 Node.js 20 以上的 LTS 版本,太老的版本可能在依赖安装时遇到兼容性问题。
安装方式很简单,用 npm 或 pnpm 都可以。核心包是@univerjs/core,然后按需安装@univerjs/sheets、@univerjs/sheets-ui、@univerjs/sheets-formula等。如果你要用它的预设包,可以直接装@univerjs/presets,里面打包了常用插件,省去一个个选的麻烦。
提示:univer 的包更新比较频繁,建议锁定版本号,不要用
^或~,否则某天自动升级后 API 变了,排查起来很痛苦。
4.2 初始化一个最小可用的表格
初始化的核心是创建一个Univer实例,然后注册插件。下面是一个简化的代码结构,我把它拆成几步来说明。
import { Univer, LocaleType, merge } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; // 创建实例 const univer = new Univer({ locale: LocaleType.ZH_CN, theme: defaultTheme, }); // 注册插件 univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); // 创建表格 univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: 'fill-form', sheets: { sheet1: { id: 'sheet1', name: '填报表格', rowCount: 100, columnCount: 10, cellData: { 0: { 0: { v: '姓名' }, 1: { v: '部门' }, 2: { v: '工时' }, 3: { v: '单价' }, 4: { v: '总价' }, }, }, }, }, });这段代码创建了一个 100 行 10 列的表格,第一行是表头。注意cellData的结构是行索引 -> 列索引 -> 单元格对象,v表示值。这个结构是稀疏的,你不需要为每个单元格都写配置。
4.3 注册权限规则实现部分可编辑
接下来是核心的权限配置。univer 的权限服务需要通过插件注册,我写一个自定义插件来演示。
import { ICommandService, IPermissionService } from '@univerjs/core'; class FillFormPermissionPlugin { constructor(private permissionService: IPermissionService) {} onStarting() { this.permissionService.registerPermissionHandler((params) => { const { row, column } = params; // 表头行不可编辑 if (row === 0) { return { readable: true, selectable: true, editable: false }; } // 第 0 到 3 列可编辑 if (column >= 0 && column <= 3) { return { readable: true, selectable: true, editable: true }; } // 第 4 列是公式列,只读 if (column === 4) { return { readable: true, selectable: true, editable: false }; } // 其他列完全隐藏 return { readable: false, selectable: false, editable: false }; }); } }这个处理函数的逻辑很直白:根据行列索引返回权限对象。实际项目里,这个规则可能来自后端接口,比如“当前用户只能编辑自己所在部门的行”,那就需要在函数里查用户信息和行数据的映射关系。
注意:权限处理函数会被高频调用,千万不要在里面做
await网络请求。正确的做法是提前把权限数据加载到内存,函数里只做同步查询。
4.4 命令拦截作为第二道防线
权限服务主要影响 UI 交互,但如果有代码直接调用命令 API,或者用户通过某些快捷键绕过 UI,权限服务可能拦不住。所以我建议再加一层命令拦截。
import { ICommandService } from '@univerjs/core'; import { SetRangeValuesCommand } from '@univerjs/sheets'; class EditGuardPlugin { constructor(private commandService: ICommandService) {} onStarting() { this.commandService.interceptCommand({ getMutations: (command) => { if (command.id === SetRangeValuesCommand.id) { const { range } = command.params; // 检查 range 内所有单元格是否都可编辑 if (!this.isRangeEditable(range)) { return { commands: [], mutations: [], error: new Error('该区域不允许编辑'), }; } } return { commands: [command], mutations: [] }; }, }); } isRangeEditable(range) { // 遍历 range 内的单元格,检查权限 // 这里省略具体实现,逻辑与权限处理函数一致 return true; } }拦截器的返回值里,如果commands为空数组,命令就不会被执行。这样即使用户通过控制台调用 API,也会被挡住。
4.5 数据持久化与 Node.js 服务端配合
前端表格填完后,数据要存到后端。univer 提供了getSnapshot()方法,可以导出整个表格的完整状态,包括单元格数据、样式、公式、合并单元格等。这个快照是一个 JSON 对象,直接 POST 给后端即可。
后端用 Node.js 接收时,我建议不要直接存整个快照,而是解析出业务需要的字段存到数据库。原因是快照结构会随 univer 版本变化,直接存快照的话,将来升级版本可能读不出来。我的做法是:前端提交时,除了快照,再额外提交一份“业务数据”,只包含可编辑列的值和对应的行标识。后端只存业务数据,快照作为附件存对象存储,用于恢复现场。
// 前端导出 const snapshot = univer.getSnapshot('fill-form'); const businessData = extractBusinessData(snapshot); await fetch('/api/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ snapshot, businessData }), });// Node.js 后端接收 app.post('/api/save', async (req, res) => { const { snapshot, businessData } = req.body; // 业务数据入库 await db.collection('form_submissions').insertOne({ ...businessData, createdAt: new Date(), }); // 快照存对象存储 await oss.put(`snapshots/${Date.now()}.json`, JSON.stringify(snapshot)); res.json({ ok: true }); });这个模式的好处是:业务查询走数据库,性能好;现场恢复走快照,保真度高。两者互不干扰。
5. 常见问题与排查技巧实录
5.1 表格初始化后一片空白
这是最常见的问题,通常有三个原因。第一,容器元素没有设置宽高。univer 的 Canvas 需要明确的尺寸,如果父容器高度是 0,表格就画不出来。第二,插件注册顺序不对。UniverSheetsUIPlugin必须在UniverSheetsPlugin之后注册,否则 UI 层找不到表格实例。第三,createUnit的配置里rowCount或columnCount为 0,导致没有可渲染的区域。
排查方法:打开浏览器控制台,看有没有报错;然后在createUnit之后打印univer.getActiveWorkbook(),确认实例创建成功。
5.2 权限规则不生效
权限规则不生效的典型表现是:明明设置了editable: false,但双击单元格还是能进入编辑态。原因通常是权限插件注册的时机太晚,或者权限处理函数返回了undefined。univer 在拿不到权限对象时,默认行为是“允许”,所以一定要确保函数对所有分支都有返回值。
另一个可能是:你用的是@univerjs/presets里的预设包,预设包里可能已经注册了一个默认的权限服务,你的自定义权限服务被覆盖了。解决办法是检查插件注册顺序,确保自定义权限插件在预设插件之后注册。
5.3 公式列不计算
公式列不计算,先检查有没有注册UniverSheetsFormulaPlugin。这个插件不是默认加载的,需要手动注册。然后检查公式的引用格式,univer 的公式和 Excel 基本一致,但有些函数不支持,比如VLOOKUP的某些变体。如果公式里引用了其他 sheet 的数据,要确保 sheet 名称拼写正确,且被引用的 sheet 已经创建。
还有一个隐蔽的坑:如果你在cellData里直接设置了公式字符串,但没有设置f字段,univer 会把它当普通文本。正确的写法是{ f: '=B2*C2' },而不是{ v: '=B2*C2' }。
5.4 粘贴操作导致数据错乱
从 Excel 粘贴数据时,如果源数据的列数和目标区域的列数不一致,univer 默认会按左上角对齐,多余的部分截断或扩展。如果你的表格有隐藏列或只读列,粘贴时可能会把数据写到错误的位置。解决办法是在粘贴命令执行前,检查剪贴板数据的列数和目标区域的列数是否匹配,不匹配就拒绝或提示用户。
我遇到过一个更诡异的情况:用户从网页上复制了一段带 HTML 格式的表格,粘贴进来后单元格里出现了奇怪的样式。这是因为剪贴板里同时有纯文本和 HTML 两种格式,univer 优先解析了 HTML。解决办法是在粘贴处理里强制使用纯文本格式,或者对 HTML 做清洗。
5.5 性能问题排查速查表
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 滚动卡顿 | 权限函数里有复杂计算 | 在权限函数里打时间戳 | 把计算移到初始化阶段 |
| 输入延迟 | 公式链过长 | 检查公式依赖关系 | 拆分公式或改用静态值 |
| 内存暴涨 | 快照未释放 | 用 Performance 面板看内存 | 及时销毁不用的实例 |
| 首屏慢 | 插件加载过多 | 看 Network 面板的包体积 | 按需加载插件 |
| 编辑态错位 | Canvas 缩放比例 | 检查 devicePixelRatio | 手动设置缩放适配 |
提示:univer 的 Canvas 渲染对
devicePixelRatio很敏感。在高分屏上,如果容器没有正确设置缩放,单元格的点击区域会和视觉区域偏移。解决办法是在初始化时传入正确的devicePixelRatio,或者用 CSS 把 Canvas 的宽高设为容器宽高的 1 倍,让浏览器自动处理。
6. 一些实操心得与扩展思路
我在实际项目里用 univer 做了三个不同的表格场景:数据填报、报表展示、配置管理。踩过的坑总结下来,最重要的一条是:不要试图用 univer 解决所有表格问题。它的强项是“大数据量 + 复杂交互 + 可编程权限”,如果你的需求只是展示一个静态表格,用普通 HTML 表格或者轻量组件反而更省事。
另一个心得是关于插件开发的。univer 的插件系统很灵活,但文档相对简略,很多 API 需要看源码才能理解。我的建议是先从修改官方示例开始,跑通一个最小插件,然后再逐步加功能。不要一上来就写复杂插件,很容易因为某个 API 用法不对而卡住。
扩展方面,univer 目前对协同编辑的支持还在完善中。如果你要做多人同时填报表,需要自己实现冲突解决和实时同步。我的做法是用 WebSocket 同步命令,而不是同步快照,因为命令的粒度更细,冲突更容易处理。具体来说,每个用户的操作都封装成一个命令,通过服务端广播给其他用户,其他用户收到命令后在本地执行。这个模式在 univer 的命令系统下是可行的,但需要处理好命令的幂等性和顺序问题。
最后分享一个小技巧:univer 的getSnapshot导出的数据里包含了大量渲染相关的配置,如果你只需要业务数据,可以写一个遍历函数,只提取cellData里的v和f字段,忽略样式和布局信息。这样导出的数据体积能小很多,传输和存储都更高效。