最近在做一个员工信息收集的需求,说起来不算复杂:管理员自己定义一张表格,把结构画好,然后扔给几十个人去填。但有个硬性要求——每个人只能填自己那几行、那几个单元格,其他人的信息只能看,不能碰。我第一反应是用现成的在线表格平台,可数据要落在自己服务端,界面也要嵌进业务系统里,绕来绕去还是决定自己集一套开源表格引擎。最后选的是 Univer,这个基于 TypeScript 的开源在线表格项目,Univer 支持用户自定义表格、按需开放指定单元格给指定用户填写,其余单元格保持只读,正是我要的"用户填若干单元格,其他区域不可修改"的典型场景。
这篇文章我会从需求分析讲到权限模型,再到落地代码和实测踩坑,覆盖"如何用 Univer 做一张只能填写指定区域的在线表格"这条完整链路。无论你是做内部办公系统、数据收集工具,还是想在网页里嵌入 Excel 体验,这篇文章都值得看完再动手。
1. 为什么从"静态表格"转向"可编辑权限控制"
1.1 这个需求到底想要什么
先说清楚场景。业务方提的需求是:运营同学在后台创建一张通讯录,字段有姓名、部门、手机号、备注。一百多号员工各自登录系统后,只能编辑自己那一行的手机号和备注,其他所有单元格都不可操作。更进一步,有些表是"管理员填表头,访客填数据"的模式,用户连选中的权利都不该有。
这种需求往下拆,其实就是三件事:
- 表结构由管理员定义,包括列数、行数、表头样式、列宽行高。
- 可编辑区域按人划分,每个人拿到的是一张小范围的"填字格子",不是整张表。
- 其余区域是展示态,不能改、不能删、不能拖拽填充,最好连内容都看不见的就直接隐藏列。
如果只是做静态展示,用普通 HTML 表格就够了;但一旦牵扯到公式、多行多列、合并单元格、数据校验,纯手写 DOM 的成本会迅速失控。这时候需要一个真正能扛的表格引擎。
1.2 为什么是 Univer,而不是 Luckysheet 或 Handsontable
决定接 Univer 之前,我实际上把主流的开源表格方案都过了一遍。这里不吹不黑,直接说对比结论:
| 方案 | 开源程度 | 权限控制粒度 | 公式能力 | 二次开发成本 |
|---|---|---|---|---|
| Excel Online / Google Sheets | 不开源 | 强但封闭 | 强 | API 受限,无法私有化 |
| Luckysheet | 开源 | 弱,需要自己改造 | 较好 | 近两年社区活跃度下降 |
| Handsontable | 部分开源 | 付费版有权限插件 | 较弱 | 商业授权限制多 |
| Univer | 全开源 | 细粒度,可扩展到单元格级 | 强且持续演进 | TypeScript 全栈,扩展机制清晰 |
选择 Univer 有几个关键因素。第一,它的渲染层是自绘 Canvas,不是 DOM 模拟,这意味着大表格的操作流畅度明显更好;第二,它内置了 Workbook、Sheet、Range 三级权限体系,支持selectable和editable两个开关自由组合,这对"锁定大部分区域、开放小部分格子"的需求几乎是量身定做;第三,它用 TypeScript 写的,命令模式和插件机制非常规范,后面要接任何自定义行为都不需要 hack 源码。
我在项目里实际用下来,还有一个体会是 Univer 的公式引擎和条件格式渲染比 Luckysheet 更稳,至少在批量合并单元格、跨 sheet 引用这些常见操作上没有遇到老式开源表格那种"公式失效"的诡异问题。
2. 初始化 Univer:从安装到页面渲染出第一张表
2.1 依赖安装
Univer 是插件化架构,核心包和功能包是分开的。先装一组最基础的依赖:
npm install @univerjs/core @univerjs/sheets @univerjs/sheets-formula @univerjs/ui我用的版本大概在 0.1.x 到 0.2.x 之间,后续 API 可能会有局部变化,但整体架构不会大动。需要注意的是,Univer 并不是一个"零配置开箱即用"的组件,它需要你指定容器、初始化实例、再挂载工作表数据,这和普通 UI 组件库的思路不太一样。
额外提一个细节:如果不涉及公式,@univerjs/sheets-formula可以不装,但我在实际项目里发现最好还是装上,因为 Univer 的单元格编辑器在公式不存在的场景下依然可能触发公式解析,装上反而能避免一些边界报错。
2.2 一个最小可运行示例
初始化 Univer 的思路是:先创建 Univer 实例,再往里面注册 Sheet 相关插件,最后通过createUniverInstance创建工作簿并绑定 DOM 容器。伪代码大概是这样的:
import { Univer, LocaleType } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; const univer = new Univer({ locale: LocaleType.ZH_CN, plugins: [ UniverSheetsPlugin(), UniverSheetsFormulaPlugin(), ], }); const workbook = univer.createUniverInstance(UniverInstanceType.UNIVER_SHEET, { id: 'employee-table', sheets: [ { id: 'sheet1', name: '通讯录', rowCount: 200, columnCount: 20, cellData: { 0: { 0: { v: '姓名' }, 1: { v: '部门' }, 2: { v: '手机号' }, 3: { v: '备注' }, }, }, }, ], }); // 将 Workbook 挂载到页面上 const container = document.getElementById('app'); workbook.getSheetById('sheet1')?.setContainer(container);这段代码跑起来以后,页面上就会出现一张 200 行 20 列的表格,表头有"姓名、部门、手机号、备注"四列。Univer 会把整个交互层渲染成 Canvas,所以无论表格多大,浏览器 DOM 开销都控制在一个很低的水平。
如果你用的是 React 或 Vue,官方也提供了对应的适配插件,可以把 Univer 实例封装成自定义组件。我这里用的是最朴素的原生方式,方便理解底层逻辑。
2.3 一个很容易忽略的事实:默认所有格子都是可编辑的
很多人第一次接入 Univer 会以为"我只要初始化一张表,系统就应该默认只读"。实际上恰恰相反,Univer 的默认权限模型里,所有单元格对当前用户都是既可选、又可编辑的。
也就是说,如果你不做任何权限设置,这张表和用户在浏览器里直接打开 Excel 表格没有区别。真正把"只读 + 指定区域可填"做出来,需要依赖 Univer 的权限模型去显式控制,下一节我会重点讲这个模型。
3. 单元格保护与可编辑区域:权限模型的底层逻辑
3.1selectable和editable:两个开关,四种组合
Univer 的权限核心不是"锁定"这个单一大词,而是selectable和editable两个独立的布尔属性。这两个开关可以组合出四种状态,理解它们比记住任何 API 都重要:
selectable | editable | 实际效果 |
|---|---|---|
| true | true | 正常可编辑单元格,用户可以点击、选中、输入 |
| true | false | 只读单元格,可以选中、查看内容,但不能修改 |
| false | false | 完全锁定,鼠标点不中,内容只能看不能操作 |
| false | true | 少见组合,通常不这么配 |
在"只让用户填写若干单元格"的场景里,我推荐的是:整张表设成selectable = true, editable = false,然后对允许填写的 range 单独设置selectable = true, editable = true。这样用户能正常浏览整张表,知道哪里可填哪里不可填,但真正能动的地方很少。
为什么不把不需要的区域直接设成selectable = false?因为在移动端或者触屏设备上,完全不可选中的单元格会让用户无法滚动查看表格内容,交互体验很差。我一般只在"敏感数据列"上才会用false/false组合隐藏,普通只读列都用"可选中不可编辑"。
3.2 权限作用域:Workbook、Sheet 和 Range 三级叠加
Univer 的权限是分层的,从上到下分别是 Workbook 级、Sheet 级和 Range 级。上层设置的权限会成为默认值,下层可以针对具体范围覆盖。
- Workbook 级:对整个文件生效,比如"这个工作簿所有用户只能读"。
- Sheet 级:对某个工作表生效,比如"sheet1 只读,sheet2 可编辑"。
- Range 级:对某片连续区域生效,比如"B2:C5 可编辑"。
实际运行时的权限由三层叠加后的最终结果决定。我的经验是:尽量把大范围的默认值设在上层,小范围的开放区域设在下层,不要把所有权限都塞到 Range 里,否则后面维护起来非常痛苦。
举个例子:
// 示意代码:将整个 sheet 设为只读 sheet.getPermission().setEditable(false); sheet.getPermission().setSelectable(true); // 对指定 range 开放编辑权 const editableRange = { startRow: 1, endRow: 1, startColumn: 2, endColumn: 3, }; sheet.getRangePermission(editableRange)?.setEditable(true);读写权限的 API 在不同版本里命名略有出入,但核心思路是一致的。如果你在官方文档里看到setSheetEditableCommand、SetRangeSelectableCommand这类命令,也是在做同一件事。
3.3 Protected Range:把"开放给谁"变成一个可配置的业务规则
如果只是一个全局只读 + 部分可编辑,上面的方案足够了。但 Univer 还提供了一层更贴近业务的概念:Protected Range(受保护区域)。
Protected Range 的意思是:你先框选一个区域,然后给这个区域绑一条"谁能编辑它"的规则。规则可以是"所有人可编辑""仅指定用户可编辑""仅指定用户在指定时间内可编辑"等等。这个机制比单纯控制editable更灵活,因为权限规则可以跟着用户身份走。
我实际项目里用的是这种配置:
const range = { startRow: 2, endRow: 5, startColumn: 2, endColumn: 3, }; sheet.addProtectedRange({ range, permissions: { [userId]: 'edit', // 指定用户可编辑 '*': 'view', // 其他人只看不动 }, });注意,这里userId是你在自己业务系统里的用户标识。Univer 本身不会管你到底是谁,它提供的是权限判定机制,用户身份的注入需要你自己在初始化时或登录态里做好映射。这一点后面第 4 节详细讲。
4. 多用户填写场景的落地实现:按身份动态分配编辑权
4.1 前后端约定一份权限数据结构
要让"不同人看到不同可编辑区域"真正跑起来,需要先约定好一份权限数据。我的做法是后端提供两个接口:一个返回表格初始化数据,一个返回当前用户的权限规则。
权限规则的数据结构长这样:
{ "sheetId": "communication-book", "editableRanges": [ { "userId": "u_001", "range": { "startRow": 3, "endRow": 3, "startColumn": 2, "endColumn": 3 } }, { "userId": "u_002", "range": { "startRow": 4, "endRow": 4, "startColumn": 2, "endColumn": 3 } } ] }这个结构的含义很直观:用户u_001可以编辑第 3 行的 C、D 两列,用户u_002可以编辑第 4 行的 C、D 两列。其他人对整张表只读。管理员不分配任何 range,因为管理员的账号可以直接走"所有单元格可编辑"的权限。
这样做的好处是,前端不需要知道整个业务权限的完整逻辑,只要拿到这张表,就能把对应 range 交给 Univer 去执行。权限的判定和维护都留在后端,符合"权限不下放"的安全原则。
4.2 页面加载时按用户身份设置权限
前端页面加载的流程我一般是这样串的:
- 进入页面,先请求表格的初始数据(行数、列数、单元格内容、样式)。
- 同时请求当前用户的权限规则,只拿当前用户对应的
editableRanges。 - 用初始数据创建 Univer 实例,把整个 sheet 默认设为只读。
- 遍历当前用户的
editableRanges,逐一设置为可编辑。 - 渲染完成后,在页面上方提示用户:"绿色框内为可填写区域。"
流程看似简单,但有一个细节容易被忽略:必须在初始化时就设置权限,而不是等 Canvas 渲染完成后再延迟设置。因为 Univer 的权限判断会影响编辑器是否弹出、光标是否允许进入单元格,等渲染完成再改权限,用户点一下可能已经触发了错误状态。
代码上大致是这样:
const workbook = univer.createUniverInstance(...); const sheet = workbook.getSheetById('communication-book'); // 1. 全表只读 sheet.getPermission().setEditable(false); sheet.getPermission().setSelectable(true); // 2. 根据当前用户身份开放指定区域 const myRanges = await fetchMyEditableRanges(currentUserId); myRanges.forEach((range) => { sheet.getRangePermission(range)?.setEditable(true); sheet.getRangePermission(range)?.setSelectable(true); });如果你的 Univer 版本更推荐使用 Protected Range,那也是同样的循环逻辑,只是把 API 换成addProtectedRange而已。
4.3 数据保存:只采集被授权的单元格
可编辑区域设置好以后,还有一个实际问题:用户改完数据,前端怎么保存?
我最初的方案是"整张表全量上传",后来发现这既不安全也不高效。正确做法是监听 Univer 的数据变更命令,只把被修改且属于当前用户可编辑范围内的数据抽出来提交。
具体做法是在 Univer 的命令服务上挂一个监听。每当单元格修改命令执行后,从命令参数里提取 row、column、newValue,然后检查这个坐标是否落在当前用户的editableRanges里。如果在,就进入待保存队列;如果不在,直接丢弃并提示"该单元格为只读区域"。
这里还要考虑一个并发问题:两个用户如果被分配了同一个 range(比如管理员误配),后保存的人会覆盖前一个用户的数据。保险起见,我在后端保存接口里加了一个version字段,每次保存带上表格当前版本号,后端发现版本过期就返回冲突,前端提示"数据已被他人更新,请刷新后重试"。
4.4 访客和未登录用户:全部只读
在线表格还有一个常见场景:表格链接分享出去,访客不需要登录也能打开看。对这种用户,我直接把他们的editableRanges返回为空数组,效果就是全表只读。
Univer 对只读模式的支持很好,访客照样能滚动、缩放、查看公式结果,只是双击单元格不会弹出编辑器,任何修改命令都会被权限系统拦截。视觉上我还会加一个顶部 banner,提示"当前为只读模式,如需填写请联系管理员"。
5. 实测踩坑:权限、公式和交互的边角问题
5.1 锁定了单元格,但公式引用并不会被锁住
这是我在验收时差点翻车的地方。我设置用户只读 A 列、可编辑 B 列,B 列里有一个 SUM 公式引用了 A 列的某个单元格。原本以为用户改不了 A 列就等于 A 列数据安全,结果发现用户虽然不能直接编辑 A 列,但可以通过添加新行、拖拽公式、或者在可编辑列里写入一个新公式来间接影响结果,甚至能通过公式显示 A 列里我不想公开计算的中间值。
Univer 的权限系统管的是"单元格编辑权限",不是"数据可见性"和"公式引用权限"。如果你的业务对安全性要求高,需要额外处理两件事:一是对真正敏感的数据列设置selectable = false,防止被鼠标选中后通过状态栏看到值;二是在可编辑区域禁用公式编辑,或者在服务端对计算结果做二次校验,不能只依赖前端权限。
5.2 Canvas 渲染下输入法候选框被遮挡
Univer 的编辑器弹出层是基于 Canvas + DOM 覆盖实现。我在 Windows 上用搜狗输入法测试时,中文候选框偶尔会出现在错误位置,甚至在部分 Chrome 版本里直接被表格覆盖。这个不是 Univer 独有的问题,所有 Canvas 表格引擎都有类似的输入法兼容问题。
我的处理方案有三步:给编辑器容器设置了很高的z-index;把输入法从"跟随应用窗口"切换成"跟随光标"模式;最后在组件的全局样式中把 Univer 编辑器覆盖层的position从fixed修正为absolute。实测下来,绝大多数场景都能正常输入中文。
5.3 保存时机:脏标记和防重复提交
一个容易被忽略的细节是,用户填完数据不一定点"保存"按钮。Univer 默认的单元格编辑是即时生效的,改完一个格子,数据在内存里就已经变了。这时候如果用户直接刷新页面,所有修改都会丢失。
我后来在页面里加了一个"脏数据标记"机制:监听 Univer 的变更事件,只要有修改就点亮保存按钮,并用beforeunload事件提示"你有未保存的修改"。还有一个更稳妥的做法:每次修改后防抖 1 秒自动保存,保存成功再把这个单元格标记为干净。在"每人只填自己几行"的场景下,自动保存体验远好于手动保存。
5.4 重新设置权限后 UI 不刷新
权限不是永远不变的。管理员可能今天开放了 10 个格子,明天改成 5 个。如果用户在页面上停留很久,权限更新后 Univer 的界面不会主动重新渲染。
我的做法是:每当权限更新接口返回新数据,先调用一次"重置工作表默认权限"的命令,再重新注册新的可编辑 range,并强制刷新一次工作表视图。注意不要在用户正在输入的时候做强制刷新,否则会把输入内容打断。最好的时机是页面重新加载,因此在业务里我会设置一个 5 分钟的权限缓存,配合前端定时轮询,权限变化最多延迟 5 分钟生效,可接受的范围内。
5.5 移动端适配
Univer 虽然依赖 Canvas,但本身对移动端有一定适配能力。不过"只能填几个格子"这种需求在手机上会遇到两个问题:一是手指精确点击小单元格困难,尤其是列宽不够的格子;二是弹起的软键盘容易遮挡编辑器。
我给可编辑单元格加了一个"轻触放大提示":双击单元格后弹出浮层,把该格内容以 input 的方式放出来编辑,确认后写回 Univer。这个体验反而比直接在 Canvas 上编辑更顺手,也绕开了移动端 Canvas 键盘定位的一堆坑。
6. 基于这个权限模型还能扩展出来的玩法
跑通"指定单元格可编辑"之后,Univer 的价值才刚打开。我接下来想做的方向有几个,分享出来供参考。
第一个是收集后的自动汇总。既然用户只能填自己的格子,那我在汇总区写几个公式:=SUM(B3:B100)、=COUNTA(C3:C100),数据一进来汇总就实时更新,完全不需要后端额外算一遍。Univer 公式执行的性能在几万行内非常够用。
第二个是条件格式联动。可编辑区域用浅绿色底色高亮,用户一眼就知道哪里能填;数据校验不合格的格子自动标红。Univer 的条件格式渲染是实时刷新的,这比传统后台表单的一堆 if 判断直观得多。
第三个是和现有业务系统打通。升级点在于:管理员后台可以直接在表格里画出"可填写区域",保存到数据库;前端渲染时按用户身份动态设置权限。这样连"配置表格"本身都变成一种可视化操作,不再需要维护复杂的 JSON 权限配置。
最后说一点个人体会。踩过一圈坑以后,我发现这类"用户自定义表格 + 指定单元格填写 + 其余只读"的需求,真正的难点不在初始化表格,而在权限模型的理解和前后端数据契约的设计。Univer 已经帮你把底层的权限判断、命令拦截、Canvas 渲染这些脏活都做完了,你要做的只是想清楚三个问题:谁是只读者、谁是填写者、填写范围长什么样。这三件事理清了,剩下的事情都是顺水推舟。