做在线表格这个方向的朋友,这两年应该没少听说 Univer。这个开源项目主打“一套代码搞定表格、文档、幻灯片”,但真正让我提起兴趣去研究的,是热词里反复出现的那段描述:支持用户定义表格模板,然后让用户去填写部分单元格,其他单元格用户无法修改,并且要在线运行。这个场景听起来很简单,拆开看其实就是“模板化数据收集”这一类产品的通用底座——管理员出题,用户填空,答案自动回收。Univer 恰好把这块能力做得挺完整,而且它是可私有化部署的开源方案,不是拖到某个 SaaS 平台里就失去掌控的那种。
这篇文章就围绕这个点展开:Univer 到底是什么、它的架构凭什么能支撑这种场景、以及从零搭建一个“只能填指定单元格”的在线收集表,具体要怎么做。适合三类人看:正在做内部数据收集系统的前端/全栈工程师、想把 Excel 协作能力嵌进自己产品的技术负责人、以及刚接触 Univer 想快速上手的新人。我会把代码、配置、避坑经验一起给出来,尽量让读者照着操作就能跑通。
1. 项目概述与场景价值拆解
1.1 Univer 是什么,能解决什么问题
Univer 是一套基于 TypeScript 的云原生办公套件解决方案,核心仓库在 GitHub 上以 Apache 2.0 协议开源。它目前包含三条产品线:Univer Sheet(在线表格)、Univer Doc(在线文档)、Univer Slide(在线演示文稿),其中表格是生态最成熟、社区讨论最集中、也最适合被二次开发的部分。
它和传统“写一个<table>塞进页面”的方案有本质区别。Univer 底层是自己实现的 Canvas 渲染引擎和公式引擎,不是用 DOM 表格拼出来的。这意味着它的渲染性能、公式计算能力、以及与 Excel 文件的交互体验,都更接近真正的桌面办公软件,而不是一个网页表单。你可以把它理解成“一个可以塞进任意前端项目的 Excel 内核”,团队不需要从零造轮子,就能给自己的业务系统加上在线表格能力。
回到文章开头提到的那个真实场景:管理员建一张表,指定哪些列是标题、哪些列是填写区、哪些单元格用户绝对不能碰。这种需求在人事信息收集、活动报名、问卷调研、订单登记里到处都是。传统做法是单独开发一套表单系统,或者让用户下载 Excel 模板再回传,前者成本高,后者数据回收噩梦。Univer 的出现让中间态有了更好的选择:直接在线渲染一张真实表格,模板固定,填写区开放,提交后数据以结构化 JSON 回流到业务系统。
1.2 为什么“在线 + 可编辑权限”这个组合是刚需
很多人第一反应是:这不就是给表格加一个只读属性吗?实际接触过真实业务后你会发现,问题远没有这么简单。一张收集表里往往同时存在多种单元格状态:固定不变的标题和公式、管理员才能改的配置区、普通用户可填写的输入区、提交后自动锁定的一次性数据区。如果只能整体只读或整体可编辑,产品根本没法上线。
用 Univer 的时候,这些状态可以通过“工作表保护 + 例外区域(unprotect ranges) + 单元格锁定样式”三层机制灵活组合。管理员定义模板时把大多数人需要填写的区域设置为可编辑,其余区域保持保护状态;用户打开表格时,能选、能看的范围很宽,但能改的只有被明确放开的那几个区域。这个“精细到单元格”的权限粒度,比很多自研表格方案都要实用。
另外一个容易被忽视的点是“在线”。表格跑在浏览器里,模板一更新用户看到的就是最新版本,不存在发出去 100 份 Excel 回来 100 个版本的问题。配合后端接口,用户填完保存,数据增量同步给服务器,管理员实时就能看到收集进度。这套链路做通之后,所谓“弱表单”的玩法基本就建立起来了。
2. 核心架构与技术原理拆解
2.1 一张在线表格是怎么跑起来的
Univer 的运行时由若干独立包组成,核心包包括:@univerjs/core(数据模型与命令中心)、@univerjs/engine-render(Canvas 渲染引擎)、@univerjs/engine-formula(公式引擎)、@univerjs/sheets(表格业务逻辑)、@univerjs/sheets-ui(表格界面交互)、@univerjs/ui(通用 UI 壳层)。这种包结构决定了它可以按需加载,如果你的产品只需要表格,就没必要把文档和幻灯片的代码打进来。
从技术角度看,Univer 的架构核心是“命令 + 状态”模式。用户所有操作(改单元格、插入行列、设置保护)都会先变成一个 Command,经过校验后派发到对应业务逻辑,最终以 Mutation 的形式提交到数据层。渲染层监听数据变化,把需要变化的区域重新画到 Canvas 上。这样做的好处是:UI 与数据解耦,协同更容易做,撤销重做也天然支持;缺点是学习曲线比普通组件库陡一些,新手写第一个“改格子”的功能时会不习惯这种事件流。
这套架构对“单元格权限控制”特别有利。因为保护规则本身就是数据模型的一部分,而不是渲染层的临时状态。你可以在加载表格时就把保护规则写入快照,也可以在运行过程中通过命令动态改权限,两种方式都能立刻反映到画面上,不需要额外刷新页面。
2.2 插件体系与业务扩展思路
Univer 的另一大设计特色是插件机制。官方提供的 UI、公式、条件格式、数据验证等功能都是插件形式,开发者也可以写自己的插件挂在生命周期里。业务代码不该直接去改 Univer 内部对象,正确做法是注册插件、监听命令、响应事件。
这个设计对“模板化填表”场景来说是救命级别的。收集表往往有大量业务规则:某个字段必须填邮箱格式、某个单元格填完自动锁死、提交按钮点击后校验并调后端保存。如果这些逻辑全部散在页面回调里,代码很快会变成一坨。用插件封装后,规则内聚在一个模块里,模板变化时只需要调整插件配置,主流程完全不动。
配合命令监听器,你还能实现很多扩展功能:比如监听SetRangeValuesCommand(设置单元格内容)来实时感知用户改了什么,把增量操作记录成日志;或者监听工具栏按钮点击,插入一个自定义的“提交数据”按钮。这些后面都会在实操部分演示。
2.3 单元格权限控制的底层原理
Univer 的权限控制核心是“保护(Protection)”机制。它分为工作表保护和区域保护两种。工作表保护的语义是:对整张表打开保护开关,之后默认所有单元格都不可编辑;再通过unprotect配置列出例外区域,这些区域内的单元格即便保护开启也可以填写。区域保护的语义反过来:只保护指定范围,范围外的照常编辑。
具体的数据结构大致是这样:
interface WorksheetProtection { password?: string; selection?: { allowSelectLockedCells?: boolean; allowSelectUnlockedCells?: boolean; }; block?: { editable?: boolean; formula?: boolean; reference?: boolean; view?: boolean; }; unprotect?: Array<{ ranges: Array<{ startRow: number; startColumn: number; endRow: number; endColumn: number; }>; }>; }配合单元格样式层面还有一个protection.locked属性。默认情况下,工作表保护开启后所有单元格都视为锁定,只有两种情况例外:要么这个单元格被列为unprotect范围内的区域,要么它的样式locked被显式设为false。理解这一点,后面调试“为什么这个格子还是不能改”时就不会一头雾水。
3. 从零搭建“只能填指定单元格”的在线收集表
3.1 环境准备与快速集成
先说结论:Univer 是一个纯前端库,不需要特殊后端,你可以先只做一个能跑起来的页面,再把数据接业务系统。我们用一个常见的“员工信息登记表”来演示:第一行是标题,第二行是表头(序号、姓名、邮箱、部门、填写说明),从第三行开始是由用户填写的区域,其中“序号”列由系统自动生成、“填写说明”列不允许用户修改。
先安装依赖。不同版本的包名和 API 有些差异,以下基于当前主流版本:
npm install @univerjs/core @univerjs/design @univerjs/ui @univerjs/engine-render @univerjs/engine-formula @univerjs/sheets @univerjs/sheets-ui初始化并渲染一个基本表格:
import { Univer, LocaleType, defaultTheme } from '@univerjs/core'; import { UniverRenderEngine } from '@univerjs/engine-render'; import { UniverFormulaEngine } from '@univerjs/engine-formula'; import { UniverUIPlugin } from '@univerjs/ui'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; const univer = new Univer({ theme: defaultTheme, locale: LocaleType.ZH_CN, }); univer.registerPlugin(UniverRenderEngine); univer.registerPlugin(UniverFormulaEngine); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverUIPlugin, { container: 'app', header: true, toolbar: true, }); univer.registerPlugin(UniverSheetsUIPlugin); const workbook = univer.createUniverSheet({ name: '员工信息登记表', sheets: [ { name: '登记表', rowCount: 200, colCount: 8, cellData: { 0: { 0: { v: '2025 年度员工信息登记表' } }, 1: { 0: { v: '序号' }, 1: { v: '姓名' }, 2: { v: '邮箱' }, 3: { v: '部门' }, 4: { v: '填写说明' }, }, }, }, ], });到这一步,你应该能在页面上看到一个真实的 Excel 风格表格了。注意rowCount我给了 200,这个收集表预留多一点行数,避免用户不够填。
3.2 设置工作表保护:除了指定区域,其它都不可编辑
接下来是重头戏。我们要把整张表保护起来,只允许用户在“姓名、邮箱、部门”三列填写。先获取活动工作表和命令服务:
const univerAPI = univer.__getAPI(); // 不同版本获取方式略有差异 const workbook = univerAPI.getActiveWorkbook(); const worksheet = workbook.getActiveSheet(); const commandService = univerAPI.getCommandService();然后执行工作表保护命令:
commandService.executeCommand(SetWorksheetProtectionCommand.id, { unitId: workbook.getId(), subUnitId: worksheet.getSheetId(), rule: { selection: { allowSelectLockedCells: true, allowSelectUnlockedCells: true, }, block: { editable: true, formula: false, reference: false, }, unprotect: [ { ranges: [ { startRow: 2, startColumn: 1, endRow: 199, endColumn: 3 }, ], }, ], }, });解释一下这段配置的意义:
allowSelectLockedCells: true:被锁定的单元格仍然可以被鼠标选中、可以查看内容,只是不能修改。这对收集场景很重要,否则用户连参考信息都看不了。block.editable: true:允许常规编辑操作,但被保护的单元格本身不会响应编辑,因为锁定规则优先。block.formula: false:禁止用户修改公式。如果你在“序号”列预置了自动编号公式,这个开关能防止用户把公式删掉。unprotect:声明从第 3 行(下标 2)第 2 列(下标 1)开始,到第 200 行第 4 列(下标 3)结束的区域为可编辑。也就是说,用户能填的只有 B3:D200 这个矩形区域。
如果后续要动态调整权限,比如管理员临时放开“填写说明”列,只需要再次执行同样的命令,把unprotect.ranges换成你想要的新范围即可。保护规则会整体覆盖,不会叠加残留。
3.3 预置公式与单元格样式,提升模板专业度
光能填还不够,模板本身也别太寒酸。我们可以在创建表的时候就把“序号”列预置好递增编号,用的办法是让序号等于当前行号减一。为了避免用户误改,这列还要带上锁定样式。
创建表时生成序号数据:
const cellData = { 0: { 0: { v: '2025 年度员工信息登记表' } }, 1: { 0: { v: '序号' }, 1: { v: '姓名' }, 2: { v: '邮箱' }, 3: { v: '部门' }, 4: { v: '填写说明' }, }, }; // 预置序号列内容与锁定样式 for (let row = 2; row < 200; row++) { if (!cellData[row]) cellData[row] = {}; cellData[row][0] = { v: row - 1, s: { protection: { locked: true } }, }; // 说明列也默认锁定 if (!cellData[row][4]) { cellData[row][4] = { s: { protection: { locked: true } }, }; } } const workbook = univer.createUniverSheet({ name: '员工信息登记表', sheets: [{ name: '登记表', rowCount: 200, colCount: 8, cellData }], });顺手还可以给表头加个背景色,让用户一眼看出哪些是模板内容:
// 通过命令修改表头样式,也可以用 Sheet 的 setStyle 接口 const style = { fill: { bg: { rgb: '#E8F1FB' } }, bl: 1, ht: 2, // 加粗标题 };在实际项目里,模板样式通常由管理员在页面里手动调好,再通过“导出快照”保存成模板数据。这个能力 Univer 也直接支持,后面第 3.4 节会说。
3.4 前后端联动:保存快照与增量更新
收集表最终要把数据交还给业务系统。Univer 提供了两层数据出口:全量快照和增量操作。全量快照适合保存模板和初始化数据,增量操作适合实时同步用户改动。
全量快照这样拿:
const snapshot = workbook.getSnapshot(); // 将 snapshot 发送给后端保存 await fetch('/api/template', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(snapshot), });下次渲染时,直接把后端返回的快照喂给createUniverSheet,模板、样式、保护规则一次恢复。
增量更新可以用命令监听实现:
import { SetRangeValuesCommand } from '@univerjs/sheets'; commandService.onCommandExecuted((command) => { if (command.id === SetRangeValuesCommand.id) { const { unitId, subUnitId, ranges, value } = command.params; console.log('用户修改了', unitId, subUnitId, ranges, value); // 把变更发到后端,例如通过 WebSocket 批量上报 } });实际生产里不要用户每次按键都发请求,建议防抖合并:记录 3 秒内的变更,定时批量提交。后端收到增量后更新对应单元格,这样一个简单的“在线收集表”就闭环了。
这里还推荐一种玩法:把模板的一部分列设置为“提交后锁定”。监听用户点击自定义的“提交”按钮后,执行一次工作表保护命令,把整个填写区域加入锁定范围,用户就再也改不了提交过的数据。这种锁定在调查问卷、考试登记、报销单场景里非常实用。
4. 我踩过的坑与排查技巧实录
4.1 权限不生效的几种典型原因
做这个功能时我至少踩过四五个坑,大部分都跟“保护规则的理解偏差”有关。
第一个坑:保护命令执行后,发现所有单元格仍然可编辑。排查后发现是命令参数里的unitId或subUnitId传错了。Univer 一个页面可能同时挂着多个工作簿,命令必须精确指定修改哪个表。用workbook.getId()和worksheet.getSheetId()取,别自己拼字符串。
第二个坑:明明在unprotect里列了区域,用户还是填不了。原因通常是行号或列号理解错了。注意 Univer 的行列都是 0 起始的,第 1 行是下标 0,第 A 列是下标 0。如果按 Excel 里的“第 3 行”直接填 3,范围就错位了。
第三个坑:单元格样式里手动设过locked: false,结果保护后这些格子居然还能编辑,与预期相反。这是因为解锁样式的优先级高于保护规则。排查时可以遍历单元格样式,把所有干扰的protection.locked清掉或统一。
第四个坑:协作场景里,保护规则没有同步给所有客户端。如果你们用的是自己的协同同步服务,记得保护命令本身也是需要同步的操作,不能只在发起端执行。Univer 官方协同方案会广播命令,但自研时很容易漏掉。
4.2 性能卡顿与大数据量处理的实战经验
Univer 的 Canvas 渲染比 DOM 表格强很多,但也不是无上限。我曾经在一个收集表里塞了 5 万行、20 列并开启实时协同,结果表单切换和滚动都出现明显卡顿。后来总结出几个有效手段。
第一,按需创建数据。模板收集表通常不需要一开始就有 200 行内容,你可以只给 50 行,用户填到接近末尾时再动态扩表,监听滚动位置判断是否追加行。这样初始渲染的内存占用会小很多。
第二,关闭用不到的插件和功能。比如不需要图表就不要注册图表插件,不需要跨表引用就不要挂太多公式引擎扩展。插件越少,事件链路越短。
第三,减少实时监听频率。命令监听器里不要做重活,更不要在监听回调里同步getSnapshot(),全量快照在大表上非常昂贵。把需要的数据提前提取成轻量结构,例如只拿用户填写的区域值,而不是整个工作簿的数据。
4.3 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 保护开启后连单元格都选不中 | allowSelectLockedCells或allowSelectUnlockedCells配置为 false | 两个选项都设 true,只限制编辑不限制查看 |
| 表格公式栏还能改公式 | block.formula未设为 false | 在保护规则里设置formula: false |
| 用户填写的数值不能做格式校验 | 未配置数据验证 | 配合 DataValidation 插件,对邮箱、数字等设置校验规则 |
| 保存的快照重新加载后保护失效 | 快照里未携带保护规则或版本不兼容 | 确认getSnapshot()结果含 protection 字段;升级版本后重新生成快照 |
| 协同编辑时保护规则只在一方生效 | 保护命令未同步给所有参与方 | 自研同步时确保 mutation 广播;或接入官方协同方案 |
| 从 Excel 导入的文件保护状态异常 | Excel 保护规则与 Univer 模型映射有限 | 先转成 Univer 快照再用,不要直接依赖原始 xlsx 保护信息 |
4.4 版本升级带来的 API 差异
这是 Univer 使用者必须心里有数的一点。Univer 目前还处于快速迭代期,命令 ID、插件注册方式、univerAPI的获取方式在不同小版本里都可能变化。我第一次从 0.1.x 升级到 0.2.x 时,光SetWorksheetProtectionCommand的参数结构就改了一轮。
建议:锁定依赖版本,用 package.json 固定精确版本号或锁文件;升级前先看官方 changelog 和迁移文档;升级后跑一遍“新建表->设置保护->填写->保存快照”的冒烟测试。不要因为某个 API 在小更新里没变就盲目升级。
另外,写代码时多用类型提示,少自己脑补强类型。Univer 的类型定义基本都齐,鼠标悬停能看到 Command 参数的具体结构,照着写能避开很多坑。
5. 把收集表扩展成真正的业务产品
5.1 从单表到多模板的管理思路
如果只是给一个小团队用,单表够用。但真要产品化,你要考虑的是多模板管理:不同部门、不同活动可能需要不同的收集表。建议在业务后端建一张模板表,每个模板存一个 Univer 快照;前端创建表格时,从后端拉模板并动态createUniverSheet。
模板版本管理也值得提前做。保存快照时带上版本号和发布状态,管理员可以预览历史版本,出问题一键回滚。这个成本不高,但用户对“数据安全”的信任感会强很多。
5.2 提交、审核、导出的一体化闭环
收集表不只是“填”,还要“收”。我在实际项目里的做法是:自定义一个“提交”按钮,点击后先在前端做一次完整性校验(比如必填列是否为空、邮箱格式是否正确),通过后把当前填写区域的数据提取出来提交后端,后端入库并标记该用户已提交。
对于管理员,再做一张汇总视图,把所有人的填写结果合并成一个二维数组,支持一键导出为 CSV。Univer 本身支持导出 Excel 文件,配合服务端也可以直接把快照转成 xlsx。数据闭环走通后,这套东西就不是花架子,而是能顶上一个轻量 BI 前端的数据采集层。
5.3 最后再分享一个实用小技巧
开发阶段调试保护规则时,别急着改代码反复刷新。你可以在浏览器控制台里直接执行命令服务,动态调整unprotect范围,实时看效果。比如:
univerAPI.getCommandService().executeCommand( SetWorksheetProtectionCommand.id, { unitId: univerAPI.getActiveWorkbook().getId(), subUnitId: univerAPI.getActiveWorkbook().getActiveSheet().getSheetId(), rule: yourModifiedRule, } );这样能快速验证“放开第 5 列”这类小改动,确定没问题后再把配置固化到模板里。我经历过太多次“改一行代码要重启半天、结果只是范围算错一位”的情况,控制台直接调命令的调试方式省下了大量时间。
Univer 这个项目最吸引我的地方在于:它把 Excel 级别的能力开放给了普通开发者,同时又在架构上给了足够的自由度去做权限、协同、数据回收这些业务化的事。如果你正好有在线表格或数据收集类的需求,非常值得花一个下午把官方 Demo 跑起来,再照着这篇文章做一版自己的收集表。跑通一次之后,你会发现这类需求比想象中好解决得多。