做后台系统这些年,我收到最多的需求不是“做一个在线Excel”,而是“给用户一张表,他只填指定的几个格子,其他区域一律不能动”。这个需求听起来简单,落到技术选型上却能把人逼疯:用传统表格库,样式和交互可控,但编辑模型和权限模型都得自己搭;用完整版在线文档,权限倒是有,却又重得没法按项目定制。Univer出现之后,这个“只读+可编辑”混合场景终于有了比较通顺的解。
Univer是一个基于TypeScript的开源在线表格/文档/幻灯片框架,核心特点是Canvas渲染、命令驱动、插件化,数据模型与UI分离。它最大的优势不是又做了一套“网页版Excel”,而是把表格内的每一次改动都变成一条可拦截、可编排、可回溯的命令。这意味着“哪些区域能改、哪些区域不能改”不再是一个前端样式的面子工程,而是一个可以在命令层统一落地的权限问题。这篇文章我会用一个最小项目为主线,讲清楚怎么用Univer搭起一个可运行的表格应用,再逐步实现“白名单可编辑区域、其余单元格锁定”的效果,最后给出我从实际项目里踩过的坑和产品化思路。适合手里有类似“在线表单表格”“数据填报系统”“业务表格权限控制”需求,并且有React/TypeScript基础的前端同学参考。
1. “只让用户填几个格子”为什么难倒了在线表格
先聊一个很多人忽视的事实:单一表格库并不稀缺,稀缺的是“表格加上业务规则”。Univer之所以值得专门说一说,是因为它把业务规则和表格内核的边界切得很干净。
1.1 在线表格选型里的经典两难
我以前给团队做过一次技术选型对比,当时候选方案大概有这几类:
- Handsontable:轻量、性能好、社区成熟,但它的定位是“表格组件”,不是“表格应用”。权限、协同、混合只读这些能力都需要自己写,而且写起来很别扭,因为编辑事件已经发生在组件内部,你只能在事件回调里做事后补偿。
- Luckysheet(老版本):功能丰富,渲染性能不错,但工程风格比较重,二次开发和现代框架集成时容易遇到维护问题。
- 纯前端自研表格:成本最高。不要以为画一个网格、能输入文字就够了,真正的表格背后有数据模型、选区模型、公式引擎、撤销栈、渲染调度、复制粘贴协议一堆东西。自己做一套“够用的”,往往最后会发现自己只是在仿制别人做了十年的东西。
- 腾讯文档/金山文档:体验顶级,但不可定制,权限规则完全跟着别人的产品设计走,接入你业务系统时要各种绕。
Univer解决的是这个两难问题:它本身提供了完整的表格内核,包括Canvas渲染、公式、协同基础、插件化和一套统一命令流。你不需要重造轮子,但又能在它上面写自己的业务规则——比如本篇主题“用户自定义表格后,只允许填写指定单元格”。
1.2 Univer的核心设计为什么适合“锁定和放行”
关键在它的架构哲学:UI不直接改数据。
用户在界面上输入一个值,流程是这样的:
- 用户操作触发一个命令,比如
SetRangeValueCommand; - 命令内部转换成底层变更(Mutation),例如
ISetRangeValueMutation; - Mutation更新数据模型,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 = useRef<HTMLDivElement>(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,你可以把它理解成表格的“操作总线”。所有动作都会以命令对象的形式走这条总线。
我常用的拦截方式是监听beforeCommandExecuted:
import { 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 在守卫里绑定用户身份与业务状态
权限守卫的拦截函数不能只接收一个范围,还需要接收“当前用户是谁、当前单据处于什么状态”。
我封装了一个PermissionContext:
interface 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本不应再改,但用户按了一下Ctrl+Z,A1的内容居然被撤销修改了,随后Ctrl+Y又能重做。
原因很简单:撤销重做会通过另一个命令通道回放历史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问题、监听问题,升级成命令拦截问题时,后面的权限体系、表单逻辑、协同约束都会顺很多。如果你正好也要做类似“用户自定义表格+局部填写权限”的需求,建议就从命令守卫开始,先把所有修改命令拦住,再一步步放开白名单。这条路走通之后,你会发现“表格即表单”并不是什么遥远的事。