1. Univer 是什么为什么大家用它做在线填报表格1.1 一个能在浏览器里跑起来的开源表格引擎这几天又有朋友问我一件事用 Univer 这种开源在线表格做填报怎么才能让用户只能填指定的几个单元格其他地方动不了问的人多了我觉得有必要把这件事从头到尾捋一遍。Univer 是奔步科技DreamNum团队开源维护的在线表格引擎用 TypeScript 编写代码托管在 GitHub 上核心包采用 MIT 授权简单理解就是你在网页里启动一个 Univer 实例把一张工作簿的数据丢给它它就能渲染出一个接近 Excel 操作体验的在线表格你还可以通过 API 操作它的数据、订阅它的变更、控制它的行为。我最早注意到 Univer是因为当时手头一个后台系统急需在线填报能力。业务方的需求很朴素“别让员工下载 Excel 再传回来直接在网页上填表我们后台汇总。”市面上能选的方案不少但真正符合“开源、可二开、体验像 Excel”这三个条件的当时绕来绕去还是绕到 Univer 身上。它把表格渲染、公式计算、数据校验、条件格式这些能力拆成了一个个插件核心包只负责状态管理和命令分发你想要什么功能就注册什么插件不需要的功能一个都不装。这种设计决定了它可以被很轻地嵌进现有系统而不是逼你把整套框架搬进项目里。一句话定位Univer 不是“又一个在线 Excel”它是一个“可编程的表格运行时”。你和你的团队如果需要在网页里做表格类的业务功能——填报、收入登记、项目排期、数据收集——那它就是适合你研究的技术底座。我后面所有经验都建立在这个定位之上。1.2 热词背后的真实需求定义表格结构只让用户填指定单元格最近“univer”相关的讨论里高频出现一个使用场景先由一个人定义好表格表头、列宽、公式、底色都摆好然后分发给其他人其他人只能往指定的单元格里填写内容其余单元格不可修改。这个场景简直是企业内部系统的标准剧本。比如 HR 收各部门年度预算表格里部门名、项目名、负责人都是固定的员工只需要填“预算金额”那一列再比如设备巡检设备编号、巡检项是模板里定好的现场人员只填巡检结果和异常说明。这类需求如果在 Excel 里做结果基本是失控的表头被改、公式被覆盖、关键列被删、格式五花八门。回头你光清洗数据就要洗半天更别说追责。Univer 在这个场景里的价值就是让你在 Web 端复刻一套“可控制的 Excel 模板”管理员定义结构普通用户只能填写被允许的区域。你既可以用数据验证限制用户填什么内容又可以用保护/拦截机制控制用户能不能编辑某个单元格。这套东西组合起来就能做出一个比“Excel 模板 邮件回收”靠谱得多的在线填报模块。我实际落地过的方案最后沉淀成了一个通用填报模块管理员在后台打开一张不受保护的 Univer 表格自由编辑表头和样式定义可编辑区域和校验规则然后点“发布”生成模板用户通过链接或系统入口打开同一张表看到的是结构完整的在线表格但真正能下笔的地方就那么几格。这个模块用到现在覆盖了预算填报、人员信息收集、项目进度上报好几个业务核心就是“Univer 渲染 编辑控制 数据收集”这三件事。2. 为什么选 Univer和 Luckysheet、Handsontable 的选型对比2.1 主流开源表格方案横向对比做技术选型的时候我把市面上能打的方案几乎都试了一遍。下面的对比表是我当时的结论现在回头看依然成立先给结论如果你的目标是“做一个长期维护的在线填报/表格系统”Univer 是性价比最高的选择。方案渲染方式公式能力插件化维护状态授权情况UniverCanvas较好强核心插件活跃社区在增长MITLuckysheetCanvasDOM尚可弱耦合较重更新缓慢Issue 堆积MITHandsontableDOM弱需额外集成一般生态有限维护中部分功能收费SpreadJSCanvas强有商业化维护商业授权x-spreadsheetCanvas简单公式很弱基本停滞MIT单独说说 Luckysheet。它对标的也是“在浏览器里跑一套 Excel”基础编辑、复制粘贴、图表都齐全社区用户不少。但深入用下来问题集中在几处源码结构偏老长尾 bug 不好修关键功能比如完整的数据校验、协同编辑的配套要么缺失要么得自己拼文档长期停在某个版本想跟进新需求只能去啃源码。我不是否定 Luckysheet如果你只是快速做一个一次性引入的表格页它上手确实不慢但如果你想把它变成公司内部的“表格基础设施”Univer 更值得押注。Handsontable 是另一个方向的选手。它本质是一个可编辑网格不是完整表格引擎公式要自己接外部库单元格合并、样式也偏 grid 风格。在前台管理系统里当表格控件可以但要承载“用户打开在线表格、按 Excel 习惯填数”这种体验它做不到位。SpreadJS 能力强商业项目里常见但它收费二次开发的自由度也受授权约束。综合下来Univer 刚好卡在“开源授权 可二开 体验接近 Excel”的交叉点上。2.2 命令系统为什么它特别适合做单元格权限控制Univer 的架构里最值得研究的是命令系统。几乎一切数据变动都走命令设置单元格值、修改工作表、添加数据验证全部封装成命令对象派发给命令服务处理。这意味着你可以在任意一层做拦截用户想改某个单元格先经过你的校验逻辑合法才放行不合法直接取消。对比传统表格控件想限制编辑一般只有两种路子要么给你一堆 set/get 接口你在外层包一层逻辑要么靠控件的“只读属性”分区控制。前者侵入性强后者不够灵活而且很难做到“不同用户看到不同的可编辑范围”。Univer 的命令总线相当于给每次编辑动作留了一个天然的切面权限控制、审计日志、操作回滚都可以挂在上面。我做填报系统里的“可编辑范围控制”本质上就是利用了这一点在命令执行前后检查范围不通过就取消或回滚然后再把界面状态恢复。另外Univer 把渲染层和业务层分离得很干净。Canvas 负责画界面业务数据层是一棵独立的状态树你甚至可以不打开浏览器在 Node 端操作同一份工作簿数据。我之前见过一个团队拿 Univer 在服务端跑公式批量计算费用前端只做展示效率很高。这种分层给后续做自动化报表、服务端校验收口都留了空间。3. 实操用 Univer 做一个只能填指定单元格的在线填报表3.1 初始化工程与依赖安装先说明一点Univer 迭代速度很快下面代码基于我在 0.2.x 版本上的实测。如果你用的是更新的版本个别 API 名称可能变了但整体思路不变照着官方文档对照调整即可。我习惯用 Vite TypeScript 起步干净利落npm create vitelatest univer-fill-demo -- --template vanilla-ts cd univer-fill-demo npm install univerjs/core univerjs/sheets univerjs/sheets-ui univerjs/sheets-formula univerjs/sheets-data-validation装完第一件事检查 package.json 里这几个univerjs/*包的版本号是否完全一致。这是 Univer 最常见的坑子包版本不一致会导致注册冲突症状通常是白屏或者控制台报Cannot read properties of undefined。接着在 HTML 里放一个容器注意容器必须给定高度Univer 不会自动撑满父元素div iduniver-container stylewidth: 100vw; height: 100vh;/div入口文件里注册插件创建一张最简单的表import { Univer, UniverInstanceType } from univerjs/core; import { UniverSheet } from univerjs/sheets; import { UniverSheetsUIPlugin } from univerjs/sheets-ui; import { UniverSheetsFormulaPlugin } from univerjs/sheets-formula; import { zhCN } from univerjs/core/locale; const univer new Univer(); univer.registerPlugin(UniverSheet); univer.registerPlugin(UniverSheetsFormulaPlugin); univer.registerPlugin(UniverSheetsUIPlugin, { container: univer-container, locale: zhCN, }); univer.createUnit(UniverInstanceType.UNIVER_SHEET, { name: 预算填报, sheetOrder: [budget], sheets: { budget: { id: budget, name: 各部门预算填报, rowCount: 100, columnCount: 8, cellData: { 0: { 0: { v: 部门 }, 1: { v: 项目名称 }, 2: { v: 预算金额元 }, 3: { v: 项目负责人 }, 4: { v: 备注 }, }, }, }, }, });细节提醒sheetOrder数组里的 id 要和sheets对象里每个 sheet 的 id 一一对应rowCount和columnCount尽量给足否则用户填到一半发现行数不够你又得临时加行权限规则还得跟着改。我最初就把模板建成了 20 行结果业务方要填 30 条被迫加了两次行很狼狈。如果你要做“管理员定义表格”的完整流程通常还要再加一个编辑模式管理员打开表格时不启用任何保护和拦截可以自由改表头、调样式、设公式点“发布”后前端把工作簿数据快照和 editableRanges 规则一起提交给后端后端保存模板。用户侧打开时只读模式 白名单范围一起生效。这样一来“定义表格”和“填写表格”就分成了两个清晰的阶段。3.2 用数据验证约束“可以填什么”数据验证解决的是“填什么内容合法”。Univer 支持的类型和 Excel 的数据有效性对齐下拉列表、整数、小数、日期、文本长度、自定义公式等。填报场景里最常用的是下拉枚举和数字范围。还是拿预算填报举例。第一列“部门”要求用户从固定列表里选防止手打出“研法部”这种错别字第三列“预算金额”要求必须是大于 0 的数字。创建数据验证命令大致是这样的import { DataValidationType } from univerjs/core; univer.getCommandService().executeCommand({ id: sheet.command.set-range-data-validation, params: { unitId: univer.getActiveUnit()!.getUnitId(), sheetId: budget, range: { startRow: 2, startColumn: 0, endRow: 99, endColumn: 0, }, dataValidation: { type: DataValidationType.LIST, formula1: 研发部,市场部,产品部,运营部, allowBlank: false, showError: true, errorTitle: 请选择部门, errorMessage: 部门必须从下拉列表中选择, }, }, });range表示数据验证作用在哪个区域这里从第 3 行第 1 列到第 100 行第 1 列。行号从 0 开始数界面上看到“第 3 行”对应代码里的startRow: 2这个换算搞错验证就会跑到错误的单元格上。预算金额的限制类似把类型换成数字再配上操作符和阈值{ type: DataValidationType.NUMBER, operator: GREATER_THAN, formula1: 0, showError: true, errorTitle: 预算金额不合法, errorMessage: 预算金额必须大于 0, }注意数据验证只是从源头限制内容它不负责“能不能编辑”。用户仍然可以选中被验证的单元格去输入只是输错会弹提示。如果你希望某些单元格根本不能点进去那得靠下一层——保护与拦截。3.3 用保护与命令拦截实现“哪些位置不能动”控制“能不能编辑”这件事我经历了两版迭代最终形成两个方案按版本情况选择。方案 A如果 Univer 版本较新直接用工作表保护命令。保护命令设置整张表的权限再通过 ranges 把可编辑区域单独放出来逻辑很像 Excel 的“保护工作表”默认全表锁定你把可编辑区域标记为 unlock其余单元格都动不了。命令大致如下univer.getCommandService().executeCommand({ id: sheet.command.set-worksheet-protection, params: { protection: { password: , ranges: [{ startRow: 2, startColumn: 0, endRow: 99, endColumn: 4 }], sheetPermissions: { selectLockedCells: true, selectUnlockedCells: true, insertColumn: false, insertRow: false, deleteColumn: false, deleteRow: false, editObjects: false, }, }, }, });这个方案最干净保护逻辑由 Univer 内部实现用户点击、粘贴、拖拽都会被挡住交互很自然。但我的建议是先在你锁定的版本上跑一个 demo 验证一下因为保护命令的参数结构在不同版本调整过有的版本还要求先设工作簿保护才能做工作表保护。别只看文档直接跑最稳。方案 B如果版本较旧或者你不想把权限绑死在 Univer 的 UI 层就用命令拦截。Univer 的编辑命令执行后会派发事件我们在事件里判断“刚才修改的范围是否在可编辑白名单内”不在就撤销并提示。逻辑如下univer.getCommandService().onCommandExecuted((command) { if (command.id ! sheet.command.set-range-values) return; const { ranges } command.params; const allowedRanges [{ startRow: 2, startColumn: 0, endRow: 99, endColumn: 4 }]; const canEdit ranges.every((range) allowedRanges.some((allowed) range.startRow allowed.startRow range.endRow allowed.endRow range.startColumn allowed.startColumn range.endColumn allowed.endColumn ) ); if (!canEdit) { univer.getCommandService().executeCommand({ id: sheet.command.undo }); // 这里再弹一个自定义提示该区域为只读 } });方案 B 的好处是不依赖保护命令的版本实现逻辑完全掌握在自己手里坏处是“事后再回滚”极端情况下存在一瞬间的脏数据。所以我的建议是如果目标是给正常用户提供良好的“只读体验”方案 B 的拦截足够如果要做真正的安全兜底两个方案一起上。这里必须把话说透所有前端锁定本质上都只是“用户体验层面的保护”不是安全防线。用户完全可以绕过前端构造请求直接往后端提交一份修改过的数据。所以我在做这个系统时后端校验才是最终裁判前端保护只是让正常用户不会误操作。3.4 拉通后端可编辑范围在后端再校验一次填报系统的数据流是管理员配置模板和权限 → 用户打开 Univer 填入数据 → 前端收集变更 → 后端校验并落库 → 下次打开时 Univer 重新渲染工作簿。Univer 不关心你用什么存储它只维护“当前这份工作簿数据”的状态落库和读取都是你自己的事。我在项目里定了一套简单约定前端每次提交的不是整表快照而是变更列表每个变更包含行号、列号、值、操作人、时间。后端接口大致长这样POST /api/fill/submit { sheetId: budget, changes: [ { row: 3, column: 2, value: 120000 }, { row: 4, column: 2, value: 85000 } ], userId: u_1024 }后端拿到 changes 后逐条做两件事第一检查坐标是否落在可编辑范围内第二用同一份校验规则再验证一次值。任何一条不通过整批拒绝并返回错误信息。“整批拒绝”的粒度是我踩过坑之后定下来的一开始做单条跳过结果用户发现“我改了 5 个格子只有 3 个生效”排查半天非常痛苦整批失败反而让用户立刻意识到问题重填一次就好。权限范围和校验规则都存在后端前端渲染时由模板接口下发。不要把允许范围硬编码在前端代码里也不要用 localStorage 存因为那个范围是管理员动态配置的。模板加载接口返回两个东西工作簿数据喂给 Univer和 editableRanges用于前端拦截和提示两者来自同一个后端源保证前后端规则永远对齐。3.5 部署上线让它真正变成“在线”服务Univer 前端构建后就是静态资源丢到 Nginx 下就能跑。真正要注意的是静态资源和接口服务的跨域配置。我的最小 Nginx 配置长这样server { listen 80; server_name fill.example.com; root /srv/univer-fill/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files是为了兼容 SPA 路由Univer 用 hash 路由也行但统一加上不会错。跨域方面接口和静态资源同域部署最省心如果必须分域后端做白名单别开*。登录态我用 Cookie 同域部署省去了 Token 传递的麻烦。如果项目以后要上多人协同编辑那是另一个量级的事Univer 的协同能力需要额外部署协作服务数据变更要走增量协议前端包也更大。但“用户各自填表、提交、入库”这种模式完全不需要协同我给业务方的解释是你要的是“多人同时可用”不是“多人同时编辑一个格子”。把需求确认到这一步整个系统会简单可靠很多。4. Univer 实战踩坑记录版本、数据验证与权限保护4.1 版本不一致导致的白屏和样式错乱这是 Univer 第一大坑。插件化设计决定了它由一堆子包组成任何一个子包版本和其他包不一致都可能出现注册冲突。症状要么是控制台报错要么是表格渲染了但工具栏缺按钮、右键菜单空白。我的排查办法笨但有效把所有univerjs/*包版本统一改成同一个版本号删掉 node_modules 和 lock 文件重新安装一遍。如果还有问题看报错堆栈指向哪个包单独调整那个包到和 core 匹配的版本。另外记得引入 UI 样式文件否则界面完全裸奔import univerjs/sheets-ui/lib/index.css;样式问题排查优先级很高很多人一上来就怀疑是代码写错了结果只是忘引 CSS。4.2 数据验证“没生效”或作用范围错误数据验证不生效十有八九是执行命令的时机不对。工作簿还没完全初始化就执行set-range-data-validationrange 校验阶段就直接失败了命令被静默吞掉。解决方法是等初始化事件或者在createUnit之后的回调里再执行验证命令。另一个高频问题是行号从 0 开始界面上第 1 行是表头你心里想的“第二行”其实是startRow: 1。还有一次我在formula1里传了数组而不是字符串下拉列表直接消失。数组格式在某些版本也能用但兼容性不如字符串稳妥起见统一用逗号拼接的字符串。4.3 保护逻辑把管理员自己也锁住了方案 A 的工作表保护一旦设了 password后面调整单元格需要先解除保护。我们内部调试时就发生过“管理员也改不了表格结构”的情况。后来我把保护密码留空通过业务账号体系区分权限管理员会话直接跳过保护设置用户会话按配置白名单走。也就是说不要把权限硬编码进 Univer 的保护命令里而是根据登录用户动态决定是否启用保护。Univer 只负责执行“是否锁”的结果至于“谁有资格解锁”那是业务逻辑应该放在应用层。4.4 前端规则和后端规则不一致前后端两套规则不一致是最隐蔽的坑。前端数据验证要求金额大于 0后端不校验的话用户直接调接口传 -100 也能入库报表一算负数全出来了。或者反过来后端把规则改严了前端没同步用户在前端填得舒舒服服提交却报错。我的解法是维护一份“校验规则 JSON”前端下发一份用于交互提示后端存储同一份用于最终校验。两份数据同源规则就不会漂移。前端提示是体验问题后端校验是数据质量问题两者必须对齐。4.5 常用排查清单把这几类问题整理成一张速查表方便直接抄现象优先排查项常见解法白屏/样式错乱子包版本是否一致统一版本重装确认引入 CSS下拉验证不显示命令执行时机、range 坐标初始化完成后再执行核对行列号单元格仍可编辑保护命令未生效或版本不支持改用命令拦截或升级 Univer 版本提交数据丢失前端未正确收集变更订阅变更事件维护变更队列再提交金额出现负数后端校验缺失后端使用同源规则 JSON 强校验5. 最后说点我自己的体会这个填报模块从立项到现在我最大的感触是在线表格类需求真正难的不是把表格显示出来而是把表格的行为纳入业务规则。Univer 的插件架构和命令系统让我能相对优雅地做到这一点——数据验证管内容保护/拦截管动作后端校验管安全三层各司其职。你不需要把 Univer 当成“Excel 替代品”去用而是把它当成一个“可编程的表格运行时”嵌进业务里。它的 API 还在快速演进版本升级时一定要留出回归测试时间。如果你准备在自己项目里做类似的填报、收集、登记模块我建议先照着第 3 节搭一个最小 demo跑通“模板定义 → 分发填写 → 提交校验”这条链路再按业务补细节比一上来就追求完整功能要稳妥得多。