最近我在做内部数据收集系统,需求一上来就卡了壳:要把一张员工信息登记表嵌到 Web 应用里,普通用户只能填写自己的那几个格子,姓名、部门、手机号以外的区域统统不能改。我第一时间想到 univer——这个开源在线表格项目我在社区关注很久了,正好趁这次需求把它从 demo 拉进了生产环境。
Univer 给我的第一印象是"不像传统开源表格库那样自嗨"。它不只是一个能画单元格的组件,而是一整套基于 TypeScript 的在线表格/文档/幻灯片引擎,尤其适合需要嵌入业务系统、控制单元格编辑权限、做数据收集和汇总的场景。这篇文章我会从选型开始,逐步拆解 Univer 的初始化、权限控制实现、验证链路和进阶玩法,把我实际踩过的坑也一并交代。如果你正在犹豫"到底该不该把 Univer 用到项目里",或者已经决定用了但不知道单元格权限怎么做,这篇应该能帮你省不少时间。
1. 为什么是 Univer:数据收集场景下的表格选型思考
1.1 需求本质:一张"局部可编辑"的数据收集表
业务方给我的需求其实很朴素:人事每周要收集一次员工的项目经历归档,每人填一行,但行里只有"本周工作内容""遇到的问题"两列是本人能填的,工号、姓名、部门、日期这些列由系统自动带出,其他人不许改。
这类需求一旦细化,就会发现它并不是"做一个表格"那么简单,而是单元格级权限控制 + 在线录入 + 数据汇总三件事的组合。用户不能看到完整表格后自由发挥,也不能绕过前端直接改数据,否则汇总出来的表就是一张废纸。
我当时把需求拆成了几个核心点:
- 表格必须嵌入现有 Web 系统,不能跳转到第三方在线文档。
- 普通用户只能编辑指定单元格,其余区域从交互层面就要锁死。
- 管理员要能预览完整表格、修改表头或调整可填写范围。
- 提交的数据要能被后端服务读取并做二次校验,不能只靠前端展示。
这些点单独拎出来都不难,但合在一起,手写一个"看起来像表格"的界面会非常痛苦,尤其是在要处理公式、合并单元格、复制粘贴、键盘导航这些细节的时候。
1.2 我认真对比过的几条技术路线
先说说我淘汰掉的方案,思路可能对你有参考价值。
第一个自然是成熟的在线文档产品,比如飞书表格、腾讯文档这些。它们开箱即用,权限功能也确实细,但问题在于:数据沉淀在第三方平台,接入自家业务系统要做一堆开放接口对接,而且"单元格级"权限在部分产品里不一定能覆盖到每个格子。对 ToB 系统来说,把核心数据放到别人的服务器上,这件事本身就要走很长时间的合规评审。
第二个是手写表格,也就是用 HTML 表格加一堆输入框。这种做法前期很快,但凡是做过的人都知道,一旦用户开始提"要能复制粘贴、要能自动求和、要能合并单元格、要能撤销",手写的表格就会膨胀成一个无法维护的怪物。
第三个是开源表格库。我重点对比了 Handsontable、Luckysheet 和 Univer,情况大致是这样的:
| 方案 | 单元格级锁定 | 公式能力 | 维护活跃度 | 接入成本 | 授权风险 |
|---|---|---|---|---|---|
| Handsontable | 支持但偏商业版 | 较弱 | 一般 | 中 | 商业授权需购买 |
| Luckysheet | 有保护概念但接口偏旧 | 较好 | 社区版停滞明显 | 中 | 开源但迭代慢 |
| Univer | 有完整的 protection 语义 | 较好 | 高,迭代快 | 中 | Apache 2.0 |
看完对比心里基本有数了。Luckysheet 我也实际跑过 demo,功能确实全,但社区版本给我的感觉是"能跑,但不太敢往生产上放",尤其是我需要的权限保护相关接口,文档和代码的匹配度一般。
1.3 我决定用 Univer 的三个理由
第一,单元格级权限控制是真需求,而 Univer 在引擎层面就有 protection 概念。它不是靠"给输入框加 readonly 样式"来糊弄,而是通过保护工作表、锁定单元格的机制,从交互层面阻止用户进入编辑状态。这一点非常重要,因为"看起来不能改"和"真的不能改"是两码事。
第二,数据驱动的工作簿模型很契合业务系统。Univer 创建表格时直接传入一个包含行、列、单元格数据的 JSON 结构,服务端返回什么,前端就渲染什么。这让我可以把"表格结构"和"业务数据"完全解耦,后端同学改配置就能调整表格,不用前端发版。
第三,插件化和命令机制让二次开发有清晰路径。我后面要做的"开启保护""解锁区域""读取数据"这些动作,都可以通过命令或者直接操作快照来完成,而不是去 hack 一个表格组件的内部 DOM。遇到问题还能顺着源码查,社区活跃度也够。
就这样,我定了 Univer。
2. Univer 的核心脉络:从实例化到第一个可编辑表格
2.1 安装和初始化最简链路
Univer 的版本迭代比较快,不同版本的 API 有差异。我用的版本是 0.6.x 系列的 preset-sheets 套件,下面的代码逻辑在这个版本下是能直接跑的。如果你用的是更新的 1.x 版本,建议以官方文档为准,但整体思路是一致的。
安装依赖很简单:
npm install @univerjs/preset-sheets创建一个 Univer 实例并注册一个工作簿,核心代码如下:
import { Univer } from '@univerjs/preset-sheets'; import { UniverInstanceType } from '@univerjs/core'; const univer = new Univer({ locale: 'zhCN', }); univer.createUnit(UniverInstanceType.UNIVER_SHEET, { name: '员工信息收集', sheetOrder: ['sheet1'], sheets: [ { id: 'sheet1', name: '员工信息', rowCount: 100, columnCount: 10, cellData: { '0|0': { v: '姓名' }, '0|1': { v: '部门' }, '0|2': { v: '本周工作内容' }, '1|0': { v: '张三' }, '1|1': { v: '研发部' }, }, }, ], });注意看cellData的结构,它是用"行|列"作为 key,value 是一个单元格对象,其中v是显示值。这种"键值对描述单元格"的方式,让我从服务端拼数据时非常舒服,因为不需要预定义一个二维数组,而是可以按需填充任意格子的内容。
实例化之后,Univer 会自动把表格挂载到页面容器里。容器需要一个固定高度,否则表格高度会塌陷。实际项目中我通常会包一层 div 并设置height: 600px,避免在弹窗或 Tab 切换时出现高度计算异常。
2.2 Univer 的命令机制和插件机制
刚开始接触 Univer 的人,往往会被它的"命令、Mutation、Operation、插件"这些概念绕晕。我用一个生活化类比来解释:命令就像你去餐厅点菜,服务员(命令服务)把订单传给后厨,后厨做完菜再让传菜员上桌(Mutation),整个过程都可以被记录下来,随时可以回放或撤销。
Univer 之所以把一次改动拆成多层,是为了支撑两个关键能力:协同编辑和 undo/redo。每一次对工作簿的修改,无论是最初的创建、中间的数据填入,还是后面的权限保护,都是通过命令或 Mutation 完成的。这样在多人协作时,每个人的操作都能变成有序事件流,谁做了什么、改了什么,一目了然。
我在实际使用中并不需要深入实现一个自定义命令,但必须理解一件事:不要绕过 Univer 的 API 去直接改内部状态。比如有的人为了让某个单元格不可编辑,会直接操作渲染层 DOM 加一层遮罩,这在前端确实能达到视觉效果,但一旦用户通过快捷键、粘贴、拖拽填充等方式操作,遮罩根本拦不住。正确做法是走 Univer 提供的保护接口,让引擎本身就不进入编辑流程。
2.3 第一个可编辑表格上线之前要注意的细节
preset-sheets 套件已经包含了完整的基础功能,包括 UI、编辑、样式、公式、合并单元格等。因此你不需要像拼积木一样手动引入十几个插件,只要按上面的方式创建实例,就能得到一个功能完整的在线表格。
但有几个点我在上线前踩过,提前给你提个醒:第一,必须设置容器的宽高,否则表格会产生诡异的渲染高度;第二,初始化数据里的单元格最好带上样式对象,比如表头背景色、字体加粗,因为用户第一眼看到的表格应该已经"像样",而不是白底黑字一片;第三,确定好 locale。
Univer 的默认语言是英文,我初始化时直接传了zhCN,这样右键菜单、工具栏提示都是中文,体验会好很多。
3. 核心需求实现:让用户只能填写指定单元格
这一节是整个项目的核心,也是你真正关心的部分。Univer 实现"用户只能填写指定单元格"的思路,和 Excel 的"工作表保护 + 单元格锁定"非常像:先把整张工作表保护起来,默认所有单元格都不可编辑;然后单独把允许用户填写的区域设为"不锁定",这样保护状态下,只有这些格子能进入编辑。
3.1 先从"保护"概念讲起:单元格、工作表两层语义
在 Univer 的模型里,每个单元格对象可以带一个p(protection 缩写)属性,用来描述这个格子的保护状态。工作表本身也有一个保护状态。两者组合的规则是:
- 工作表未保护时,不管单元格的
p怎么设置,所有格子都可以编辑。 - 工作表保护开启后,只有那些被显式标记为"不锁定"的单元格才能编辑。
这个设计非常关键,它不是"设置某个格子 readonly",而是反过来的"默认全锁,局部开放"。我在做权限控制时,最喜欢这种白名单模式,因为漏配的风险更小——某个格子忘了解锁,用户顶多是不能填;要是哪天我忘了锁掉一个敏感区域,数据就乱了。
Univer 还允许在开启保护时设置密码,用于后续解锁工作表。在业务系统里我一般不会用密码,因为前端保护更多是防误操作,真正的数据安全要靠服务端做,这个后面细说。
3.2 正向实现:全表锁定,再解禁可填区域
在 0.6.x 版本里,开启工作表保护和设置区域保护,需要借助ICommandService来执行对应命令。完整实现代码如下:
import { ICommandService } from '@univerjs/core'; import { SetWorkSheetProtectedMutation, SetRangeProtectedMutation, } from '@univerjs/sheets'; async function enableFillMode(unitId, subUnitId, editableRanges) { const commandService = univer.__getInjectedDependency(ICommandService); // 第一步:开启工作表保护 await commandService.executeCommand(SetWorkSheetProtectedMutation.id, { unitId, subUnitId, isProtected: true, password: '', }); // 第二步:把允许填写的区域设置为“不锁定” await commandService.executeCommand(SetRangeProtectedMutation.id, { unitId, subUnitId, ranges: editableRanges.map((r) => ({ startRow: r.startRow, endRow: r.endRow, startColumn: r.startColumn, endColumn: r.endColumn, })), isProtected: false, }); }这个editableRanges就是可编辑区域的数组。比如员工可以填写B2:E2这一行的空白格,那 ranges 就是[{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 4 }]。
第一次跑这段代码时,我还担心"保护整个工作表 + 解禁部分区域"会不会造成性能问题,实测下来没有任何卡顿,因为 Univer 对保护状态的管理是在数据模型层完成的,不涉及大批量 DOM 重绘。
3.3 配置驱动:把"可填写区域"做成 JSON 配置
上面代码里的editableRanges不应该是前端写死的,否则每次调整可填写区域都要发一次版本。我把它做成了服务端下发的配置,结构大概是这样的:
{ "unitId": "employee-survey-2024", "sheetId": "sheet1", "editableRanges": [ { "name": "工作内容", "startRow": 1, "endRow": 50, "startColumn": 2, "endColumn": 2 }, { "name": "遇到的问题", "startRow": 1, "endRow": 50, "startColumn": 3, "endColumn": 3 } ] }前端拿到配置后,把unitId、sheetId、editableRanges直接传给enableFillMode函数,就能动态控制哪些格子可编辑。后端同事想开放新的填写列,只需要在配置中心改一条记录,前端完全不用动。
这个设计还有一个额外的好处:同一套配置可以用来做服务端的数据校验白名单。用户在哪些格子填了数据,提交时后端会拿这份配置逐一比对,凡是不在允许范围内的单元格数据,一律拒绝入库。这对防止有人通过浏览器开发者工具篡改数据非常重要。
3.4 设计一个"编辑状态开关":查询编辑模式/填表模式
在真实业务里,光有"全表锁定"还不够。人事要维护这张表,得能编辑表头、调整列宽、修改校验规则,这就需要在两种模式之间切换:
- 管理编辑模式:工作表不启用保护,管理员可以任意编辑。
- 填表模式:工作表启用保护,普通用户只能填写白名单内的格子。
我用一个布尔变量配合两段函数来控制:
async function setMode(isAdminMode) { const unitId = univer.getCurrentUnitId(); const subUnitId = 'sheet1'; if (isAdminMode) { await commandService.executeCommand(SetWorkSheetProtectedMutation.id, { unitId, subUnitId, isProtected: false, password: '', }); } else { await enableFillMode(unitId, subUnitId, currentEditableRanges); } }切换模式只涉及对保护状态的修改,不会影响已有单元格数据,因此往返切换很安全。我一直觉得"状态开关 + 配置驱动"这种组合,是处理这类权限需求最直观的方案,既好理解又不容易出 bug。
4. 验证与演示:一个真实的"用户填写"链路
4.1 模拟一次完整操作流程
代码写完只是第一步,我更关心的是真实用户操作链路是否顺畅。我搭了一个模拟页面,里面有几个核心步骤:
- 页面初始化时,请求服务端拿到表格结构和可编辑范围配置。
- 用 Univer 创建工作簿,渲染出完整表格。
- 自动切换到填表模式(全表保护 + 白名单解锁)。
- 普通用户打开页面,只能点击和编辑高亮的可编辑区域。
- 提交时,前端把
cellData快照发送给服务端,服务端根据同一份白名单配置校验数据。
我现在把第 4 步的细节展开。普通用户看到表格后,如果想去编辑一个被锁定的单元格,鼠标点击上去只会出现选中状态,不会出现编辑光标,双击也不会进入编辑。这个交互是 Univer 引擎层面控制的,不是我用 CSS 遮罩挡住的,所以非常可靠。当用户点击到可编辑区域时,输入流畅度和普通在线表格没有区别。
有一次我拿这个 demo 给人事同事体验,她在被锁定的单元格里试了半天,以为系统卡了。我告诉她"这些格子本来就不让你改",她才恍然大悟。这说明从产品层面来说,锁定区域的视觉提示还需要更明显一些。
4.2 通过 API 验证单元格的保护状态
光靠"看起来不能编辑"还不够,我在自动化测试里还需要直接断言某个单元格的保护状态。Univer 提供了命令服务,可以查询当前工作簿的信息。我当时用一个辅助函数来检查:
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; function isCellLocked(unitId, subUnitId, row, col) { const instanceService = univer.__getInjectedDependency(IUniverInstanceService); const workbook = instanceService.getUnit(unitId, UniverInstanceType.UNIVER_SHEET); const worksheet = workbook.getSheetBySheetId(subUnitId); const cell = worksheet.getCellRaw(row, col); // 这里根据实际版本的 protection 字段做判断 return cell && cell.p && cell.p.locked === true; }实际版本里 protection 相关字段的嵌套结构可能不太一样,但思路是对的:单元格对象会带一个有保护信息的字段,判断它的锁定状态即可。我在做端到端测试时,会遍历白名单内外的几个关键格子,断言"白名单内 locked 为 false,白名单外的格子 locked 为 true",跑通后基本就能安心。
4.3 锁定单元格的用户体验增强
交互层面的保护做到了,接下来是体验优化。默认情况下,被锁定的单元格和可编辑单元格长得一样,用户根本分不清。我做了几件事来提升辨识度:
- 给可编辑区域设置浅色背景,或者添加一个明显的边框标记。
- 给锁定区域设置灰色背景,但文本保持可读,让用户知道"这里有内容但不归我管"。
- 在页面顶部给出一段提示文案:"请在黄色区域填写,其他区域不可编辑。"
样式怎么做呢?我直接用 Univer 的单元格样式对象来设置背景色。
function buildEditableStyle() { return { backgroundColor: '#fffbe6', border: { top: { color: '#faad14', style: 'thin' }, left: { color: '#faad14', style: 'thin' }, bottom: { color: '#faad14', style: 'thin' }, right: { color: '#faad14', style: 'thin' }, }, }; }把可编辑区域的初始单元格对象加上这个样式,视觉上就很清楚。另外我还做了一个改进:在用户用 Tab 或方向键移动选中单元格时,跳过锁定区域。这个功能 Univer 不一定自带,我在键盘事件里做了过滤处理,虽然代码不多,但对录入效率的提升非常明显。用户填完一个格子按 Tab,下一个可编辑单元格会直接获得焦点,而不是被锁定区域截住。
4.4 提交后的数据读取与服务端比对
数据提交这块,很多人会忽略,但它恰恰是权限控制里最重要的一环。前端再怎么锁定单元格,懂技术的人还是可以通过浏览器控制台直接修改 Univer 内部数据。因此我在提交时做了一层完整的服务端校验。
前端的提交逻辑很简单:读取整张表的cellData,POST 给后端接口。
const snapshot = univer.getActiveSheet().getSnapshot(); // 这里把 snapshot.cellData 提交到后端后端拿到数据后,会对照配置中心的下发配置,逐格检查:
- 该单元格是否在允许编辑的白名单内。
- 该单元格的数据格式是否符合要求。
- 该单元格是否为自动计算或系统填入的字段(比如工号、日期)。
凡是白名单外的单元格发生变化,后端统一拒绝,并返回一条错误信息。这个逻辑虽然多写几行代码,但它才是这整个权限方案的真正底线。前端保护是方便用户,后端校验才是保障数据。
5. 进阶玩法:多角色权限、按行动态解锁、服务端校验
5.1 多角色权限:不同用户看到并填写不同区域
单一"全表锁定 + 白名单解锁"的模式很快就不够用了。在更复杂的业务里,一张表可能同时被多种角色填写。比如一张研发项目周报:
- 开发人员只能填写"工作内容"列。
- 组长可以填写"工作内容"和"风险项"两列。
- 项目经理可以看到整表,能编辑"结论"列。
这个需求用 Univer 做起来并不算难。核心思路是:前端根据当前登录用户的角色,从服务端获取不同的 editableRanges 配置,然后用这套配置去执行保护和解锁操作。
前端代码没有太大变化,差异只是配置来源:
async function initTableForUser(user) { const config = await fetchEditableConfig(user.role); await createWorkbook(config.data); await enableFillMode(config.unitId, config.sheetId, config.editableRanges); }同样的表格结构、同一个页面入口,不同用户打开后看到的可编辑区域完全不同。管理员可以在一个页面里预览所有角色的配置效果,方便核对权限是否有遗漏。
我在做这块时还发现一个细节:白名单配置不要只传"行列范围",最好带一个"用途标签",比如"财务填写""HR 填写"。这样将来排查权限问题时,一眼就能看出某块区域是为哪个角色开放的,不用拿着行列坐标去对数。
5.2 按行动态解锁:审批流场景
还有一类场景是"按行控制",常见于审批表格、巡检记录。比如一张巡检表有 10 条记录,每条记录由不同的巡检员填写,用户只能填写属于自己的那一行。
实现方式也很直接:根据当前用户的数据权限,动态计算出可编辑的行列范围。比如用户张三对应的是第 5 行到第 7 行,那 editableRanges 就是:
[ { "startRow": 5, "endRow": 7, "startColumn": 0, "endColumn": 4 } ]然后照旧执行enableFillMode即可。
这里有个实践建议:不要把多个连续范围拆成多个命令一次次执行。我在早期版本里犯过这个错误,循环调用SetRangeProtectedMutation,每次都会触发一次保护状态的重算,数据量大时页面会有明显卡顿。正确做法是把所有需要解锁的区域合并成一个数组,一次性传给命令服务执行。如果确实遇到"解锁 A 区域的同时锁定 B 区域"的需求,也尽量合并成一次保护设置来完成。
5.3 权限的最终闸门是服务端
前端权限方案做得再好,也挡不住恶意用户。Univer 的数据模型在前端是完全暴露的,我可以通过控制台直接读取或者修改数据,甚至调用内部命令绕过 UI 界面。因此,任何涉及生产数据的权限控制,都必须把服务端校验作为最终闸门。
那服务端到底要校验哪些东西?我的经验是至少要覆盖三点:
第一,校验"谁可以编辑哪个单元格"。这和前端白名单共用一套配置,后端在做数据写入前,遍历提交的每个单元格 key,不在白名单内的直接拒绝。
第二,校验数据内容是否合法。比如某单元格要求填入数字类型的工时,用户却填了"加班两小时",服务端要能根据配置里的类型定义做校验。
第三,校验提交的数据完整性。有些单元格是系统自动带入的,比如工号、创建时间、登记人,用户根本不需要填。前端会把这些格子锁定,但服务端要反过来检查这些字段是否被篡改,尤其要防止用户把系统自动填充的值改成其他内容。
我的建议是:前端权限做好"用户体验",后端权限做好"数据安全",两者各司其职,缺一不可。
6. 我在 Univer 实践里踩过的坑
6.1 版本升级让 API 变了
Univer 的迭代速度真的很快,从 0.6 到 1.x,一些命令的 ID、参数结构都有调整。我第一次踩坑是在项目进行到一半时,想升级一下小版本,结果发现SetWorkSheetProtectedMutation的导入路径变了,参数也从isProtected变成了protect配置对象。
从那以后我养成了一个习惯:项目锁版本,不跟风升级。Univer 这种还在快速演进的开源项目,升级前一定要先跑一遍完整 demo,把所有用到的 API 列个清单,逐个验证。光看 release note 是不够的,source break 往往藏在细节里。
6.2 "保护"设置和视觉样式是两回事
我最初只设置了保护,没改任何样式。结果用户打开表格后,看到了一个"看似完全正常"的表格,点半天发现有些格子进不了编辑,还以为是浏览器卡了。后来我在可编辑区域加了明显的边框和背景色,再加上页面顶部的引导文案,用户就再也没有疑惑了。
这个经验给我一个启发:权限的交互反馈要直接、明显。用户在可编辑区域输入,应该感受到这是"专为他开放的输入区";锁定区域则应该用一种低调但清晰的视觉语言告诉他"这里不需要你操作"。不要指望用户会去猜测。
6.3 单元格数据格式:数字别当字符串写
我在初始化 cellData 时,一开始图省事,把所有值都写成{ v: '123' }这样的字符串。结果测试统计功能时,SUM 公式算出来永远是 0,排查了半天才发现是因为单元格值类型不是数字。
Univer 的单元格对象里其实有类型字段,数值应该写成数字类型,或者用t字段显式声明。我后来在构建后端返回的数据时做了类型转换,确保数字字段是v: 123而不是v: '123'。这个问题看起来很小,但一旦上线,会让所有统计类公式哑火。
6.4 大数据量的渲染性能
Univer 自带虚拟滚动,几万行数据渲染起来没问题,但我在做"全表保护 + 区域解锁"时发现一个性能瓶颈:连续给多个区域设置保护状态,如果区域数量过多,前端会有明显的卡顿。
应对方式前面提到过,尽量合并 ranges。另一个做法是:不要一次性加载整个超大数据表,可以按需加载部分行,或者在初始化时用一个较小的rowCount,等用户滚动到底部再加载更多。这个思路对所有在线表格都适用,Univer 也不例外。
6.5 自定义工具栏按钮和提交逻辑
用户填完表格后,需要一个明确的"提交"动作来把数据发到后端。Univer 默认的工具栏没有这个按钮,我通过它的扩展机制注册了一个自定义按钮,放在工具栏右上角,点击后触发数据提交流程。
我在实际项目中的体会是,与其让用户自己去点浏览器里的某个外部按钮,不如把"提交"融入到表格界面里。用户在这个表格里完成了所有录入,自然期望在这里结束整个流程。自定义按钮这个能力 Univer 是支持的,文档里有扩展工具栏的示例,照着做就行。
个人经验小结
Univer 解决了我最初那个"局部可编辑数据收集表"的难题,而且比预期顺利。它真正打动我的点是:权限控制不是靠前端 hack,而是引擎层面原生支持;数据模型是干净的 JSON,和后端服务可以很好地配合;自定义能力足够强,能把它揉进复杂的业务系统里,而不是反过来让业务迁就一个僵硬的表格组件。
如果你只是需要一张静态展示表格,完全没必要上 Univer,HTML table 就够了。但如果你要做的是"嵌入系统、带单元格权限控制、还要能处理公式和数据汇总"的在线表格,Univer 值得一试。最后分享一个我自己的小技巧:遇到 Univer 的问题,不要只搜文档,直接去 GitHub 仓库看它自带的使用示例和源码,往往比任何博客都更快让你找到答案。