开头先聊个背景。在线表格这个领域,做出来能看容易,做出来能用的少,能开源出来的更是凤毛麟角。Univer是我最近半年持续跟的一个开源项目,它定位是一套完整的office套件,支持表格、文档、演示三类在线编辑,其中最成熟、也是被用得最狠的就是它的电子表格模块。很多人第一次看到Univer会把它理解成"又一个web excel",但如果只停留在功能对比层面,你会错过它真正值得借鉴的地方——插件化架构和对编辑行为的统一管控。这篇文章我主要想讲两件事:Univer到底解决了什么问题,以及如何用它落地一个非常常见的需求——允许用户自定义一张表格并填写指定单元格,同时锁住其他不允许改动的内容。
如果你做过类似"数据收集表""员工信息填报""设备盘点录入"这类功能,应该能立刻get到我的意思。传统的做法是前端写一堆input框,后端再慢慢拼数据结构,做起来累,改起来也累。用Univer的话,表格本身的建模、渲染、交互、协同都由它承担,我们要做的只是定义规则:哪些单元格用户可以碰,哪些碰了就拦下来。
1. Univer是什么:从项目背景到核心能力
1.1 一套代码覆盖表格、文档、演示
先说项目本身。Univer来自dream-num团队,最初是从内部的一个在线表格产品演化而来,后来逐步扩展成了文档、幻灯片都能编辑的通用办公基础库。技术栈是TypeScript为主,上层通过插件机制按需加载模块。它的核心价值在于:你不必为"做一张在线表格"而从头维护一个庞大的编辑器,而是引入一个已经打磨过的引擎,把精力集中在自己的业务逻辑上。
我之所以强调"一套代码覆盖三类编辑器",是因为在实际项目中,很多管理后台本身就混合着表格、表单、报表几种形态。比如一份年度计划表,主表用Univer渲染,备注说明需要富文本,汇总页要一份PPT式的演示稿——如果这三块由不同编辑器承担,数据模型很难打通,用户也要频繁切换工具。Univer的设想是底层数据模型统一,交换成本低,未来甚至可以做表格嵌套文档这类高级操作。
1.2 插件化架构是最大分水岭
Univer的架构拆得很干净,核心引擎只负责命令、数据模型、插件生命周期,具体的单元格渲染、编辑框、工具栏、公式解析都是插件。换句话说,你可以只引入表格核心,不引入UI,完全用自己的React或Vue组件去承载画布,这在做定制化系统时非常关键。
以我自己接入的经验为例,它有几个核心包:
| 包名 | 职责 |
|---|---|
| @univerjs/core | 数据模型、命令系统、协同底层 |
| @univerjs/sheets | 表格核心逻辑,含行列、单元格、选区等 |
| @univerjs/sheets-ui | 表格UI渲染,包括Canvas画布、编辑框、选择框 |
| @univerjs/ui | 通用UI基础组件,如工具栏、菜单、弹窗 |
这种分层带来的直接好处是,如果我不需要官方那套工具栏,完全可以去掉@univerjs/ui,只保留Core和Sheets,再用自己的按钮去触发命令。很多二次开发项目都是在Univer之上做了一套"轻桌面"式的工作台,底层表格引擎负责渲染,业务框架控制交互逻辑,两者互不干扰。
1.3 在线协作与命令系统的底层逻辑
Univer在线编辑能力之所以强,底层是它的命令系统。你在界面上做的一切操作——输入文字、删除行、修改样式、撤销重做——都会被封装成一个结构化的命令对象。命令对象记录了操作类型、目标单元格、值、范围等附加信息,然后通过协调层分发给服务端和其他客户端。简单说,它做的事情很像"把每一次键盘敲击变成一条可传输、可回放、可合并的操作日志"。
这个设计对开发者极其友好。正因为编辑行为抽象成了命令,我们才可能精准地在命令执行前后插入校验逻辑——这恰恰是"锁定指定单元格"这类需求的最佳切入点。如果是那种每个操作都散落在各处直接改DOM的编辑器,你根本没法在一个统一的位置做拦截。
2. 需求拆解:用户自定义表格 + 指定单元格可编辑
2.1 这类需求出现的高频场景
我接到过很多类似的需求,几乎都长这样:有一个"管理员"角色,他在页面上拖拽、增删行列、填写表头,形成一张自定义表格;然后系统把这张表格发布给普通用户,用户只能往指定的空白单元格里填数据,其他单元格——尤其是表头、公式、只读字段——一律不允许修改。
这个流程听起来简单,但真实操作起来会碰到不少隐蔽问题:
- 用户不小心改了表头文字,导致整列语义错误;
- 用户把公式单元格复制覆盖了,算好的结果变成静态值;
- 用户拖拽填充时把相邻列内容一并覆盖;
- 用户清除某个单元格的格式,报表样式乱了。
这些问题如果靠HTMLinput那套逻辑解决,工作量不小。而Univer的命令系统天然允许我们在数据变更入口处做统一拦截,处理起来顺手很多。
2.2 Univer在单元格保护上的原生能力边界
Univer官方对"工作表保护"这个功能确实有支持,你可以通过配置工作表保护状态,让整张表在UI上进入只读模式,再对选区做权限设置。对于简单场景,这个机制是够用的:管理员打开保护,普通用户只能操作被标记为可编辑的区域。
不过用它落地生产环境时,我遇到的典型限制有三个。
第一,保护规则是"整表级别"的,但业务需求往往是"单元格级别"的。管理员要锁A列、开放B列、再开放C2到C10,规则一旦复杂,配置表达的复杂度会直线上升。
第二,保护规则生效范围偏静态,它更适合"这张表就是谁都不能改样式"这种场景,而实际业务往往还需要配合"提交后锁定""特定角色可改""数值大于某个阈值禁止修改"这类动态规则。
第三,如果你自己封装了一套后端权限体系,希望把可编辑范围从服务端下发,那么官方保护的配置接口不一定能覆盖到每一种情况。
所以,在实际项目中,我更倾向于把"保护"理解为一个业务规则,而不是一个静态配置。用Univer的命令拦截机制来做动态控制,比硬搬官方保护功能更灵活。
2.3 实现路线的选择:命令拦截优先
最终我采用的方案是:在Univer的命令执行链上插入拦截器,对所有可能修改单元格内容的命令做一次"目标单元格是否允许编辑"的校验。如果目标在允许列表里,放行;否则直接拒绝,甚至可以弹提示。
为什么优先选命令拦截,而不是自己监听内容变化再回滚?原因很简单:回滚是事后补救,存在一个"改了又被改回来"的闪烁过程,而且撤销记录里会留下脏数据。而拦截发生在命令真正执行之前,数据模型都不会被改动,效率和一致性都更好。
第二个原因是,命令拦截天然能覆盖多种操作通路。键盘输入、粘贴、拖拽填充、公式重算后更新值、清除单元格、导入导出,这些操作最终都会映射为不同类型的命令。只要在正确的位置拦截,等于把大部分的"非法编辑路径"都堵住了,而不需要每个交互入口单独处理。
第三个原因跟协同有关。Univer的命令本身是可序列化的,拦截器在本地判断一次,协同服务端还可以再做一次校验,形成双重保险。这在多人同时操作同一张表的时候相当重要。
3. 实战:搭建"只能填写指定单元格"的Univer页面
3.1 安装与最小初始化
先把Univer跑起来。当前社区的版本,推荐直接用npm包引入,以TypeScript项目为例:
npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/ui安装完成后,最简初始化代码大概长这样:
import { Univer } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import '@univerjs/sheets-ui/lib/index.css'; const univer = new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin, { container: document.getElementById('app'), });在这里先提个建议:如果你只是想在某个页面嵌一个表格,不要一上来就追求"完整工具栏"体验,先最小化跑通,再逐步加模块。Univer有一个特点,插件注册的顺序和组合方式会影响启动体积和功能表现,一开始把公式、数据透视表这些模块全挂上,万一出问题排查起来会头大。
3.2 创建可编辑的模板表格
跑起来之后,需要创建一张工作簿并填充模板内容。Univer中创建表单的代码经常用univer.createWorkbook(),然后基于工作表对象操作单元格。为了便于理解,我按社区常见写法演示:
const workbook = univer.createWorkbook(); const sheet = workbook.getActiveSheet(); // 模拟管理员自定义模板 sheet.getRange(0, 0).setValue('设备名称'); sheet.getRange(0, 1).setValue('负责人'); sheet.getRange(0, 2).setValue('领用日期'); sheet.getRange(1, 2).setFormula('=TODAY()'); // 设置第一行高度、加粗表头等 sheet.getRange(0, 0, 1, 3).setFontWeight('bold');到这里,管理员就完成了一张"用户自定义表格"的创建。真正发布给用户时,我们并不希望用户能修改第一行的表头,也不希望第三列那个TODAY()公式被用户改成静态日期。于是我们需要在代码里维护一个可编辑范围的清单。
3.3 核心逻辑:拦截值写入命令
Univer里,修改单元格内容的核心命令是SetRangeValuesCommand。无论是用户手动输入、粘贴数据还是公式重算,最终都会走这个命令,或者走与它同级别的变种命令。针对这个命令做一次"目标区域是否允许写入"的判断,是整条防线最重要的一环。
一个可运行的拦截思路如下:
import { CommandType } from '@univerjs/core'; import { SetRangeValuesCommand } from '@univerjs/sheets'; // 可编辑范围列表,这里以sheetId + 行列号表示 const editableCells = new Set<string>(); const allowRange = (row: number, column: number) => { // 示例:开放 A2、B2、C2(对应 row=1, column=0/1/2) if (row === 1 && column >= 0 && column <= 2) return true; return false; }; const interceptorDispose = univer.getCommandService().registerCommandInterceptor({ // 该拦截器的优先级,数字越大越先执行 priority: 100, // before阶段:在命令真正执行前拦截 before: async (command) => { if (command.id === SetRangeValuesCommand.id) { const params = command.params; const cells = params.cells; for (const key in cells) { const parts = key.split('_'); const row = Number(parts[0]); const column = Number(parts[1]); if (!allowRange(row, column)) { return { error: true, message: '该单元格已被锁定,不可编辑', }; } } } return { error: false }; }, });这段代码主要做的事情是:如果用户试图写入任何不在允许范围内的单元格,拦截器直接返回error,命令不会真正写入数据模型。由于判断范围是按单元格遍历的,哪怕用户一次选中了10行,其中只有1个单元格是非法的,整次操作也会被拦下。
这里有个细节需要专门提一下:command.params.cells的结构在不同版本里可能有差异,有的场景是对象、有的场景是二维数组。写法不重要,关键是要理解Univer的拦截点——在before阶段去遍历目标单元格并核对权限。
3.4 反馈弹层与提交
拦截成功后,最好能给用户一个明确的提示。我通常在拦截器返回error前,把消息存到一个全局变量里,然后通过SetCommand或者UI事件抛出一个轻量提示:
import { notify } from '@univerjs/ui'; if (!allowRange(row, column)) { notify.warning('当前单元格为只读区域,只有白色单元格可以填写'); return { error: true, message: 'locked' }; }用户填写完数据后,往往需要把结果提交给后端。Univer本身不会替你处理业务落地,但我们可以通过getActiveSheet().getRange()把整张表读出来,序列化后POST到自己的服务:
function submitSheet(workbook) { const sheet = workbook.getActiveSheet(); const range = sheet.getRange(0, 0, 20, 10); const data = range.getValues(); fetch('/api/sheet/submit', { method: 'POST', body: JSON.stringify({ data }), }); }值得提醒的是,不要把"前端拦截"当成唯一的安全边界。任何纯前端拦截都可以被跳过,真正要保证数据合法,还得在服务端基于同一份allowRange规则重新校验一遍。这个原则适用于任何在线编辑器场景。
4. 实测中的坑:复制粘贴、拖拽、协同冲突
4.1 粘贴与拖拽填充如何绕过单纯的值拦截
我第一次实现"单元格锁定"时,只拦截了SetRangeValuesCommand,以为万事大吉。上线后用户很快反馈:虽然手动输入被拦住了,但他可以选中一整行"拖拽填充",把只读单元格全部覆盖成新数据。
排查发现,拖拽填充走的是另一个命令FillCommand,它在语义上是"把源单元格填充到目标区域",并不会逐格调用SetRangeValuesCommand。清除内容同理,走的是ClearCommand。如果要把"不允许修改"贯彻到底,拦截清单需要进一步覆盖:
| 命令 | 说明 |
|---|---|
| SetRangeValuesCommand | 输入、粘贴、公式重算等值写入 |
| FillCommand | 拖拽填充、序列扩展 |
| ClearCommand | 清除内容或格式 |
| SetRangeFormatCommand | 修改样式,如加粗、填色 |
| InsertRowCommand / InsertColCommand | 插入行列,可能会影响表格结构 |
| RemoveRowCommand / RemoveColCommand | 删除行列 |
如果你用的是较新版本的Univer,命令查找方式可以参照官方文档的CommandRegistry列表,逐个比对名称。我的习惯是写一个公共方法,把这些命令统一包一层isTargetLocked(params)检查,把所有写操作都收口到同一个判断函数里,避免每个命令都复制一遍校验逻辑。
4.2 协同编辑下的并发写入竞态
第二个坑更隐蔽。单机场景下,命令拦截器判断逻辑很直接:目标单元格不在允许列表就拒绝。可一旦开启协同编辑,其他用户的远程操作并不会经过你自己本地的before拦截器,而是直接同步到本地数据模型。这就意味着:如果A用户的客户端不遵守规则,他依然有能力通过协同通道把数据写进只读单元格。
这在多人同时维护同一张公单时会出现一个很尴尬的场景——管理员明明锁定了C2单元格,但另一个用户通过协同接口依然改写了它,前端拦截器完全无感。
解决思路通常有两种。第一种是在产品层面不开放协同编辑,用户填写各自提交,提交时由后端做校验。第二种是启用Univer的协同服务端能力,在服务端命令流转层再挂一层权限校验,双端同时拦截。如果你做的是对外公开的填报系统,我更推荐第一种:限制表格的实时协同,填写完成后提交到业务后端,由后端做最终裁决。实时协同在"C2C共同编辑同一份在线文档"时有价值,但在"用户向系统提交数据"的场景里,它带来的并发问题远大于收益。
4.3 大表格渲染与页面卡顿
Univer用Canvas做渲染,比传统DOM表格在性能上强很多,但这不代表你可以无限制地塞数据。我实测过一张200行 x 20列左右的表格,在主流笔记本上操作流畅,但一旦把列数扩展到100列,并且开了公式引擎,初始化时间和滚动时的重绘帧率会有明显波动。
为了保持顺畅体验,我建议在模板设定上做约束:
- 模板默认只创建必要行列,不需要用户访问的区域直接不生成;
- 如果表数据量确实大,建议开启Univer的视口预渲染配置,把视口外的单元格推迟渲染;
- 避免在模板里大量使用"整列公式",每列公式都会在每次数据变化时参与重算,公式多了计算延迟会叠加。
还有一个需要注意的小问题:给整张表设置背景色或边框时,如果覆盖行列过多,会拖慢样式更新的速度。我之前做一个设备巡检表时,给200行数据都设置了隔行变色,结果每次输入都会等待几百毫秒的样式计算。最后优化方式是只给实际有数据的区域设置样式,空余部分保留默认配置,交互恢复流畅。
5. 从"表格表单"到更多场景的延伸
5.1 同一套思路直接复用
这套"命令拦截 + 可编辑范围配置"的方案并不只服务于"用户填表"这一种场景,往周边延伸一下,能覆盖不少业务:
- 只读报表:拦截所有写操作,表格就变成高保真的只读视图,适合嵌入BI大屏或者工单详情页;
- 数据校验录入:在拦截器里可以追加业务逻辑,比如"销售额不能为负""日期必须晚于下单日期",不满足就不放行,比后端返回错误消息的交互体验好很多;
- 多角色表格:管理员登录时绕过拦截,编辑全表;普通用户登录时表头锁定、数据列可写,调整角色权限只需要修改
allowRange的判定来源。
让我觉得比较赞的是,这些场景不需要改Univer本身,只在你自己的业务代码里做规则切换。拦截器本质上是一个可以随环境变量、用户身份、后端配置动态变化的函数,表达力远强于静态配置。
5.2 服务端权限与模板下发的配合
如果你要在多租户或微服务架构中使用Univer,模板和权限最好从服务端下发,而不是在前端写死。流程大致是:
- 管理员在后台创建模板表格;
- 服务端把模板结构、内容、可编辑范围序列化;
- 用户访问时,服务端按角色过滤后可编辑范围,随模板一并下发;
- 前端根据下发范围初始化拦截器和表格数据。
这样管理员改了模板,用户端下一次刷新就能生效,不需要发版。我实践下来,模板下发用JSON结构,把单元格内容、样式、公式、合并区域、行高列宽、可编辑范围全部统一编码,后端只需要维护这一份JSON,前端负责把它还原成Univer工作簿。好处是版本管理、对比、回滚都变得异常简单,坏处是刚开始设计序列化结构时稍微费点工夫,但这个成本值得付。
我实际写完这套拦截逻辑后,最大的收获是理解了Univer把一切变成命令的代价与红利。代价是学习曲线,红利是边界在哪你都有得改。如果你的目标是做一个在线填报系统或者自定义模板工具,Univer绝对值得认真对待,但别一上来就追求所有功能,先把最需要的那个编辑闭环跑通,再去碰协同、公式、样式那些更深的领域。等你的业务规则开始变复杂,回过头来会发现,当初在命令拦截器上打的那些补丁,全都是最有价值的资产。