1. 从“univer”这个标题说起:一个被低估的表格内核
第一次看到“univer”这个词,很多人会以为是某个新出的前端框架或者某个云服务品牌。但如果你在开源社区里翻过它的仓库,会发现它其实是一个定位非常清晰的在线表格与文档协作内核,官方给自己的定位是“可嵌入的电子表格与文档 SDK”。换句话说,它不是给你一个现成的在线 Excel 产品,而是给你一套可以塞进自己系统里的表格引擎,让你在自己的后台、自己的编辑器、自己的业务系统里,长出一个“类 Excel”的能力。
这件事的价值在哪里?我举个实际场景。很多做 SaaS 的团队,业务做到一定阶段都会遇到同一个需求:客户想要在系统里“填表”。注意,不是那种简单的表单,而是带公式、带合并单元格、带数据校验、带权限控制的复杂表格。你如果从零写一个表格组件,光是单元格渲染、选区、公式解析、撤销重做这几块,就够一个前端团队啃半年。而 univer 这类内核做的事情,就是把这部分最难啃的骨头封装成 SDK,你只需要关心“我的业务数据怎么映射到单元格”“哪些单元格允许用户改”“改完之后怎么回写数据库”。
所以这篇内容我打算从一个实际从业者的角度,把 univer 这个项目拆开讲清楚:它的核心架构为什么这么设计、插件体系怎么用、Canvas 渲染在表格场景里到底解决了什么问题、Node.js 侧能做什么、以及最关键的——怎么实现“用户只能填指定单元格,其他单元格锁死”这种典型需求。如果你正在做在线表格、低代码平台、数据填报系统,或者单纯想研究一下现代表格引擎是怎么搭起来的,这篇应该能给你省不少试错时间。
2. univer 到底是什么:核心定位与架构拆解
2.1 它不是 Excel 的替代品,而是表格能力的“发动机”
先把定位说清楚,避免走弯路。univer 不是一个开箱即用的在线 Excel 网站,你 clone 下来不会看到一个完整的、带登录和文件管理的产品。它更像是一台发动机:你给它一个容器 DOM,它给你渲染出一张可交互的表格;你给它数据和配置,它按你的规则运行;你想加功能,通过插件挂上去。
这种定位决定了它的使用方式。官方提供了@univerjs/core、@univerjs/sheets、@univerjs/sheets-ui、@univerjs/sheets-formula等一系列包,你需要按需组合。比如你只想要一个只读的表格展示,那引入 core 和 sheets 的基础渲染就够了;如果你要公式计算,再加 formula 包;要协同,再上协同相关的模块。这种“按需拼装”的思路,和很多大而全的表格库完全不同,好处是包体积可控、职责清晰,代价是上手时需要理解它的模块划分。
我个人的判断是:如果你的需求只是“展示一个静态表格”,用普通的 table 标签或者轻量表格库就够了,没必要上 univer。但如果你需要公式、选区、多 sheet、权限粒度控制、协同编辑中的任意两项以上,univer 的架构优势就会体现出来。
2.2 插件架构:为什么核心包那么“薄”
univer 最核心的设计决策之一,就是把几乎所有功能都做成了插件。核心包@univerjs/core里主要放的是:依赖注入容器、命令系统、事件总线、生命周期管理、插件注册机制。真正的表格渲染、公式、UI 交互,全都在各自的插件包里。
这么设计的原因很实际。表格产品的需求差异极大:有人要公式,有人不要;有人要协同,有人只要单机;有人要复杂的条件格式,有人只要基础样式。如果把这些全塞进核心,核心会变得无比臃肿,而且任何一个功能的改动都可能影响其他功能。插件化之后,每个能力都是独立的,可以单独升级、单独替换,甚至你可以自己写一个插件覆盖官方实现。
从实操角度看,这意味着你引入 univer 时,脑子里要有一个“插件清单”的概念。你需要什么能力,就装什么插件,然后在初始化时注册进去。这个思路和 VS Code 的插件体系、Webpack 的 plugin 体系是一脉相承的,理解了一个就理解了一片。
2.3 Canvas 渲染:表格性能的关键一战
热词里出现了“Canvas”和“canvas绘图引擎”,这不是偶然。univer 的表格渲染层用的是 Canvas,而不是传统的 DOM 表格。这个选择背后有非常明确的性能考量。
传统的 DOM 表格,每个单元格是一个 DOM 节点。一张 1000 行 × 50 列的表格就是 5 万个 DOM 节点,浏览器光是布局和重绘就会卡到怀疑人生。而 Canvas 渲染的思路是:整个表格就是一张画布,所有单元格、文字、边框、选区都是画上去的。无论表格多大,DOM 层面始终只有一个 canvas 元素。滚动、缩放、选区变化时,只需要重绘画布上可见区域的内容。
当然,Canvas 渲染也有代价。比如文字选中、无障碍访问、输入法交互这些,都需要额外处理。univer 的做法是在需要输入时,动态在 Canvas 上方叠加一个真实的输入框,输入完成后再把值写回数据层并重绘。这种“Canvas 打底 + DOM 叠加交互”的混合模式,是目前高性能表格引擎的主流方案。
我实测下来的感受是:在几千行数据量级下,univer 的滚动流畅度明显优于 DOM 方案,尤其是横向滚动和快速拖拽选区时,帧率稳定得多。但如果你只是渲染几十行数据,两者的体感差异其实不大,这时候选型就要看其他因素了。
3. 环境搭建:从 Node.js 到第一个可运行表格
3.1 Node.js 环境准备与版本选择
univer 的开发环境依赖 Node.js,这一点和绝大多数现代前端项目一样。热词里大量出现“node.js安装教程”“node.js官网下载”“如何查看有没有安装node.js”,说明很多刚接触的人卡在第一步。我把关键点说清楚。
首先,版本选择。univer 的包对 Node.js 版本有要求,建议使用Node.js 18 LTS 或更高版本,如果要用最新的构建工具链,Node.js 20 LTS 会更稳妥。热词里提到的“node.js 22.12+”属于比较新的版本,也能跑,但如果你团队里有老项目共用环境,建议用 nvm 或 fnm 这类版本管理工具做隔离,避免全局版本冲突。
安装完成后,验证方式很简单:
node -v npm -v两条命令都能输出版本号,说明安装成功。如果提示“command not found”,大概率是环境变量没配好,Windows 下检查安装时是否勾选了“Add to PATH”,macOS/Linux 下检查 shell 配置文件里有没有 source 对应的路径。
提示:不要用系统自带的包管理器装 Node.js(比如某些 Linux 发行版自带的旧版本),版本太老会导致依赖安装失败。老老实实从官网下载或用版本管理工具装。
3.2 创建项目与安装 univer 依赖
环境就绪后,创建一个前端项目。用 Vite 是最省事的,启动快、配置少:
npm create vite@latest my-univer-demo -- --template vanilla-ts cd my-univer-demo npm install然后安装 univer 的核心包。这里要注意,univer 的包名都带@univerjs/前缀,按需安装:
npm install @univerjs/core @univerjs/sheets @univerjs/sheets-ui @univerjs/design如果你需要公式能力,再加@univerjs/sheets-formula;需要协同,再加对应的协同包。安装时留意 peer dependency 的提示,univer 各包之间版本要对齐,建议统一用同一个大版本,避免出现 API 不兼容。
3.3 初始化一个最小可运行表格
依赖装好后,写一个最小的初始化代码。核心逻辑是:创建 Univer 实例、注册插件、创建 workbook 和 worksheet、挂载到 DOM。
import { Univer, LocaleType, merge } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { defaultTheme } from '@univerjs/design'; const univer = new Univer({ theme: defaultTheme, locale: LocaleType.ZH_CN, }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.createUniverSheet({ id: 'demo-sheet', sheetName: '数据填报表', cellData: { 0: { 0: { v: '姓名' }, 1: { v: '部门' }, 2: { v: '本月工时' }, }, 1: { 0: { v: '张三' }, 1: { v: '研发' }, 2: { v: 168 }, }, }, }); univer.mount(document.getElementById('app')!);这段代码跑起来,页面上就会出现一张带表头和一行数据的表格。看起来简单,但背后已经完成了插件注册、Canvas 初始化、数据绑定、事件监听这一整套流程。我建议第一次跑通后,先别急着加功能,而是打开浏览器开发者工具,看看 DOM 结构——你会发现表格区域只有一个 canvas,这就是前面说的 Canvas 渲染。
4. 核心需求实战:让用户只能填指定单元格
4.1 需求拆解:什么叫“锁定其他单元格”
这是热词里最具体、也最有代表性的一个需求:univer 支持用户定义表格,然后让用户去填写一些单元格,其他的单元格用户无法修改。翻译成业务语言就是:管理员配置一张模板表,指定哪些格子是“可填写区”,然后把这张表发给普通用户,用户只能在这些格子里输入,其他格子看得到但改不了。
这个需求在数据填报、审批单、问卷、预算表等场景里非常常见。实现它需要解决几个层面的问题:第一,如何标记哪些单元格可编辑;第二,如何在用户尝试编辑锁定单元格时拦截;第三,如何保证锁定状态在数据层面也生效,而不只是 UI 层面禁用。
4.2 基于权限与保护的实现思路
univer 提供了工作表保护(worksheet protection)和范围保护的能力。核心思路是:默认整张表处于保护状态,然后对允许编辑的区域设置例外。
具体来说,你需要用到@univerjs/sheets里的保护相关 API。大致流程是:
- 创建 worksheet 后,先对整张表启用保护。
- 定义一个或多个允许编辑的范围(range),把这些范围加入“可编辑白名单”。
- 用户交互时,引擎会检查当前选区是否落在白名单内,不在则拒绝编辑操作。
这里的关键是范围的定义方式。univer 的范围通常用起始行、起始列、结束行、结束列来描述。比如你希望 B2 到 D10 可编辑,那就是 startRow=1, startColumn=1, endRow=9, endColumn=3(注意行列从 0 开始计数)。
// 伪代码示意,具体 API 以官方文档为准 const protection = worksheet.getProtection(); protection.enable(); protection.setEditableRanges([ { startRow: 1, startColumn: 1, endRow: 9, endColumn: 3 }, ]);注意:保护是数据层的能力,不是简单的 CSS 禁用。这意味着即使有人通过控制台改 DOM,也无法真正修改被保护单元格的值,因为写入操作会在命令层被拦截。这一点比纯前端禁用靠谱得多。
4.3 编辑拦截与用户反馈
光有保护还不够,用户体验上要给出明确反馈。当用户点击一个锁定单元格并尝试输入时,应该有提示,而不是“点了没反应”。univer 的命令系统允许你监听编辑命令,在命令执行前做判断。
我的做法是:监听单元格编辑相关的事件,判断目标单元格是否在可编辑范围内。如果不在,弹一个轻量提示(比如“该单元格为模板内容,不可修改”),同时阻止命令继续执行。这样用户能立刻明白为什么改不了,而不是反复尝试。
另外,视觉上也要有区分。可编辑区域可以用浅色背景或边框标出来,锁定区域保持默认样式。这种视觉暗示能大幅降低用户的困惑。univer 支持单元格样式设置,你可以在初始化时给可编辑范围统一加一个背景色。
4.4 数据回写与校验
用户填完之后,你需要把数据取出来回写数据库。这里有个容易踩的坑:不要直接读 Canvas 上的显示值,而应该从数据层读取。univer 的数据模型里,每个单元格有原始值(v)和格式化后的显示值,业务回写要用原始值。
读取方式大致是遍历 worksheet 的 cellData,或者用 API 获取指定范围的数据。取到之后,建议在服务端再做一次校验,确认用户只修改了允许的单元格。因为前端保护再严密,也不能完全信任客户端提交的数据,服务端必须有一份同样的“可编辑范围”配置做二次校验。这是安全底线,别偷懒。
5. 插件架构深入:怎么按自己的需求扩展
5.1 理解命令系统与依赖注入
univer 的插件不是随便挂个函数就完事,它有一套基于依赖注入(DI)和命令系统的架构。每个插件在注册时,可以往容器里注册自己的服务,也可以注册命令处理器。其他插件通过容器拿到这些服务,通过命令系统触发操作。
这套机制的好处是解耦。比如公式插件需要读取单元格数据,它不需要知道数据是哪个插件写的,只需要从容器里拿到数据服务;UI 插件需要执行“设置单元格值”这个操作,它不直接改数据,而是发一个命令,由命令处理器去改。这样一来,任何一方替换实现,只要接口不变,其他方都不受影响。
理解这一点,对你写自定义插件至关重要。你要做的不是“直接操作数据”,而是“定义命令 + 注册处理器 + 在合适的时机派发命令”。
5.2 写一个自定义插件的骨架
一个最小插件大概长这样:
import { ICommandService, Plugin, Inject } from '@univerjs/core'; class MyCustomPlugin extends Plugin { static override pluginName = 'my-custom-plugin'; constructor( @Inject(ICommandService) private readonly _commandService: ICommandService ) { super(); } override onStarting(): void { // 注册命令、监听事件、初始化服务 } override onReady(): void { // 所有插件就绪后的逻辑 } override onRendered(): void { // 首次渲染完成后的逻辑 } }生命周期钩子给了你明确的介入时机。onStarting适合注册命令和服务,onReady适合做跨插件协调,onRendered适合做依赖 DOM 的操作。我踩过的坑是:在onStarting里就去读渲染结果,那时候 Canvas 还没画出来,拿不到任何东西。一定要看清楚每个钩子的触发时机。
5.3 插件之间的通信方式
插件之间不要直接互相引用实例,那样会形成硬耦合。正确做法是通过命令系统和事件总线。A 插件想通知 B 插件,就派发一个命令或事件;B 插件监听这个命令或事件,做出响应。
这种模式在协同编辑场景里尤其重要。比如用户 A 修改了单元格,这个操作会变成一个命令,经过协同层同步到其他客户端,其他客户端再执行同样的命令。如果插件之间是直接调用,协同层就很难介入。所以从一开始就养成“一切通过命令”的习惯,后面加协同会顺很多。
6. 常见问题与排查技巧实录
6.1 表格渲染空白或错位
这是新手最常见的问题。页面挂载了,但表格不显示,或者显示位置不对。排查顺序建议如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全空白 | 容器没有宽高 | 检查挂载元素的 CSS,必须有明确尺寸 |
| 只显示一部分 | 容器被 overflow 裁剪 | 检查父级 overflow 设置 |
| 位置偏移 | 挂载时机太早 | 确保 DOM 已就绪再 mount |
| 样式错乱 | 主题未注册 | 确认 defaultTheme 已传入 |
我遇到最多的是容器没有高度。univer 挂载的容器必须有明确的宽高,因为它要按容器尺寸计算 Canvas 大小。如果容器高度是 0,Canvas 就是 0 高,自然什么都看不到。给容器设一个height: 600px之类的固定值,问题立刻解决。
6.2 公式不计算或计算结果不对
公式能力是单独插件,不装就不会算。如果你装了公式插件但结果不对,先检查单元格值的类型。univer 里数字和字符串是区分对待的,如果你把数字存成了字符串"168",公式可能按文本处理,结果就错了。确保数值型数据用 number 类型存储。
另外,公式的依赖链更新是异步的。如果你在设置完数据后立刻读取公式结果,可能拿到的是旧值。正确做法是监听计算完成事件,或者在下一个事件循环里再读。
6.3 编辑被拦截但没有提示
前面讲了保护机制,但如果你只开了保护没做提示,用户会一脸懵。检查你的命令监听是否真的拦截到了编辑命令。有时候命令名对不上,监听器根本没触发。建议在开发阶段把所有命令都打上日志,看看用户操作时到底派发了哪些命令,再针对性地拦截。
6.4 大数据量下的性能问题
虽然 Canvas 渲染性能好,但如果你一次性往 cellData 里塞十万行数据,初始化还是会卡。建议的做法是分页或虚拟加载:初始只加载可见区域附近的数据,滚动时再动态补充。univer 本身对大数据量有优化,但数据准备阶段的开销还是在你这边。另外,避免在每次编辑后全量重设 cellData,应该用增量更新的 API。
实操心得:开发阶段打开浏览器的 Performance 面板,录制一段滚动和编辑操作,看看时间花在哪里。大部分性能问题都能通过这个方式定位到具体环节。
7. 从单机到协同:univer 的扩展边界
7.1 协同编辑的架构前提
univer 的架构从一开始就为协同留了口子,这也是它命令系统设计得比较重的原因。协同的核心逻辑是:所有修改都表达为命令,命令可以被序列化、传输、重放。本地执行命令的同时,把命令发给服务端,服务端广播给其他客户端,其他客户端重放同样的命令,从而达到状态一致。
这个模型叫“命令重放式协同”,和 OT、CRDT 是不同层面的东西。univer 提供的是命令层的基础设施,具体的冲突解决策略需要你在协同层实现。如果你的场景是“多人同时编辑同一张表”,那冲突处理是绕不开的,需要引入 OT 或 CRDT 算法。如果只是“一个人填完另一个人再填”,那简单得多,甚至不需要实时协同。
7.2 Node.js 侧能做什么
热词里 Node.js 出现频率很高,很多人关心服务端能做什么。univer 的核心逻辑是可以在 Node.js 环境里跑的,这意味着你可以在服务端做几件事:一是公式的批量计算,比如用户提交后,服务端重新算一遍公式做校验;二是数据导入导出,把 Excel 文件解析成 univer 的数据结构,或者反过来导出;三是协同服务端,处理命令的分发和持久化。
不过要注意,univer 的 UI 相关包依赖浏览器环境,在 Node.js 里只能跑核心和数据层,不能跑渲染层。做服务端计算时,只引入 core 和 formula 相关包即可,别把 UI 包也拉进来。
7.3 什么场景不适合用 univer
说了这么多优点,也得说说边界。如果你的需求是:纯静态展示、数据量很小、不需要公式和复杂交互,那用 univer 属于杀鸡用牛刀,引入成本和包体积都不划算。另外,如果你需要的是完全自定义的表格外观和交互,univer 的 Canvas 渲染反而会成为限制,因为改 Canvas 上的样式比改 DOM 麻烦得多。选型时想清楚:你要的是“表格能力”,还是“一个长得像表格的自定义组件”。前者选 univer,后者可能自己写更合适。
8. 一些踩坑之后的个人体会
我在实际项目里用 univer 做过数据填报系统,最大的体会是:它的学习曲线不在 API,而在架构思维。如果你带着“找个表格组件,调几个方法就能用”的心态来,前两小时会很痛苦,因为你要理解插件、命令、依赖注入这一套。但一旦理解了,后面加功能会非常顺,因为所有扩展都遵循同一套模式。
另一个体会是关于版本管理。univer 迭代比较快,各包之间的版本要对齐,升级时建议整体升,不要单独升某一个包。我吃过一次亏,单独升了 sheets 包,结果和 core 的接口对不上,排查了半天。现在我的做法是锁定版本号,升级时统一改,升完跑一遍核心用例。
最后说一个实用技巧:开发阶段把 univer 的日志级别调高,能看到命令派发、插件加载的详细过程。很多“为什么没生效”的问题,看日志比看文档快得多。这个技巧帮我省了大量时间,推荐你也试试。