ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Univer开源在线表格框架:在线填报与单元格权限控制的完整实践

Univer开源在线表格框架:在线填报与单元格权限控制的完整实践

聊 Web 端表格需求时,绕不开一个名字:Univer。它是一款 TypeScript 写成的开源在线表格框架,目标是提供接近 Office 的编辑体验,同时把公式、数据透视、图表、协作这些能力都塞进浏览器里。我今年几个项目都在用 Univer 做“在线填报类表格”,也就是让用户在一个表格模板里填自己的数据,其余区域不能乱改。这类需求做起来比想象中棘手,Univer 提供了很顺滑的解法。这篇文章就把我的选型思路、接入过程、权限保护方案和踩过的坑一起摊开讲。

1. 项目核心定位与设计思路

1.1 为什么我们需要一个自研的表格框架

在 Univer 这类框架出现之前,做“网页版 Excel”的常规路子就那么几条:直接引第三方表格控件、用 Canvas 手搓渲染、或者干脆套个 iframe 嵌在线 Office。这几条路都有明显痛点。第三方控件大多闭源且收费,交互停留在“表格能用”,但离“像 Excel”还有距离;手搓渲染的维护成本极高,公式、样式、合并单元格、冻结窗格这些基础能力全都得自己实现;iframe 嵌入的方案受限于外部系统,离线、私有化部署和二次定制都很难受。Univer 解决的是这个结构性痛点:它把表格引擎本身做成开源组件,允许开发者嵌入自己的应用,UI、数据源、协作、公式引擎全部可替换可扩展。项目定位很清晰——做一个真正属于 Web 的表格基础设施,而不是简单仿一个 Excel 壳。

Univer 的设计思路,我理解下来就是“数据与视图分离,命令驱动一切”。你在界面上做的每一次操作,无论是改一个单元格、合并一个区域还是插入一行,最终都会变成一条命令流,写入数据模型。这个架构让很多高级能力变得容易实现:协作时可以同步命令而非整个文件,回放用户操作时可以直接逐条重放,做权限控制时也能在命令层拦截非法修改。它不是把 UI 和逻辑硬拆开,而是从根上就以数据为中心建模,这种底子决定了它的上限比较高。

1.2 Univer 的核心架构拆解

Univer 的架构可以拆成几个核心层:渲染层、工作簿模型层、命令系统层、插件层。渲染层负责把表格绘制到浏览器里,基于 Canvas 与 DOM 结合的方式,滚动、缩放、选区高亮这些交互都做了硬件加速优化,所以表格即使到了几千行数据也能保持流畅。工作簿模型层管理 Sheet 的单元格数据、样式、合并信息、行高列宽、图表等,这个模型独立于 UI,意味着你能把它跑在 Node 环境里做服务端计算,也能把它嵌入到 Electron 桌面端。命令系统层则是所有操作的统一入口,比如设置单元格格式、写入公式、删除行列,都有对应的 Command 对象。插件层是最灵活的扩展机制,像公式引擎、条件格式、数据透视、协同编辑这些能力,本质上都以插件形式挂载。

这个架构带来的直接好处有两个。一是可测试性好,因为 UI 可以剥离,模型和命令可以被单测直接覆盖,出问题能很快定位到是渲染层还是数据层。二是扩展点清晰,想给表格加一个自定义菜单,或者做一个按钮一键填充数据,只需要注册自己的插件,不需要动核心代码。我实际用下来,这种“按需加载插件”的方式也能很好地控制包体积,不会一上来就把所有功能都塞进 bundle。

1.3 为什么它适合“在线填报”这类场景

在线填报和普通在线编辑是两种形态。普通编辑要求用户随意改任意单元格,而填报类需求往往要求“模板是固定结构,只能填允许填的格子”。这种场景在技术上有几个共性要求:必须能限制用户的编辑范围;需要校验输入格式,比如数字、日期、选项;填报内容要能提交回后端;可能有大量用户并发编辑同一份模板。Univer 对这几个点的支持都比较成熟。

单元格级保护能力可以直接锁定除了指定区域外的所有格子,配合校验规则可以做到填错就提示;公式引擎也可以在模板里内置自动求和、条件判断,降低用户填写时的错误率。更重要的是,Univer 的数据模型允许把一份模板发给 N 个用户各自独立编辑,这比“所有人同时改同一份文件”要稳妥很多。因为用户可以基于同一个 Univer 模板创建自己的实例,填完再把数据导出或提交,从架构上天然契合“一人填写一份独立数据”的需求。

2. 核心功能与适用场景拆解

2.1 多人协同编辑:从命令同步到冲突处理

Univer 的协同能力不是简单把文件内容同步,而是把用户操作同步给其他端。比如 A 修改了 B3 单元格的值,系统会把这个 SetValueCommand 广播给房间内的其它客户端,客户端重放这个命令更新本地模型。这种基于操作转换(OT)的机制,比整个文件覆盖同步高效得多,特别是在多人同时操作大表格时,网络吞吐量会明显降低。

但协同能力也要看你的实际需求决定要不要开启。我的经验是:如果只是几个人同时看一份表,不开协同反而更简单,直接强制刷新读取最新数据就行。真正需要协同的是实时协作编辑同一个工作表的场景,比如产品多部门联合作战表、需求评审表。开启之后你还需要考虑选区和命名冲突,Univer 在这块预留了扩展接口,可以在命令层做自定义冲突策略,实际项目中一般建议控制同屏编辑人数,避免高频冲突造成体验下降。

2.2 单元格级权限保护:锁定与可编辑区域的组合

这也正是标题热词里“用户定义表格,让用户填某些单元格、其余无法修改”的核心能力。Univer 的保护机制和 Excel 的思路一脉相承:先保护整个工作簿或工作表,再在保护基础上放开部分单元格的编辑权限。具体到实现上,单元格格式里带有一个 lock 属性,当工作表处于受保护状态时,lock 的格子不可编辑,没有 lock 的格子可以编辑。它的组合方式非常灵活,既能做到“整张表仅个别单元格可填写”,也能做到“不同区域分给不同角色填写”。

权限控制能级上,我更推荐“两道锁”的思路:前端用 Univer 的 lock 属性限制交互层面的不可编辑,后端在提交数据时再做一次权限校验。因为前端 lock 只是限制了 UI 层的编辑能力,并不代表用户无法通过 DevTools 强行改数据。真正要紧的数据,后端校验不能省。

2.3 公式引擎与数据校验

公式引擎是表格框架的硬骨头,Univer 的公式引擎支持常用函数、跨工作表引用、数组公式和自定义函数。在线填报场景里,公式特别适合做模板里的自动计算。比如一份预算表,用户只需要填写各科目金额,合计、占比这些格子用公式自动算,不给用户填,既减少录入误差,又避免有人算错。这个能力让“只能填、不能改”的表格看起来依然很智能,是 Univer 体验上的加分项。

数据校验方面,Univer 支持单元格规则配置,比如必填、整数范围、日期范围、下拉列表等。我通常会在模板里把“允许填写的单元格”预先设置好校验条件,用户填完触发校验,错误单元格会给出明确提示。如果是在提交时才校验,用户兜了一圈回来改,体验上会差很多。

2.4 图表、条件格式与数据可视化

Univer 的可视化能力也值得单独说。在线表格不只是算数字,更多时候需要把数字变成一目了然的图表。Univer 支持柱状图、折线图、饼图等常用图表,数据源可以直接引用工作表区域,联动效果比较自然。条件格式同样是高频功能,比如把超预算的单元格标红、把达标的数据加底色,这些在填报模板里非常实用,能让端用户很快识别出异常数据。

这里要提醒一下,数据可视化和权限控制叠加时要注意盲区。如果条件格式支持用户自己新建规则,那么用户看到的视图可能和我们预设的完全不同。因此如果是严肃的填报场景,我一般会在模板层就把条件格式规则固定下来,并限制普通用户新建规则的能力。

2.5 典型应用场景:报名表、审批表与预算采集

综合上面的能力,Univer 适合的典型场景可以归纳成三类。一是报名登记类,组织者定义一个包含姓名、手机、所属部门、备注等字段的模板,用户填自己的信息,其余单元格不可见或不可改。二是审批流程类,申请人填写申请内容,审批人只能填写审批意见和审批结论,双方不能跨界编辑。三是数据采集汇总类,总部下发一个预算采集模板,各分公司填写自己的业务数据,所有数据通过后端汇总,模板结构和公式权限统一管理。这三类场景往往还会叠加数据字典、下拉选项和校验规则,用 Univer 都能比较高效地实现。

3. 实操:从零把 Univer 嵌入自己的项目

3.1 环境与依赖准备

Univer 本质上是一个前端框架,不需要特定后端环境,Node.js 18 以上都能跑 Demo。官方推荐用 Vite 做工程底座,你也不一定非得引入 React 或 Vue,Univer 自身基于 DOM 操作即可挂载到任意容器。我的项目用的是 React + Vite,依赖上主要装了 @univerjs/core、@univerjs/sheets 以及对应的 UI 插件,新版还有 @univerjs/presets 可以快速开启完整预设。建议先按官方文档的 Vite 模板初始化一个项目,等跑通之后再考虑集成到现有工程里。

依赖安装可以这样做:

npm create vite@latest univer-demo -- --template react-ts cd univer-demo npm install @univerjs/core @univerjs/sheets @univerjs/ui @univerjs/sheets-ui @univerjs/sheets-formula

版本兼容是第一个坑点。Univer 的插件是严格配套的,core 和 sheets 的版本必须一致,否则容易出现渲染空白或者 API 调用报错。我一般直接用官方文档锁定的版本组合,升级时也会整体一起升,绝不单独升某一个包。

3.2 初始化一个最小的表格 Demo

初始化 Univer 的代码非常简洁,核心思路是创建 Univer 实例,注册插件,然后挂载到页面容器上。如果使用新版 preset,可以一键启动多个插件,比一个一个手动注册要省心很多。下面是一个最小化的初始化示例:

import { Univer } from '@univerjs/presets'; import { UniverSheetsCorePreset } from '@univerjs/presets'; import '@univerjs/presets/lib/styles.css'; const univer = new Univer({ preset: UniverSheetsCorePreset({ container: 'app', }), });

如果你用的是老版本的节奏,也就是手动注册各个插件,则代码大概长这样:

import { Univer, UniverInstanceType } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; import { UniverUIPlugin } from '@univerjs/ui'; const univer = new Univer(); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverUIPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); const sheetData = { sheets: [{ name: 'Sheet1', cellData: { 0: { 0: { v: '你好,Univer' } } } }], }; univer.createUnit(UniverInstanceType.UNIVER_SHEET, sheetData);

两种方式我都跑过,preset 更适合快速搭建,手动注册更利于按需裁剪功能,减少打包体积。我自己的观点是,做正式项目用手动注册更可控,因为 preset 引入的是全套能力,哪怕你不用的模块也会被打进去。

容器渲染还有一个关键点:宿主元素必须有明确高度,不能是零高度的容器。常见误区是给容器设了 flex 布局却忘了给高度,结果表格区域完全空白。我一般会在 CSS 里显式写height: calc(100vh - 60px)或者别的固定高度,确保 Univer 能正确计算可视区域并渲染滚动条。

3.3 加载已有的表格数据与模板

实际项目中很少从零开始画表,更多是加载后端返回的模板数据。Univer 的数据结构以工作簿为顶层对象,每张工作表包含 cellData、rowCount、columnCount、styles 等字段。你可以把这些 JSON 数据在 createUnit 时直接传入,Univer 会一次性渲染出表格内容。

如果你的模板是在 Excel 文件里,还可以用官方提供的导入工具把 .xlsx 转成 Univer 的数据结构。这一步在填报场景很重要:产品经理和业务人员通常习惯用 Excel 维护模板格式,开发同学不需要手工定义单元格位置,直接用工具转换数据即可。转换完成后,把 JSON 存到后端或文件里,前端加载时解析出来 createUnit,模板就复现了。

经验上,如果模板包含大量样式、合并单元格和条件格式,建议先做一次转换预览,确认渲染效果和 Excel 原文件一致再交付。条件格式在部分版本里的兼容性还有瑕疵,比如某些自定义格式规则可能失效,需靠手工补规则。

3.4 操作数据:写入单元格、读取内容与事件监听

表格嵌入后,接下来就是要操作数据。Univer 提供了面向开发者的 API,可以获取当前活动工作簿、活动工作表、某个区域,然后读取或修改单元格值。以下是一个简单示例:

const workbook = univer.getActiveWorkbook(); const sheet = workbook?.getActiveSheet(); sheet?.getRange(0, 0)?.setValue('2024-01-01'); const val = sheet?.getRange(0, 0)?.getValue(); console.log(val);

这段代码能把 A1 单元格写为日期字符串,再读出来。实际开发中,读取用户填写结果一般用 getRange 按区域批量获取,而不是一个个格子去拿,性能会好很多。Univer 的事件系统也很丰富,单元格点击、选中区域变化、值修改都对应着事件,可以在修改后自动把数据提交到后端,或者触发一次校验。

关于 API 版本我要多说一句:Univer 还在快速迭代,早期版本和新版在获取工作簿的方法名上有差异。我写这段示例时是基于常见使用路径,如果你用的是完全不同的版本,建议直接查当前版本的文档确认方法名。

3.5 把 Univer 嵌入到中后台系统的小建议

如果你要把它嵌入到已有的中后台系统里,有几个细节值得注意。一是路由切换时 Univer 实例的销毁很重要,如果不销毁就跳转页面,可能出现内存泄漏和渲染残留。二是有多标签页系统时,尽量让 Univer 实例和路由上下文绑定,不要全局保留一个实例在不同页面直接复用。三是暗色主题的适配,Univer 默认是亮色主题,但不少中后台习惯用暗色管理端,这时需要额外配置主题变量,否则表格区域会明显“闪眼”。这些偏工程化的问题通常在教程里不会被提到,但遇到时很影响上线体验。

4. 实现“用户可填写指定单元格、其余锁定”的核心方案

4.1 需求场景拆解

“支持用户定义表格,然后让用户去填写一些单元格,其他单元格用户无法修改”这个需求,本质上是做一个“受控表单”。管理员定义表头、表结构和校验规则,用户按字段录入数据,未开放的字段只读。最常见的形式是两阶段:模板配置阶段和用户填写阶段。配置阶段,管理员用 Univer 打开表格,标记哪些单元格开放填写;用户填写阶段,模板对这个用户是只读的,只有被标记的格子能输入。

要把这个需求做成,至少要解决三个问题:怎么标记开放区域、怎么让保护生效、怎么在保护状态下读取数据和提交数据。Univer 的思路是把“保护区域”和“可编辑区域”作为表格配置的一部分,保存下来,在用户打开模板时加载同一份配置。这样不需要给每个用户单独生成一份表格,只需一份模板加一套权限配置即可。

4.2 方案:基于工作表保护与单元格锁定的组合

Excel 里的核心逻辑是这样的:默认情况下所有单元格都是锁定状态,工作表开启保护后,锁定单元格不可编辑,未锁定单元格可以编辑。Univer 沿用了同样思路即可。实现上,先对整张工作表开启保护,再针对允许用户填写的区域去掉锁定标记。去掉锁定标记的方式可以通过设置单元格样式中的 lock 属性来完成。提到这里需要明确:Univer 在不同版本中暴露的 API 名称略有差异,但思路一致,核心是“工作表保护 + 单元格样式解锁”。

伪代码逻辑大概如下:

// 开启工作表保护 sheet.protect({ password: 'template-pwd' }); // 将可编辑区域清除锁定标记 const editableRange = sheet.getRange('C3:F8'); editableRange.setStyle({ lock: false });

设置了保护之后,用户点击锁定单元格时会发现无法编辑,而未解锁的 C3:F8 区域保持可用。这个组合方式可以延展:如果你希望不同区域开放给多个不同角色,只需要在配置阶段分别设置对应区域的锁定状态,并在后端维护角色到区域的映射关系即可。

顺带说一句,很多同学会问“要不要设密码”。我建议在模板层不设密码,因为密码只对“通过 UI 操作解除保护”这种场景有意义,而前端代码无论如何都拦不住精通开发者工具的人。把保护当成轻量交互限制就好,真正的安全靠后端校验。设了密码反而可能造成某些版本下保护配置序列化异常,得不偿失。

4.3 二次校验:用命令拦截补一层保护网

只依赖工作表保护,有一个体验漏洞:用户虽然不能通过键盘输入修改锁定单元格,但通过拖动填充、复制粘贴等批量操作,仍然有可能覆盖锁定区域之外的数据。计算下来的边缘 case 不少,比如从可编辑区域拖拽填充到相邻锁定区域,或者是撤销重做之后,保护状态出现短暂不一致。遇到这种情况,最好再补一层命令拦截。

Univer 的命令系统允许监听所有写操作命令,比如 SetRangeValuesCommand,你可以在命令执行前判断目标区域是否允许被当前用户编辑,如果不允许,直接拦截并给出提示。这也是我在第 1 节里提到的“命令层的控制能力”发挥作用的地方。实现上,大概可以这样理解:

univer.onCommandBeforeExecute((command) => { if (command.type === 'SetRangeValuesCommand') { const target = extractEditableRange(command); if (!isAllowedForCurrentUser(target)) { return false; // 阻止命令执行 } } return true; });

这里的位置其实适合埋伏笔:不要拦住所有命令,只拦截用户主动写入数据的那几个命令,选区变化、滚轮、格式化之类的命令不需要管。把“只读”的管控提前到命令层,比只靠样式 lock 更牢靠。Univer 的设计允许在命令执行前做校验,这是它适合承接复杂业务规则的原因。

4.4 数据回读与提交的正确姿势

用户填写完表格后,我们需要提取填写数据提交到后端。Univer 提供的数据读取 API 可以按区域获取值,也可以遍历整张工作表。我推荐按可编辑区域精确读取,而不是读全表,这样数据格式固定,后端解析也简单。每次填完触发 change 事件后,把需要提交的区域值整理成 JSON 结构,过滤掉空值,再通过接口提交。

有一个细节容易漏:用户填写过程中可能会用公式,读取时你拿到的可能是公式字符串,也可能拿到的是公式计算结果,具体取决于你调用 API 的时机和方式。如果模板里有计算公式,建议提交时同时提交公式和计算值,方便后端校验和溯源。另外,提交完毕后应该给用户一个明确的成功反馈,同时把表格切换到只读模式或回收编辑权限,避免二次提交覆盖数据。

4.5 结合后端权限做彻底闭环

前面反复强调,前端锁定只是交互层限制。真要防止恶意用户绕过权限直接篡改数据,后端必须参加校验。我给你总结一套比较完整的上线级方案:

  • 模板存储在服务端,字段元信息记录每个可编辑区域的 key、行列区间、数据类型、是否必填。
  • 用户打开模板时,前端请求得到模板数据和管理员配置的权限,Univer 根据配置渲染出锁定或解锁状态。
  • 提交数据时,前端只提交可编辑区域的值,后端拿到后比对字段元信息,校验必填项和格式,再落库。
  • 数据库层面记录提交人、提交时间、原始 JSON 甚至操作日志,方便审计。

这套闭环做完,才可以说“用户只能填允许填的内容”。前端 Univer 负责的是体验流畅和误操作防御,后端负责真正的数据安全和权限强制。两个层面都到位,这个功能才算扎实。

5. 常见问题与排错实录

5.1 首屏加载太慢,包体积过大

Univer 功能全,代价就是包体积不小。如果是手动注册插件,可以只注册 sheets 相关核心插件,不要引入 docs 和 slides。如果用了 preset,你会发现一套 preset 同时会引入多端能力,这对只做表格的项目并不友好。建议打包时开启按需导入,配合 Vite 的 manualChunks 做代码分割,把 Univer 相关模块拆到独立的 chunk。首屏可以先渲染一个简单的骨架屏,等 Univer 加载完成后再填充容器,避免用户对着空白页面干等。

5.2 容器高度为零导致渲染空白

这个我踩过不止一次。Univer 的表格区域依赖容器的高度计算滚动条和画布尺寸,如果容器在不可见或高度为 0 的 DOM 里初始化,即使后续调整布局,表格也可能不刷新。解决方案是在初始化前检查容器尺寸,或者借助 ResizeObserver 监听容器大小变化,必要时调用重新渲染。还有一个容易被忽略的场景:标签页切换到后台又切回来时,如果布局发生变化,表格可能没有正确刷新,需要主动触发一次重新布局。

5.3 公式计算不更新或结果不一致

公式不更新的问题,常见原因是公式插件没注册全,或者工作簿数据是从旧版本 JSON 迁移而来,缺少公式缓存信息。排查时可以打开开发者工具看控制台有没有报错,再检查公式单元格的值到底有没有被计算出结果。在我遇到的版本里,不同版本的公式引擎对日期、文本拼接等函数的行为有细微差异,从旧版本升级后不能只看界面效果,要抽查一批边界数据。若发现某些函数不支持,可以注册自定义函数来兜底。

5.4 多人协作时数据覆盖

开启协作之后,最烦人的是两个人同时编辑同一个单元格。Univer 的 OT 机制能处理很多基础冲突,但不能保证所有操作都符合业务预期。我们项目里的策略是:高风险区域不允许同时编辑,比如模板的元信息区、公式计算区,在命令拦截阶段直接拒绝并发修改;用户填写区域采用“先声明再编辑”的方式,编辑前锁定某个范围的通知,别人想改时会提示对方正有用户在做修改。这个方法简单粗暴,但实测下来最稳。

5.5 升级版本的破坏性变更

Univer 迭代快,版本之间的 API 更名是很普遍的。我从一个较旧版本升到新版时,光初始化代码就重写了一次,插件名和方法名几乎都换了。升级前先看官方的升级日志,搜索你要用的所有 API 是否变化;升级后至少跑一遍核心流程,包括初始化、读取数据、保护锁定、提交。如果你有长期维护的打算,我甚至建议固定一个确定能跑通的版本,只在确有必要时统一升级,而不是每次都追新。

5.6 中文检索和社区资料少

Univer 的资料相比老牌框架要少一些,中文的资料更少。遇到问题时,我通常优先看官方 Demo 源码和 GitHub Issues,那里往往能找到最新版的正确用法。使用过程中遇到问题,先自查版本是否配套,再检查是不是和容器布局、主题有关,最后才是业务逻辑问题。多花点时间把 Demo 跑明白,比自己闭门造车要高效得多。写这篇内容的其中一个目的,也是希望能给正在用 Univer 的开发者一点参考,少趟点坑。

6. 项目落地的一些个人体会

说实话,Univer 不算一个开箱即用的工具,它更像一个基础设施,需要开发者自己组装成适合业务的表格系统。它 Value 最大的地方在于,你不用从零应对表格渲染和公式引擎这些真正费时费力的底层问题,可以把精力放在权限、数据和业务规则上。我用它落地了几个填报类项目之后,最大的感受是“模板是灵魂”。模板设计得清晰,允许填写的区域、公式、条件格式、下拉校验都配置到位,用户那边几乎不用培训就能上手;模板设计得混乱,即使用再多技术手段去兜底,体验也救不回来。

最后再分享一个小技巧:如果业务里需要同一份模板给大量用户并发填写,不要在 UI 层面让所有用户直接编辑一个工作表,而是让每个用户基于同一份模板数据创建各自的 Univer 实例,填写完成后再把结果统一回传。这样既能保证模板不被动,又能避免高并发协作带来的技术和业务风险。Univer 的模型层完全支持这种“复制模板”的用法,这也是我至今仍愿意持续使用它的重要原因。

RELATED READING

延伸阅读

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