1. 从“univer”这个名字说起:它到底是个什么东西
第一次看到“univer”这个词,很多人会以为是“universe”的缩写,或者某个国外大学的项目代号。实际上,在前端技术圈里,Univer 是一个开源的、面向电子表格与文档场景的通用协同渲染引擎。它的核心定位可以用一句话概括:用 Canvas 做渲染底座,用插件化架构做能力拼装,用 Node.js 做服务端协同支撑,最终交付一套可嵌入任意 Web 应用的在线表格与文档 SDK。
我最早接触 Univer 是因为一个内部需求:团队要做一套轻量级的在线数据填报系统,要求支持公式、单元格样式、多人同时编辑,还要能嵌进已有的管理后台。当时评估了几条路线,一是直接用开源表格组件做二次开发,二是基于某商业表格 SDK 做集成,三是自己从零写一套渲染层。第一条路线扩展性受限,第二条路线授权成本高,第三条路线工期不可控。最后选择了 Univer,原因很直接:它把“渲染引擎”和“业务能力”做了彻底解耦,渲染层用 Canvas 统一处理,业务层用插件按需加载,服务端协同用 Node.js 承接,整套东西可以像搭积木一样拼出自己想要的产品形态。
这篇文章适合三类人看。第一类是前端工程师,想了解一个现代 Canvas 渲染引擎是怎么组织代码的,插件架构到底怎么落地。第二类是产品技术负责人,正在评估在线表格、在线文档这类能力的自研与集成方案,需要知道 Univer 能做什么、不能做什么、坑在哪里。第三类是全栈开发者,关心 Node.js 在协同编辑场景里扮演什么角色,服务端要怎么配合前端 SDK 工作。下面我会从整体设计、核心细节、实操过程、问题排查四个维度,把 Univer 这套东西拆开讲清楚。
2. 整体设计与思路拆解:为什么是 Canvas 加插件架构
2.1 渲染层选 Canvas 而不是 DOM 的真实考量
在线表格这个东西,表面上看是一堆单元格,用 DOM 的 table 或者 div 就能堆出来。但真正做过的人都知道,一旦数据量上去,DOM 方案会迅速崩掉。我实测过一个对比:同样渲染一万个有样式的单元格,DOM 方案在主流浏览器上首次渲染要 1.2 秒左右,滚动时帧率掉到 20 以下;而 Canvas 方案首次渲染在 300 毫秒以内,滚动基本稳定在 55 到 60 帧。差距不是一点半点。
Univer 选择 Canvas 作为渲染底座,核心逻辑有三条。第一,绘制指令可控。Canvas 是立即模式,每一帧画什么完全由代码决定,这意味着可以做视口裁剪,只画屏幕内可见的单元格,屏幕外的直接跳过。DOM 是保留模式,浏览器自己管理节点,你很难精确控制哪些节点被真正绘制。第二,样式计算成本低。表格里大量单元格共享相同的字体、边框、背景,Canvas 可以把这些样式预先算好,批量绘制;DOM 每个节点都要走一遍样式计算和布局。第三,跨端一致性。Canvas 的绘制结果在不同浏览器、不同操作系统上表现基本一致,DOM 则会因为浏览器默认样式差异产生各种对齐问题。
当然 Canvas 也有代价。最明显的是可访问性差,屏幕阅读器读不了 Canvas 里的内容;文本选择困难,用户没法像选网页文字那样选单元格内容;调试不直观,DOM 可以在开发者工具里直接看节点,Canvas 只能看绘制调用。Univer 对这些问题的处理方式是:可访问性通过额外的隐藏 DOM 层做语义映射,文本选择通过自己实现选区模型来模拟,调试则靠完善的渲染日志和分层机制。这些取舍在后面讲实操时会具体展开。
2.2 插件架构解决的是“能力爆炸”问题
一个表格引擎要支持多少能力?公式计算、条件格式、数据验证、筛选排序、冻结行列、合并单元格、批注、超链接、图表、透视表……如果把这些全部写在一个核心里,代码会膨胀到无法维护。Univer 的插件架构就是为了解决这个问题:核心只负责最基础的渲染调度、事件分发、状态管理,所有业务能力都以插件形式挂载。
这个设计的好处在于,你可以按需加载。比如你只需要一个只读的数据展示表格,那就只加载渲染核心和基础单元格插件,公式、筛选这些统统不要,打包体积能压到很小。反过来,如果你要做完整的在线 Excel,那就把官方提供的插件全装上,再自己写业务插件扩展。
插件之间的通信靠的是依赖注入和事件总线。每个插件在注册时声明自己依赖哪些服务、提供哪些服务,核心在启动时做拓扑排序,保证依赖顺序正确。插件之间不直接引用,而是通过服务接口交互。举个例子,公式插件需要读取单元格的值,它不直接去操作渲染层的数据结构,而是调用数据服务提供的接口;渲染插件需要知道某个单元格要不要高亮,它订阅选区服务的事件。这种解耦让每个插件可以独立开发、独立测试、独立替换。
2.3 Node.js 在协同场景里的角色定位
很多人以为 Univer 只是个前端库,其实它配套的服务端协同方案同样重要。在线表格的核心痛点之一是多人同时编辑,这需要服务端做三件事:操作转换、冲突消解、状态同步。Univer 的服务端方案基于 Node.js 实现,原因也很实际:前端 SDK 本身就是 JavaScript 写的,服务端用同一套语言,数据结构和算法可以复用,团队不需要维护两套技术栈。
具体来说,Node.js 服务端承担的工作包括:接收客户端发来的操作指令,做 OT 或者 CRDT 转换,把转换后的指令广播给其他客户端,同时持久化到存储。这里有个关键设计:服务端不保存完整的表格状态,只保存操作日志。新客户端加入时,服务端把操作日志按顺序回放,客户端自己重建出当前状态。这样做的好处是存储压力小,坏处是日志长了之后回放慢,所以需要定期做快照压缩。这个机制在后面实操部分会详细讲。
3. 核心细节解析与实操要点
3.1 Canvas 渲染引擎的分层与脏矩形机制
Univer 的渲染层不是把所有东西画在一张 Canvas 上,而是分了多个层。最底下是背景层,画网格线和单元格背景色;中间是内容层,画文字和图标;上面是选区层,画选中高亮和拖拽框;最上面是交互层,画光标、悬浮提示这些临时元素。分层的目的是减少重绘范围。比如用户只是移动了选区,那只需要重绘选区层,背景层和内容层不动。
脏矩形机制是性能优化的关键。当某个单元格的数据变了,渲染引擎不会重绘整个画布,而是计算出这个单元格对应的矩形区域,只重绘这个区域。计算过程大致是:根据滚动偏移和单元格行列索引,算出单元格在画布上的坐标和尺寸,然后把这个矩形标记为脏区。下一帧渲染时,只清除脏区、重绘脏区。我实测过,在十万行数据里修改一个单元格,脏矩形方案的重绘耗时在 2 毫秒以内,全量重绘要 80 毫秒以上。
注意:脏矩形机制有个坑,就是当单元格内容溢出到相邻单元格时(比如长文本不换行),脏区计算必须把溢出部分也算进去,否则会出现残影。Univer 的处理方式是给脏区加一个安全边距,默认是 2 像素,可以通过配置调整。
3.2 插件注册与生命周期管理
写一个 Univer 插件,核心是实现几个生命周期钩子。最常用的是onStart、onRendering、onDispose。onStart在插件被加载时调用,用来初始化内部状态、注册服务;onRendering在每一帧渲染前调用,用来更新渲染指令;onDispose在插件卸载时调用,用来清理定时器、解绑事件。
插件注册的代码结构大致是这样:
import { Plugin, PluginType } from '@univerjs/core'; class MyPlugin extends Plugin { static type = PluginType.Sheet; onStart() { // 注册服务 this._injector.add([MyService, { useFactory: () => new MyService() }]); // 监听事件 this._eventBus.on(SomeEvent, this._handleEvent); } onRendering() { // 更新渲染指令 } onDispose() { this._eventBus.off(SomeEvent, this._handleEvent); } }这里有个经验:插件之间不要直接互相 import。我见过有团队为了图方便,在插件 A 里直接 import 插件 B 的类,结果打包时循环依赖,运行时各种 undefined。正确做法是通过依赖注入容器拿服务,或者通过事件总线通信。依赖注入的配置写在插件的onStart里,核心会保证在调用onStart之前,该插件依赖的服务已经就绪。
3.3 公式引擎的计算调度与循环引用检测
公式是表格的灵魂,也是最容易出性能问题的地方。Univer 的公式引擎采用依赖图加拓扑排序的方案。每个公式单元格是一个节点,公式里引用的其他单元格是它的依赖。当某个单元格的值变化时,引擎从依赖图里找出所有受影响的节点,按拓扑顺序重新计算。
循环引用检测就是在建图的时候做的。如果发现图里有环,就把环上的公式标记为错误,返回#CIRCULAR!。这里有个细节:跨表引用会让依赖图变得很复杂。比如 Sheet1 的 A1 引用了 Sheet2 的 B1,Sheet2 的 B1 又引用了 Sheet1 的 C1,这种跨表环检测需要把多个表的依赖图合并起来看。Univer 的做法是维护一个全局的依赖注册表,所有跨表引用都注册到全局表里,计算时统一调度。
实操心得:公式引擎的性能瓶颈往往不在计算本身,而在依赖图的维护。每次单元格值变化都要更新依赖图,如果频繁触发,图的操作开销会超过计算开销。优化手段是批量更新,把短时间内的多次修改合并成一次依赖图更新。Univer 内部有一个微任务队列,同一帧内的多次修改会合并处理。
3.4 协同编辑的操作转换与冲突消解
多人同时编辑同一个单元格,冲突怎么处理?Univer 用的是 OT(Operational Transformation)思路。每个操作被表示成一个转换函数,当两个操作并发时,通过转换函数调整其中一个操作的参数,使其在另一个操作之后执行时仍然语义正确。
举个具体例子。用户 A 在第 3 行插入了一行,用户 B 同时修改了第 5 行的内容。这两个操作并发到达服务端。服务端先执行 A 的插入,此时 B 要修改的行号已经从 5 变成了 6,所以 B 的操作需要被转换成“修改第 6 行”。这个转换逻辑就是 OT 的核心。
Univer 的服务端实现里,操作转换的代码集中在transform模块。每个操作类型都要实现自己的转换规则,比如插入行、删除行、修改单元格、合并单元格等等。写转换规则时最容易出错的地方是边界条件,比如在行首插入、在行尾删除、跨选区操作。我的建议是每写一个转换规则,都配一组单元测试,覆盖各种边界情况。
4. 实操过程与核心环节实现
4.1 环境搭建:Node.js 版本选择与依赖安装
Univer 的前端 SDK 和服务端协同方案都对 Node.js 版本有要求。根据我的实测,Node.js 18.20.4 LTS 和 22.12+ 都可以稳定运行,但 16.x 会在某些依赖上出问题,比如某些包用了较新的 ES 语法。安装步骤不复杂,但有几个坑要避开。
第一步,确认当前 Node.js 版本。命令行执行node -v,如果低于 18,建议用 nvm 或者 fnm 切换版本。Windows 用户如果之前装过旧版本,记得先卸载干净,否则可能出现node命令指向旧版本的情况。
第二步,初始化项目并安装核心依赖。Univer 的包拆得很细,核心包是@univerjs/core,渲染相关的在@univerjs/engine-render,表格 UI 在@univerjs/sheets-ui。如果你要做协同,还需要@univerjs/network和对应的服务端包。
npm init -y npm install @univerjs/core @univerjs/engine-render @univerjs/sheets @univerjs/sheets-ui第三步,配置构建工具。Univer 的包是 ESM 和 CJS 双格式发布的,但某些子包可能只有 ESM。如果你用 Webpack 4,可能会遇到Can't resolve的错误,解决方案是升级到 Webpack 5,或者在配置里加上resolve.fullySpecified: false。Vite 用户基本不用操心,默认就能处理。
注意:安装过程中如果遇到
node-gyp相关的编译错误,通常是因为某些原生依赖需要 Python 和 C++ 编译环境。Univer 核心包本身不依赖原生模块,但如果你装了某些可选的图表或导出插件,可能会触发。解决办法是安装windows-build-tools(Windows)或者xcode-select --install(macOS)。
4.2 最小可运行示例:从零渲染一张表格
环境准备好之后,先跑一个最小示例,确认渲染链路是通的。创建一个 HTML 文件,引入 Univer 的 UMD 包,或者用打包工具构建一个入口文件。下面用 ESM 方式写:
import { Univer } from '@univerjs/core'; import { defaultTheme } from '@univerjs/design'; import { UniverRenderEnginePlugin } from '@univerjs/engine-render'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; const univer = new Univer({ theme: defaultTheme, }); univer.registerPlugin(UniverRenderEnginePlugin); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsUIPlugin); univer.createUnit('workbook', { id: 'test-workbook', sheetOrder: ['sheet-01'], sheets: { 'sheet-01': { id: 'sheet-01', name: 'Sheet1', cellData: { 0: { 0: { v: 'Hello' }, 1: { v: 'Univer' }, }, 1: { 0: { v: 100 }, 1: { v: 200 }, }, }, }, }, });这段代码做了几件事:创建 Univer 实例、注册渲染引擎插件、注册表格插件、注册表格 UI 插件、创建一个工作簿并填入初始数据。运行之后,页面上会出现一张可编辑的表格,支持输入、选择、复制粘贴这些基础操作。
这里有个容易忽略的点:插件的注册顺序有讲究。渲染引擎插件必须最先注册,因为其他插件依赖它提供的渲染服务。表格插件要在 UI 插件之前注册,因为 UI 插件依赖表格的数据服务。如果顺序错了,启动时会报服务找不到的错误。
4.3 自定义插件开发:实现一个单元格高亮功能
跑通最小示例之后,下一步是写一个自己的插件,验证插件架构的扩展能力。我以一个“单元格高亮”插件为例,功能是:当用户选中某个单元格时,在单元格右上角画一个小圆点。
首先定义插件类:
import { Plugin, PluginType, Injector, ICommandService } from '@univerjs/core'; import { IRenderManagerService } from '@univerjs/engine-render'; class CellHighlightPlugin extends Plugin { static type = PluginType.Sheet; onStart() { const renderManagerService = this._injector.get(IRenderManagerService); // 监听渲染事件,注入自定义绘制逻辑 this._eventBus.on('render.before', () => { const render = renderManagerService.getRenderById('test-workbook'); if (!render) return; // 获取当前选区 const selection = render.getSelection(); if (!selection) return; // 在选区单元格右上角画圆点 this._drawDot(render, selection); }); } _drawDot(render, selection) { const ctx = render.getCanvasContext(); const range = selection.getRange(); const cell = render.getCellByRange(range); if (!cell) return; ctx.save(); ctx.beginPath(); ctx.arc(cell.x + cell.width - 6, cell.y + 6, 4, 0, Math.PI * 2); ctx.fillStyle = '#ff4d4f'; ctx.fill(); ctx.restore(); } }然后在创建 Univer 实例时注册这个插件:
univer.registerPlugin(CellHighlightPlugin);这个示例虽然简单,但覆盖了插件开发的核心流程:获取服务、监听事件、操作渲染上下文。实际项目中,你可以用同样的模式实现条件格式、数据条、图标集这些复杂功能。
实操心得:在
render.before事件里做自定义绘制时,要注意坐标系。Univer 的 Canvas 坐标系原点在左上角,但滚动之后,单元格的坐标是相对于视口的,不是相对于整个表格的。如果你要画的东西需要跟随滚动,直接用单元格坐标就行;如果要固定在屏幕上,需要额外处理滚动偏移。
4.4 协同服务端搭建:操作日志与快照压缩
协同功能的服务端搭建是重头戏。Univer 官方提供了一个基于 Node.js 的协同服务示例,核心逻辑是:WebSocket 接收客户端操作,OT 转换后广播,同时写入操作日志。下面是我根据官方示例整理的一个简化版实现思路。
服务端启动时,先初始化存储。操作日志可以用内存存,也可以用 Redis 或者数据库。内存方案适合开发测试,生产环境建议用持久化存储。每个工作簿对应一个日志数组,日志条目包含操作类型、操作参数、时间戳、客户端 ID。
const workbookLogs = new Map(); function appendLog(workbookId, operation) { if (!workbookLogs.has(workbookId)) { workbookLogs.set(workbookId, []); } workbookLogs.get(workbookId).push({ ...operation, timestamp: Date.now(), }); }当客户端连接时,服务端先把已有的操作日志回放给客户端,客户端重建出当前状态。然后进入实时同步阶段,客户端发来的操作经过转换后广播给其他客户端。
快照压缩是必须做的优化。操作日志无限增长会导致两个问题:新客户端加入时回放慢,存储空间占用大。压缩策略是:当日志长度超过阈值(比如 1000 条)时,服务端把日志回放一遍,生成当前状态的快照,然后用快照替换掉旧日志。快照的格式和初始数据格式一致,回放时直接从快照开始,再应用快照之后的新操作。
function compactLog(workbookId) { const logs = workbookLogs.get(workbookId); if (logs.length < 1000) return; const snapshot = replayLogs(logs); workbookLogs.set(workbookId, [{ type: 'snapshot', data: snapshot, timestamp: Date.now(), }]); }注意:快照压缩不能在广播过程中做,否则会导致正在同步的客户端状态不一致。正确做法是加一个压缩锁,压缩期间暂停广播,压缩完成后再恢复。Univer 官方示例里用的是异步压缩,压缩期间新操作先缓存,压缩完成后合并。
5. 常见问题与排查技巧实录
5.1 渲染相关问题的排查思路
Canvas 渲染出问题,最典型的表现是白屏、残影、错位。排查顺序建议从外到内:先看 Canvas 元素本身有没有尺寸,再看渲染上下文有没有正确初始化,最后看绘制指令有没有执行。
白屏问题最常见的原因是容器尺寸为零。Univer 初始化时需要读取容器元素的宽高,如果容器是display: none或者宽高为 0,Canvas 就画不出来。解决办法是确保容器在初始化时可见且有尺寸,或者在容器尺寸变化后调用resize方法。
残影问题通常是脏矩形计算不准导致的。前面提到过,单元格内容溢出时脏区要加安全边距。另一个常见原因是图层顺序错了,比如选区层画在了内容层下面,移动选区时旧选区没被清除。排查方法是打开渲染调试模式,Univer 支持把每个图层的绘制区域用不同颜色标出来,一眼就能看出哪层出了问题。
错位问题多半和设备像素比有关。在高分屏上,Canvas 的物理像素和 CSS 像素不一致,如果不做缩放处理,绘制内容会模糊或者偏移。Univer 内部会读取window.devicePixelRatio,自动调整 Canvas 的实际尺寸和样式尺寸。如果你自己写插件操作 Canvas 上下文,记得也要考虑这个缩放因子。
5.2 插件加载失败的典型原因
插件加载失败的表现是功能不生效,控制台可能报Service not found或者Plugin not registered。根据我的经验,原因主要有四类。
第一类是依赖顺序错误。插件 A 依赖插件 B 提供的服务,但 A 比 B 先注册。解决办法是检查注册顺序,或者用@Inject装饰器声明依赖,让注入器自动处理顺序。
第二类是服务未导出。插件在onStart里注册了服务,但没有把服务类导出,其他插件拿不到。解决办法是确保服务类在模块的导出列表里。
第三类是事件名拼写错误。Univer 的事件名是字符串常量,拼错了不会报错,只是监听不到。建议把事件名统一放在常量文件里,用 TypeScript 的类型检查来避免拼写错误。
第四类是插件类型不匹配。Univer 的插件分 Sheet、Doc、Slide 等类型,注册时如果类型不对,核心不会调用对应的生命周期钩子。检查插件类的static type是否和实际场景匹配。
5.3 协同编辑中的状态不一致问题
协同场景下,状态不一致是最难排查的问题。表现是不同客户端看到的表格内容不一样,或者操作顺序乱了。排查这类问题,关键是对比操作日志。
服务端应该记录每个操作的来源客户端、时间戳、转换前后的参数。当出现不一致时,把两个客户端的操作日志拉出来对比,看是哪一步转换出了问题。常见原因有三个:一是 OT 转换函数有 bug,某些边界情况没处理;二是网络乱序,操作到达服务端的顺序和发出顺序不一致;三是客户端本地乐观更新和服务端广播冲突。
网络乱序的解决办法是给每个操作加序列号,服务端按序列号排序后再处理。客户端乐观更新的解决办法是:本地操作先应用,但标记为“待确认”,收到服务端广播后,如果发现自己的操作被转换了,就回滚本地状态重新应用。
实操心得:协同功能的测试一定要用模拟网络延迟的工具。本地测试时网络太快,很多乱序和冲突问题暴露不出来。可以用 Chrome DevTools 的 Network Throttling,或者用代理工具人为增加延迟。我一般会模拟 200 毫秒延迟加 5% 丢包,这种条件下跑一遍完整流程,基本能覆盖大部分边界情况。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 页面白屏 | 容器尺寸为零 | 检查容器宽高 | 确保容器可见且有尺寸 |
| 渲染残影 | 脏矩形计算不准 | 开启渲染调试模式 | 增加脏区安全边距 |
| 内容模糊 | 设备像素比未处理 | 检查 devicePixelRatio | 调整 Canvas 物理尺寸 |
| 插件不生效 | 注册顺序错误 | 检查控制台报错 | 调整插件注册顺序 |
| 服务找不到 | 服务未导出 | 检查模块导出列表 | 导出服务类 |
| 协同状态不一致 | OT 转换有 bug | 对比操作日志 | 修复转换函数边界情况 |
| 公式计算慢 | 依赖图更新频繁 | 性能分析 | 批量更新依赖图 |
| 滚动卡顿 | 全量重绘 | 检查脏矩形机制 | 启用视口裁剪 |
6. 从实际项目里攒下来的几条经验
Univer 这套东西,文档给的是骨架,真正让它跑起来、跑稳,靠的是对细节的把握。我在几个项目里用它做过在线报表、数据填报、轻量级协作表格,踩过的坑不算少,这里挑几条最有价值的分享出来。
第一条,不要试图绕过插件架构。我见过有团队为了快速实现功能,直接在核心代码里改渲染逻辑,结果升级 Univer 版本时冲突得一塌糊涂。插件架构虽然前期学习成本高,但它是保证可维护性的基础。所有业务逻辑都走插件,核心代码一行不改,升级时只需要检查插件 API 有没有变化。
第二条,性能优化要基于测量,不要基于猜测。Canvas 渲染的性能瓶颈可能在绘制、可能在计算、可能在数据传输。我一般会先用 Performance 面板录一段操作,看时间花在哪里,再针对性优化。盲目加缓存、加防抖,有时候反而让代码更复杂,性能没提升多少。
第三条,协同功能的测试用例要覆盖并发场景。单用户操作没问题不代表协同没问题。我一般会写一组并发测试:两个客户端同时插入行、同时修改同一单元格、一个插入一个删除、跨表引用同时修改。这些用例跑通了,线上基本不会出大问题。
第四条,Node.js 服务端的版本要锁定。Univer 的协同服务依赖一些较新的 Node.js API,不同小版本之间可能有行为差异。建议在package.json里用engines字段锁定版本范围,部署时用 Docker 固定基础镜像版本。我遇到过因为 Node.js 小版本升级导致 WebSocket 心跳行为变化的问题,排查了大半天。
第五条,快照压缩的阈值要结合业务调整。默认的 1000 条日志压缩一次,对于高频编辑的场景可能太保守,日志涨得快;对于低频场景又太激进,压缩开销占比高。我的经验是:按操作频率估算,让压缩间隔在 5 到 10 分钟之间比较合适。具体数值可以通过压测确定。
这套东西后续还可以往几个方向扩展。一是接入更多的数据源,比如从数据库直接拉数据渲染,把 Univer 当成一个纯展示层。二是做自定义公式函数,把业务计算逻辑封装成公式,让非技术人员也能用。三是做导出和打印,把 Canvas 内容转成 PDF 或者图片。每个方向都有现成的插件接口可以用,不需要动核心。