1. 为什么选Univer做在线填报表格:场景与选型分析
1.1 一个很常见的需求:表格能看,但不能随便改
先说我手上这个项目。甲方要做一个报表平台,其中一个核心功能是:运营人员从后台选择一张报表模板,模板里已经预置好表头、公式、部分汇总数据,然后把它“下发”给不同部门。各部门同事只需要在指定的几个空白单元格里填数字,比如本月实际产值、人员到岗数、费用支出;其余单元格全部只读,连误删公式、改表头这种事都不能发生。
这类需求在真实业务里太常见了:预算收集表、月度绩效反馈、考试分数录入、问卷汇总……本质都一样——你希望用户拿到的是一张“半成品表格”,他只能补充内容,不能破坏结构。我最早用的是老式做法:后端用JavaPOI生成Excel模板,设置单元格保护区域,再下发。这套东西维护成本极高,业务改一个下拉选项,都要重新发版。
后来决定换在线表格方案。既然用户都在浏览器里办公,为什么不让这套流程直接在网页上完成?这时候就碰到选型问题。
1.2 主流开源表格库的横向对比
当时市面上能打的就这么几个:x-spreadsheet、Luckysheet、Univer。我拉了表格挨个比对:
| 方案 | 技术栈 | 核心能力 | 可定制性 | 维护活跃度 |
|---|---|---|---|---|
| x-spreadsheet | 原生JS | 基础编辑、简单公式 | 中低,API面比较窄 | 一般,多年没有大版本更新 |
| Luckysheet | 原生JS | 公式、图表、透视表 | 中等,但源码结构复杂 | 个人项目,功能强但文档混乱 |
| Univer | TypeScript,插件化 | 表格/文档/幻灯片,Canvas渲染,命令架构 | 很高,可以深度改行为 | 社区和官方迭代都很频繁 |
x-spreadsheet胜在简单,但它那种“整个表格就是一个可编辑区域”的模型,压根不支持我要的细粒度只读控制。Luckysheet功能确实猛,但源码风格偏早期单仓模式,想要在它的核心编辑器里拦截命令,得上手改源码,后期维护很痛苦。
Univer打动我的是它的架构:整个项目从底层就是TypeScript + 插件化,编辑器行为全部通过命令(Command)驱动。这意味着我可以在不改源码的前提下,在“用户要修改某个单元格”和“单元格真正写入新值”之间插入一层自己的判断逻辑。
1.3 最终选型:可扩展性比功能数量更重要
我最后选了Univer。现在回头看,这个决策是对的。最初我只用到了它10%的能力,但后面业务方连续提需求——要支持按部门隐藏列、要限制只能填数字、要自动校验合计值——这些全都在Univer的命令体系上层解决了,没有动过核心包。
还要说一句:Univer不是“又一个在线Excel”,它更像一个办公套件SDK,底层有统一的渲染引擎和命令流,上层支持Sheet、Doc、Slide。只做表格的人会觉得它重,但如果你要做的恰恰是“深度定制表格行为”,它反而是最轻的路线——因为几乎所有行为都能用代码控制。
2. 快速接入:在React项目中初始化Univer的完整步骤
2.1 先装依赖,注意版本别混搭
Univer的包名和版本演进比较快。我接入的时候用的是@univerjs/presets这套预设方案,它把一堆插件打包成了几个入口,对新手友好很多。如果你翻到的是老教程,还在用@univerjs/sheets-ui、@univerjs/sheets一个个手动装配插件的写法,也能跑通,但没必要。
npm install @univerjs/presets @univerjs/core装完检查一下package.json里这几个核心包的版本号是不是一致的。Univer对版本一致性很敏感,我之前一次升级只升了core没升presets,页面直接白屏,控制台全是类型断言错误的报错。遇到这种问题,先把版本统一再谈其他。
还需要引入样式文件,这是最容易漏的:
import '@univerjs/presets/lib/styles/index.css';不引会怎样?表格能出来,但所有单元格的边框、选中态、工具按钮全都成了“裸奔”状态,看起来像浏览器默认渲染的HTML表格,完全没法交付。
2.2 最小初始化代码,把表格渲染到页面
我的项目是React,但Univer本身框架无关,你用Vue、Svelte、或者纯HTML都行,核心就是给它一个挂载节点。
import React, { useEffect, useRef } from 'react'; import { Univer, UniverSheetsCorePreset, UniverSheetsUIPreset } from '@univerjs/presets'; import '@univerjs/presets/lib/styles/index.css'; export default function ReportTable() { const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { if (!containerRef.current) return; const univer = new Univer({ presets: [ UniverSheetsCorePreset(), UniverSheetsUIPreset({ container: containerRef.current, }), ], }); // 初始化后,presets 会把 Facade API 挂到全局 // 如果你不想用全局对象,也可以从 univer 实例里自己导出一份 const api = (window as any).univerAPI; return () => { univer.dispose(); }; }, []); return <div ref={containerRef} style={{ width: '100%', height: '600px' }} />; }这里有一件必须强调的事:挂载节点的宽高一定要在初始化前就确定下来。我在写第一个Demo的时候,给容器设的是height: '100%',结果它的父级没有高度,表格渲染出来只有一行,拖动也没反应,逻辑代码全对但就是“站不起来”。Univer基于Canvas渲染,计算画布尺寸时拿不到有效高度,后面对用户交互的命中检测全部会偏移。老老实实给固定高度,或者确保父容器高度链完整。
2.3 通过Facade API拿到表格实例,开始操作单元格
Univer提供的Facade API,说人话就是“给普通开发者用的高级接口”。不需要深入理解内部的服务定位、命令ID,就能完成读取单元格、写入值、设置样式这些操作。
const api = (window as any).univerAPI; // 获取当前工作簿和当前工作表 const workbook = api.getActiveWorkbook(); const sheet = workbook.getActiveSheet(); // 给单个单元格赋值 sheet.getRange('A1').setValue('部门'); sheet.getRange('B1').setValue('本月产值'); // 读取单元格 const value = sheet.getRange('A1').getValue(); console.log(value);这段代码建议在Univer初始化并加载完默认工作簿之后再执行。如果你在初始化的同步代码里立刻调用getActiveWorkbook(),大概率拿到的是null,因为工作簿创建也是异步流程。我实际项目里是在点击“加载模板”按钮之后,或者用setTimeout兜一下,再到另一个模块里去操作表格。
3. 单元格“可填不可填”的核心机制与实现方案
3.1 先把需求拆开:是“锁定单元格”还是“拦截行为”
业务方的原话是“其他单元格用户无法修改”。但这句话在技术上有两种理解:
- 第一种:单元格本身就是只读的,用户点上去光标变成箭头,双击也没反应。
- 第二种:单元格看起来正常,可一旦试图修改,会提示无权限或不生效。
区别很关键。前者偏“界面层控制”,实现思路是设置保护/锁定属性;后者偏“行为层控制”,实现思路是在编辑命令执行前拦截。不同Univer版本、不同介面上,这两种方式的可行性和稳定性都不一样。我建议你不要一开始就钻“锁定属性”的牛角尖,先理解Univer的命令驱动机制。
Univer里几乎每一次单元格操作,本质都是向命令服务提交一个命令,比如“设置区域值”“合并单元格”“插入行”。命令在执行前都经过统一的管线。这就给了我们一个机会:在管线里加一个检查员,看这次要改的区域是不是在白名单里。如果不在,直接拒绝执行。
3.2 方案A:工作表级别保护(如果版本支持,做第一道防线)
较新版本的Univer在工具栏里能看到“保护工作表”的入口,关闭之后整个工作表就只允许浏览,不能编辑了。如果你接手的是这种版本,最简单的组合是把整张表先锁死,再用“允许用户编辑区域”把这些区域加回白名单。
这很像Excel里的操作:先保护工作表,然后指定一部分区域排除保护。好处是用户从交互上就能感受到“大部分格子点不动”,不需要我们写太多逻辑。但我遇到的坑是:这个保护能力在不同预设版本里入口不一样,有的版本通过右键菜单开启,有的版本在工具栏的“审阅”Tab里;而且保护状态和后续的命令拦截如果同时存在,需要处理好优先级,否则会出现“我这里明明解锁了,用户还是填不了”的情况。
3.3 方案B:命令前置拦截(通用性最强,我最终采用)
因为版本API的不确定性,我最终没有把“工作表保护”作为核心方案,而是用一个更原始、更可控的手段:命令拦截。
核心逻辑是:
- 定义一组可编辑区域的规则,比如
B2:D10、F2:F10。 - 监听所有“修改值”类命令,在真正执行前,取到命令要操作的单元格区域。
- 判断目标区域是否完全落在白名单内。
- 是,放行;否,阻止,并弹提示。
// 伪代码,实际命令类型以你项目中实际的命令标识为准 const editableRanges = parseRangeString(['B2:D10', 'F2:F10']); api.onWillCommandExecute((command: any) => { const commandType = command.type; // 比如 'sheet.command.set-range-values' if (!isValueChangeCommand(commandType)) return; const ranges = getCommandTargetRanges(command); // 从命令参数里解析目标区域 const allowed = ranges.every(range => isRangeInsideEditableAreas(range, editableRanges)); if (!allowed) { // 返回一个拒绝结果,阻止本次写入 return { success: false, message: '该区域为只读,不允许修改' }; } });这个方案的优点是和Univer的版本解耦:不管底层如何实现渲染,所有修改都必须经过命令管线;只要我拿到的命令参数里能解析出目标区域,就能控制。缺点是需要自己维护区域解析、合并判断的逻辑,第一次写稍微费点功夫。
3.4 方案C:数据校验(辅助,不能替代前两种)
Univer支持数据校验,比如只允许填数字、限制文本长度、设置下拉列表。但它本质是“限制你填什么”,不是“限制你填不填”。就算我在一个区域设置了校验规则,用户还是可以双击进去,输入非法值后只是被标红提示。所以我把数据校验定位成辅助手段:用于告诉用户“这里应该填什么格式”,而不是“这里不能碰”。
| 方案 | 拦截粒度 | 用户体验 | 维护成本 | 我的建议 |
|---|---|---|---|---|
| 工作表保护 | 单元格 | 强,直接点不动 | 依赖版本,不稳定 | 能做就做,作为兜底 |
| 命令拦截 | 命令 | 中,能弹自定义提示 | 自己维护规则,稳定 | 核心方案,强烈推荐 |
| 数据校验 | 内容格式 | 弱,能输入但会报错 | 低 | 配合使用,提示填写规范 |
实际生产环境里,我把三者叠着用:工作表保护锁整表、命令拦截做白名单强校验、数据校验做格式提示。三层都上,用户无论从哪个入口进来(键盘输入、粘贴、拖拽填充)都绕不过去。
4. 可编辑区+只读区配置:一个完整填报模板的落地实现
4.1 定义“填报模板”的数据结构
先想清楚一张填报模板在代码里长什么样。我约定用一个配置对象描述:
interface ReportTemplate { name: string; headers: string[]; // 表头 editableRanges: string[]; // 允许用户填写的区域 defaultValues: Record<string, string | number>; // 预置内容 validations: Record<string, any>; // 数据校验规则 }以“部门产值填报”为例:
const template: ReportTemplate = { name: '月度产值填报', headers: ['部门', '计划产值', '实际产值', '备注'], editableRanges: ['C2:C11', 'D2:D11'], defaultValues: { A1: '部门', B1: '计划产值', C1: '实际产值', D1: '备注', B2: 120, B3: 135, // 更多预置数据... }, validations: { 'C2:C11': { type: 'number', min: 0, allowBlank: false }, 'D2:D11': { type: 'text', maxLength: 50 }, }, };这样设计的好处是,一个模板就是一个纯数据对象,后端可以存JSON,前端可以按这个对象渲染,将来要做“模板市场”也方便。
4.2 初始化之后把模板灌进表格
在Univer初始化事件完成后,按照 template 数据依次写入:
const api = (window as any).univerAPI; const workbook = api.getActiveWorkbook(); const sheet = workbook.getActiveSheet(); // 1. 写入默认值和表头 Object.entries(template.defaultValues).forEach(([cell, value]) => { sheet.getRange(cell).setValue(value); }); // 2. 给可编辑区域加上底色,提示用户“这里可以填” template.editableRanges.forEach((range) => { const area = sheet.getRange(range); area.setBackgroundColor('#FFF7E6'); // 淡黄色底 area.setBorder({ style: 'thin', color: '#FFB648', }); }); // 3. 给可编辑区域加数据校验 Object.entries(template.validations).forEach(([range, rule]) => { const area = sheet.getRange(range); area.setDataValidation(rule); }); // 4. 锁定不可编辑区域(能做锁定的版本就做,不能做也先不阻塞) // 这里把命令拦截注册放在最后,避免初始化写入自己的命令也被拦 registerReadonlyGuard(api, template.editableRanges);这里有一个很容易踩的坑:很多人一上来就先注册拦截命令,然后在同一个初始化流程里写默认值。结果默认值写入也被拦截了,因为此时白名单还没配置到对应的表头区域。顺序应该是:先写数据、再设置样式和校验、最后开启拦截。拦截器只负责“用户操作”,不负责“系统初始化”。
4.3 用户提交时,把可编辑区域的数据捞出来
提交侧也简单。用户填完点“提交”,我们需要把所有可编辑区域的内容收集起来:
function collectEditableData(sheet: any, editableRanges: string[]) { const result: Record<string, string | number | null> = {}; editableRanges.forEach((range) => { const area = sheet.getRange(range); const values = area.getValues(); // 二维数组 // 遍历每个单元格,记录“非空且被用户改过”的值 // 具体实现取决于你的数据模型 }); return jsonResult; }实测下来,这里推荐使用单元格范围的二维数组整体读取,而不是一个格子一个格子getValue()。虽然数据量不大时二者没区别,但一旦模板扩大到几十行几十列,单格读取会有肉眼可见的卡顿。
4.4 粘贴和拖拽填充也要盯住
命令拦截看起来简单,但“用户改数据”的方式远比想象中多。除了直接键盘输入,还有:
- 从一个Excel文件复制数据后粘贴进表格
- 拖动单元格右下角向下填充
- 双击单元格后系统自动填充相邻数据
- 撤销/重做操作
这些在Univer里都对应不同的命令,但最终大多会落到“设置区域值”这一类核心命令。所以我的isValueChangeCommand不是只判断一个命令ID,而是维护了一份名单,把所有会写入数值的命令都放进去。每次新增版本,先跑一遍表格的“编辑链路”,看看有没有漏网之鱼。
5. 实测中踩过的坑:渲染、权限与事件联动的细节
5.1 初始化时容器尺寸为0,表格“站不起来”
这是我最开始说的坑。Univer的Canvas渲染依赖容器尺寸计算视口,如果外层父级用了flex: 1但又没给实际高度,表格初始化时拿到的可能是一个0高度的容器。表现是:工具栏出现了,但表格区域只有一条线,或者完全空白。
解决方式很朴素:给挂载节点显式设置高度,或者用ResizeObserver监听容器尺寸变化后调用Univer的resize()方法。我个人更喜欢后者,因为实际页面里侧边栏折叠、窗口缩放都会触发尺寸变化,纯固定高度不够灵活。
const observer = new ResizeObserver(() => { // 通知 Univer 重新计算视口 api?.resize?.(); }); observer.observe(containerRef.current);5.2 命令拦截别误伤:判白名单时,要允许表头单元格被选中
我第一版拦截器写得比较粗暴:只要目标区域不在白名单内,直接拒绝。结果用户连“选中只读单元格”这个动作都被拦了——不是写入,只是点击选中,也走了同一个命令?后来我调了命令类型判断,把“设置选区”“滚动”“切换工作表”这类命令全部放行,只对真正写入值的命令做拦截。
这里有个经验:拦截要精准到“写值”这个语义,而不是“操作”这个动作。用户点一下只读单元格本身不应该被禁止,他只是不能往里面写。过度拦截会让交互变得莫名其妙:表格点都点不动,用户会以为系统坏了。
5.3 撤销/重做会绕过“自以为正确”的逻辑
还有一次比较隐蔽的bug:用户填了个非法数字,被数据校验拦下并标红;随后他按了Ctrl+Z撤销,表格内容回退到了修改前。看起来正常,但我们的后端提交逻辑读到的却是“客户端缓存里的旧值”,而不是“当前表格里的实际值”。原因是我们只在命令拦截上做了判断,没有在提交时重新从表格实例里取一次最新值。
教训是:任何时候都不要信任前端缓存的业务数据,提交前必须从Univer实例重新读取一遍可编辑区域。在线表格是一个强交互组件,用户的操作顺序根本无法预测,只有“重新读取”是唯一可靠的数据来源。
5.4 大数据量渲染和公式重算
我们一个模板最多也就几百行,Univer用Canvas渲染,滚动非常顺滑。但有一个场景会卡:模板里带了很多跨表引用公式,且每次可编辑区域的单元格变更都会触发公式链重算。几十个公式没问题,几百个公式同时重算,肉眼能感觉到延迟。
优化思路是:
- 减少公式单元格数量,能用纯数值解决的不要用
SUMIFS。 - 把重计算频率降低,比如通过命令拦截做“防抖”,用户停止输入500ms后再真正刷新公式结果。
- 如果公式特别复杂,考虑在后端算好再同步到表格,而不是让前端表格承担计算。
5.5 隐藏列、插入行这类操作要提前想好政策
业务方后来提了一个需求:不同部门看同一张表,看到的列不一样。这个需求牵扯到隐藏列,而隐藏列和“可编辑区域”配置需要联动。我当时的做法是:
- 后台下发模板时同时附一份“可见列范围”。
- 前端初始化后,直接隐藏不在范围内的列,并把隐藏列从白名单里剔除。
- 白名单始终以“当前可见的单元格”为准,避免用户通过键盘方向键“走进”隐藏列后触发异常。
这块的联动代码不复杂,但一定要在架构设计阶段留出位置。如果一开始就把editableRanges写死在配置里,后面加了列权限,所有历史模板都要改。
6. 把只读/可编辑的粒度再细化:行列分组与细粒度控制的进阶玩法
6.1 冻结窗格,让表头始终可见
填报表格最怕用户滚到最后几行时忘了每一列是什么含义。Univer支持冻结窗格,我把第一行和第二行固定住,这样表头和单位信息永远在视野内。
const api = (window as any).univerAPI; const sheet = api.getActiveWorkbook().getActiveSheet(); // 冻结到第二行 sheet.freezeRows(2);这里的体验收益非常大,尤其表格行数超过30行以后。建议所有带表头的填报模板都默认冻结前两行,如果左边有部门名称列,再考虑冻结第一列。
6.2 用数据校验实现“只能填数字/必须填/选下拉”
Univer的数据校验能力我前面提到了,再展开一些。实际项目里我常用几条:
- 数字范围校验:产值不能为负数,完成率不能超过100%。
- 必填校验:关键单元格不允许为空,提交时若为空则高亮。
- 下拉列表:比如“状态”列只能选“已完成/进行中/未开始”,减少人工输入造成的脏数据。
- 文本长度校验:备注列最多200字,超出直接标红。
数据校验的提示文案尽量写成人话,比如:“实际产值请输入大于0的数字”。用户看到红色标记的第一反应是“我哪里填错了”,而不是“系统出bug了”。
6.3 按角色加载不同白名单,实现更复杂的权限模型
只读/可编辑的边界,不一定是“固定区域”。更真实的需求是:不同角色看到同一张表,能填写的区域不一样。比如部门经理可以填“实际产值”,HR只能看“人员到岗数”。
这套逻辑其实不复杂。只要把白名单从“一个数组”升级成“一个根据角色生成的数组”:
function resolveEditableRanges(role: string): string[] { if (role === 'manager') return ['C2:C11', 'D2:D11']; if (role === 'hr') return ['E2:E11']; return []; }后端在返回模板数据时,根据当前用户的角色把editableRanges计算好;前端无脑按这个数组渲染和拦截。这样权限模型和前端代码完全解耦,将来要增加更多角色,只需要后端多返回一份配置,前端一行都不用改。
6.4 多工作表的填报流程:一个工作簿拆成多个Sheet
再往后走,你可能发现一个Sheet不够用。比如总部下发的报表,每个部门一个Sheet,最后还有个汇总Sheet。Univer天然支持多工作表,我建议不要在单个Sheet里堆到几百行,而是按业务维度拆表。
以“月度经营分析会”为例:
- Sheet1:公司整体汇总
- Sheet2:华东区填报
- Sheet3:华南区填报
- Sheet4:补充说明
每一张Sheet都可以独立配置白名单和数据校验。提交时,后端按Sheet分别读取,互相不干扰。Univer的API也支持按名称切换工作表:
const workbook = api.getActiveWorkbook(); const sheet1 = workbook.getSheetByName('华东区填报'); const sheet2 = workbook.getSheetByName('华南区填报');多Sheet之后,唯一要更注意的是命令拦截的判断:因为不同Sheet的可编辑区域不同,拦截器里必须带上Sheet标识,不能只判断单元格坐标。这个我在刚开始做多Sheet时漏掉了,导致在Sheet1的白名单校验逻辑误伤了Sheet2的合法填写。
做到这里,整个基于Univer的“半只读在线填报表格”已经可以正常工作了。我自己的体会是:这类需求不复杂,但它真正考验的不是“会不会调API”,而是对编辑器机制的信任程度——你要敢把编辑行为托付给命令层来控制,而不是靠一堆UI hack去模拟。如果以后你们要升级到Univer新版,请务必把命令拦截器单独抽成模块,版本升级后第一件事就是回归跑一遍编辑链路,确认拦截器没有漏掉新的写入命令。这比任何功能清单都重要。