ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Univer实现单元格级只读控制的在线填报系统实践

基于Univer实现单元格级只读控制的在线填报系统实践

最近在做一个企业内部的“数据填报系统”,需求听起来平平无奇:主管先创建一张表格,把自己想让员工填写的格子标出来,其他单元格谁都别想动;员工打开页面后,只能在指定的可编辑单元格里录入数据,表头、说明、公式区域全部只读。这个需求放在网页端做,第一反应是直接用Excel文件上传,后端解析就完了。但产品加了一个限制:员工必须在线看到原始表格,填完还能复核、导出Excel。Excel上传方案只能做单向下发,做不了基于单元格粒度的编辑权限控制。于是我把目标转向纯前端表格内核,最后选定了开源的Univer,整体跑下来效果符合预期。这篇文章算是一份从选型到落地、再到踩坑的完整记录,如果你也在做类似的台账录入、在线填报、静态模板收集工具,应该能少走不少弯路。

1. 为什么用Univer,而不是SpreadJS或Luckysheet

1.1 业务场景拆解

先把这个需求掰开看:它本质上是“带权限约束的在线表格填写工具”,而不是一个多人协作文档。主管在管理端负责定义模板,包括表头、列宽、公式、默认值、下拉菜单,然后框选一块区域作为“可填写区”,其余地方都是“只读区”。

员工侧打开的页面,需求就三条:第一,能看清整张表格的结构;第二,只能在指定单元格里输入内容;第三,录入后能保存、能导出。表面上是表格组件的能力,实际上考验的是“编辑控制”的精细程度,毕竟多数在线表格组件默认允许用户随便点随便改。

结合这个场景,我当时给方案定了几个硬性指标:

  • 必须是前端渲染的类Excel交互体验,而不是一张“看起来像表格”的HTML表。
  • 支持列宽、合并单元格、样式、公式,因为主管设计的模板经常会用到这些能力。
  • 能控制单元格的可编辑性,最好可以拦截具体的编辑操作。
  • 与现有前端框架的集成成本不能太高,团队是React技术栈。
  • 开源优先,能自己改代码,不依赖黑盒授权。

1.2 SpreadJS、Luckysheet与Univer的横向对比

市面上的前端表格方案其实不少,真正纳入过评估的只有三个:SpreadJS、Luckysheet、Univer。

SpreadJS是商业组件,功能确实全面,数据透视、图表、打印、导入导出都做得很成熟。团队试用过,稳定性也让人放心,但授权费用摆在那里。对于一个内部填报工具来说,这个成本不值得背,而且商业组件的扩展边界即使再宽,也总有“咨询工单式”的等待成本。

Luckysheet是较早的开源方案,Excel互导顺手,UI也接近Office风格。但它的代码维护节奏一直不太稳定,而且协同能力被拆成了另一个产品,插件体系相对封闭。拿来做常规展示可以,要在上面做“单元格编辑权限”这种深水区改造,心里没底。

最终敲定的是Univer。它把表格、文档、幻灯片放在一套统一内核里,UI层和数据层分离,扩展上确实灵活。更关键的是它的命令驱动架构:界面上每一次操作,最终都会变成一条Command在命令系统里跑。既然是命令,就能在它执行之前拦下来做权限判断。这对于“部分单元格只读”这种需求来说,属于天然对口的技术底座。

1.3 Univer的架构优势:命令驱动带来的可能性

Univer的核心概念并不复杂:整个表格数据存储在Workbook里,每个Sheet由CellData、样式、行列信息等组成,而所有对数据的修改,都要经由命令系统派发。

这个设计带来一个好处:你要控制用户能不能改某个单元格,不一定要去改UI层的事件绑定,只需要在命令入口处做判断。用户按了Delete、手动输入、粘贴数据,走到最后都会触发对应的编辑命令,拦截到这一层,权限控制就变得可控且统一。

Univer还提供了一套Facade API,面向业务开发者的封装,很多常用操作不需要直接碰命令层代码。比如要设置某个区域的值,标准写法是拿活动工作表,然后getRange(row, col, rowCount, colCount)后调用setValue,非常直观。官方推荐的集成方式也简单,支持React、Vue、Angular等主流框架,容器挂载的方式和普通DOM组件差别不大。

2. 工程接入:5分钟让Univer跑起来

2.1 依赖安装与Preset方案

当前版本Univer推荐使用Preset方式进行安装,它会把创建Univer实例所需的模块打包好,免去手动组装一套Service的繁琐过程。以Sheet场景为例,安装命令如下:

npm install @univerjs/presets

然后就可以创建Univer实例:

import { FUniver } from '@univerjs/core/facade'; import { UniverPresetSheet } from '@univerjs/presets/lib/sheets'; const container = document.getElementById('univer-container')!; const univer = FUniver.newUniver({ locale: 'zhCN', presets: [ UniverPresetSheet({ container }) ] });

容器是一个普通的div,注意需要给这个容器一个明确宽度和高度,否则画布能渲染出来但交互区尺寸会是0。我在项目里统一用的:

#univer-container { width: 100%; height: 640px; border: 1px solid #ddd; border-radius: 4px; }

Univer的版本迭代非常快,不同小版本之间API名称和参数结构会有调整。我下面给的代码都基于当时使用的0.3.x版本,如果你安装的版本更新,碰到方法名对不上,就去node_modules/@univerjs/**/lib/facade里翻类型定义,这个习惯在Univer社区里几乎是必须的。

2.2 创建Workbook并写入初始数据

实例创建完成后,可以直接在工作表上做初始化。我们要做的模板会先在管理端预填好表头、公式、样式,然后由员工端加载。

const activeSheet = univer.getActiveSheet(); // 直接给一块区域写入二维数组 activeSheet .getRange(0, 0, 2, 4) .setValue([ ['指标名称', '上期值', '本期填写值', '环比变化'], ['营收(万元)', 120, null, '=C2-B2'] ]);

写入的数据支持数字、字符串、公式字符串,公式会由Univer内置计算引擎计算。注意我在“本期填写值”那里填了null,表示这是一个空单元格,留给员工填写,后端返回模板数据时,空单元格保持为空即可。

如果工作薄里需要多个Sheet,可以在创建时配置好,也可以用后续的命令动态添加。项目里我们的模板是按Sheet组织的,一个模板一个Sheet,加载完数据后通过sheet.setName()设置成指标名称。

2.3 通过Facade操作Sheet:公式、样式、行列配置

除了写数据,模板还需要设置列宽、合并单元格、背景色。这些能力全都可以通过Facade API完成:

// 设置第1列宽度为120像素 activeSheet.setColumnWidth(0, 120); // 合并A1:A2 activeSheet.mergeCells(0, 0, 2, 1); // 给表头区域加灰色背景 activeSheet .getRange(0, 0, 1, 4) .setStyle({ backColor: '#F2F2F2', fontWeight: 'bold', horizontalAlign: 'center' });

这些操作在界面上会有立即可见的反馈,也可以全部在初始化阶段执行。我们甚至给管理端做过一个“模板设计预览”,就是复用同一套Facade代码,只是把输入源从后端配置换成管理员在页面上框选的结果。

3. 核心需求:指定单元格可编辑,其余只读

3.1 将“可编辑区域”抽象成配置

“允许用户填写一定区域、其他单元格不能修改”这个需求,实现前先要设计数据模型。不能把可编辑状态散落在各个单元格上,否则模板配置、权限判断、后端校验都会很难维护。

我采用的方式是定义一份区域配置,精确到行、列、行数、列数:

{ "templateId": "template-001", "editableRanges": [ { "row": 1, "col": 2, "rowSpan": 5, "colSpan": 1 }, { "row": 1, "col": 3, "rowSpan": 5, "colSpan": 1 } ], "protectedRanges": [] }

意思很明确:整张表的默认状态是“只读”,只有两个区域允许编辑。一来方便管理端通过框选自动生成,二来员工端加载模板时直接套用这套配置,三来后端在接收提交数据时也能根据同一个配置做二次校验,避免有人绕过前端直接造接口请求。

3.2 方案A:工作表保护与单元格锁定

Univer本身有工作表保护的概念,思路和Excel一致:先把允许编辑的单元格标记为“不锁定”,再开启工作表的保护模式,这样被保护的单元格就不可编辑了。

const editableRanges = [ { row: 1, col: 2, rowSpan: 5, colSpan: 1 }, { row: 1, col: 3, rowSpan: 5, colSpan: 1 } ]; // 先给所有单元格上锁 activeSheet .getRange(0, 0, activeSheet.getConfig().rowCount, activeSheet.getConfig().columnCount) .setStyle({ protection: { lock: true } }); // 再把允许编辑的区域解锁 for (const range of editableRanges) { activeSheet .getRange(range.row, range.col, range.rowSpan, range.colSpan) .setStyle({ protection: { lock: false } }); } // 开启工作表保护 activeSheet.setProtection({ protect: true, password: '' });

这段代码对Excel场景非常自然,导出后如果用户把文件下载到本地,保护信息也能保留。但Univer在不同版本里对保护能力的实现深度有差异,尤其是拖拽填充、双击编辑这类操作,不一定完全被保护逻辑拦住。我在测试中就碰到过“单元格锁住了,但下拉填充还能把数据拖进锁定区”的情况,所以不能只依赖这一层。

3.3 方案B:命令拦截实现软只读

更稳妥的方式是在命令系统层面做拦截。Univer的大部分编辑行为最终都会派发编辑类命令,比如BeforeActiveCellEditCommand这类前置命令。我们可以在命令进入执行管线前拿到当前操作的目标单元格坐标,然后判断这个坐标是否落在可编辑区域内,不在就直接阻止。

这部分的代码是核心,但不同版本命令ID略有差异,我给出的是经过验证的思路:

import { ISetRangeValuesCommandParams } from '@univerjs/sheets'; import { SetRangeValuesCommand } from '@univerjs/sheets'; const editableRanges = [ { row: 1, col: 2, rowSpan: 5, colSpan: 1 } ]; function isEditable(row: number, col: number) { for (const range of editableRanges) { if ( row >= range.row && row < range.row + range.rowSpan && col >= range.col && col < range.col + range.colSpan ) { return true; } } return false; } univer.onBeforeCommandExecute((command) => { if (command.id === SetRangeValuesCommand.id) { const params = command.params as ISetRangeValuesCommandParams; // 解析出目标区域 const { range } = params; // 遍历区域的每个单元格,只要有不可编辑的格子就整体阻止 for (let row = range.startRow; row <= range.endRow; row++) { for (let col = range.startColumn; col <= range.endColumn; col++) { if (!isEditable(row, col)) { // 阻止命令执行,弹提示或忽略 return false; } } } } });

命令被阻止后,界面上的表现是“输入无效”,但用户没有反馈会觉得很奇怪。所以我在拦截的同时会调用业务层的Toast组件,提示“当前单元格为只读区域,请在可填写范围内录入”。

这个方案不影响公式计算、不影响界面渲染,只是把编辑入口关掉。缺点是它只对在线交互有效,如果用户导出Excel后本地修改,那就管不到了。

3.4 双轨方案与取舍建议

这两个方案不是二选一,我最终在线上版本里是组合使用的。

  • 交互层:用命令拦截,保证员工在网页上没法误触只读单元格,也不会有奇怪的拖拽填充行为。
  • 导出层:用工作表保护,保证下载到本地的Excel自带锁定规则,二次分发后也不会被随意修改。

如果只是做内部工具,单用方案B其实也够;如果要交付给外部供应商或跨部门使用,Excel导出的保护信息就是刚需。建议还是两个都做,成本不高,收益是权限链路完整。

4. 进阶应用:用户自定义模板、填写、回收与校验

4.1 管理端如何设计模板

既然热词里提到了“支持用户定义表格”,那就必然涉及管理端如何把模板“设计”出来。Univer整个界面是Web应用,可以直接在管理端页面嵌入一个Univer实例,让管理员像操作Excel一样编辑模板。

我在项目里给管理端做了一个“模板编辑模式”:

  • 管理员打开编辑器,像操作Excel一样填写表头、设置样式、插入公式。
  • 编辑完成后,切换到一个“标记可编辑区域”的状态,这个状态下可以直接在页面上框选一块区域,框选后点击“标记为可填写”,系统会把区域坐标追加到editableRanges里。
  • 点击保存后,前端把当前工作表的配置、样式、数据、公式连同editableRanges一起提交到后端,存成一套模板JSON。

保存模板的核心数据可以这样拿:

const workbook = univer.getActiveWorkbook(); const sheet = workbook!.getActiveSheet(); const config = sheet.getConfig(); // 把工作簿、公式、样式保存为模板 const templatePayload = { name: sheet.getName(), cellData: config.cellData, rowData: config.rowData, colData: config.colData, // 来自管理员的框选结果 editableRanges };

这份JSON就是模板的完整定义。员工端加载时,只要拿到它,用同样的一套Univer API重建即可。

4.2 用户填写后的数据提取

员工填写完成后,最核心的问题是:如何把数据保存到后端?

我通常不用setValue反向读取全部单元格。更合理的做法是只遍历editableRanges里的区域,因为这些才是用户真正被允许修改的地方,其他单元格无论是什么数据,都不应该被提交。

const result: Record<string, unknown>[] = []; for (const range of editableRanges) { const values = activeSheet .getRange(range.row, range.col, range.rowSpan, range.colSpan) .getValue(); result.push({ range: `${range.row}:${range.col}`, values }); } // 提交给后端 api.post('/submit', { templateId: 'template-001', data: result });

这里有个小坑:如果可编辑区域里混有公式单元格,getValue()拿到的可能是计算后的值,而不是公式本身。如果业务需要在服务端重新计算,建议前端提交时把公式也顺带一起提交,或者只允许在纯输入单元格上做可编辑标记,别把公式单元格开放出来。一般模板设计规范里就该禁止这个操作。

4.3 数据校验与公式联动

填报表单光能输入还不够,需要有一层校验。Univer自带的数据校验能力可以用于下拉选择、数值范围控制,但如果我们希望校验逻辑完全由后端统一维护,更可控的方式还是在命令拦截层做自定义校验。

举个例子,某个指标要求填写的数值不能小于0:

univer.onBeforeCommandExecute((command) => { if (command.id === SetRangeValuesCommand.id) { // 拿到value后做校验 const newValues = params.value; for (let i = 0; i < newValues.length; i++) { for (let j = 0; j < newValues[i].length; j++) { if (typeof newValues[i][j] === 'number' && newValues[i][j] < 0) { showError('数值不能小于0'); return false; } } } } });

这样做的好处是即使员工输入了非法值,也会在单元格层面直接被拦下,不会污染数据。配合后端的提交校验,数据质量可控。

公式联动方面,我建议把公式放在只读区,而不是可填写区。比如“环比变化”那一列,管理员在模板里预先写好公式=C2-B2,员工只需填“本期填写值”,环比自动算出来。由于公式列不在可编辑范围内,员工也没法覆盖公式,这个设计天然保护了计算逻辑。

5. 避坑实录:Univer落地过程中的常见问题

5.1 只读控制被工具栏绕过

只拦截编辑命令并不代表高枕无忧。Univer自带的工具栏里还有“清除内容”、“删除单元格”、“排序”等操作,这些操作会绕过用户输入层面的拦截,直接改掉单元格数据。

我在一开始就吃过这个亏:员工按了全选然后点“清除内容”,整张表的内容全没了。后来处理方式是把工具栏和右键菜单裁剪掉:

UniverPresetSheet({ container, toolbar: false, rightMenu: false });

对于纯填写场景,工具栏对用户来说本来就是多余的。保留单元格选择和筛选就有足够好的体验,工具栏关掉后误操作大幅减少。如果确实需要部分按钮,Univer支持自定义工具栏配置,按需保留即可。

5.2 保护状态下的公式编辑冲突

使用工作表保护后,公式单元格和锁定的样式单元格虽然不能手动编辑了,但Univer内部某些命令或者拖拽自动填充还是可能触达这些区域。我在测试中复现过:选中一个可编辑单元格,拖拽填充柄往下拉,Univer会把可编辑区域的公式或值延续到下面几行,即使这些行不在解锁范围内。

排查下来发现,这是自动填充逻辑没有实时校验锁定状态导致的。我的处理方式是:

  • 在模板初始化阶段就把表格设置为只读默认态,避免“先全部可编辑再标记只读”造成的保护盲区。
  • 命令拦截层把“拖拽填充”这一类命令也纳入判断,只要目标区域包含非可编辑单元格,就整体拒绝。

如果你在线上也遇到类似问题,建议在onBeforeCommandExecute里把能想到的命令都打印出来,然后逐个检查哪些命令操作了非可编辑区域。

5.3 大数据量下渲染与性能调优

表格数据到几千行时,Univer渲染和交互会有一定的卡顿,这是Web表格的通病。我们的填报模板一般控制在一百行以内,暂未遇到大的性能瓶颈。但如果你的模板确实要承载大量数据,有几个调整方向:

  • 关闭网格线渲染,Univer设置里能省一部分绘制开销。
  • 减少复杂合并单元格的使用,合并会给计算带来额外开销。
  • 初始化数据时避免逐格写入,尽量用区域批量写入,也就是getRange(...).setValue(二维数组)的方式。
  • 如果模板只读,可以设置后不启用编辑服务,轻量模式下性能提升明显。

5.4 版本升级导致的API不兼容

Univer到现在依然处于快速迭代期,差不多半年内API就可能发生不兼容变化。我们曾因为升级小版本,导致setProtection的参数结构变了,编译不通过,排查花了不少时间。

我的建议是:

  • 锁定主版本,不要随便升级小版本,除非有明确的功能修复。
  • 升级前先在本地跑一遍核心的模板初始化、只读控制、数据提取自动化测试。
  • 关注官方升级日志,尤其留意Facade层的变更说明。

这类项目里,稳定比新特性重要得多。

5.5 撤销栈把只读逻辑绕过去

最后一个坑比较隐蔽。Univer默认有撤销/重做功能,用户先执行了一个允许的操作,然后再通过撤销命令把表格恢复到某个中间状态,这个恢复操作可能会把只读区域的数据也一起改动。

处理方式是看场景:填报工具原则上不允许撤销恢复覆盖其他区域,我把撤销栈范围做了限制,在命令拦截层禁止撤销命令影响非可编辑区域,或者干脆在员工端关闭撤销功能。内部工具里,填写准确比操作便利更重要。

最后想分享的一个心得

这个项目最让我满意的不是Univer能渲染出多像Excel,而是它的命令机制让权限控制变得足够干净。你要负责的是“定义规则”而不是“对抗UI”:把一份模板的可编辑区域配好,拦截命令,校验数据,这套链路跑通后,员工真的很难通过常规操作破坏表格。后续如果想再接协同编辑、接入AI生成公式模板,Univer的架构也能撑得住。至少再让我选一次,我还会用这套方案。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进