Univer实战:命令拦截实现表格指定区域可编辑与只读锁定
做后台系统这些年我收到最多的需求不是“做一个在线Excel”而是“给用户一张表他只填指定的几个格子其他区域一律不能动”。这个需求听起来简单落到技术选型上却能把人逼疯用传统表格库样式和交互可控但编辑模型和权限模型都得自己搭用完整版在线文档权限倒是有却又重得没法按项目定制。Univer出现之后这个“只读可编辑”混合场景终于有了比较通顺的解。Univer是一个基于TypeScript的开源在线表格/文档/幻灯片框架核心特点是Canvas渲染、命令驱动、插件化数据模型与UI分离。它最大的优势不是又做了一套“网页版Excel”而是把表格内的每一次改动都变成一条可拦截、可编排、可回溯的命令。这意味着“哪些区域能改、哪些区域不能改”不再是一个前端样式的面子工程而是一个可以在命令层统一落地的权限问题。这篇文章我会用一个最小项目为主线讲清楚怎么用Univer搭起一个可运行的表格应用再逐步实现“白名单可编辑区域、其余单元格锁定”的效果最后给出我从实际项目里踩过的坑和产品化思路。适合手里有类似“在线表单表格”“数据填报系统”“业务表格权限控制”需求并且有React/TypeScript基础的前端同学参考。1. “只让用户填几个格子”为什么难倒了在线表格先聊一个很多人忽视的事实单一表格库并不稀缺稀缺的是“表格加上业务规则”。Univer之所以值得专门说一说是因为它把业务规则和表格内核的边界切得很干净。1.1 在线表格选型里的经典两难我以前给团队做过一次技术选型对比当时候选方案大概有这几类Handsontable轻量、性能好、社区成熟但它的定位是“表格组件”不是“表格应用”。权限、协同、混合只读这些能力都需要自己写而且写起来很别扭因为编辑事件已经发生在组件内部你只能在事件回调里做事后补偿。Luckysheet老版本功能丰富渲染性能不错但工程风格比较重二次开发和现代框架集成时容易遇到维护问题。纯前端自研表格成本最高。不要以为画一个网格、能输入文字就够了真正的表格背后有数据模型、选区模型、公式引擎、撤销栈、渲染调度、复制粘贴协议一堆东西。自己做一套“够用的”往往最后会发现自己只是在仿制别人做了十年的东西。腾讯文档/金山文档体验顶级但不可定制权限规则完全跟着别人的产品设计走接入你业务系统时要各种绕。Univer解决的是这个两难问题它本身提供了完整的表格内核包括Canvas渲染、公式、协同基础、插件化和一套统一命令流。你不需要重造轮子但又能在它上面写自己的业务规则——比如本篇主题“用户自定义表格后只允许填写指定单元格”。1.2 Univer的核心设计为什么适合“锁定和放行”关键在它的架构哲学UI不直接改数据。用户在界面上输入一个值流程是这样的用户操作触发一个命令比如SetRangeValueCommand命令内部转换成底层变更Mutation例如ISetRangeValueMutationMutation更新数据模型Univer内部通知渲染层刷新Canvas重新绘制用户看到新值。这个流程意味着无论用户是键盘输入、粘贴、拖拽填充、公式重算还是通过API脚本改数据最终都要经过命令和变更这两个环节。于是“只允许用户填指定单元格”就变成了一个很纯粹的问题在命令执行之前判断这次变更涉及的范围是否合法如果不合法就拦下来。你不需要监听每一个UI事件不需要DOM上禁用输入框也不需要遮挡单元格。只要在命令层统一守门所有入口都会自动被收敛。1.3 Univer自身的工作表保护与普通业务场景的差异Univer内部其实也有类似Excel的“工作表保护”能力可以设置哪些单元格锁定、哪些区域允许编辑。但实际项目里我会建议你把它当成“最后一道公共防线”而不是唯一的业务规则引擎。原因是业务权限往往不是单纯“锁死”或“放开”而是“这个人能填A区那个人能填B区提交之后全表锁定管理员可以临时开放例外”。这些规则需要很灵活地绑定用户身份、单据状态、时间窗口纯粹靠Excel模板式的保护规则很难描述清楚。所以我的做法通常是两层都上业务层自己写一个权限插件挂在命令流上实时判断“这个操作是否符合当前用户、当前表格上下文”模型层用Univer的保护能力做兜底防止某些绕过命令层的边缘修改路径。2. 用Univer搭起一个基础可编辑表格应用在细化权限之前得先让Univer跑起来。网上有很多版本差异导致的坑这里我把一个最简可运行方案一步步说清楚。2.1 环境与依赖安装Node建议用16以上包管理器随意。项目本身最好是React/Vue工程Univer也支持纯TypeScript接入但如果你后续要做业务表单、弹窗提示推荐还是用框架工程。安装时注意版本对齐Univer的包更新比较快不同版本之间API偶有移动npm install univerjs/core univerjs/sheets univerjs/sheets-ui univerjs/ui univerjs/design univerjs/icons如果要使用公式能力再加univerjs/engine-formula。如果要做文档和幻灯片再按需引入对应插件。这一篇主要聚焦表格所以核心是core、sheets、sheets-ui、ui四个包。2.2 创建一个最简单的Sheet实例以React为例核心初始化代码大致是这样的import { useEffect, useRef } from react; import { Univer, UniverInstanceType } from univerjs/core; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; import { UniverUIPlugin } from univerjs/ui; function UniverTable() { const containerRef useRefHTMLDivElement(null); useEffect(() { if (!containerRef.current) return; const univer new Univer({ locale: zhCN, locales: { zhCN: () import(univerjs/locale/zhCN), }, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverUIPlugin, { container: containerRef.current, }); // 创建一个带初始数据的Sheet univer.createUnit(UniverInstanceType.SHEET, { name: 订单登记, sheetData: { A1: { v: 客户名称 }, B1: { v: 联系方式 }, C1: { v: 提交日期 }, A2: { v: }, B2: { v: }, C2: { v: }, }, }); return () { univer.dispose(); }; }, []); return div ref{containerRef} style{{ width: 100%, height: 600 }} /; }2.3 初始化时的几个关键注意点容器必须有确定高度。Univer的Canvas渲染引擎启动时会读取容器尺寸如果你给的是height: 0或者依赖异步图片加载后才有高度经常会出现白屏或者表格只渲染一半。React 18 StrictMode 下注意重复初始化。开发模式里Effect会执行两次如果不做处理会出现“页面出现两个Univer实例”或者某个插件注册冲突。最稳的做法是把初始化封装成单例或者在useEffect里先检查容器上是否已有实例。版本锁定。这是踩过最多次的坑。univerjs/core和univerjs/sheets-ui如果版本跨度大可能出现核心命令ID对不上、插件不兼容。建议把所有univerjs/*包锁定到同一个发布批次。跑起来之后你应该能看到一个带基本工具栏、行列头、可编辑网格的在线表格。接下来才是重头戏怎么把除指定区域外的单元格“锁死”。3. 把“其他单元格锁死”落地命令拦截的完整实现思路很多人的第一反应是做样式层把不可编辑的格子背景置灰监听单元格点击事件如果是锁定格就不进入编辑状态。这个方案不是没用但非常脆弱。用户会通过粘贴、拖拽填充、查找替换、双击后修改甚至别人写的脚本命令绕过去。真正可靠的做法是在命令执行前做权限校验。3.1 读懂Univer命令流的拦截点Univer的命令流核心是一个CommandService你可以把它理解成表格的“操作总线”。所有动作都会以命令对象的形式走这条总线。我常用的拦截方式是监听beforeCommandExecutedimport { ICommandService } from univerjs/core; import { SetRangeValueCommand } from univerjs/sheets; import { filter } from rxjs; class EditableRangeGuard { constructor(ICommandService private readonly commandService: ICommandService) {} enable(editableRanges: Range[]) { this.commandService.beforeCommandExecuted$ .pipe(filter((cmd) cmd.id SetRangeValueCommand.id)) .subscribe((cmd) { const params cmd.params; if (params !this.isAllowed(params.range, params.value, editableRanges)) { // 取消本次命令或者抛出“不允许修改”的信号 cmd.cancel?.(); this.notifyUser(该单元格为只读区域无法修改); } }); } }具体取消API在不同版本里可能叫cancel、preventDefault或者通过返回值拦截实际写法请对齐你安装的版本类型定义。但核心思想是稳定的在命令真正改变数据模型之前有机会把它终止掉。3.2 不只是输入命令要拦截一整套修改入口如果只拦截SetRangeValueCommand你会发现还有这些入口可以修改只读区域粘贴命令PasteCommand可以一次性写入大范围拖拽填充有对应的填充命令合并单元格MergeCellCommand会吞掉多个格子的内容插入/删除行列InsertRowCommand、RemoveRowCommand会移动整个表格结构清空内容ClearRangeContentCommand一个快捷键就能删掉只读区数据公式重算结果写回某些场景下公式结果变化也会触发变更。所以我建了一张“危险命令清单”把所有会修改单元格数据的命令ID都列出来统一在守卫里做范围检查const MUTATE_COMMANDS new Set([ SetRangeValueCommand.id, PasteCommand.id, ClearRangeContentCommand.id, MergeCellCommand.id, FillRangeCommand.id, // 根据实际业务继续补充 ]);在拦截时如果命令不在清单里就放行如果在就去检查它涉及的所有范围。3.3 用“完全包含”而不是“相交”来判断可编辑区域这是我在实际开发中遇到的一个关键决策点。早期我判断“目标区域是否可编辑”用的是区间相交逻辑只要目标范围与白名单有一个格子重叠就放行。结果用户反馈了一个很严重的问题他复制了B1:B10粘贴到A1其中A1是合法格子A2:A10是锁定格子。系统按“相交”判断发现A1可编辑就允许了整体粘贴于是A2:A10被一并覆盖。所以后来我把判断策略改成了“完整包含”如果用户目标是写入A1:A10那么这10个格子必须全部落在白名单区域之内才允许执行只要有任何一个格子不在白名单里整条命令就被拒绝。这样就不会出现“一次操作既改了合法区又改了锁死区”的漏洞。function rangeContained(target: { row: number; col: number; rowEnd: number; colEnd: number }, allowed: Array{ row: number; col: number; rowEnd: number; colEnd: number }) { return allowed.some((a) { return target.row a.row target.col a.col target.rowEnd a.rowEnd target.colEnd a.colEnd; }); }提示Univer的Range对象在表示整行/整列时rowEnd或colEnd可能是很大的数值甚至Infinity判断时注意归一化别把整列操作误判成“合法”。4. 从锁单元格到业务表单白名单权限体系的工程化设计单纯锁几个格子当然只是开胃菜。真正有价值的是把Univer变成一套“业务表单系统”核心是权限规则能跟着用户身份和业务状态走。4.1 设计一份可下发的权限配置我实际用的权限结构大概是这样的interface EditableRange { row: number; col: number; rowEnd: number; colEnd: number; } interface RolePermission { roles: string[]; editableRanges: EditableRange[]; expiresAt?: number; // 可选的过期时间比如“未提交之前可以改” } interface SheetPermissionConfig { sheetId: string; permissions: RolePermission[]; }这份配置可以写在前端常量里也可以由后端接口下发。每次用户切换身份、或者单据状态变化时重新拉取配置即可。举个例子一个订单登记表普通销售人员只能编辑[row:4, col:1]到[row:50, col:6]销售主管可以编辑[row:4, col:1]到[row:50, col:8]还可以修改审核意见列财务人员只能编辑金额列提交后的单据所有人都不允许编辑任何单元格。这些规则用上面的配置结构完全可以表达。4.2 在守卫里绑定用户身份与业务状态权限守卫的拦截函数不能只接收一个范围还需要接收“当前用户是谁、当前单据处于什么状态”。我封装了一个PermissionContextinterface PermissionContext { userId: string; roleId: string; bizStatus: draft | submitted | rejected; }然后在拦截命令时先取出上下文function isCommandAllowed(cmd: ICommand, context: PermissionContext): boolean { if (context.bizStatus submitted) return false; const allowed getEditableRangesForRole(context.roleId); return allowed.some((range) rangeContained(getCmdRange(cmd), [range])); }这样Univer就不再只是一个“能填写的网格”而是变成了一台受业务规则驱动的数据采集前端。4.3 用户反馈也要做拦截后给出明确提示命令被拦截时如果什么都不提示用户会觉得是Bug。我在实际项目里做了两件事拦截时用Univer的UI能力弹一条轻提示例如“当前区域为只读你只能填写C3:F10区间”对只读单元格做视觉上的弱化处理比如背景色改成浅灰让用户一开始就知道哪里能填、哪里不能填。视觉弱化我是在Sheet初始化或每次权限配置更新时把只读区域的单元格样式统一设置一遍。注意这只是“提示”不是“安全机制”安全机制依然在命令流里。5. 实测遇到的坑权限拦截、撤销重做与性能优化理论说得通不等于实际不出问题。下面这几个坑都是在真实项目里修过不止一回的写出来省得你重复踩。5.1 撤销重做可以绕过只读判断最开始我只在beforeCommandExecuted里做了校验其他命令一概放行。结果用户反馈先正常填写A1然后提交锁定此时A1本不应再改但用户按了一下CtrlZA1的内容居然被撤销修改了随后CtrlY又能重做。原因很简单撤销重做会通过另一个命令通道回放历史Mutations这个通道并没有走我的业务守卫。这就等于我在门口设了保安但有人从地下车库开进去。解决思路是所有回放类命令也要经过守卫。如果你用的版本里有类似UndoCommand、RedoCommand需要检查它们的回放目标确认目标范围是否仍然合法。在一些版本里更彻底的做法是让每次变更都附带权限校验桩回放时也重新调用一遍。建议做完整读锁定后一定要亲自测三轮锁定前修改、锁定后撤销、锁定后重做。缺一个都不行。5.2 粘贴和拖拽填充会修改大范围区域这是5.3里“完全包含”判断的延伸。实际开发中会遇到更复杂的情况用户从外部Excel复制了一大片区域粘贴到表格里命令的目标范围是按粘贴起点自动展开的可能四五百个格子。你不能只校验起点格也不该机械地逐个格子校验性能会崩正确的做法是计算粘贴目标展开后的完整Range用这个完整Range与当前用户的白名单做“完全包含”判断包含才放行否则直接拒绝整次粘贴。不要想着“那我只允许粘贴重叠部分”散装粘贴会留下半修改的状态后续撤销、重算都很难处理。5.3 样式锁和模型锁不一致的问题还有一个常见的半吊子做法给只读单元格设置CSSpointer-events: none或者在编辑器里设置univerReadOnly之类的前端标记。这些会让用户“操作不了”但在命令层看来它并不认识什么CSS粘贴、查找替换、脚本API依然可以写入数据。尤其涉及到协同编辑时另一个端没有CSS锁就能直接改掉。我把这个原则写进了团队规范锁单元格必须锁命令样式只是提示。5.4 权限判断的性能不要每条命令都做全量计算如果白名单区域很多或者表格是大数据量场景每次拦截都循环所有配置做矩形判断可能会有明显卡顿。优化方案我用了两个一是位图缓存把每个Sheet的可编辑范围预计算成一张稀疏数组或bitmap判断时直接看目标格子是否命中缓存命中即合法。区域不大时尤其快。二是分级拦截先在“Sheet”级别上判断如果整张Sheet都没有任何可编辑区域直接拒绝所有修改命令根本不用进入Range级别计算只有Sheet存在白名单时才做细致判断。5.5 协同场景下的权限时效性如果你接了协同还得注意用户A在本地的权限判断结果不能作为服务端最终依据。正确做法是前端命令拦截用来提升体验服务端协同消息到达时再做一次权威校验。否则用户A改了锁定区域同步消息直接分发到所有端本地判断就成了摆设。6. 从Demo到线上产品Univer表单化改造的后续路线“只允许用户填指定格子”只是第一步。聊到这里你应该能感受到Univer的真正优势在于它把表格从“工具”变成“可编程的业务载体”。顺着这个方向继续往下走大概会有这么几条路线。6.1 从“表格工具”到“业务表单系统”常见的落地方案是把Univer嵌入到一个“任务表单”里后端下发一张模板Sheet定义好哪些字段需要用户填写用户填写完成后点“提交”前端拉取所有单元格数据生成JSON传给后端后端把JSON存库需要展示时再把数据反渲染回Univer提交之后权限配置更新为“全表单只读”用户看到的是一张定稿的登记表。这个模式下Univer的表格就是表单的皮肤但它比传统input表单强得多支持行列布局、批量粘贴、公式自动汇总、区域锁定。我做过一个巡检数据采集项目原本用上百个input拼成的页面换成Univer之后巡检人员可以用复制粘贴的方式批量填入设备数据效率提升非常明显。6.2 读回数据与汇总联动Univer支持从实例取出当前Sheet的快照数据。拿到数据后可以自己落库也可以用Univer的公式引擎和前端汇总功能。一个更省事的做法是模板Sheet里预设几个汇总单元格用公式写成SUM(C4:C30)这些汇总格放在白名单之外用户看不到修改入口但它会自动计算并展示。这样既保证用户只填原始数据又能享受表格的自动计算能力。6.3 多人协同时的权限版本管理真正上线之后若同一张表有多人同时编辑权限配置就不能是纯前端静态数据了。我建议把权限配置做成一个带版本号的资源用户打开表格时拉取permissionVersion后端一旦更新权限比如某单据已提交、某用户被提升角色立刻通知客户端重新拉取前端守卫拦截时使用“当前已加载的版本”作为判断依据避免和协同数据产生时间差。从工程角度看这套设计并不复杂但非常实用。很多人做在线表格都只盯着“能不能编辑”忽略了这类业务状态流转带来的权限变化而Univer的命令拦截机制刚好可以把这些状态变化全部嵌入到用户操作链路里。6.4 如果要对接Univer的协同基础设施Univer本身也在演进协同能力长远看多端实时同步是内置趋势。但作为经验之谈我会建议即便用了协同也不要删除前端命令守卫。协同适合解决“多端并发写同一份数据”的问题而“谁有权限写、哪些区域不能动”是业务规则问题两者要分开。最稳妥的架构是协同负责同步权限负责过滤服务端校验兜底。就目前做过的项目来说我最深的体会是Univer这类表格框架真正的价值不在“像不像Excel”而在于它把表格内每一次变化都变成了可编程的事件。当你把“锁定单元格”从CSS问题、监听问题升级成命令拦截问题时后面的权限体系、表单逻辑、协同约束都会顺很多。如果你正好也要做类似“用户自定义表格局部填写权限”的需求建议就从命令守卫开始先把所有修改命令拦住再一步步放开白名单。这条路走通之后你会发现“表格即表单”并不是什么遥远的事。