最近在社区里看到关于 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 "用户定义表格 + 部分单元格可填"到底在说什么
把热搜词翻译成业务语言,这个诉求通常长这样:
- 管理员(或业务人员)先在系统里创建一个表格,自己定义表头、结构和一些计算字段,这就是"用户定义表格"。
- 填表人打开这个表格,看到的是管理员设计好的结构。
- 管理员预先开放某些单元格(比如"姓名""联系电话""报名项目"),填表人只能在这些单元格里输入内容。
- 其余单元格,比如表头、说明文字、公式区域,填表人无法选中编辑,更不能修改公式或格式。
这是典型的"数据收集型"表格需求。它的核心不是做一个能自由编辑的表格,而是做一个有管理权限的轻量应用。只有把需求拆成"表格 = 静态结构 + 开放填写区 + 受控数据"这三个部分,才能选对实现策略。
我见过不少团队在做类似需求时,直接用表单组件拼一个界面出来,Excel 模板只是展示。这样做的弊端很明显:一旦业务人员想调整字段顺序、增加几行备注、改动计算逻辑,就要提需求让开发改代码,表单的灵活性完全体现不出来。而用表格做容器,业务人员可以在受限范围内自己调整结构,这正好契合"用户自定义表格"的诉求。
2.2 权限设计策略:先解锁,再保护
做单元格级编辑权限,我推荐采用和 Excel 一致的模型,它最成熟,用户心智成本也最低:
- 每个单元格有一个
lock属性(是否锁定)。 - 默认情况下所有单元格是锁定状态。
- 当工作表开启"保护模式"后,锁定的单元格不可编辑,未锁定的单元格(
lock: false)可编辑。 - 即使开启保护,你也可以通过"允许编辑区域"(allowEditRanges)额外指定一些区域不受保护。
在这个模型下,实现"只允许填 B2:C4"就变成三步:
- 准备数据:创建表格、写入表头和说明文字。
- 设置开放区域:把需要用户填写的区域(如 B2:C4)的
lock属性设为false。 - 开启保护:对整张工作表开启保护,此时除 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 与执行路径
实现权限控制,主要涉及两个层面:
- 样式层:通过设置单元格样式,把指定区域的
lock设为false。这个动作在 Excel 中对应的就是"设置单元格格式 — 保护 — 锁定"的取消勾选。 - 保护层:对工作表开启保护(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 玩顺的转折点,就是不再把它当作一个"表格组件",而是当成一个有渲染外壳的数据模型。所有功能都围绕数据和命令展开,问题就变得简单了。