
做前端这么多年浏览器里的表格一直是个绕不开的话题。Univer 是我最近重点跟进的开源表格方案用它做“在线模板填报”这一类需求体验非常对路。做后台管理系统、数据中台、进销存、内部 OA十有八九都要跟“在线填数据”杠上早几年我用过 x-spreadsheet评估过 Handsontable也关注过 Luckysheet各有各的难受。直到我把 Univer 拉出来做了深度试验才算找到一套能同时满足“开源、可嵌入、功能接近 Excel”的在线表格方案。Univer 是 DreamNum 团队开源的办公套件核心是表格Sheets同时也覆盖文档和幻灯片。底层用 TypeScript 编写渲染基于自研 Canvas 引擎整体采用插件化架构。它解决的核心问题很直接让你在自己的 Web 项目里嵌入一个具备 Excel 大部分核心能力、可自由定制、可被代码驱动的在线表格。而对我这次要做的“管理员定义模板、用户只填写指定单元格、其它内容不可修改”的需求Univer 通过工作表保护、单元格锁定、数据校验这一套组合拳给出了非常顺畅的实现路径。这篇文章我会从零开始把我用 Univer 做“在线模板填报系统”的完整过程记录下来包括技术选型思路、接入步骤、权限填充的核心实现以及踩过的坑和排查方法。无论你是想给内部系统加一个在线表格还是想做一个对外收集数据的表单工具这篇都应该能帮你少走不少弯路。1. Univer 是什么我为什么换了赛道1.1 一个可嵌入的在线表格引擎Univer 的定位不是“又一个网盘里的 Excel”而是一个可以被开发者嵌入到自己应用中的表格引擎。它把整个产品拆成了几个关键层面数据层核心数据模型、命令系统、协同基础对应univerjs/core渲染层基于 Canvas 的高性能渲染引擎对应univerjs/engine-render交互层表格 UI、工具栏、右键菜单、弹窗对应univerjs/sheets-ui、univerjs/ui公式层独立的公式引擎对应univerjs/engine-formula校验层数据校验能力对应univerjs/data-validation这种拆分的直接好处是如果只想用表格而不要文档和幻灯片完全可以只引入 sheets 相关包如果项目不需要公式库公式引擎这部分也可以不注册体积还能再压一压。很多刚接触的朋友会把 Univer 和纯前端表格控件混淆。其实它的架构更像一个“自带渲染器的迷你应用系统”——所有操作都是命令Command所有状态都收口到统一的 Store 里。这意味着你可以脱离鼠标键盘用纯代码去驱动表格变化比如“程序自动往 A1 写入一个值”“程序自动锁定某一片区域”。这对做模板填报系统来说恰恰是最关键的一点因为权限控制本质上就是代码对单元格状态的精确干预。1.2 都叫开源表格Univer 赢在哪里我在选型的时候把市面常见的开源表格方案都拉了一遍做对比整理成表格大家感受一下方案渲染方式扩展性公式协同授权成本HandsontableDOM高基础需自研商业收费LuckysheetCanvas中全有限MITx-spreadsheetCanvas低基础无MITUniverCanvas高全支持Apache 2.0Univer 是 Apache 2.0 协议商用友好。它的插件体系非常干净官方把渲染引擎、UI、表格核心、公式、数据校验都拆成了独立插件社区也能按同一套规范写自己的插件。这一点和 Handsontable 那种“大而全但封闭”的思路完全不同。另外Univer 的团队本身就是原 Luckysheet 的核心团队Luckysheet 积累的公式解析、Canvas 绘制、协同冲突处理经验在 Univer 里是继承并重写过的。我实际测试中十万行左右的数据滚动、缩放、筛选帧率表现比之前用 DOM 渲染的方案稳很多。对数据量大的内部系统来说这个差异是体感级别的。1.3 什么样的业务适合用 Univer从这次实践看有三类场景特别贴合。第一类内部系统的“在线录入”模块。比如仓库进出库、销售台账、项目工时登记数据要录又不想为每个表单独写一套页面直接在系统里嵌一个表格最省事。第二类模板填报系统。这正好对应 Univer 支持用户定义表格、让用户填写某些单元格、其它单元格无法修改的需求。管理员在系统里画好模板指定哪些区域是用户可编辑的用户在只读框架里填数填完系统自动读取数据落库。第三类报表展示。把统计结果渲染成带格式的表格用户自己调整列宽、筛选、导出后端只出数据前端只负责呈现。如果只是要一个最简单的只读表格展示Univer 确实有点大材小用。但如果需要“用户在上面编辑、你能控制哪些能改哪些不能改、还要自动算公式”那它就是非常合适的选择。2. 快速接入从零把 Univer 跑起来2.1 装包别一上来就全量装第一次接入我踩了个坑把文档里看到的包全部装了一遍结果项目构建体积直接爆炸。后来才意识到Univer 的包是按功能拆的应该按需安装。以我现在用的版本系列为例一个标准表格界面需要这些核心包npm install univerjs/core univerjs/sheets univerjs/sheets-core univerjs/sheets-ui univerjs/ui univerjs/engine-render univerjs/design如果还要公式、数据校验再加npm install univerjs/engine-formula univerjs/sheets-formula univerjs/data-validation univerjs/sheets-data-validation注意Univer 的版本迭代比较快不同大版本之间的插件注册方式、包名都可能变。我实操下来最稳妥的做法是打开 Univer 官方文档的 Quick Start 页面对照当前文档版本对应的 npm 包列表来装别拿三个月前的博客命令硬套不然大概率会遇到模块找不到的报错。2.2 最小初始化代码页面里放一个容器div iduniver-container stylewidth: 100%; height: 100%;/div然后做初始化import { Univer, LocaleType } from univerjs/core; import { defaultTheme } from univerjs/design; import { UniverRenderEnginePlugin } from univerjs/engine-render; import { UniverSheetsPlugin } from univerjs/sheets; import { UniverSheetsCorePlugin } from univerjs/sheets-core; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; import { UniverUIThreadPlugin } from univerjs/ui; // 样式 import univerjs/design/lib/index.css; import univerjs/ui/lib/index.css; import univerjs/sheets/lib/index.css; import univerjs/sheets-ui/lib/index.css; const univer new Univer({ theme: defaultTheme, locale: LocaleType.ZH_CN, }); univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverSheetsCorePlugin); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.registerPlugin(UniverUIThreadPlugin, { container: univer-container, });这里特别说明一下UniverUIThreadPlugin它的container参数决定了表格挂载到页面哪个元素上。如果你用的是 Vue 或 React注意不要在组件还没挂载完成时就调用注册逻辑最好放在onMounted/useEffect里否则容器还拿不到表格就渲染不出来。2.3 创建第一个带数据的表格Univer 的初始化数据是一个类似 Excel 工作簿的结构。每个工作表里单元格数据用“行_列”作为 key值是{ v: 值 }的形式import { UniverInstanceType } from univerjs/core; const unitId workbook-template-001; const workbook univer.createUnit(UniverInstanceType.UNIVER_SHEET, { id: unitId, sheets: { sheet1: { id: sheet-1, name: 填报模板, rowCount: 100, colCount: 15, cellData: { 0_0: { v: 项目 }, 0_1: { v: 金额元 }, 1_0: { v: 差旅费 }, 1_1: { t: s, v: 待填写 }, }, }, }, });createUnit创建完成后页面里就会出现一个完整的表格工具栏、编辑区、行号列号全都齐了。这个工作簿对象要保留下来后面做数据回读、权限控制都要用到它。这里有个细节需要留意cell 的v是显示值t是类型标记。{ t: s, v: 文本 }表示字符串数字单元格直接用{ v: 123 }就行日期类型用d标记。类型不对会引发一连串问题最典型的是公式对文本型数字做求和时直接跳过或者数据校验把合法数字判成非法。所以初始化模板数据时所有单元格的类型都要按真实业务含义来定不要图省事全塞字符串。3. 核心需求实现让用户只能填指定单元格这个需求就是文章开头提到的管理员定义一张表用户打开后只有某些单元格能填其它单元格动不了。Univer 的实现路径其实就是 Excel 里最经典的那套“单元格锁定 工作表保护”机制只不过我们可以用代码把它做得很灵活。3.1 思路拆解锁定与保护的配合关系很多人第一次接触会搞反“锁定”和“保护”的关系。记住一句话单元格锁定Locked是“状态”工作表保护Protection是“开关”。默认情况下所有单元格都是锁定状态但工作表没有保护所以你可以随便编辑。只有当你同时满足“单元格处于锁定状态 工作表保护已开启”时这个单元格才会被禁止编辑。反过来只要单元格被设为未锁定即使工作表保护开启用户依然可以编辑它。所以实现模板填报的标准姿势是把允许用户填写的单元格显式设置为未锁定其余单元格保持默认锁定状态开启工作表保护可选加密码再给可填写单元格加上数据校验进一步限制内容。这个模型的好处在于“白名单式”默认全锁只放开你指定的区域漏掉的情况也不会被误编辑。对业务方来说这套逻辑非常好解释——凡是黄色的格子就是留给用户填的其它地方都是系统定的框架。3.2 通过数据配置控制单元格锁定在 Univer 里单元格的样式、保护属性都挂在styles和cellData上。以我用的版本为例模板数据里可以在样式表里定义一个“未锁定”样式然后让对应单元格引用它const templateData { id: workbook-template-001, sheets: { sheet1: { id: sheet-1, name: 填报模板, rowCount: 100, colCount: 15, // 样式表key 是样式 idvalue 是样式对象 styles: { header: { cl: #ffffff, bg: #4472C4, bl: 1, // 加粗 }, lockedCell: { bg: #F2F2F2, // 灰色底纹表示只读 }, editableCell: { bg: #FFF8E1, // 浅黄色底纹表示可填 protection: { locked: false }, // 解除锁定 }, }, cellData: { 0_0: { v: 费用项目, s: header }, 0_1: { v: 金额元, s: header }, 0_2: { v: 备注, s: header }, 1_0: { v: 差旅费, s: lockedCell }, 1_1: { v: , s: editableCell }, 1_2: { v: , s: editableCell }, }, }, }, };我在实际项目里习惯用颜色本身做“可不可编辑”的视觉提示可编辑区域统一浅黄色底纹只读区域灰色底纹表头深蓝底白字。用户打开表格一眼就知道哪里能点哪里不能点基本不用培训。提示protection: { locked: false }的写法在不同 Univer 版本里可能有差异有的版本把锁定状态放在单元格对象上有的放在样式对象里。建议以当前版本的 TypeScript 类型定义为准编辑器自动补全会给你答案。3.3 开启工作表保护数据准备好之后下一步是激活保护。这一步既可以在界面上手动操作右键工作表标签 → 保护工作表 → 设置选项也可以用代码完成。代码方式大致思路是调用工作表相关的命令服务传入被保护的工作表和可编辑区域列表// 以下为示意代码命令名以当前版本为准 const commandService univer.getCommandService(); await commandService.executeCommand({ id: sheets.protection.activate, params: { unitId: workbook-template-001, sheetId: sheet-1, protection: { password: , // 留空表示无密码 options: { selectLockedCells: true, // 允许选中锁定单元格 selectUnlockedCells: true, // 允许选中未锁定单元格 }, ranges: [ { range: { startRow: 1, startColumn: 1, endRow: 10, endColumn: 2, }, permission: edit, }, ], }, }, });因为 Univer 的命令体系版本差异较大我强烈建议接入时先在官方示例里找到“保护工作表”的 demo把命令 id 和参数结构抄下来再用到自己的代码里。就算命令配不上保护依然可以通过用户界面完成先把模板锁好再手动开保护最后把整个工作簿数据序列化保存成模板。用户端加载这个模板时保护状态就已经带上了。这里补充一个很容易踩的坑如果设置了密码一定要保证密码能保存到模板数据里。有些版本出于安全考虑保存的会是密码的哈希值而不是明文。如果你的用户端是纯前端加载模板密码哈希校验没问题但模板要传给后端保存时你可能要在系统里单独维护一份“密码明文字段”否则以后想改保护时没人记得密码只能重建模板。3.4 数据校验限制用户能填什么只控制“能不能编辑”还不够。比如金额列用户手一抖填了个“abc”后端做汇总时必然报错。这时候要上数据校验。Univer 的数据校验插件univerjs/data-validation支持类似 Excel 的校验规则。初始化时注册插件import { UniverDataValidationPlugin } from univerjs/data-validation; import { UniverSheetsDataValidationPlugin } from univerjs/sheets-data-validation; univer.registerPlugin(UniverDataValidationPlugin); univer.registerPlugin(UniverSheetsDataValidationPlugin);然后给单元格区域添加校验规则。比如“金额必须是大于等于 0 的数字”await commandService.executeCommand({ id: sheets.data-validation.add-rule, params: { unitId: workbook-template-001, sheetId: sheet-1, rule: { uid: rule-amount-1, type: number, operator: greaterThanOrEqualTo, formula1: 0, ranges: [{ startRow: 1, startColumn: 1, endRow: 10, endColumn: 1 }], allowBlank: false, showErrorMessage: true, errorMessage: 金额必须是不小于 0 的数字, }, }, });有了数据校验之后用户填非法内容会即时弹出错误提示表格也不会接受非法值。这个体验和 Excel 的“数据验证”几乎一致对收数据的一方来说非常省心。而且校验规则是跟模板一起序列化保存的用户端加载模板后规则自动生效不需要后端额外干预。遇到必填项漏填的情况还可以配合条件格式在保存前做一轮颜色标记把问题单元格高亮出来用户自己就能改完再提交。另外还可以把“下拉选择”塞进校验规则里。比如费用类型列只允许从“差旅费、餐费、办公用品、其他”里选{ uid: rule-type-1, type: list, formula1: 差旅费,餐费,办公用品,其他, ranges: [{ startRow: 1, startColumn: 0, endRow: 10, endColumn: 0 }], showDropDown: true, }这样用户填表时点单元格会出现下拉箭头既规范了数据又降低了录入成本。3.5 读取已填写的数据并回传后端用户填完表我们得把数据拿回来。Univer 提供了从工作簿快照读取数据的能力const snapshot workbook.getSnapshot(); // 或者按 sheet 拿 const sheet workbook.getSheetBySheetId(sheet-1); const cellValue sheet.getCellData(); // 读取整个 sheet 的单元格数据 // 比如读 A2坐标 1_0的值 const val cellValue[1_0]?.v;拿到原始数据后按后端接口的字段结构组装 JSON 提交即可。我在项目里写了一个通用的“模板区域映射”配置把业务字段和单元格坐标对应起来const fieldMap [ { key: travelExpense, row: 1, col: 1, required: true }, { key: mealsExpense, row: 2, col: 1, required: true }, { key: remark, row: 3, col: 1, required: false }, ]; function collectData(sheet, fieldMap) { const data {}; for (const field of fieldMap) { const cell sheet.getCellData()[${field.row}_${field.col}]; data[field.key] cell?.v ?? ; } return data; }这样业务层根本不用关心表格内部结构只管字段映射就行后续模板改版也只需要改映射配置。如果模板里某一格需要联动校验比如“差旅费超过 5000 就必须填备注”也可以在collectData之后统一做一层业务校验比写死在表格里更容易维护。4. 进阶玩法让模板真正“活”起来4.1 模板里的公式自动计算模板填报最爽的一个点是把公式预先埋进只读单元格。用户只填原始数据合计、占比、状态判断全部自动算好。比如在“合计”单元格假设坐标是 4_1里预置公式4_1: { s: lockedCell, v: SUM(B2:B4), // 汇总金额区域 }Univer 的公式引擎会在用户修改相关单元格后自动重算。我在测试中试过 SUM、IF、VLOOKUP 这些常用函数表现正常。注意使用公式之前一定要在初始化时注册UniverSheetsFormulaPlugin和UniverFormulaEnginePlugin否则单元格会把公式当普通文本显示。还有一个细节如果你的模板要给不同用户共用公式单元格的锁定状态一定要设置好避免用户手滑把公式覆盖了。公式区统一套用灰色只读样式配合保护机制基本没人能破坏模板结构。另外公式引用的区域最好预留大一些比如用户最多填 50 行公式就写到 100 行免得用户填到一半发现合计没带上新加的行。4.2 条件格式标识填写状态我们可以用条件格式把“没填、已填、校验不过”的状态可视化。Univer 的条件格式可以通过配置或者代码触发。比如给金额区域加一个“空值显示浅红底”的规则用户一眼就能看出哪里还没填// 示意条件格式规则 { ranges: [{ startRow: 1, startColumn: 1, endRow: 10, endColumn: 1 }], rule: { type: cellIs, operator: equal, formula1: , style: { bg: #FFC7CE }, }, }把条件格式和前面的“黄色可填区”结合交互就立体了默认黄色表示待填填了内容变白填错标红全表哪里空哪里错一目了然。这个功能我强烈建议在正式上线前做一轮真机测试因为不同版本的条件格式刷新时机略有差异有个别版本要重新计算才会触发规则重绘。4.3 多 Sheet 与多模板管理一个工作簿可以放多个工作表。比如财务报表模板汇总Sheet 放公式联动明细Sheet 让用户填数据说明Sheet 放填写规范。初始化数据里sheets对象多写几个 key 就行sheets: { instructions: { name: 填写说明, ... }, detail: { name: 费用明细, ... }, summary: { name: 自动汇总, ... }, }用户打开工作簿后在底部标签栏切换。汇总 Sheet 通过跨表公式如SUM(费用明细!B2:B100)自动聚合明细数据整个流程非常顺。实际业务里我推荐把“填写说明”放第一个 Sheet 并锁定全部单元格避免用户一进来就误操作。多 Sheet 的结构还有一个好处模板的“说明”和“规则”不需要额外做文档直接沉淀在表格里业务上更好传递。5. 常见问题与排查技巧实录5.1 我踩过的几个坑第一样式不生效。我一开始把styles写成了动态生成 style 对象直接塞到每个 cell 上结果渲染完全没反应。后来才搞明白Univer 的单元格样式是通过样式 id 在styles表里引用的cell 里的s字段是样式 id不是内联对象。把所有样式收敛到styles表之后一切正常。排查这类问题时打开控制台看快照里的s值如果是一串找不到的 key基本就是引用关系写错了。第二公式不计算。第一次把公式写进单元格显示出来的还是公式原文。排查到最后发现是忘了注册公式插件。记住公式引擎插件是独立注册的不注册就不会解析公式。另外公式区域对应的单元格类型也要正确如果公式单元格被写成了字符串类型结果可能一直不被更新。第三保护状态没保存。我在模板初始化数据里写好保护配置但重新加载后用户还是能编辑。原因是保护只加在了内存里没有把序列化快照重新存成模板。解决方案是把开启保护后的getSnapshot()结果整份存到后端下次加载直接用这份快照创建工作簿而不是每次临时拼一个初始数据。第四数据校验弹窗不出现。这个多半是数据校验插件没注册或者规则里的uid重复。校验规则一定要保证uid唯一否则后加的规则会覆盖之前的。建议所有规则 id 在系统里做统一前缀比如rule-${模板id}-${字段key}避免多个模板之间互相冲突。5.2 性能与体验优化建议大模板建议减少初始渲染行数和列数用不到的区域别开太大。Univer 是按需渲染的但rowCount越大滚动条、选区计算压力也越大。我给一个参考模板只需要 20 行数据rowCount就设 50不要一上来设成 10000。大量单元格数据初始化时cellData就用“row_col→ cell”这种平铺 map 组织千万别用数组套对象的写法序列化和查找都慢。如果你的模板有大量重复样式比如几百个可填单元格都是同一个浅黄样式千万不要每个 cell 新建一个样式 id。复用同一个样式 id内存和序列化体积都能大幅下降。我实测一个几百单元格的模板样式复用后快照体积能缩小一半以上。事件监听用完记得清理。Univer 的命令服务是全局的如果在 React/Vue 组件里多次挂载销毁表格事件订阅不清理会造成内存泄漏页面切几次路由后明显卡顿。组件卸载时主动调用销毁方法并移除自己订阅的事件回调这是必须养成的习惯。另外遇到布局问题先检查容器高度。Univer 不会自己撑开高度容器高度为 0表格就显示不出来。给容器一个明确的height比如 600px或者100%配合父级定高是第一步。如果表格出来了但工具栏缺失多半是 UI 插件注册时少了参数比如没有传可用的工具栏配置。5.3 排查工具与调试姿势Univer 的社区版没有官方 DevTools但我发现一个小技巧把初始化时的logLevel设置成LogLevel.DEBUG控制台里能看到命令执行、数据变更的日志排查“为什么这个单元格还能编辑”这类问题就方便多了const univer new Univer({ theme: defaultTheme, locale: LocaleType.ZH_CN, logLevel: LogLevel.WARN, // 开发时改成 DEBUG });还有一个通用的排查思路权限类问题先确认保护状态再确认单元格锁定状态。在控制台打印快照看sheetProtection是否存在、对应单元格的s样式里locked是否为 false两步就能定位八九成。数据回读不对则优先检查单元格类型文本型数字在求和、比较时最容易出问题。最后说一点我自己的体会。Univer 的学习曲线并不陡但它和传统 jQuery 插件不一样命令、插件、数据快照这一套设计更接近“把表格当成一个微型应用”的思维方式。刚开始不习惯很正常我前三天也是一直在看文档、翻类型定义。但一旦理解了“单元格锁定 工作表保护 数据校验”这套组合逻辑你就能用非常少的代码做出体验相当专业的在线填报功能。希望这篇记录能帮你省掉一些我走过的弯路。