ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Univer在线表格实战:用命令拦截实现单元格只读与可编辑区域控制

Univer在线表格实战:用命令拦截实现单元格只读与可编辑区域控制 1. 为什么选Univer做在线填报表格场景与选型分析1.1 一个很常见的需求表格能看但不能随便改先说我手上这个项目。甲方要做一个报表平台其中一个核心功能是运营人员从后台选择一张报表模板模板里已经预置好表头、公式、部分汇总数据然后把它“下发”给不同部门。各部门同事只需要在指定的几个空白单元格里填数字比如本月实际产值、人员到岗数、费用支出其余单元格全部只读连误删公式、改表头这种事都不能发生。这类需求在真实业务里太常见了预算收集表、月度绩效反馈、考试分数录入、问卷汇总……本质都一样——你希望用户拿到的是一张“半成品表格”他只能补充内容不能破坏结构。我最早用的是老式做法后端用JavaPOI生成Excel模板设置单元格保护区域再下发。这套东西维护成本极高业务改一个下拉选项都要重新发版。后来决定换在线表格方案。既然用户都在浏览器里办公为什么不让这套流程直接在网页上完成这时候就碰到选型问题。1.2 主流开源表格库的横向对比当时市面上能打的就这么几个x-spreadsheet、Luckysheet、Univer。我拉了表格挨个比对方案技术栈核心能力可定制性维护活跃度x-spreadsheet原生JS基础编辑、简单公式中低API面比较窄一般多年没有大版本更新Luckysheet原生JS公式、图表、透视表中等但源码结构复杂个人项目功能强但文档混乱UniverTypeScript插件化表格/文档/幻灯片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 useRefHTMLDivElement(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: Recordstring, string | number; // 预置内容 validations: Recordstring, 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: Recordstring, 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用户填了个非法数字被数据校验拦下并标红随后他按了CtrlZ撤销表格内容回退到了修改前。看起来正常但我们的后端提交逻辑读到的却是“客户端缓存里的旧值”而不是“当前表格里的实际值”。原因是我们只在命令拦截上做了判断没有在提交时重新从表格实例里取一次最新值。教训是任何时候都不要信任前端缓存的业务数据提交前必须从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新版请务必把命令拦截器单独抽成模块版本升级后第一件事就是回归跑一遍编辑链路确认拦截器没有漏掉新的写入命令。这比任何功能清单都重要。
返回列表