1. 从一张“只能填指定格子”的表格说起
第一次接触 Univer 是在一个内部数据填报系统的需求评审上。业务方的诉求听起来特别朴素:给一张类似 Excel 的表格,让填报人只能改其中几列,其他列锁死,改完提交,后台校验。当时团队第一反应是找个开源表格组件嵌进去,结果试了几个方案都卡在同一个点上——要么是渲染性能撑不住几千行,要么是权限控制粒度太粗,没法做到“单元格级别”的锁定。
后来有人甩了个链接过来,就是 Univer。它的定位不是“又一个表格组件”,而是一套前后端一体的表格与文档 SDK,底层用 Canvas 做渲染,对外暴露一套叫 Facade API 的高层接口,同时提供 Node.js 侧的服务端能力。这几个关键词——univer、SDK、Node.js、Canvas、Facade API——基本就是它的技术骨架。我花了两周时间把它从 demo 跑到生产可用,中间踩的坑不算少,这篇就把整个落地过程拆开讲清楚。
这篇文章适合几类人看:正在做在线表格、协同编辑、数据填报类产品的前端或全栈;想了解 Canvas 渲染引擎怎么撑起复杂表格的;以及需要一套能同时跑在浏览器和 Node.js 里的表格内核的。哪怕你之前没听过 Univer,看完应该能判断它到底适不适合你的场景。
2. Univer 到底是什么,为什么值得单独拿出来讲
2.1 它解决的不是“显示表格”,而是“表格内核”
市面上大部分表格方案,本质是“渲染 + 交互”的封装,你拿到的是一个组件,能显示、能编辑,但一旦你要改它的行为逻辑,比如自定义公式、自定义权限、自定义协同策略,就得往源码里钻。Univer 的思路不一样,它把表格拆成了几层:底层是 Canvas 渲染引擎,中间是数据模型和命令系统,上层是 Facade API。你操作的是 API,不是 DOM。
这个分层带来的直接好处是:同一套内核可以跑在浏览器,也可以跑在 Node.js 服务端。浏览器里负责交互和渲染,Node.js 里负责计算、校验、批量处理。比如你要做“用户只能填指定单元格”这个需求,前端用 Facade API 把非填报区域设成只读,后端用同一套 API 做二次校验,逻辑是一致的,不用写两遍。
2.2 Canvas 渲染为什么是关键选择
表格这东西,行数一上去,DOM 方案就顶不住。一万行 DOM 节点,滚动直接卡成幻灯片。Canvas 的优势在于它只画“可视区域”,滚动时重绘,节点数量恒定。Univer 用 Canvas 做渲染,配合虚拟滚动,实测下来几万行的表格滚动依然跟手。
但 Canvas 也有代价:它没有 DOM 的天然可访问性和事件冒泡。所以 Univer 在 Canvas 之上自己实现了一套命中检测和事件分发,你点击某个单元格,它得先算出你点的是哪个格子,再触发对应逻辑。这部分是它比较重的地方,也是为什么它的包体积不算小。
2.3 Facade API 的设计意图
Facade 这个词本身就是“门面”的意思。Univer 内部有大量模块——渲染、公式、协同、权限、导入导出——如果每个模块都暴露一堆接口,使用者会疯掉。Facade API 把这些能力收敛成一套统一的调用方式,比如univerAPI.getActiveWorkbook()拿到当前工作簿,然后.getActiveSheet()拿工作表,再.getRange()拿区域,链式调用下去。
这种设计的好处是学习成本集中在一个入口,坏处是灵活性受限于它暴露了什么。好在 Univer 的 Facade API 覆盖度还不错,常见的单元格操作、样式、公式、冻结、合并都有,权限控制也能通过它实现。
3. 环境搭建:Node.js 与 SDK 的配合
3.1 Node.js 版本选择与安装
Univer 的服务端能力依赖 Node.js,官方推荐 18 以上,我实际用的是 20 LTS。如果你机器上还没装,去官网下载对应系统的安装包,一路下一步就行。装完在终端敲node -v和npm -v,能出版本号就说明成了。
有个细节要注意:如果你之前装过旧版本,最好先卸干净再装新的,不然可能出现node和npm版本不匹配的怪问题。Windows 上尤其容易残留,卸载后手动检查一下环境变量里有没有旧的路径。
CentOS 这类服务器环境,用包管理器装可能版本太老,建议用 nvm 或者直接下二进制包解压。我试过在 CentOS 7.9 上直接yum install nodejs,装出来是 10.x,跑 Univer 直接报语法错误。后来换成 nvm 装 20.x 才正常。
3.2 项目初始化与依赖安装
新建一个目录,npm init -y生成 package.json,然后装 Univer 的核心包。这里有个坑:Univer 拆了很多子包,比如@univerjs/core、@univerjs/sheets、@univerjs/sheets-ui、@univerjs/facade,你得按需装。如果只是想跑个最小 demo,装 core + sheets + facade 就够了。
npm install @univerjs/core @univerjs/sheets @univerjs/facade版本号建议锁死,Univer 迭代比较快,不同版本之间 API 可能有变动。我用的是一套 0.x 的稳定版,具体版本号看官方 release 说明。
3.3 最小可运行示例
装完之后,写一个最简单的入口文件,创建一个 Univer 实例,挂到一个 div 上,然后往里面塞点数据。这一步的目的是验证环境通了,别急着上复杂功能。
import { Univer, LocaleType } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverFacadePlugin } from '@univerjs/facade'; const univer = new Univer({ locale: LocaleType.ZH_CN, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverFacadePlugin); const container = document.getElementById('app'); univer.createUniverSheet(container, { sheetData: { id: 'sheet1', name: '填报页', cellData: { 0: { 0: { v: '姓名' }, 1: { v: '部门' }, 2: { v: '工时' } }, 1: { 0: { v: '张三' }, 1: { v: '研发' }, 2: { v: '' } }, }, }, });跑起来能看到一个表格,说明环境没问题。接下来才是重头戏——权限控制。
4. 核心需求实现:让用户只能填指定单元格
4.1 需求拆解与技术选型
回到最开始那个需求:一张表,用户只能改其中几列,其他列锁死。拆开来看,要解决三件事:
第一,视觉上要能区分哪些能填、哪些不能填。通常做法是把只读区域设成灰色背景,或者加个锁的图标。
第二,交互上要拦截。用户点到只读单元格,不能进入编辑态;就算通过粘贴、拖拽等方式想改,也得被挡住。
第三,数据上要校验。前端拦截是体验,后端校验是底线。用户绕过前端直接调接口提交,后端必须能识别并拒绝。
Univer 的 Facade API 里,区域对象有setLocked或者类似的权限方法,具体名字看版本。我用的版本是通过getRange().setEditable(false)来控制的。后端则用同一套 API 在 Node.js 里重建表格模型,逐格校验。
4.2 前端只读区域的设置
先拿到工作表,再拿到要锁的区域,调只读方法。假设 A 列和 B 列是系统预填的,C 列开始才是用户填的,那就把 A、B 两列锁掉。
const workbook = univerAPI.getActiveWorkbook(); const sheet = workbook.getActiveSheet(); // 锁定 A、B 两列(索引 0 和 1) const lockedRange = sheet.getRange(0, 0, sheet.getMaxRows(), 2); lockedRange.setEditable(false); // 给只读区域加个灰色背景,视觉上区分 lockedRange.setBackgroundColor('#f0f0f0');这里getRange的参数是(startRow, startColumn, numRows, numColumns),注意别搞反。getMaxRows()拿当前最大行数,如果后面动态加行,得重新锁一次。
4.3 拦截粘贴和拖拽的越界修改
只读设置能挡住直接编辑,但挡不住“从可编辑区域复制,粘贴到只读区域”这种操作。Univer 有命令系统,可以监听粘贴命令,判断目标区域是否只读,是就拦截。
univerAPI.onCommandExecuted((command) => { if (command.id === 'sheet.command.paste') { const targetRange = command.params.range; if (isRangeLocked(targetRange)) { // 抛出错误或静默取消 return false; } } });isRangeLocked需要你自己维护一份锁定区域的记录,因为 Facade API 不一定提供“查询某区域是否只读”的方法。我的做法是在初始化时把锁定区域存到一个数组里,拦截时遍历判断。
4.4 后端 Node.js 侧的二次校验
前端再怎么拦,都不能信。后端拿到提交的数据后,用 Univer 在 Node.js 里重建一个表格实例,把原始数据填进去,然后逐格对比:只读区域的格子,提交值和原始值是否一致,不一致就拒绝。
const { Univer } = require('@univerjs/core'); const { UniverSheetsPlugin } = require('@univerjs/sheets'); function validateSubmission(originalData, submittedData, lockedColumns) { for (const rowIndex in submittedData) { for (const colIndex in submittedData[rowIndex]) { if (lockedColumns.includes(Number(colIndex))) { const original = originalData[rowIndex]?.[colIndex]?.v; const submitted = submittedData[rowIndex][colIndex]?.v; if (original !== submitted) { return { valid: false, reason: `第 ${rowIndex} 行第 ${colIndex} 列不允许修改` }; } } } } return { valid: true }; }这段逻辑不依赖 Univer 也能写,但用 Univer 的好处是数据模型一致,公式、格式这些复杂情况也能覆盖。
5. 实操中踩过的坑与排查记录
5.1 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 表格不渲染,白屏 | 容器没有宽高 | 检查挂载 div 的 CSS | 给容器设明确宽高 |
| 只读设置无效 | 区域参数顺序错 | 确认 getRange 参数 | 按 startRow, startCol, numRows, numCols 传 |
| 粘贴仍能改只读区 | 未监听命令 | 检查命令监听是否注册 | 注册 paste 命令拦截 |
| Node.js 侧报模块找不到 | 包未装全 | 检查依赖列表 | 补装 sheets、facade 等子包 |
| 滚动卡顿 | 行数过多未虚拟化 | 确认是否开启虚拟滚动 | 默认开启,检查配置 |
| 中文乱码 | locale 未设 | 检查 Univer 初始化配置 | 设 LocaleType.ZH_CN |
5.2 几个容易忽略的细节
容器宽高必须明确。Canvas 不像 DOM 会自动撑开,父容器没高度,画布就是 0 高,看起来就是白屏。我一开始用 flex 布局,父级没设高度,折腾了半小时才发现。
区域索引从 0 开始。Excel 里 A 列是第 1 列,但 API 里是 0。写代码时脑子里要转个弯,不然锁错列。
动态增行后要重新锁。如果表格支持用户新增行,新增的行默认是可编辑的,得在增行事件里重新对只读区域调一次锁定。
后端校验要处理空值。用户没填的格子,提交上来可能是 undefined 或空字符串,和原始值的比较要统一处理,不然会误判。
5.3 性能调优的一点经验
几千行的表格,Univer 默认表现还行。但如果你的只读区域很大,每次滚动都重绘灰色背景,可能会有开销。我的做法是把只读区域的背景色通过样式表统一设置,而不是逐格设,减少重绘指令。
另外,命令监听里尽量做轻量判断,别在里面做复杂计算。粘贴命令触发很频繁,监听函数重了会拖慢整体响应。
6. 这套方案还能怎么扩展
Univer 的能力不止于表格。它还有文档、幻灯片的内核,Facade API 也在持续扩展。如果你做完填报系统,想加个“填报说明”的富文本区域,可以直接用它的文档能力,和表格共享同一套底层。
协同编辑也是它原生支持的。多个用户同时填一张表,Univer 有协同插件处理冲突合并。不过协同对后端要求高,需要配套的协同服务,这块我还没深入,等后面有场景再补。
导出 Excel 也是常见需求。Univer 有导入导出插件,能把当前表格状态导出成 xlsx。实测下来格式保留得不错,公式也能带出去。
最后分享一个我在实际项目里的小技巧:把锁定区域的配置抽成一个 JSON,前端和后端都读同一份配置。这样改需求时只改一处,不会出现前端锁了后端没锁的尴尬。配置大概长这样:
{ "lockedColumns": [0, 1], "editableColumns": [2, 3, 4], "lockStyle": { "backgroundColor": "#f0f0f0" } }前端拿lockedColumns去设只读,后端拿它去校验,两边逻辑对齐,维护起来省心不少。