如果你需要在网页里放一个"能编辑的表格",会发现选择比想象中少。
不是没有表格组件。AG Grid、TanStack Table 都是好东西,但它们本质是数据网格——擅长把一万行数据渲染得又快又好,按列排序筛选。可一旦用户想双点单元格改个值、拖一下填充柄、写个 =SUM(B2:B100)、把 A1 和 C1 合并起来,它们就不够用了。你要的是一个电子表格编辑器,不是表格。
而电子表格编辑器这个品类,长期被两家商业产品把持:SpreadJS 和 Handsontable。都能用,都有中文资料,都要按年付费、按开发者数量计费。国产的 Luckysheet 曾经是个免费的出路,但它的 GitHub 仓库最后更新停在 2025 年,README 现在写着一句话:
Luckysheet upgraded to Univer
Univer 就是 Luckysheet 团队的重写版本。 同一批人(DreamNum),推倒重来,用 TypeScript + Canvas + 插件化架构重新做了一遍。Apache-2.0 协议,可以商用。
为什么关注它
两个理由。
一是它刚发布了 1.0。 2026 年 9 月 24 日,v1.0.0 正式发布——这个项目从 2022 年 9 月开始迭代,四年时间才走到 1.0。1.0 不是修修补补,而是一次定位重写:从一个"表格组件"变成一个六个编辑器的统一 SDK。表格、文档、演示、白板、结构化数据、PDF,共享同一套插件系统、命令系统,每种内容有自己的 Facade API。
二是它在被真实使用。 npm 上的周下载量:
(数据取自 npm downloads API,2026-09 下旬的一周)
周下载五十万次这个量级,说明不是玩具。GitHub 上 18,800 star,Apache-2.0,最后提交日期 2026-09-24——就是发 1.0 那天。
1.0 带来了什么
对你的意义取决于你要做的东西:只做表格,就只装 Sheets 相关包;要做"文档里嵌表格",1.0 支持嵌入式文档——表格可以嵌进文档并保持可编辑,两者各存各的数据模型。
许可证边界:先说清楚哪些要钱
这一点必须放在前面,因为它决定了你的技术选型成本。
用许可证的方式是注册一个插件:
import { UniverLicensePlugin } from '@univerjs-pro/license'
univer.registerPlugin(UniverLicensePlugin, {
license: 'YOUR_CLIENT_LICENSE',
})
关键结论:如果你要的是"网页里能编辑、能算公式的表格",开源部分就够了,零成本。如果你要"导入 xlsx、导出 xlsx、多人同时编辑、打印"——这些是 Pro 能力,需要拿许可。别看到 Apache-2.0 就以为整个产品免费,这是最容易踩的预期错位。
安装
先决条件
浏览器基线在 1.0 从 Chrome 70 提到了 88,构建目标变了,注意你的构建配置。
两种集成模式
Univer 提供两条路,先选对再动手:
先走 Preset 模式。等你知道自己要砍掉哪些功能了,再换 Plugin 模式。
# 网页端(Preset 模式)
npm install @univerjs/presets @univerjs/preset-sheets-core
# 服务端无头(Preset 模式)
npm install @univerjs/presets @univerjs/preset-sheets-node-core
版本必须对齐。 所有 @univerjs/* 和 @univerjs-pro/* 要用同一个版本号(当前 1.0.2)。这是官方反复强调的一条,混版本会出现各种离奇错误。
十五行跑起来
一个完整的、能编辑的电子表格:
<div id="app" style="height: 600px"></div>
import { createUniver, LocaleType, mergeLocales } from '@univerjs/presets'
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'
import UniverPresetSheetsCoreZhCN from '@univerjs/preset-sheets-core/locales/zh-CN'
import '@univerjs/preset-sheets-core/lib/index.css'
const { univerAPI } = createUniver({
locale: LocaleType.ZH_CN,
locales: {
[LocaleType.ZH_CN]: mergeLocales(UniverPresetSheetsCoreZhCN),
},
presets: [
UniverSheetsCorePreset({
container: 'app',
}),
],
})
univerAPI.createWorkbook({})
就这样。一个带工具栏、公式栏、右键菜单的中文表格编辑器就跑起来了。
三个要点:
container对应 DOM 的 id,容器必须有明确高度(height: 600px或100vh),否则你会看到一片空白——这是最常见的"装了没反应"。CSS 必须导入。
@univerjs/preset-sheets-core/lib/index.css,漏了就是一堆错位的裸 DOM。中文语言包路径是
locales/zh-CN,Univer 内置 19 种语言(含zh-TW、zh-HK)。
核心概念
搞清这四个,剩下的查 API 就行。
Facade API 是分层包装的,记住这个链路:
univerAPI (FUniver)
└─ workbook (FWorkbook) 工作簿:增删工作表、撤销重做、保存快照
└─ worksheet (FWorksheet) 工作表:行列操作、冻结、合并区查询
└─ range (FRange) 区域:读写值、公式、样式、格式
一个必须知道的规则:改数据是异步的。 Facade API 里凡是会修改文档的方法,很多返回 Promise 或异步生效。如果改完立刻读,可能拿到旧值。需要立刻读的话加 await 或等一拍:
sheet.getRange('C5').setFormula('=SUM(B2:B4)')
await new Promise((r) => setTimeout(r, 300)) // 等公式引擎算完
console.log(sheet.getRange('C5').getDisplayValue()) // "76.7"
速查表
FWorkbook —— 工作簿
FWorksheet —— 工作表
FRange —— 区域(最常用)
getRange() 的四种写法
sheet.getRange(1, 0) // 第 2 行第 A 列(0 基)
sheet.getRange(1, 0, 3, 4) // 起始行、起始列、行数、列数
sheet.getRange('A1:C4') // A1 表示法
sheet.getRange({ startRow: 1, startColumn: 0, endRow: 3, endColumn: 2 })
行列索引是 0 基的。 getRange(1, 0) 是 A2,不是 A1。而从 A1 表示法进来时,'A2' 就是 A2。同一个方法两套坐标系,这是最容易出错的地方。
五个实战场景
一、把后端数据渲染成一张表
const sheet = workbook.getActiveSheet()
const HEADER = ['设备编号', '温度(℃)', '压力(MPa)', '采集时间']
const ROWS = [
['DEV-001', 25.6, 1.2, '2026-09-26 10:00'],
['DEV-002', 26.3, 1.15, '2026-09-26 10:01'],
['DEV-003', 24.8, 1.22, '2026-09-26 10:02'],
]
// 一次写入整块,比逐个单元格快得多
sheet.getRange('A1:D1').setValues([HEADER])
sheet.getRange('A2:D4').setValues(ROWS)
// 表头样式
sheet.getRange('A1:D1')
.setBackgroundColor('#e8f0fe')
.setFontWeight('bold')
.setHorizontalAlignment('center')
sheet.getRange('B2:B4').setNumberFormat('0.00')
sheet.setFrozenRows(1)
sheet.setColumnWidth(0, 110)
两个要点:
用 setValues() 一次写一块,不要循环 setValue()。每次写都是一个命令,一万个单元格就是一万次命令。
用 A1 表示法,别用行列索引。 getRange('A2:D4') 和 getRange(1, 0, 3, 4) 是同一个区域,但后者要你在脑子里做一次"从 0 数还是从 1 数"的换算——我自己就在这上面写错过一次(见坑表第一条)。只要不是动态计算行列,就用 A1 表示法。
二、公式:写进去、让它算、读回来
sheet.getRange('A6').setValue('平均温度')
sheet.getRange('B6').setFormula('=AVERAGE(B2:B4)')
sheet.getRange('A7').setValue('最高温度')
sheet.getRange('B7').setFormula('=MAX(B2:B4)')
sheet.getRange('A8').setValue('最大温差')
sheet.getRange('B8').setFormula('=MAX(B2:B4)-MIN(B2:B4)')
await new Promise((r) => setTimeout(r, 500))
console.log(sheet.getRange('B6').getDisplayValue()) // "25.5666666667"
console.log(sheet.getRange('B7').getDisplayValue()) // "26.3"
console.log(sheet.getRange('B8').getDisplayValue()) // "1.5"
公式由独立的公式引擎计算,是异步的。三个值的实际计算:25.6+26.3+24.8 = 76.7,平均 25.5667,最大温差 26.3-24.8 = 1.5。
想让它好看点,加个数字格式:
sheet.getRange('B6:B8').setNumberFormat('0.00')
// 现在 B6 显示 "25.57",B8 显示 "1.50"
注意公式里的 B2:B4 指的是行号 2 到 4,不是数组下标。 这里的数据正好写在 A2:D4,所以三行全含。范围写错一行不会报错,只会静默算出错误的数——=AVERAGE(B1:B3) 会得到 25.95 而不是 25.57,因为它们看起来都"像个合理的温度值"。
三、数字格式
setNumberFormat() 接受的是 Excel 风格的格式串:
注意 getValue() 拿到的仍是数字 25.5666666667,getDisplayValue() 才是格式化后的字符串。做后续计算用前者,做展示用后者。
四、服务端无头处理
Univer 是同构的——同一套 Facade API 在浏览器和 Node.js 里用法一致,只是不带 UI。适合做批量处理:定时任务里生成报表、把数据库导出成工作簿快照、在 CI 里校验模板。
npm install @univerjs/presets @univerjs/preset-sheets-node-core
import { createUniver, LocaleType, mergeLocales } from '@univerjs/presets'
import { UniverSheetsNodeCorePreset } from '@univerjs/preset-sheets-node-core'
import sheetsZhCN from '@univerjs/preset-sheets-node-core/locales/zh-CN'
import { UniverSheetsNumfmtPlugin } from '@univerjs/sheets-numfmt'
const { univer, univerAPI } = createUniver({
locale: LocaleType.ZH_CN,
locales: { [LocaleType.ZH_CN]: mergeLocales(sheetsZhCN) },
presets: [UniverSheetsNodeCorePreset()],
})
// ⚠️ 必须手动补这一个插件,原因见下方坑表第二条
univer.registerPlugin(UniverSheetsNumfmtPlugin)
const workbook = univerAPI.createWorkbook({ name: '报表' })
const sheet = workbook.getActiveSheet()
sheet.getRange('A6').setValue('平均温度')
sheet.getRange('B6').setFormula('=AVERAGE(B2:B4)')
await new Promise((r) => setTimeout(r, 500))
console.log('平均温度 =', sheet.getRange('B6').getDisplayValue())
const snapshot = workbook.save() // 得到纯 JSON,可存库、可回传给前端
console.log(Object.keys(snapshot)) // id, sheetOrder, name, appVersion, locale, styles, sheets, resources
save() 返回的就是一个普通对象,可以 JSON.stringify 存进数据库。前端和服务端交换数据就靠这个快照。
但恢复时有个坑,先看下面场景五。
五、快照的往返
const snapshot = workbook.save()
// 服务端存库,前端取回来
const restored = univerAPI.createWorkbook(snapshot) // ❌ 报错
// Error: [UniverInstanceService]: cannot create a unit with the same unit id: 5-lEak
快照里带着 id,直接恢复会因为单元 ID 重复而抛错。 改掉它就行:
const restored = univerAPI.createWorkbook({
...snapshot,
id: `copy-${Date.now()}`, // 换一个不重复的 id
})
console.log(restored.getSheets()[0].getRange('A1').getValue()) // 值都在
或者先 workbook.dispose() 把原工作簿释放掉,再用原快照恢复。工作表自己的 id 不用改,只有工作簿这一层的 unit id 会冲突——save() 出来的快照里 sheetOrder 那串 id 保持原样即可。
这对服务端流程尤其重要:每次从库里读出一份快照要恢复时,都得给它一个新的 unit id,否则第二次加载同一个文档就崩了。
这是 Univer 相比"渲染型表格组件"的根本差异:文档是可序列化的数据,不是 DOM 的投影。
我实测踩到的坑
下面这些都是实际跑出来的,不是从文档抄的。
一、行列索引是 0 基的,跟 A1 表示法混用会静默算错
getRange() 有两套坐标系:
sheet.getRange(1, 0) // 行列索引:0 基 → 第 2 行、第 A 列
sheet.getRange('A1') // A1 表示法:第 1 行、第 A 列
getRange(row, column, numRows, numColumns) 里的 row、column 是 0 基下标,而 numRows、numColumns 是个数。实测对照:
sheet.getRange(1, 0, 1, 4).getA1Notation() // "A2:D2" ← 不是 A1:D1
sheet.getRange(2, 0, 3, 4).getA1Notation() // "A3:D5"
我自己就在这上面写错过一次数据。 当时混用了两种写法——表头用 getRange(1, 0, 1, 4) 写(落到第 2 行),数据用 getRange(2, 0, 3, 4) 写(落到第 3~5 行),公式却按直觉写了 =AVERAGE(B2:B4)。结果范围里多了一个表头文字单元格、漏了最后一行数据。
危险的是它不报错。 实测对照:
25.95 看起来是个完全合理的温度。而且 AVERAGE 会静默忽略文本单元格,所以范围里多含一个表头也不会报错。这类错误肉眼看结果发现不了,只能交叉验证。
规避办法两条:
静态区域一律用 A1 表示法(
'A2:D4'),只有循环里按索引访问时才用数字坐标;用了数字坐标就立刻回头验证一次落点:
const r = sheet.getRange(1, 0, 3, 4)
console.log(r.getA1Notation()) // 先确认落在哪,再往里写
二、无头预设缺了 numfmt 插件(会直接抛错)
按官方文档用 UniverSheetsNodeCorePreset,然后调 setNumberFormat(),你会得到:
Error: [CommandService]: command "sheet.command.numfmt.set.numfmt" is not registered.
根因:@univerjs/preset-sheets-node-core 的源码里,一边 import '@univerjs/sheets-numfmt/facade'(所以 setNumberFormat 方法存在、能调用),一边插件列表里没有 UniverSheetsNumfmtPlugin(所以命令没注册)。方法存在但底层没有实现,调用即崩。
我核对了同期的网页端预设 @univerjs/preset-sheets-core——它两个都有,所以网页端没这个问题,只有无头预设会踩。
修复就是补一行:
import { UniverSheetsNumfmtPlugin } from '@univerjs/sheets-numfmt'
const { univer, univerAPI } = createUniver({
// ...
presets: [UniverSheetsNodeCorePreset()],
})
univer.registerPlugin(UniverSheetsNumfmtPlugin) // ← 补上
补完之后实测正常:1234.567 用 '#,##0.00' 显示为 1,234.57,0.2567 用 '0.00%' 显示为 25.67%,日期格式输出 2026年09月26日。
三、isMerged() 不是"这个区域被合并了吗"
它要求范围完全相等。合并了 A1:C1 之后:
sheet.getRange('A1:C1').merge()
sheet.getRange('A1:C1').isMerged() // true
sheet.getRange('A1').isMerged() // false ← 别以为是 bug
sheet.getRange('A1').isPartOfMerge() // true ← 判断"在不在合并区里"要用这个
worksheet.getMergedRanges() // 列出全部合并区,最可靠
源码注释原话是 Return true only for an exact merged range match. Use isPartOfMerge() to check overlap. 我第一次测的时候查了 A1 拿到 false,以为合并异步失败了,查了源码才明白是语义问题。
实践建议:要判断某格是否在合并区内,用 isPartOfMerge();要列出所有合并区,用 getMergedRanges()。
四、deleteSheet() 不认名字
workbook.deleteSheet('第三车间') // false ← 名字无效
workbook.deleteSheet(sheet.getSheetId()) // true
workbook.deleteSheet(sheetObject) // true
文档注释写的是 deleteSheet(sheet: FWorksheet | string),那个 string 指的是 sheetId,不是 sheetName。注意 getSheetByName('名字') 是接受名字的——同一个类里两个方法对 string 的含义不一样。
五、改完数据立刻读可能拿到旧值
前面反复出现过。所有修改类 Facade API 都可能异步生效,涉及公式、合并、格式时尤其明显。稳妥写法:
sheet.getRange('A1:C1').merge()
await new Promise((r) => setTimeout(r, 100))
// 现在再查状态
或者用生命周期钩子,等引擎进入稳定阶段再操作:
univerAPI.addEvent(univerAPI.Event.LifeCycleChanged, ({ stage }) => {
if (stage === univerAPI.Enum.LifecycleStages.Rendered) {
// 渲染完成
}
if (stage === univerAPI.Enum.LifecycleStages.Steady) {
// 稳定,可以放心读写
}
})
六、快照的 id 不能复用
const snapshot = workbook.save()
univerAPI.createWorkbook(snapshot)
// Error: [UniverInstanceService]: cannot create a unit with the same unit id: 5-lEak
快照里带着原工作簿的 id,同一个 Univer 实例里再建一个同 id 的单元会直接抛错。实测三种处理:
工作表层面的 id(快照 sheetOrder 里那串)不需要改,实测保持原样也能正常恢复。
对服务端流程的影响:从库里读出一份快照要恢复时,必须给它一个新的 unit id。否则同一个进程里第二次加载同一份文档就会崩——这个坑在"读库 → 生成 → 返回"的批处理里很容易踩到。
七、TypeScript 报 CSS 导入错误
import '@univerjs/preset-sheets-core/lib/index.css'
// error TS2882: Cannot find module or type declarations for side-effect import
TS 不认识 CSS 模块。在 tsconfig.json 里加一行:
{
"compilerOptions": {
"types": ["vite/client"]
}
}
Vite 项目加了就过。Webpack 项目换成对应的类型声明。
八、包体积要有预期
一个最小的表格编辑器(Preset 模式 + 中文语言包),Vite 生产构建产物实测:
未压缩 6.78 MB 的主包,gzip 后通常降到 1.5~2 MB 量级,但这个体量意味着首屏加载要当回事。两个应对:走 Plugin 模式只装需要的插件;或者用预设的 workerURL 把公式计算挪进 Web Worker,同时做代码分割。
九、0.x 升 1.0 的破坏性变更
如果你已经有一个 0.25 的项目,1.0 有几个必须改的地方:
另外 presets 不再提供 UMD 构建——如果你是用 <script> 标签直接引的,必须换成模块化构建。
0.25 系列的维护政策:从 1.0 发布起只提供一年安全更新。也就是说 2027 年 9 月起,0.25 连安全补丁都没有了。
上手路径
判断要不要上车的三个问题:
你要的是表格编辑器(用户改单元格、写公式)还是数据展示网格(万行滚动、列筛选)?后者用 AG Grid 这类更合适,更轻。
你需要 xlsx 导入导出吗?需要的话这是 Pro 能力,要先谈许可。
你接受 6~7 MB 的主包吗?如果这是个首屏加载要求极严格的小程序/H5,得先做体积优化方案。
三个都是"是"或都能接受,Univer 是当下开源选项里最完整的一个。
参考
版本与数据核对时间:2026-09-26。当前最新版本 1.0.2(2026-09-24 发布,与 v1.0.0 同日)。