1. 为什么我会盯上Univer:从一次"表格权限"需求说起
前段时间接了个让我头疼的需求:公司几十个部门要在线上填同一张预算收集表,每个部门只能改自己那几行的指定列,其他任何格子都不能动。同事甩过来一版Excel模板,说"做到网页上就行"。我一听就知道麻烦来了——市面上能嵌入网页的表格组件不少,但绝大多数要么只能展示、要么要钱、要么交互太弱,真正能模拟Excel那套"先锁定部分单元格,再保护工作表"心智的,掰着指头数得过来。
我在GitHub上翻了半天,最终把一个叫univer的开源项目拉进了项目清单。这是一个完全基于TypeScript构建的在线表格/文档/演示文稿一体化引擎,代码结构非常现代,和传统那种"把Excel功能抄一层皮"的轮子完全是两个路子。更关键的是,它从底层设计了命令系统和插件机制,保护工作表、数据验证这类"限制用户操作"的能力不是后期打补丁,而是架构里留有明确的位置。
如果你也在做这类"在线表格+可控填报"的需求,这篇文章值得看完。我会把选型逻辑、Univer的核心设计、实现"只能填指定单元格"的完整路径,以及我在实跑过程中踩到的几个坑都讲清楚。不管你是准备自己搭一套数据收集工具,还是在评估要不要对现有系统里的表格模块动刀,这些内容都能帮你省至少两三天调研时间。
先说结论:Univer对标的不是"又一个Luckysheet",而是一套真正的Office引擎——它未来要覆盖的不只是表格,还有文档和幻灯片。但代价也很明显:版本推进快、API变动频繁、社区示例不够多。我用它做完第一版之后的最大感受是,值得用,但别指望所有问题都能从文档里找到答案,很多时候得自己读源码。
2. 为什么"用户只能改指定单元格"这么难:需求拆解与三条实现路径
把需求翻译成技术语言之前,先看看"让用户填一部分单元格、其他格子动不了"到底涉及哪几层能力。
第一层是可读性。用户需要看到整张表,能滚动、能选中、能复制,只是不能改。这一层相对容易,但有些实现光做"表格展示"就会丢掉选择和高亮交互,体验很糟。
第二层是可写性。系统必须精确控制哪些range可编辑、哪些range不可编辑。控制不是"画个灰色蒙层"那种假把式,而是从事件层面拦住输入。比如键盘输入、粘贴、拖拽填充、批量填充,这些入口全部要走同一套管控机制。
第三层是有效性。能填的区域还得校验格式——填金额不能写中文、填百分比不能超范围。这就是数据验证(Data Validation)的活了。
在Univer之前,我调研过几条实现路径:
- 路径A:给Excel模板加锁定,交给前端只读展示。简单,但用户没法提交数据,也不适合做流程流转。
- 路径B:用成熟商业组件。功能全但预算高,而且许可证和私有化部署的问题够扯皮一阵。
- 路径C:用开源组件做深度定制。Luckysheet和x-data-spreadsheet我都试过,前者功能多但代码历史长、维护有点随缘,后者的编辑能力太基础。
Univer吸引我的恰恰是它在路径C里走出了不一样的设计:所有用户的编辑操作都通过**Command(命令)**对象走一遍,这个设计天然适合做"拦截——校验——放行/拒绝"的逻辑。后面我需要做操作审计时也会受益于此。
回到需求本身。在实现方案设计阶段,我把"只允许改B2:E10"这样的需求分成了三种落地模式:
| 模式 | 适用场景 | 实现核心 | 缺点 |
|---|---|---|---|
| 模板+工作表保护 | 固定结构的收集表、预算表 | 工作簿/工作表保护 + 单元格锁定样式 | 需要用户前后端同时维护模板结构 |
| 数据验证+公式联动 | 填报内容本身还要做下拉选择或公式计算 | 在可编辑区域上施加数据验证规则 | 拦截的是"非法值"而不是"非法位置" |
| 命令拦截+后端鉴权 | 同一张表多人多角色,且审计要求高 | 在Command层按range和角色做权限判断 | 需要自行实现规则和数据模型 |
注意,这三个模式不是互斥的。我实际做预算填报表的时候,三者是叠加使用的:工作簿保护挡住大范围操作,单元格锁定精确到"哪些range可写",数据验证再保证填入的值合法。这种组合拳的思路,我觉得才是"用户定义表格并限制他人修改"这类需求的正解。
3. Univer的架构里藏着什么:命令系统、插件与保护机制
Univer这个词乍看陌生,它的主要能力覆盖Sheet、Docs、Slide三种文档类型,核心是Sheet。我第一次看它的目录结构时,第一反应是"这不像一个表格组件,更像一个小型Office套件"。它对开发者最友好的地方在于:你不需要操作DOM或者Canvas事件来管理单元格,而是操作Univer提供的业务对象,比如Workbook、Worksheet、Range,然后用命令来改变它们的状态。
这句话决定了整篇文章的思路:在Univer里,"限制编辑"本质上是给一个状态机加一道防线,而不是给输入框加监听器。
3.1 命令系统:所有修改都有迹可循
Univer里几乎每个操作都对应一个Command。插入行是命令,合并单元格是命令,设置数据验证是命令,开启工作表保护也是命令。你可以通过CommandService来执行命令、监听命令执行前后的事件,甚至可以在命令执行前阻断它。
这个设计对"限制编辑"意义重大。因为你可以做一个命令拦截器:在用户执行任何会修改单元格值的命令之前,先判断目标range是否在允许编辑的集合内,不在就直接拒绝。这样就不怕用户绕过UI——比如他不用输入法直接粘贴、拖拽填充,这些途径都会被统一切到命令层。
我在做审计功能时就受益于此。每次用户改动单元格,会在命令的历史记录里留下一条可追踪的记录,我可以据此做数据快照对比。如果用的组件不支持命令模式,这种能力要额外写一堆防穿透代码。
3.2 保护工作表与单元格锁定:来自Excel的老朋友
用惯了Excel的人都知道,要在Excel里实现"只能填某些格子",最标准的操作是:全选整表设置单元格锁定,然后把允许编辑的区域取消锁定,最后在"审阅"里开启保护工作表。这样会弹出一个密码框,别人能不能改、能不能选锁定区域、能不能插入行列,都可以逐项配置。
Univer继承了这套心智:每个单元格的样式里有一个锁定位,工作表上有一个保护开关,开关打开后默认锁定单元格不可编辑,同时会记录密码以及若干放行策略。这和Excel的差别在于,Univer把它做成了一套可供编程调用的API,而不是藏在界面菜单里。
3.3 插件与预设:不要被陌生名词吓住
早期使用Univer需要手动引入十几个包,比如@univerjs/core、@univerjs/sheets、@univerjs/sheets-ui、@univerjs/sheets-formula等。后来官方提供了预设包,一个UniverPresetSheet就能把前端编辑、工具栏、快捷键等能力打包拉起。我实际项目里用的是React+Vite,直接通过npm安装了预设包再调用new Univer({ locale: ... })创建实例,就得到一个完整的在线表格界面。
初学阶段,不必把所有包都理解透,只记住几个关键概念即可:
- Univer实例:整个应用的总管理器。
- Workbook(工作簿):一个文件,包含若干Sheet。
- Worksheet(工作表):具体那张网格表。
- Range(区域):比如
A1:C10,几乎所有操作都围绕Range展开。 - CommandService:执行和监听命令的服务,做拦截时主要用它。
- SheetProtection:工作表保护对象,控制锁定的行为与密码。
有了这张地图,后面写代码时就不会迷路。
4. 实操:从零搭建一个"只有指定单元格可编辑"的在线表格
这节是全文的核心,我直接把当时在项目里跑通的步骤铺开来讲。环境是这样:前端用Vite + React + TypeScript,Univer的相关包锁定在当时的稳定版本线。如果你跟着做,建议先搭一个最小Vite项目,再逐步加Univer。
4.1 安装依赖与创建容器
先把Univer的几个核心包装进项目。以我当时的依赖为例:
npm install @univerjs/core @univerjs/presets @univerjs/sheets @univerjs/sheets-ui @univerjs/sheets-formula @univerjs/design @univerjs/ui注意:Univer版本更新比较频繁,不同版本之间的包名和API会有调整,我的示例基于9.x系列,拿到新版本后如果提示找不到某个类或方法,优先去看仓库里的examples目录,那里通常有最新的写法。
然后准备一个占满屏幕的容器:
// App.tsx import { useEffect, useRef } from 'react'; import { LocaleType } from '@univerjs/core'; import { Univer, UniverPresetSheet, UniverSheet } from '@univerjs/presets'; export default function App() { const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!containerRef.current) return; // 1. 初始化 Univer 实例 const univer = new Univer({ locale: LocaleType.ZH_CN }); // 2. 创建一张电子表格 univer.createUniverSheet( new UniverPresetSheet({ container: containerRef.current, toolbar: true, }) ); // 后面补上数据模板、锁定、保护的逻辑 }, []); return <div ref={containerRef} style={{ width: '100%', height: '600px' }} />; };这样跑起来,页面上会出现一个带工具栏的空白表格。先别急着写业务,确认Univer能正常渲染再往后走。
4.2 把表格内容模板定义好
要控制"哪些单元格能改",前提是表格本身的结构是确定的。我的做法是预先定义一个模板数据,交给Univer渲染。以预算收集表为例:A列是部门,B列是预算项,C列是金额,D列是说明。市场部有3条预算项,但每个部门只能填写自己那一段的C、D列。
写代码时,我通过初始化数据塞入了一个简单模板:
const sheetData = { sheets: { budget: { name: '预算填报', rowCount: 30, colCount: 10, cellData: { 0: { 0: { v: '部门' }, 1: { v: '预算项' }, 2: { v: '金额' }, 3: { v: '说明' } }, 1: { 0: { v: '市场部' }, 1: { v: '差旅费' } }, 2: { 0: { v: '市场部' }, 1: { v: '活动物料' } }, 3: { 0: { v: '市场部' }, 1: { v: '投放费用' } }, 4: { 0: { v: '研发部' }, 1: { v: '服务器资源' } }, // 更多预算项…… }, // 也可以用 worksheetData 等方式设置样式和列宽 } } };这里强调一点:先定义模板结构,再谈锁定和权限。如果模板本身还在变化,锁定规则很容易被需求推翻,后面改起来心态会崩。
4.3 设置单元格锁定与解除锁定
Univer的单元格样式里有锁定位。按照Excel的玩法,我要先给整张表"默认锁定",再把允许用户编辑的range"解除锁定"。实际操作中如果模板数据已渲染好,我可以逐单元格读取再修改样式;更省事的做法是直接读取当前Sheet实例,批量设置样式。
伪代码思路是这样的:
// 获取当前工作簿和工作表 const workbook = univer.getUniverSheetInstance(univer.getActiveUniverSheet()?.getUnitId()); const worksheet = workbook?.getActiveSheet(); // 遍历需要的区域,设置 locked 状态 // locked === true,表示打开工作表保护后不可编辑 const lockRange = ({ sheet, startRow, startCol, endRow, endCol, locked }) => { sheet.getRange(startRow, startCol, endRow - startRow + 1, endCol - startCol + 1).setStyle({ locked }); }; // 1. 先全表锁定:A1 到 最大范围 lockRange({ sheet: worksheet, startRow: 0, startCol: 0, endRow: 29, endCol: 9, locked: true }); // 2. 解锁允许填写的区域:比如 C2:D3 市场部可填写区 lockRange({ sheet: worksheet, startRow: 1, startCol: 2, endRow: 3, endCol: 3, locked: false });这段代码没有完全照抄Univer的API,因为我当时用的Range方法的名称在不同版本里也变过几次。如果你的版本里没有setStyle,就打开控制台,在worksheet实例上直接看它的方法列表,通常有setRangeValues、setStyle或setRangeStyle之类的入口。读实例方法的习惯,比死记API更重要。
4.4 开启工作表保护
光有锁定样式还不够,必须开启工作表保护,让锁定状态真正生效。这里的关键命令是SetWorksheetProtectionCommand之类的命令(命令ID以版本为准)。开启后,所有被锁定的单元格都会拒绝用户的编辑操作,而被解锁的区域可以正常填写。
我的实现参考:
const protection = { lock: true, password: '这里是可选密码', selectLockedCells: false, // 是否允许用户选中被锁定单元格 selectUnlockedCells: true, // 是否允许用户选中可编辑单元格 allowSelectLockedCells: false, allowSelectUnlockedCells: true, // 如果要严格控制,可设置不允许插入行列、不允许删除行列 allowInsertRows: false, allowDeleteRows: false, }; await commandService.executeCommand({ id: 'sheet.command.set-worksheet-protection', params: { unitId: workbook?.getUnitId(), subUnitId: worksheet?.getSheetId(), protection, }, });执行完这段,刷新页面,马上会感觉到不一样:整张表虽然看着是普通的表,但只要点到锁定的单元格,Univer的编辑交互会被拒掉;而填写区可以随意输入。
这里有个很重要的细节:保护工作表开启后,用户还是能选中和复制单元格内容,只是不能改。对收集类场景来说,这个体验基本是对的——允许查看、允许选取、拒绝写入。如果你连"选中"都想限制,那要把selectLockedCells这类参数调成false,但建议慎用,限制太多会引起使用者的反感。
4.5 加一层数据验证,保证填的值合法
单元格锁定了"能填哪里",数据验证负责"填的什么值合法"。我在这张预算表上用了一个简单规则:C列(金额)必须是0到100万之间的数字。
const dataValidationRule = { type: 'number', operator: 'between', formula1: '0', formula2: '1000000', allowBlank: true, showInputMessage: true, promptTitle: '金额校验', prompt: '请输入0到100万之间的数字', showErrorMessage: true, errorTitle: '金额超范围', error: '金额必须在0到100万之间', };把这个规则绑到允许编辑的C2:D3区域上后,用户即使能填,填了超范围的值也会被即时拦截。下拉列表、日期范围、自定义公式都可以做成类似的规则。
5. 我实际踩过的坑:锁定与保护相关的四段排查实录
纸上谈兵的部分讲完了,这节是真正让我觉得"Univer还年轻"的部分。几乎每个坑排查的时候都能从源码里翻出答案,但官方文档往往只字不提。我挑了四个最有代表性的,按我当时的排查过程还原。
5.1 坑一:开启保护后,连"程序写入"也被挡住了
现象是:我把申请表模板初始化好之后,自己往C列预填了几个默认值,结果页面加载完,这几个值死活显示不出来。排查了一会儿,发现不是数据没写进去,而是我已经在上一行代码里就开启了工作表保护,后续用命令写入被保护单元格的操作会被一并拦截。
而在Excel里,即便是保护状态,VBA/脚本写入通常不受影响。Univer的命令系统则是一视同仁——保护机制工作在命令层,不管是人点的还是代码发的,只要目标是锁定单元格,都会被拒绝。
这个坑没造成业务问题,但排查思路值得记录:你得先设数据、设置好解锁区域,最后再开保护。顺序反了,不光用户写不进,你自己也写不进。
修复很简单,把"开启保护"挪到所有初始化数据写入之后执行。我后来干脆封装了一个sheetReady异步任务,按"模板渲染 -> 填充默认值 -> 设置锁定样式 -> 设置数据验证 -> 开启保护 -> 通知UI"的顺序跑,避免再出错。
5.2 坑二:设置了locked样式,保护开了却不生效
这个是让我最头痛的一个。前几步都照着做了,单元格样式里也确认了locked: true,但切换到页面后,锁定单元格照样可以输入。我一度怀疑是不是Univer的锁定性子有问题。
排错排了很久才找到根因:样式设置的对象没有走命令系统,或者命令没有真正找到目标sheet实例。我传入的sheetId写错了,导致设置样式的命令在某张匿名表上执行,而用户看到的那张表没被锁。检查方法很简单,在浏览器控制台打印出所有workbook实例和sheet的ID,和我代码里传的id比对,一查一个准。
另一个可能的原因是:你需要用workbook.getActiveSheet()在页面加载完成后再获取Sheet,如果在创建实例的同一个同步代码块里直接拿,拿到的是尚未激活的表。我在代码里加了延迟或者用setTimeout后才稳定。
5.3 坑三:下拉列表会在保护模式下失灵
我在D列(说明)加了一个数据验证下拉选项,想让用户从预设的文案里选。保护开启后,这个下拉箭头点击后居然弹不出来。一开始以为是数据验证和保护的冲突,后来查了源码和示例才知道:下拉选择本质上也是编辑操作,如果触发下拉的那些单元格被锁定了,交互会被保护逻辑提前拦截。
解决办法有两个,二选一:
- 把带下拉的单元格也设为
locked: false,然后对区域只应用数据验证,而不依赖保护来限制; - 或者保持锁定状态,不要用"下拉"这种验证形式,改用输入提示。
我在预算表里选择了第一种,把需要下拉的区域解锁,其他区域保持锁定。数据验证本身已经能限制值,下拉功能也就正常了。
这个坑让我意识到一个原则:锁定功能和数据验证功能是两套独立的系统,叠加使用时必须清楚每套系统分别拦截的是"位置"还是"值"。如果你把二者混为一谈,出了问题很难定位。
5.4 坑四:用户拖拽填充,竟然能填进锁定区域
用户反馈了一个诡异操作:在可编辑单元格里输入数字后,往下拖拽填充,内容居然能覆盖到下方的锁定单元格。保护机制没有拦住拖拽填充。
这个我倒是很快理解了。Univer里的拖拽填充和普通的setValue走的是不同的命令链,在某些版本里,填充操作对锁定状态的检查不完整。这属于开源组件在边界功能上的疏漏,不是我没有配置正确。
虽然这个行为不一定是Bug,但对我的业务场景来说是不可接受的。我的处理方式是在命令层加了一个拦截器——这也是依赖命令系统的好处:
commandService.beforeCommandExecuted((command) => { // 判断命令是否属于修改单元格内容的类型 if (command.id === 'sheet.command.set-range-values') { const { range, rangeType } = command.params ?? {}; // 检查range是否与允许编辑区域匹配 if (!isAllowedRange(range)) { return false; // 阻断该命令 } } });用这层拦截后,不管用户是从输入法敲进来的、粘贴进来的、还是拖拽填充来的,只要目标range不在白名单内,统统被拒。这个兜底策略我强烈建议加,纯靠组件的内置保护,在边界操作上还是会漏。
6. 三种落地模式怎么选:给不同场景的配置建议
上面讲了怎么用一个实例实现"让用户填指定单元格",但实际项目里,这个需求往往不是单独一个页面,而是一套流程的环节。我把三种典型的落地模式整理成一个对照,方便你按自己情况判断。
| 需求特征 | 推荐模式 | 配置要点 | 备注 |
|---|---|---|---|
| 固定模板、填完就交 | 模板+工作表保护 | 全表锁定,只放行填报区,开启保护时不允许插入行列 | 最省事,适合预算、问卷 |
| 需要下拉、日期、级联选择 | 数据验证+解锁下拉区域 | 可编辑区解锁,数据验证按列配置 | 注意下拉的问题见5.3 |
| 多角色、多口径、需要审计 | 命令拦截+后端鉴权 | 实现beforeCommandExecuted,转发后端权限判断 | 最灵活,也最费工夫 |
举个例子,如果你只在内部做个报销表收集,模式A完全够用,一个前端页面加一个提交按钮就落地了。但如果你做的是对外收集的订单登记表,各部门人员权限还不一样,那就必须上模式C——命令拦截层配合后端返回的权限规则,才能在同一个模板上做到"相同范围不同角色"的精细化控制。
我在预算表项目里采用的就是模式A+模式的叠加,保护机制主防,命令拦截兜底。有一个原则始终不变:凡是用户能看到的东西,后台都要再做一遍权限判断。前端锁定只是体验优化,不是安全机制。
7. 再次谈选型心得:什么时候用Univer,什么时候绕开它
用了Univer做了几个功能后,我把它放到心里的"嵌套组件库"里,每次新项目选型都会拿出来掂量掂量。总结一下我个人的判断,希望能给你提供参考。
适合用Univer的情况:
- 需要在线编辑表格,且交互要尽量向Excel靠拢。
- 有一些"非表格软件但需要表格能力"的场景,比如把表格嵌进CRM工单、内容管理后台。
- 未来还打算做在线文档或演示文稿,想让技术栈复用同一套架构。
- 团队里有能读TS源码的人,不排斥在搜不到文档时自己去翻代码定位问题。
要谨慎的情况:
- 只做纯展示、不需要编辑,那直接用轻量渲染方案,Univer有点大材小用。
- 业务对版本稳定性极度敏感,不接受API变动,那要在发布前把版本锁死并保留排期做升级。
- 需要表格里极其冷门的能力,比如复杂的多区域打印专属定制,建议先查一下issue里是否有人提过。
当时选择trước这个方案前,我也对比过努里的其它开源库,最后打动我的还是那句朴素的话:它是少数把"在线表格"当成核心产品而不是附带功能的项目。指定单元格可编辑、其他单元格不可修改,这类需求在Univer的架构里有清晰的实现路径,而不是靠外围hack。
最后分享一点个人体会:用这类年轻开源项目,最大的敌人不是功能缺失,而是"你以为它和你熟悉的组件一样"。实际上Univer的角色更接近一个框架,你要把它当成业务系统的一部分去设计,而不是当现成的表格插件。理解命令、保护、数据验证这三者的边界,比抄一段示例代码重要得多。把基础概念吃透之后,剩下的就是和版本打交道的事了。