Univer 表格引擎实战:自定义填写与单元格权限锁定实现

发布时间:2026/10/2 22:22:41
Univer 表格引擎实战:自定义填写与单元格权限锁定实现 1. Univer 是什么先从一张在线表格说起前几天有个朋友问我有没有一种前端方案能在自己的网页里嵌入一个表格让用户自己定义一部分单元格、自主填写但表格里的其他区域用户碰都不能碰管理员和普通用户看到的权限完全不同。他原本打算用 Excel 宏 局域网共享文件夹来解决我说你趁早死了这条心直接上 Univer一个基于 TypeScript 的开源表格引擎 SDK。Univer 的核心定位一句话就能讲清楚它是一个跨平台、可嵌入、可扩展的在线办公套件 SDK目前最成熟的是电子表格Spreadsheet同时也在覆盖文档和幻灯片。它解决的核心问题就是让开发者不再从零自研表格渲染引擎、公式引擎、协同编辑、权限控制这一整套东西而是像搭积木一样把表格能力直接集成到自己的 Web 应用里。如果你对前端生态比较熟可以把 Univer 理解成“表格界的 Monaco Editor”——Monaco 把 VS Code 的编辑器能力拆出来给你用Univer 把办公软件的表格能力拆出来给你用。它不绑定任何框架React 能接、Vue 能接、原生 JS 也能接底层是基于 Canvas 自绘渲染的而不是那种拿着 HTML table 硬撑的伪表格。这篇文章我想从实际项目出发把 Univer 的架构、渲染管线、协同机制、权限控制这几个核心模块逐一拆开讲重点放在“如何用 Univer 实现用户自定义填写、其他单元格锁定不可改”这个最常见的业务场景上最后再整理一批我实际踩过的坑。无论你是架构师、前端工程师还是正在做数据采集系统的产品负责人这篇文章应该都能帮你少走很多弯路。2. 为什么选 Univer不重新造轮子的技术理由2.1 在线表格引擎的现状对比在 Univer 出现之前Web 端做表格这件事可选方案其实很有限。传统路线是拿开源库直接在 HTML 里铺表格比如 Handsontable、AG Grid这些库对“类 Excel”体验的支持其实很浅虽然交互流畅但公式引擎、单元格合并、条件格式、数据透视这些能力要么是残缺的要么需要自己造。紫金山下的老牌项目 Luckysheet 当年很火可惜维护节奏不稳定而且底层是 DOM 渲染数据量一大页面就卡成 PPT。商业路线则是 SpreadJS、Handsontable Pro 这类付费方案功能确实完备但价格不菲而且源码封闭想改渲染逻辑或者深度定制交互是很痛苦的。Univer 正好卡在中间开源、免费商用Apache 2.0、Canvas 渲染、自带公式引擎、自带协同能力、插件化架构。它更像是一个“办公套件底座”而不是简单的一张表格。2023 年到 2024 年这个项目在 GitHub 上热度涨得非常猛一度冲到前端开源项目趋势榜前列背后的团队DreamNum本身就是做办公软件出身对 Excel 行为的还原度很高。2.2 Univer 的四大核心优势第一渲染层是 Canvas 自绘。Univer 的表格区域不是一堆 div 或者 table 标签而是用 Canvas 统一绘制。这意味着上万行单元格不会产生成千上万个 DOM 节点内存占用和渲染性能比 DOM 渲染方案高一个量级。第二数据模型是 MVVM 改良版。Univer 内部把表格数据拆成了命令Command与数据快照Snapshot两套体系所有用户操作都先变成命令命令再改数据快照数据快照再异步推到渲染层。这个设计天然适合做撤销重做、历史记录和协同同步。第三插件机制非常干净。你可以像装 VS Code 插件一样给 Univer 挂各种自定义模块自定义单元格类型、自定义菜单项、自定义工具栏按钮、自定义公式函数甚至自定义渲染层浮层。我后面讲到的单元格权限锁定就是通过这个插件体系实现的。第四协同编辑是内建能力。Univer 提供了基于 CRDT 的协同方案多人同时编辑同一个表格不会出现文档互相覆盖的问题。对于做企业内部数据采集、多人填报这类场景来说这条太关键了——你不需要自己设计 OT 算法或者 WebSocket 数据同步协议了。2.3 它适合解决哪些具体问题如果只是做一个展示型报表Univer 可能有点过度设计但如果你遇到下面这些场景Univer 的性价比就非常高你在自己的 SaaS 系统里需要内嵌一个可编辑表格用户填完自动落库你需要让不同角色看到不同编辑范围比如销售填“本月销售额”经理填“目标值”其他人只读你需要表格支持公式联动、批量选择、撤销重做、键盘导航这些 Excel 级操作体验你需要多人同时录入同一张表且不能互相锁死你需要把一个复杂的 Excel 模板迁移到 Web 端保留原有格式和公式。我见过太多团队走弯路一开始用 Excel 模板 公司群收发文件数据汇总全靠人工 copy后来用 Form 表单收集但老板说字段太死板产品说需要像 Excel 一样灵活再后来自己写一个可编辑表格前端从选型到落地拖了三个月。这三个坑用 Univer 基本都能绕过去。3. 核心架构拆解Univer 的渲染、模型与命令系统3.1 三层架构业务层、命令层、渲染层Univer 的整体架构可以简化为三层理解。最上层是业务层也就是你通过univerjs/preset-sheets这类包暴露出来的 API初始化表格实例、注册插件、监听单元格变更、修改样式、设置权限等。这一层直接面向开发者接口风格比较接近“配置化”前端框架填参数就能跑。中间是命令层Univer 的一切用户操作输入数据、拖动边框、合并单元格、改变列宽都会被封装成一个个 Command例如SetRangeValuesCommand、MergeCellCommand。命令是什么它就是一个带有类型标识和参数负载的对象像这样{ type: SetRangeValuesCommand, payload: { worksheetId: sheet-01, cellValues: { A1: { v: 你好, s: bold }, A2: { v: 100 } } } }统一命令化的好处在于前端可以做到任何操作都是可追踪、可回放、可撤销重做的。这在逻辑上和 Git 的 commit 很像你不是直接改数据而是产生一个“操作记录”由框架决定如何把记录落到数据模型上。最底层就是数据模型 渲染引擎。数据模型保存了工作簿、工作表、单元格值、样式、行列配置、合并矩阵等完整的表格快照渲染引擎监听数据模型的变化把差异部分绘制到 Canvas 上。Univer 的渲染引擎并非每次都全量重绘而是通过区域脏检查对变更区域打标记局部更新画布这也是它能承载大表格的关键。3.2 插件机制为什么说是“办公套件底座”Univer 的插件系统借鉴了前端微内核的思路。核心包负责最小化的表格能力数据模型、渲染、基础交互。其他一切功能——公式、数据验证、条件格式、协同、筛选、分页、工具栏 UI——都是插件。这个设计带来的实际收益很直接体积可控、按需加载。如果你只做只读报表可以只引入渲染和核心命令包首屏 JS 体积能控制在几百 KB 级别如果要上线 Excel 级编辑再按需加载公式包和协插件。我一个项目里首屏加载从全量引入的 2.3MB 降到按需引入的 900KB体感差异非常明显。插件注册的代码风格大致是这样import { Univer } from univerjs/core; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverFormulaEnginePlugin } from univerjs/engine-formula; const univer new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverFormulaEnginePlugin);这个模式的好处是你可以用同一套机制去写自己的业务插件比如给表格加一个“提交到后端”的按钮、加一个自定义校验规则、加一个导出 PDF 的功能全部通过registerPlugin挂进去不需要改 Univer 核心代码。3.3 公式引擎与计算链公式这块单独拿出来说因为它是用户感知最强的部分。Univer 内置的公式引擎不是简单地把公式字符串 split 一下然后 eval它维护了一个依赖图dependency graph知道A1的数值变化会影响哪些公式单元格从而做到增量计算。举个例子你在C1写IF(A1100, B1*0.8, B1*0.9)Univer 会记录C1依赖A1和B1。当用户把A1从 50 改成 120公式引擎只重算依赖链上被影响到的单元格而不是全表重算。这个机制在大型数据表上的性能差距极大几百个公式联动时几乎无感刷新。而且这套公式引擎是在 Web Worker 里跑的。Univer 会把公式计算任务丢到 Worker 线程主线程只负责渲染和交互这样公式崩溃或者计算超时不会卡住 UI。对用户来说就是“录入数据瞬间算出结果”的丝滑体验。4. 场景实战用户自定义填写其他单元格锁定的完整实现4.1 需求场景拆解现在来聊正题。标题里提到的高频场景是Univer 支持用户定义表格然后让用户去填写一些单元格其他的单元格用户无法修改。这个场景在企业里实在是太常见了。我举三个真实例子项目周报项目经理预置好每行要填的内容工作项、优先级、负责人员工只能填“完成进度”和“阻塞说明”两列其余不可动门店月度盘点总部锁定了 SKU 名称、分类、标准库存门店只能填实际库存数和差异说明预算申请表财务锁定了支出科目和额度上限申请人只能填本次申请金额而且填完不能改历史数据。这类需求在纯 Excel 时代就是“工作表保护 锁定单元格”但在 Web 端要实现同样的体验你需要做三件事定义可编辑区域、拦截非编辑区域的修改操作、搭建编辑权限与后端数据的双向同步。Univer 刚好都提供了对应的接口只是没那么直白下面我把每一步拆开讲。4.2 方案一启用 Univer 工作表保护Univer 内置了工作表保护机制对应的命令是SetWorksheetProtectionCommand。你可以通过它锁定整个工作表然后单独放行指定区域。这和 Excel 的“保护工作表”思路非常像。先用一个最小示例说明。假设你已经初始化好了一个 Univer 实例并拿到了当前工作簿对象import { Univer } from univerjs/core; import { UniverSheetsPlugin } from univerjs/sheets; // 初始化已在前面完成 const univer new Univer(); const sheet univer.getActiveWorkbook()?.getActiveSheet(); // 设置工作表保护 univer.getCommandService().executeCommand({ type: SetWorksheetProtectionCommand, payload: { unitId: workbook-001, sheetId: sheet?.getSheetId(), protection: { password: pwd-123456, rules: { allowEdit: [] } } } });这里protection.rules.allowEdit是一个数组用来声明例外放行的单元格区域。比如你希望B2:B20和C2:C20这两块区域可以由用户自由编辑其余单元格全部锁定那allowEdit应该这样填allowEdit: [ { range: { startRow: 1, startColumn: 1, endRow: 19, endColumn: 1 } }, { range: { startRow: 1, startColumn: 2, endRow: 19, endColumn: 2 } } ]注意 Univer 的行列索引是从 0 开始所以B2对应的是startRow: 1, startColumn: 1C2是startRow: 1, startColumn: 2。这里的细节很容易踩坑如果你填成 Excel 的行列编号锁定区域会完全错位。设置了工作表保护之后Univer 会在命令层拦截所有“修改单元格值”的命令。即使用户通过键盘敲击、复制粘贴、拖拽填充到锁定区域命令都会被拒绝执行表格画面保持原样。这个拦截发生在数据模型变更之前因此不存在“改了又弹回去”的闪烁问题。4.3 方案二通过权限策略做更精细的动态控制工作表保护适合“整表锁定局部放行”的静态场景。但现实中很多需求是动态的同一张表给 A 用户放行B2:B20给 B 用户放行D2:D20管理员全放开。这种场景下工作表保护的静态规则就不够用了。Univer 社区常用的做法是结合一份“权限 JSON”来实现动态控制。你可以在业务层维护一个用户权限对象const userPermissions { user-001: { allowEdit: [ { startRow: 1, startColumn: 1, endRow: 19, endColumn: 1 } ], allowSetStyle: false, allowInsertRow: false, allowDeleteColumn: true }, user-002: { allowEdit: [ { startRow: 1, startColumn: 2, endRow: 19, endColumn: 2 } ] } };然后根据当前登录用户动态构建 protection 规则function applyPermissionForUser(userId: string) { const perm userPermissions[userId]; univer.getCommandService().executeCommand({ type: SetWorksheetProtectionCommand, payload: { unitId: workbook-001, sheetId: sheet.getSheetId(), protection: { rules: { allowEdit: perm.allowEdit } } } }); }切换用户时重新执行一遍这个命令保护范围就变了。由于命令本身就是幂等的重复执行不会叠加所以动态切换权限是安全且干净的。从架构角度看这个设计把“权限判定”拆到了前端后端只需要在保存时再校验一次数据合法性就行。前端控制交互体验后端控制数据安全两侧各自把关这是我在生产环境里验证过的最稳妥搭配。4.4 再加一道保险监听命令阻断非编辑操作前面两种方案的核心都是“单元格值不能改”但业务上往往还有更多隐形要求锁定列的列宽能不能调锁定的行能不能删除表头能不能合并这些都超出了allowEdit的管辖范围。这时候就要用到 Univer 的命令拦截机制在命令执行前注册一个拦截器动态判断该命令是否允许执行。官方 API 是interceptor你可以拦截BeforeCommandExecute事件检查命令名称和涉及的区域命中非授权区域就直接返回错误结果。示例univer.getCommandService().onCommandExecuted((command) { if (command.type SetRangeValuesCommand) { const payload command.payload; // 检查 range 是否落在用户的允许编辑区域内 if (!isRangeAllowed(payload.range, currentUser)) { // 通知 UI 层提示“当前区域不可编辑” toast(该区域已被锁定无法修改); } } });不过说实话这种方式属于“阻断 提示”的应用层兜底不建议作为唯一的权限控制手段。原因很简单前端拦截只能影响 UI 操作如果用户自己打开控制台调用命令 API拦截器照样可以绕过。真正的数据安全永远要靠后端入库前校验。前端拦截的作用是让普通用户有良好的交互反馈不至于按键没反应也不知道为什么。4.5 编辑权限与后端同步把用户填写的数据落库用户填完数据只是开始真正要解决的是数据怎么同步到后端。Univer 的推荐做法是监听CellChangeCommand或者SetRangeValuesCommand的刚执行的时机然后把变更的区域和值批量发送给后端。我在项目里用的模式是这样的let batchBuffer: ArrayCellChangeEvent []; let flushTimer null; univer.getCommandService().onCommandExecuted((command) { if (command.type SetRangeValuesCommand command.payload.cellValues) { batchBuffer.push({ sheetId: command.payload.sheetId, values: command.payload.cellValues }); // 防抖500ms 合并一次批量提交 clearTimeout(flushTimer); flushTimer setTimeout(flushBatchChanges, 500); } }); function flushBatchChanges() { if (batchBuffer.length 0) return; fetch(/api/spreadsheet/batch-update, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ changes: batchBuffer }) }); batchBuffer []; }这个做法的好处是显著减少网络请求频率。用户连续录入 20 个单元格前端只会在停止输入 500ms 后发起一次合并请求后端压力小很多。我实测过高频编辑场景下单用户请求量降低了 90% 以上。不过要注意防抖提交在高并发协同场景下有个小坑如果两个用户同时改同一行后端合并顺序不同最终值可能不一致。解决方法是后端按“用户 变更时间戳”做最后写入优先last-write-wins或者直接用 Univer 的协同服务来做合并。单机数据采集场景下last-write-wins 就够用了。5. 常见问题与排查技巧实录5.1 为什么工作表保护设了用户按下键盘还是能改这个坑我一开始也踩过。问题通常出在你设置了SetWorksheetProtectionCommand但用户表格当前激活的 Sheet 并不是你设置保护的那个。Univer 一个工作簿里有多个 Sheet保护是挂在具体sheetId上的如果你拿到的getActiveSheet()和用户实际编辑的 Sheet 不是同一个保护自然就不生效。另一个高频原因是命令发送到了univer实例但你注册了多个 Univer 实例比如老代码和新代码各初始化了一个用户操作的是第二个实例命令发到了第一个实例。排查方法很简单打印出当前操作实例的唯一标识确认命令执行实例和渲染实例是同一个。5.2 大表格性能问题怎么优化首屏渲染和滚动流畅度Univer 的 Canvas 渲染虽然比 DOM 方案强很多但也不是无限性能的。我测试过一张 5 万行、30 列的表格首屏渲染大概 200ms正常滚动流畅但一旦做全列填充或者复制粘贴超大范围交互还是会有卡顿。解决思路有三个优先用univerjs/preset-sheets的按需引入不要全量倒入所有插件对超大表格关闭自动公式计算等用户操作完手动触发一次全量计算如果你只需要展示可以把表格置于只读模式直接禁用交互层渲染压力会大幅下降。5.3 SSR 环境怎么集成 Univer如果你用的是 Next.js 这类 SSR 框架Univer 初始化依赖window和document不能在服务端渲染过程中直接实例化。解决方案是动态导入 只在mounted或useEffect里创建实例import dynamic from next/dynamic; const UniverComponent dynamic( () import(./UniverSheet), { ssr: false } );组件内部再初始化 Univer。这个坑在官方文档里提过但藏得比较深遇到的兄弟不在少数。5.4 协同冲突时两个用户同时改同一个单元格谁赢Univer 的协同方案基于 CRDT理论上不会出现“最后保存的人覆盖别人”的粗暴崩溃。但实际使用中如果两个用户几乎同时修改同一个单元格最终值以哪一个为准取决于合并策略的配置。默认情况下Univer 协同服务会按操作的时间戳排序后到达的版本覆盖先到的。如果你的业务要求“不能覆盖别人的填写”比如多人填报同一张活动报名表每个人的填报区域完全不同那其实不存在冲突问题。但如果区域重叠我建议你在前端配合区域锁定把重叠的可能性直接消灭掉。5.5 数据验证如何限制用户只能填数字或下拉选择权限锁定解决的是“能不能改”数据验证解决的是“能改成什么样”。Univer 支持数据验证插件可以给指定区域设置校验规则univer.getCommandService().executeCommand({ type: AddDataValidationCommand, payload: { rule: { type: list, options: [进行中, 已完成, 已阻塞], allowBlank: true }, ranges: [{ startRow: 1, startColumn: 1, endRow: 19, endColumn: 1 }] } });设置之后用户只能从下拉列表里选择或者输入符合校验规则的内容。这个功能配合区域锁定基本覆盖了 90% 的企业数据采集需求。6. 实战心得Univer 项目落地后的几点体会项目拆到这儿我最后聊几句大实话。Univer 的文档质量在开源项目里算中上水准但它的 API 更新速度也很快小版本之间偶尔会有 breaking change所以直接把官方的示例代码复制到旧版本项目里可能会遇到找不到模块的报错。我的经验是锁版本号不要无脑 latest。生产环境我用的是固定版本升级前先跑一遍测试用例确认核心命令和服务接口没变。另外尽量在项目早期就规划好业务插件层。Univer 的插件机制很强大但它不会替你想清楚业务边界。你只需要把“表格能力”当作基础设施把“业务规则”写在插件层比如权限判断、数据回写、按钮功能这样后续加需求时不用动表格核心。最后再分享一个细节Univer 的选中区域信息、单元格值、权限规则都有对应的监听事件你在调试时可以直接在控制台打印univer.on(FocusCellChangeEvent, (cell) { console.log(当前选中单元格, cell); });这个能力在做调试和日志分析时特别方便比瞎猜内部状态高效得多。我把这段代码放在项目的内部调试工具里每次排查用户反馈的问题都能快速定位到具体单元格和操作命令。Univer 还在快速迭代但它已经能满足非常大量的真实业务场景了。如果你手头正好有嵌入表格、权限填空、协同录入类的需求不必再纠结自研还是付费方案把 Univer 拉下来跑一个 demo可能会发现原来上午还在群里讨论的方案下午就已经能上线演示了。