看到"univer"这个词的搜索热度,我猜不少人和我一样,第一眼以为是某个老牌办公软件,点进去才发现这是一个正在快速迭代的开源在线表格项目。我最近正好在做一个内部数据收集工具,场景很典型:管理员定义一张表,其他人只能填指定的单元格,其余区域锁死不让碰。用Univer把它落地之后,整个过程踩了不少坑,也把它的权限模型摸了一遍。这篇就把完整思路、代码和避坑经验都整理出来,希望能帮你少走弯路。
1. 为什么是Univer:选型思路与同类方案对比
1.1 从"发Excel副本"的痛点说起
先说说项目背景。团队每个月要收一次项目周报,以往的方式是发一个Excel模板下去,让各小组负责人填写后再收回来合并。听着简单,实际执行起来全是坑:有的人把合计列的公式删了,有的人在表头中间插了几行,有的人直接改了汇总口径。等到收集齐了,光清洗数据就要花掉半天。
我的核心需求其实只有一条:做一个网页版表格,表格模板由管理员预先设计好,普通用户打开后只能在灰色的填写区内输入内容,其他任何单元格都无法点击修改。听起来简单,但市面上的方案绕了一圈,要么重(比如集成在线Office全家桶),要么不够灵活,直到我注意到Univer。
1.2 Univer到底解决了什么问题
Univer是一个开源的全端电子表格引擎,底层用TypeScript重写,核心定位是"开发者可以像搭积木一样构建自己的在线表格"。它吸引我的点很直接:
- 渲染层用Canvas绘制,大表格缩放下不卡顿,单元格操作流畅度比传统的DOM方案高一个量级。
- 架构是插件化的,不需要的功能可以不注册插件,包体积和启动开销都更可控。
- 提供了相对完整的编辑能力:公式、条件格式、筛选、数据校验、排序、撤销重做等,基本覆盖日常90%的表格操作。
- 最关键的,它有一套独立的权限管理模块,能控制到工作簿、工作表、单元格范围三个层级,这正好是我这个"可填写白名单"需求的天然底座。
1.3 和同类开源方案的横向对比
为了确认选型,我把市面上能用的方案都过了一遍,简单列个对比:
| 方案 | 授权方式 | 复杂度 | 权限粒度 | 维护状态 | 适合场景 |
|---|---|---|---|---|---|
| Univer | Apache-2.0,开源免费 | 中上,插件化设计 | 工作簿/工作表/范围三级 | 活跃,社区更新频繁 | 企业级嵌入,需要深度定制 |
| Luckysheet | MIT,但官方转向Univer | 中 | 较粗,主要靠操作API限制 | 基本停更维护 | 简单数据展示,老旧项目维护 |
| x-spreadsheet | MIT | 低 | 无内置权限,需自己写拦截 | 更新缓慢 | 轻量表格展示 |
| SheetJS | Apache-2.0,但社区版功能有限 | 低 | 只负责数据解析,不涉及在线编辑 | 更新缓慢 | 纯数据导入导出 |
| Handsontable | 非开源,商业授权 | 中 | 有cells属性可配置只读 | 正常维护 | 商用的数据录入组件 |
如果只是做展示和导出,x-spreadsheet够用;但要做"精确到某几个单元格允许编辑、其他全部锁定"这种权限分区,Univer是唯一让我觉得不用自己造轮子的选择,而且它的社区活跃度在同类里明显更好。
2. 跑起来一个Univer实例:环境准备与初始化
2.1 创建项目与安装依赖
我使用Vite + TypeScript作为基础工程。直接执行:
npm create vite@latest my-univer-app -- --template vanilla-ts cd my-univer-app npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/sheets-formula @univerjs/sheets-numfmt @univerjs/design @univerjs/ui @univerjs/sheets-filter注意版本建议,Univer迭代很快,API在0.x版本之间会有调整。我当时使用的版本是0.6.x,以下代码和API示例在更高版本可能存在差异,但核心思路不变,遇到改动时以官方仓库的examples目录为准。
2.2 最小Univer实例代码
安装完成后,在项目入口文件里初始化Univer实例:
import { Univer, LocaleType } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; import { UniverSheetsNumfmtPlugin } from '@univerjs/sheets-numfmt'; import { UniverSheetsFilterPlugin } from '@univerjs/sheets-filter'; import { greenTheme } from '@univerjs/design'; import zhCN from '@univerjs/locale/zh-CN'; const univer = new Univer({ locale: LocaleType.ZH_CN, locales: { [LocaleType.ZH_CN]: zhCN, }, theme: greenTheme, }); univer.addPlugin(UniverSheetsPlugin); univer.addPlugin(UniverSheetsNumfmtPlugin); univer.addPlugin(UniverSheetsFormulaPlugin); univer.addPlugin(UniverSheetsUIPlugin, { container: 'univer-container', }); univer.addPlugin(UniverSheetsFilterPlugin);container指向HTML里的一个div元素,这个div就是表格挂载点。UI插件负责渲染工具栏、公式栏和单元格区域,所以不用额外写DOM结构。
2.3 初始化Workbook时如何预置模板
接下来是创建Workbook,核心是把模板数据直接放进初始化参数里,而不是让用户临时创建。单元格数据用cellData字段,格式是行号、列号的二维索引:
const workbook = univer.createUniverSheet({ sheet: { id: 'sheet-weekly-report', name: '周报填写', cellData: { 0: { 0: { v: '项目名称', s: { 'font-weight': 'bold' } }, 1: { v: '负责人', s: { 'font-weight': 'bold' } }, 2: { v: '本周进展', s: { 'font-weight': 'bold' } }, 3: { v: '风险项', s: { 'font-weight': 'bold' } }, }, 1: { 0: { v: '客户门户改版' }, 1: { v: '张三' }, 2: { v: '待填写' }, 3: { v: '待填写' }, }, }, }, });这里第0行是表头,第1行开始是具体数据。我在初始化时把第0行预设好文字和样式,用户看到的就是一张已经有一行模板数据的表格。
2.4 开发中的小坑
几个容易踩的问题,提前说一声:
- 样式字段和CSS不同,Univer的单元格样式用
font-weight、background等字段,但赋值时需要用样式字符串或对象,我建议遇到不确定的字段直接查源码里IStyleBase类型定义,不要在文档里瞎猜。 - 中文界面要配置
locales,否则工具栏和右键菜单会显示英文。回退英文界面虽然也能用,但给终端用户的体验差很多。 - 插件必须按依赖顺序注册,比如公式插件要在UI插件之前注册,否则公式栏渲染不出来。我一开始顺序写反,界面直接空白,控制台报错才定位到。
3. 核心需求拆解:让用户填指定的单元格,其余锁死
3.1 把需求翻译成权限描述
"管理员定义表格,用户只能填写某些单元格,其他单元格无法修改"这句话,在权限设计里可以翻译成一条明确的规则:
- 默认状态:整个工作表不可编辑。
- 白名单范围:
A2:D5(举例)允许编辑。 - 白名单之外:所有单元格不能输入、不能修改样式、不能插入行、不能删除列。
这个思路的关键是"默认拒绝、白名单放行",比"默认放行、逐个锁定"安全得多。因为如果默认放行,总会漏掉一些角落里的单元格;反过来用白名单,永远只会开放你明确指出的区域。
3.2 Univer的三层权限模型:Workbook / Worksheet / Range
Univer的权限体系分三个层次,理解它才能正确配置:
- 工作簿权限(WorkbookPermission):控制整个工作簿级别的操作,比如新建sheet、删除sheet、整体编辑。
- 工作表权限(WorksheetPermission):控制某个sheet内的大类操作,比如能否选中单元格、能否编辑单元格、能否格式化。
- 范围权限(RangePermission):控制某个具体单元格区域的编辑权限,这是实现"局部可填"的关键。
实际控制时,我采用"先关全局,再开特定范围"的组合:
// 1. 关闭整个工作簿的编辑 univer.getPermissionManager().setWorkbookPermission({ workbook: { edit: false }, }); // 2. 关闭目标工作表的一般编辑和选中 univer.getPermissionManager().setWorksheetPermission({ worksheet: { sheetId: 'sheet-weekly-report', permission: { edit: false, select: false, }, }, }); // 3. 对允许填写的区域放开编辑权限 univer.getPermissionManager().setRangePermission({ ranges: [ { sheetId: 'sheet-weekly-report', startRow: 1, startColumn: 2, endRow: 10, endColumn: 3, }, ], permission: 'edit', status: true, });第一步是"工作簿不可编辑"这一总闸,第二步把"具体sheet不可编辑"继续收紧,第三步针对C2:D10区域把编辑权限打开。注意select: false这一步,如果不关掉"选择"权限,用户虽然改不了单元格内容,但仍可以框选大片区域,视觉上会误以为表格卡住了。
3.3 范围权限和补充边界
这里有一个容易被忽略的点:范围权限不只控制单元格内容编辑,还控制"能否修改这个范围内的样式"和"能否用快捷键粘贴内容"。如果只想让用户输入纯文本,不想让他们粘贴格式,可以只开放edit而保持style权限关闭。
我当时的权限矩阵最终定为:
| 操作 | 表头区域 | 填写区域 | 汇总区域 |
|---|---|---|---|
| 查看 | 允许 | 允许 | 允许 |
| 选中 | 禁止 | 允许 | 禁止 |
| 内容编辑 | 禁止 | 允许 | 禁止 |
| 插入行/列 | 禁止 | 禁止 | 禁止 |
| 删除行/列 | 禁止 | 禁止 | 禁止 |
初始化完成后,我在填写区域加了浅黄色背景,表头保留深色底白字。用户打开表格,视觉上就能直观区分哪里能填、哪里不能动,比任何权限配置都有说服力。
3.4 完整代码示例
把上面的代码整合进一个函数里,方便复用:
function initReadonlySheet( univer: Univer, sheetId: string, editableRange: { startRow: number; startColumn: number; endRow: number; endColumn: number } ) { univer.getPermissionManager().setWorkbookPermission({ workbook: { edit: false }, }); univer.getPermissionManager().setWorksheetPermission({ worksheet: { sheetId, permission: { edit: false, select: false, }, }, }); univer.getPermissionManager().setRangePermission({ ranges: [ { sheetId, ...editableRange, }, ], permission: 'edit', status: true, }); } initReadonlySheet(univer, 'sheet-weekly-report', { startRow: 1, startColumn: 2, endRow: 10, endColumn: 3, });这段代码的核心是用setRangePermission这个方法在已锁定的大盘子里切开一个可编辑的口子。跑起来之后,用户只能点击填写区域的单元格输入内容,其他区域点击后没有任何反应,连光标都不进去。
4. 权限拦截的底层原理:Command流水线和Permission策略
4.1 为什么Univer能精准拦截"改单元格"操作
如果你用过一些老旧表格组件,会发现"禁止编辑"往往靠监听keydown事件、在输入框上加readonly来实现,这种方式很容易被绕过:拖拽填充、快捷键粘贴、通过单元格右键菜单操作,都能绕过事件监听。
Univer的架构从根本上避免了这个问题。它的每一条用户操作,包括输入文字、修改样式、删除行、复制粘贴,在内部都被封装成一条"命令"(Command)。所有命令在真正执行之前都要经过一条管道,管道上挂着多个拦截器(Interceptor)和权限检查器。任何一条命令在执行业务逻辑之前,都会先问权限模块:"用户有权做这件事吗?"
4.2 以editCell命令为例拆解拦截过程
以最简单的"双击单元格输入内容"为例,大致链路是:
- 用户在界面上触发双击,UI层监听到
dblclick事件。 - UI层生成一条
editCell命令请求,命令里携带目标单元格坐标和新内容。 - 命令进入执行管道,权限检查器对命令参数中的单元格范围做一次范围权限匹配。
- 如果匹配到的权限是
edit: false,命令会被直接拦截并终止,界面上不会有任何变化。 - 如果匹配到
edit: true,命令继续往下走,最后更新数据模型。
这个链路的好处是,无论用户通过什么入口操作(键盘、鼠标菜单、快捷粘贴、脚本API),最终都要经过同一条命令管道,所以权限检查不会漏。我尝试过用Ctrl+V往锁定区域粘贴内容,发现粘贴整个动作也会被拆成一条或多个单元格编辑命令,而编辑命令在第一步就被拦下,复制进来的内容不会落进表格。
4.3 白名单模型背后的安全边界
前端权限模型解决的是"交互层面防误操作"的问题,它能让普通用户碰到界面上的锁,但并不能成为数据安全的最后一道防线。一个懂技术的用户完全可以通过浏览器开发者工具直接拿到表格数据源,甚至在控制台里调用底层API绕过命令拦截。
所以正确的认知是:
前端权限保护的是"操作体验"和"常规误操作",服务端权限校验才是真正的数据边界。
如果你的表格数据最终要归档到后端,服务端一定要对提交上来的数据进行白名单校验,只允许更新约定范围内的字段。这也是我在做这个项目时和后台同学反复对齐过的点:不要相信前端传来的任何坐标和字段名。
4.4 拦截不到的地方:需要留意的边界情况
几个实测中发现的边界问题,值得留意:
- 列宽和行高的调整不属于单元格内容编辑,即使设置了编辑权限为false,用户仍然可以拖拽改变列宽。如果连列宽都不想被改,需要额外配置对应的sheet设置权限。
- 通过筛选插件做的排序操作,如果在某个锁定区域内有合并单元格,排序后内容的位置会变化。如果表单对"位置"敏感,建议把筛选插件也关掉,或者只允许在指定区域筛选。
- 复制锁定区域再去解锁区域粘贴,复制操作本身不会被拦截,因为复制不修改数据;只有粘贴时才触发编辑命令。这个逻辑是合理的,但有些用户会疑惑"明明复制了却粘贴不进来",我在界面上加了明确的"灰色区域不可编辑"提示,从根本上避免这种困惑。
5. 进阶玩法:数据校验、下拉选项、公式联动与多Sheet权限矩阵
5.1 在可填写单元格上配置数据校验
锁定了可编辑区域之后,下一步是控制用户"能填什么"。Univer支持数据校验规则,我配置了两种最常用的:
- 数值范围校验:比如对"本周完成百分比"限制在0到1之间。
- 日期格式校验:对"计划日期"限制必须填写合法日期。
配置校验规则的代码示例:
const rule = { type: 'number', formula: ['0', '1'], operator: 'between', formula2: null, errorMessage: '请填写0到1之间的小数', }; univer.getSheetDataValidation().addRule( { name: '完成率校验', ranges: [ { startRow: 1, startColumn: 4, endRow: 10, endColumn: 4, }, ], }, rule );填了非法值时,单元格会显示校验错误提示,不允许确认输入。对"用户乱填"这件事,数据校验比事后人工检查高效得多。
5.2 下拉列表快速落地
有些单元格不适合自由输入,比如"风险等级"只有高、中、低三个选项。Univer支持下拉列表类型的数据校验,实现方式类似:
const dropdownRule = { type: 'list', formula: ['高', '中', '低'], operator: 'in', errorMessage: '请从下拉列表中选择', };这样用户点击单元格时会出现下拉箭头,只能从预设三个选项里选一个。收数据时枚举值完全可控,省掉了后续文本统一口径的过程。
5.3 锁定区域内的公式自动汇总
填写区开放,汇总区锁死,这正好是公式发挥价值的地方。我在"汇总"列上预设了一个SUM公式:
{ 11: { 2: { v: '=SUM(C1:C10)', s: { 'font-style': 'italic' } }, }, }用户每填入一条数据,汇总单元格自动更新,但由于汇总区是锁定的,用户删不了公式,也不会在汇总列覆盖出自造的假数据。这就是"管理员定义表格、用户只负责填数"的最理想状态。
5.4 多Sheet场景下的权限矩阵
实际业务中往往不止一张表。我做了两类sheet:一类是"模板区",管理员自己维护;另一类是"填写区",面向不同角色开放不同区域。这时权限要按sheet维度配置:
| 角色 | 模板Sheet | 填写Sheet-A | 填写Sheet-B |
|---|---|---|---|
| 管理员 | 全部可编辑 | 全部可编辑 | 全部可编辑 |
| 普通用户A | 只读 | C2:D10可编辑 | 只读 |
| 普通用户B | 只读 | 只读 | B2:E8可编辑 |
实现方式就是在创建Workbook之后,对不同的sheetId分别执行setWorksheetPermission和setRangePermission。每一份都让服务器返回当前用户的角色,前端根据角色动态配置权限,一套代码支持任意数量的角色和表单组合。
5.5 服务端协同保存的思路
Univer本身不提供开箱即用的后端存储服务,编辑的数据需要自己处理。我在项目里选了最简单的方案:
- 用户在界面上编辑完,点击"提交"按钮。
- 前端通过API读取当前Workbook的JSON快照,把快照发到服务端。
- 服务端保存快照,下次用户打开时用
createUniverSheet传入历史快照恢复现场。
这个方案不追求实时多人协同,但对"收集模板数据"这类低频写入场景已经够用。如果后续需要多人同时编辑同一张表,可以关注Univer官方的协同SDK方案,或者自建基于操作日志的协作通道。我在一个分支版本里做过基于OT的简化实现,核心思路是把每条Command序列化后广播给其他人,再在接收端重放。这种方式比传全量快照节省带宽,但并发冲突处理很考验细节,别轻易在生产环境尝试。
6. 从"能填"到"好用":实测中的坑与优化建议
6.1 权限状态初始化时序
第一个坑来自于权限配置的时序。如果在创建Workbook之后立即执行setWorkbookPermission,有可能因为插件还没完全初始化完毕导致权限配置失效。我用过的可行做法是:把权限配置写在createUniverSheet回调之后的下一个事件循环里,或者直接用setTimeout延迟一帧执行。
更好的做法是通过生命周期钩子或插件机制在onReady之后统一做权限初始化,避免依赖时序。我后来把权限逻辑抽成一个独立函数,在UniverSheetsUIPlugin的onMounted回调触发时调用,实测稳定。
6.2 大数据量渲染性能
Univer用Canvas渲染之后,滚动性能改善明显,但也不是没有上限。我在测试一个5000行、20列的sheet时,初次渲染耗时能感知到,滚动时轻微卡顿。如果表格规模预期很大,建议:
- 尽量按需加载数据,不要一次性把整个sheet的数据塞进
cellData。 - 避免给大量单元格逐一设置重复的样式对象,能用列级默认样式就尽量用列级默认样式。
- 如果只是做展示,考虑关闭部分插件,比如不加载公式插件时会明显降低初始化开销。
6.3 让用户一眼看懂"哪里能填"
权限配置再好,如果用户看不懂界面,还是会困惑。我在视觉上做了三件事:
- 可填写单元格底色统一用浅黄色,并在Sheet顶部的说明文字里写明"黄色区域可编辑,白色区域只读"。
- 冻结前两行,让用户滚动时始终看得见表头。
- 在提交前做一步"校验全部可填写区域是否合法"的逻辑,避免到后端才发现数据有问题。
这些细节在演示给业务方看的时候效果特别好:他们完全不关心权限模型怎么设计,只关心"我打开之后知道该点哪里"。
6.4 我踩过的几个坑
最后集中说一下踩过的坑,希望能帮你省点时间:
setWorksheetPermission里的select权限默认为true。如果不显式关掉它,用户虽然不能编辑,但可以框选锁定区域,视觉效果很怪。- 单元格样式中的
backgroundColor在从UI层读取时有格式差异,如果你用脚本把样式对象再塞回cellData,有些属性要规范化,否则会出现样式丢失。 - 快速启动页的React版本和纯TS版在插件上略有差异。如果你用React构建,记得配置对应的
@univerjs/react相关依赖,不要只装核心包。 - 包体积比较大,Vite默认构建会在dev server启动时加载很多插件,建议开启代码分割或按需加载,否则首次开发预览会慢得让人怀疑人生。
说回项目本身,做完这个"分类填写"的在线表格之后,我的体验是:Univer的插件化和权限模型正好踩中了需求的核心,它没有把"只读"做成一个简单的布尔开关,而是设计成了可以精确控制的策略体系。如果你也是要做一个"管理员定义表、其他人只负责填一部分格子"的项目,用它来搭基础框架,再在上层补充数据校验和视觉引导,基本能覆盖绝大多数业务场景。我自己还在继续往下研究它的协同能力和服务端保存方案,等有阶段性成果了,再来补充后续的内容。