拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Univer在线表格:用户自定义表格与单元格权限控制的实践指南

Univer在线表格:用户自定义表格与单元格权限控制的实践指南

最近在社区里看到关于 univer 的讨论明显多了起来,热搜词连续出现了"univer"、"univer 支持用户定义表格"、"univer在线"这些组合。作为一个常年折腾表格类产品的开发者,我非常理解这个需求为什么会频繁出现:在线表格编辑器最核心的价值,不是替用户画一堆格子,而是让业务人员可以自定义表格结构,再按规则开放一部分单元格给填表人。这类场景在报名表、数据收集、内部审批、绩效填报里实在太常见了。

Univer 是一个开源的在线表格/文档/幻灯片解决方案,底层基于 Canvas 渲染,用 TypeScript 开发,最大的特点是插件化架构和前后端分离。它解决的核心问题是:你不需要从零写一套 Excel 的渲染引擎,就能在自己的产品里嵌入一个接近桌面级体验的在线表格。适合的对象很明确:想给自己的 SaaS 产品加表格能力的前端团队、做低代码平台需要"表单 + 嵌套表格"能力的团队,以及像我这样需要快速交付"可填写的在线表格"项目的个人开发者。

在后面的内容里,我会从选型思路、需求拆解、环境搭建、权限控制实现,到实测中的坑,一层层把"用户定义表格 + 指定单元格可填写 + 其他单元格不可改"这套方案讲明白,代码给到可以直接跑的程度。

1. 为什么是 Univer:在线表格的选型与定位

1.1 在线表格方案的横向对比

做在线表格能力选型的时候,大部分团队会在几条路线之间纠结:

方案优点缺点
基于近年流行的开源表格库(如 Luckysheet、x-spreadsheet)集成快,社区有基础代码大数据量渲染卡顿,功能扩展基本靠改源码
引入重量级套件(OnlyOffice 等)与 Office 格式兼容性强部署重,前后端耦合深,定制交互成本高
使用商业产品嵌入方案效果好,省心有平台绑定,无法深度定制与私有化
Univer渲染性能好,插件化架构,前后端分离版本迭代快,需要盯紧文档更新

我的选择逻辑很简单:项目需要在私有化环境中嵌入表格,同时要深度定制"哪些单元格能编辑"这类权限交互,所以必须选一个不受平台约束、能控制渲染层和命令层的开源方案。Univer 的 Canvas 渲染引擎在大数据量下依然能保持流畅交互,而插件化的设计让我可以只加载表格模块,不需要为用不到的功能付出体积和复杂度代价。

所谓"不受平台约束",指的是表格的渲染、命令、数据模型这些关键部分都在自己手里。商业方案通常只提供 iframe 嵌入,你在外面只能做有限二次开发;Luckysheet 这类的旧一代开源方案虽然也能改源码,但底层基于 jQuery + Canvas 混合架构,越往深处改越吃力。Univer 整体用 TypeScript 编写,模块边界清晰,这对需要长期维护的业务来说非常重要。

1.2 Univer 的核心架构理念

理解 Univer 的架构,对后面实现权限控制特别有帮助。它分成几层:

  • @univerjs/core:最底层的数据结构和命令系统。所有对表格的操作,本质上都是通过命令(Command)来修改数据模型。
  • @univerjs/sheets:表格的领域逻辑层,负责行列、单元格、选区、样式等纯逻辑计算。
  • @univerjs/sheets-ui:UI 层,负责 Canvas 编辑器界面渲染。
  • @univerjs/preset-sheets:预设包,把核心模块和 UI 模块打包好,方便通过 npm 快速初始化。

我在实际开发中最大的体会是,Univer 的命令系统非常关键。当你有"禁止用户修改某些单元格"这类需求,与其在 UI 层拦截鼠标事件,不如在命令层做控制。何况用户的操作入口远不止鼠标输入这一种,键盘录入、粘贴、拖拽填充、公式批量填充,最终都会走命令通道。在命令层做权限判断,才能保证控制逻辑不会漏掉入口。

这也是我把 Univer 和其他同类方案区分开的一个重要标准。别的方案是"给你一个可配置的编辑器",Univer 更像是"给你一套可以改写的表格引擎"。权限控制这种强业务相关的功能,恰恰需要这样的可改写能力。

2. 核心需求拆解:用户填写指定单元格,其他区域锁定

2.1 "用户定义表格 + 部分单元格可填"到底在说什么

把热搜词翻译成业务语言,这个诉求通常长这样:

  1. 管理员(或业务人员)先在系统里创建一个表格,自己定义表头、结构和一些计算字段,这就是"用户定义表格"。
  2. 填表人打开这个表格,看到的是管理员设计好的结构。
  3. 管理员预先开放某些单元格(比如"姓名""联系电话""报名项目"),填表人只能在这些单元格里输入内容。
  4. 其余单元格,比如表头、说明文字、公式区域,填表人无法选中编辑,更不能修改公式或格式。

这是典型的"数据收集型"表格需求。它的核心不是做一个能自由编辑的表格,而是做一个有管理权限的轻量应用。只有把需求拆成"表格 = 静态结构 + 开放填写区 + 受控数据"这三个部分,才能选对实现策略。

我见过不少团队在做类似需求时,直接用表单组件拼一个界面出来,Excel 模板只是展示。这样做的弊端很明显:一旦业务人员想调整字段顺序、增加几行备注、改动计算逻辑,就要提需求让开发改代码,表单的灵活性完全体现不出来。而用表格做容器,业务人员可以在受限范围内自己调整结构,这正好契合"用户自定义表格"的诉求。

2.2 权限设计策略:先解锁,再保护

做单元格级编辑权限,我推荐采用和 Excel 一致的模型,它最成熟,用户心智成本也最低:

  • 每个单元格有一个lock属性(是否锁定)。
  • 默认情况下所有单元格是锁定状态。
  • 当工作表开启"保护模式"后,锁定的单元格不可编辑,未锁定的单元格(lock: false)可编辑。
  • 即使开启保护,你也可以通过"允许编辑区域"(allowEditRanges)额外指定一些区域不受保护。

在这个模型下,实现"只允许填 B2:C4"就变成三步:

  1. 准备数据:创建表格、写入表头和说明文字。
  2. 设置开放区域:把需要用户填写的区域(如 B2:C4)的lock属性设为false。
  3. 开启保护:对整张工作表开启保护,此时除 B2:C4 外全部不可编辑。

这套策略的好处是:如果后续要新增一个可填字段,不需要重新做权限判断逻辑,只需把新区域的单元格设为lock: false即可,保护层不用动。如果你的业务还要求"不同角色看到不同填写区",那就在角色进入页面时动态执行这套操作,逻辑也是同一套。

3. 环境准备与 Univer 初始化

3.1 从零搭一个最小项目

我用 Vite + React 做了验证,这里也按这个路径写。纯前端项目,不需要后端也可以先把效果跑出来。

先初始化项目:

npm create vite@latest univer-demo -- --template react-ts cd univer-demo npm install

然后安装 Univer 预设包:

npm install @univerjs/preset-sheets @univerjs/core

如果你用的是较新的 Univer 版本(0.x 系列),包路径基本就是这样。不同版本之间 API 可能会有调整,但整体结构是稳定的。

注意:安装版本建议直接使用 npm 上的 latest。Univer 的版本迭代比较快,旧版本可能存在已修复的 bug,直接踩坑没必要。

3.2 最小可运行的 Univer 页面

在App.tsx里写一个最简单的 Univer 实例:

import { Univer, defaultTheme } from '@univerjs/preset-sheets'; import { UniverInstanceType } from '@univerjs/core'; import { useEffect, useRef } from 'react'; import '@univerjs/preset-sheets/lib/styles/index.css'; export default function App() { const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { const univer = new Univer({ theme: defaultTheme, }); // 创建一个表格单元 univer.createUnit(UniverInstanceType.SHEET, { id: 'workbook-1', name: '报名信息收集表', rowCount: 30, columnCount: 10, }); return () => { univer.dispose(); }; }, []); return ( <div style={{ width: '100%', height: '600px' }}> <div ref={containerRef} style={{ width: '100%', height: '100%' }} /> </div> ); }

跑起来后,你就能在页面上看到一个可以自由编辑的表格了。

这里有一个细节:Univer实例创建后会自动挂载到默认容器上。如果你在同一个页面创建多个实例,需要显式指定容器,否则多个编辑器会错乱地挂到同一个节点。我在第一次做多实例测试时就踩了这个坑,页面看起来像两个表格叠在一起,怎么点都不对。

3.3 初始数据准备

为了模拟"用户定义表格",我建议在创建单元时直接写入表头和数据,或者在创建后通过命令批量写入。先给出直接写入的方式:

univer.createUnit(UniverInstanceType.SHEET, { id: 'workbook-1', name: '培训报名表', rowCount: 30, columnCount: 10, cellData: { // 第 1 行:表头 0: { 0: { v: '姓名' }, 1: { v: '联系电话' }, 2: { v: '报名项目' }, 3: { v: '备注(管理员填写)' }, }, // 第 2 行:提示与示例 1: { 0: { v: '张三' }, 1: { v: '仅登记用,不开放编辑' }, }, }, });

这种初始化方式的优点是,单元格数据、样式、合并信息可以一次性声明。但如果你需要从后端读取一个 Excel 或 JSON 模板,通常会用 Univer 的导入能力(比如@univerjs/sheets-import插件)把文件转成工作簿结构,再交给createUnit渲染。后文我会重点讲在页面上如何通过命令去修改这些数据。

4. 实操:实现单元格编辑权限控制

4.1 关键 API 与执行路径

实现权限控制,主要涉及两个层面:

  1. 样式层:通过设置单元格样式,把指定区域的lock设为false。这个动作在 Excel 中对应的就是"设置单元格格式 — 保护 — 锁定"的取消勾选。
  2. 保护层:对工作表开启保护(Worksheet Protection)。开启后,Univer 会检查单元格的lock状态,锁定单元格只读,未锁定单元格可写。

在 Univer 中,可以通过命令服务执行类似下面的命令(不同版本命令名可能略有差异,以官方文档为准):

const commandService = univer.getCommandService(); // 1. 解析工作簿与工作表 id const workbookId = 'workbook-1'; const worksheetId = 'sheet-1'; // 2. 将 B2:C4(行索引 1-3,列索引 1-2)设置为未锁定 await commandService.executeCommand({ id: 'sheet.command.set-range-style', params: { workbookId, worksheetId, ranges: [ { startRow: 1, endRow: 3, startColumn: 1, endColumn: 2, }, ], style: { lock: false, }, }, });

上面是"解锁区域"的命令。接下来是"保护工作表":

// 3. 保护工作表 await commandService.executeCommand({ id: 'sheet.command.set-worksheet-protect', params: { workbookId, worksheetId, protection: { // 可选参数:是否允许选中锁定单元格、是否允许选中未锁定单元格等 selectLockedCells: true, selectUnlockedCells: true, }, }, });

执行完这两条命令,效果就是用户仍然可以点击 B2:C4 输入内容,但点其他单元格时,虽然能看到选区,却无法编辑内容。

注意:set-range-style这条命令在 Univer 中也用于改字体颜色、背景色、边框等,style对象里不止lock一个字段。你只需要设置lock: false,不必把整份样式都传进去,否则会把之前的格式覆盖掉。

4.2 完整代码示例(可直接跑)

我写一个更完整的 React 示例,把"创建表格 + 写入表头 + 解锁填写区 + 开启保护"四件事放到一起:

import { Univer, defaultTheme } from '@univerjs/preset-sheets'; import { UniverInstanceType } from '@univerjs/core'; import { useEffect, useRef } from 'react'; import '@univerjs/preset-sheets/lib/styles/index.css'; const WORKBOOK_ID = 'wk-baoming'; const WORKSHEET_ID = 'sheet-1'; export default function App() { const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { const univer = new Univer({ theme: defaultTheme }); univer.createUnit(UniverInstanceType.SHEET, { id: WORKBOOK_ID, name: '培训报名表', rowCount: 30, columnCount: 10, cellData: { 0: { 0: { v: '姓名' }, 1: { v: '联系电话' }, 2: { v: '报名项目' }, 3: { v: '备注' }, }, }, }); const commandService = univer.getCommandService(); (async () => { await commandService.executeCommand({ id: 'sheet.command.set-range-style', params: { workbookId: WORKBOOK_ID, worksheetId: WORKSHEET_ID, ranges: [ { startRow: 1, endRow: 3, startColumn: 1, endColumn: 2 }, ], style: { lock: false }, }, }); await commandService.executeCommand({ id: 'sheet.command.set-worksheet-protect', params: { workbookId: WORKBOOK_ID, worksheetId: WORKSHEET_ID, protection: { selectLockedCells: true, selectUnlockedCells: true, }, }, }); })(); return () => univer.dispose(); }, []); return ( <div style={{ width: '100%', height: '600px', display: 'flex', justifyContent: 'center', alignItems: 'center', background: '#f0f2f5', }} > <div ref={containerRef} style={{ width: '1000px', height: '500px' }} /> </div> ); }

跑起来后,你切换到浏览器中点击表格:B2、B3、B4、C2、C3、C4 这几个单元格可以正常输入,其他单元格即使弹出选区也无法输入文字和数字。

4.3 保护范围与动态控制补充

上面是"静态"设置,实际业务往往需要动态控制。比如用户身份不同,填写的字段不同。这时候建议把权限配置下沉到后端,由接口返回"哪些区域可编辑",前端拿到后动态执行命令。

我常用的一种方式,是在后端返回如下 JSON:

{ "workbookId": "wk-baoming", "editableRanges": [ { "startRow": 1, "endRow": 10, "startColumn": 1, "endColumn": 2 } ] }

前端拿到后,先对所有单元格设置锁定(或让所有单元格保持默认lock: true),再遍历editableRanges设置lock: false,最后统一开启保护。这样即使表格结构变化,只需要改配置,不需要改代码逻辑。

如果还要区分"管理员可编辑所有单元格、填表人只能编辑指定区域",还可以通过切换保护状态实现:管理员进入时关闭保护(或另开一个不带保护的工作表),填表人进入时开启保护。注意保护状态要在服务端保存,最好持久化到数据库字段里,否则刷新页面就失效了。

比如在 React 中,你可以用一个isAdmin变量来控制:

if (isAdmin) { await commandService.executeCommand({ id: 'sheet.command.set-worksheet-protect', params: { workbookId: WORKBOOK_ID, worksheetId: WORKSHEET_ID, protection: null, // 示意:关闭保护 }, }); } else { await commandService.executeCommand({ id: 'sheet.command.set-worksheet-protect', params: { workbookId: WORKBOOK_ID, worksheetId: WORKSHEET_ID, protection: { selectLockedCells: true, selectUnlockedCells: true, }, }, }); }

这样权限模型就非常清晰了:后端决定"你这个角色能看什么、能填什么",前端只负责把策略翻译成 Univer 的解锁和保护命令。

5. 实测中的坑与经验速查

5.1 高频问题排查表

现象可能原因处理建议
设置lock: false后仍然不能编辑工作表保护没有开启,或保护开启时间早于解锁命令先解锁区域,再开启保护;命令执行顺序不要反
开启保护后整个表都无法输入没有先设置任何区域为lock: false至少把开放填写的区域在开启保护前设为未锁定
粘贴、拖拽填充仍能修改锁定区域粘贴和拖拽走的命令与直接输入不同,部分版本对保护检查不完全覆盖同时在命令层监听相关命令,命中锁定区域时阻止执行
样式覆盖导致之前设置的格式丢失set-range-style传了整个 style 对象覆盖原样式只传需要变更的字段,或先读取当前样式再合并
多实例时编辑器错乱创建多个 Univer 实例未指定容器初始化时显式指定容器,销毁时调用dispose()
初始化数据量大时页面卡顿一次性写入大量 cellData 导致渲染压力大分批写入,或使用批量命令合并执行

5.2 几个值得记录的细节

第一,保护工作表在某些版本中,仅拦截"修改值"这件事,不会拦截格式调整。也就是说,用户可能仍然可以拖动行高列宽,或者给自己能编辑的单元格加颜色。如果你的业务要求"版面完全固定",要额外监听set-row-height、set-cols-width这类命令,按需拦截。

第二,锁定的单元格只是"不能编辑内容",但默认是"可以选中"的。如果你希望用户根本看不到选区,需要修改保护参数里的selectLockedCells。我建议根据实际体验决定:多数数据收集场景保留选中是更好的,因为用户能看清有哪些格子不能填;如果担心误操作,再把选中权限关掉。

第三,Univer 的命令名在不同版本之间发生过变化。我刚上手 0.x 早期版本时,照着旧文档写完的代码在升级后 command id 对不上,运行没报错但就是不生效。我的习惯是:在每个项目里先做一次console.log观察命令执行记录,开了保护后手动点击一个锁定单元格验证,而不是只看控制台有没有异常。这套"先跑通最小命令,再叠加业务逻辑"的方法,帮我避免了很多文档滞后带来的麻烦。

第四,如果表格要交给填表人使用,建议在初始化模板时就写好所有表头和提示信息,不要等前端渲染完再一条条命令插入。因为模板写入属于"建表阶段",做完后再开启保护,整个交互流程会简单很多。

5.3 命令层拦截作为兜底

如果你想绝对确保任何入口都改不了锁定区域,还可以在命令执行前做统一拦截。逻辑大概是:监听命令执行事件,如果命令是修改单元格数据,就遍历要修改的 range,判断是否包含锁定单元格;包含则取消命令。

// 示意图:在命令执行前拦截 univer.getCommandService().beforeCommandExecute$.subscribe((command) => { if (command.id === 'sheet.command.set-range-data') { const { ranges } = command.params; if (rangesContainsLockedCell(ranges)) { command.preventDefault?.(); } } });

注意,事件订阅的具体写法和preventDefault的调用方式以你使用的 Univer 版本为准。这里要传达的思路是:不要只依赖 UI 层的禁用状态,命令层拦截才是权限控制的最终兜底。尤其当你接手的是一个老项目,里面可能有很多历史操作按钮没有经过权限判断,统一拦截会比逐个按钮排查安全得多。

这套组合拳做下来,"univer 支持用户定义表格,开放特定单元格,其他区域锁定"的需求就完整落地了。方案稳定之后,我甚至把同一套逻辑封装成了一个独立的权限配置模块,后端只需下发一份"可编辑区域清单",前端就自动完成解锁和保护操作,后续其他项目接进来也就两三天的事。

Univer 这个项目仍在快速迭代中,我写这篇文章时使用的版本与最新版之间可能存在细微差异。如果你在实操中发现某个命令在官方文档里变了名字,强烈建议顺手给项目提一个 issue 或 PR,这本身就是开源社区互相成就的过程。对我个人而言,真正把 Univer 玩顺的转折点,就是不再把它当作一个"表格组件",而是当成一个有渲染外壳的数据模型。所有功能都围绕数据和命令展开,问题就变得简单了。

返回列表