
从去年开始我一直在找一个能嵌入Web项目、又足够灵活的表格方案。需求其实很简单让业务方自己定义一张表格给用户去填其中一部分单元格剩下的格子全部锁死不能碰。市面上在线表格不少但要么太封闭要么集成成本极高。直到我试了univer一个开源的在线表格引擎才算是把这件事彻底跑通了。这篇文章就聊聊univer是什么、怎么在项目里落地以及我为了实现指定单元格可编辑、其余只读这个需求踩过哪些坑、用了哪些招希望能给同样在做表格类功能的人一点参考。1. 为什么我会在项目里盯上univer这个开源表格引擎1.1 从用户自定义表格这个需求说起先还原一下我当时的场景。运营团队要做一个小型的数据收集页面参与方需要填写每个人的周报内容。如果用普通表单字段一多就非常死板遇到临时要加一列、改一列的情况后端接口和前端模板都要跟着动。运营同事随口说了一句能不能就像填Excel一样只填需要的几列其他列不让动——这句话让我开始关注在线表格类工具。市面上的方案我大致盘过一轮直接用Google Sheets嵌入国内访问不稳定而且权限体系跟我们的用户系统对不上用腾讯文档或金山文档集成自由度低界面没法跟产品风格一致自己用Canvas手写一个表格工作量太大公式、样式、合并单元格这类基础能力开发起来至少要一两个月。然后我搜到了univer发现它是一个原生为Web打造的表格引擎可以打包进自己的前端项目而不是一个SaaS应用。这就意味着我可以把它当作一个组件来用接口逻辑自己控制权限规则自己写UI也能一定程度上定制。1.2 univer的核心定位和架构优势univer的定位其实不局限于中国版Google Sheets它更是一个表格即组件的开发平台。整个项目基于TypeScript编写核心渲染引擎在Canvas上重绘了表格界面交互体验接近原生桌面软件同时又在架构上把UI层、数据层、插件层拆开了。用官方的话来说它支持多端但对我最实在的意义是我可以只引入一个Sheet模块不需要把所有功能都加载进来。我比较看重的架构特性有三个。第一univer的数据模型是响应式的——表格里的值、样式、公式、行列信息都对应一套可序列化的数据流这让我们可以很自然地把表格内容存到后端或者从后端恢复。第二univer的插件体系非常开放——菜单栏、工具栏、右键菜单、单元格事件都能通过插件机制拦截和扩写后面我要做的可编辑区域控制本质上也是靠这套机制去处理的。第三univer默认支持公式引擎这意味着用户在表格里填数据时可以用SUM、IF这类函数不用我们自己在后端再造一遍计算器。当然它也有缺点比如文档还在持续完善中、发布节奏比较快刚上手时可能会觉得API变动有些频繁。但整体来说对于想自建表格能力的团队univer是现阶段我认为最合适的基础件。2. 从零搭建一个univer表格实例2.1 安装与依赖引入我使用的是univer的Sheet模块版本是当前最新的稳定版。安装非常简单npm项目里直接跑npm install univerjs/core univerjs/sheets univerjs/sheets-ui univerjs/ui这里需要另外说明一下univer采用monorepo结构核心包是univerjs/core但真正让表格出现在页面上还需要引入UI层和Sheet的UI层。在我用的版本里还额外引入了univerjs/sheets-formula来启用公式功能但如果你只需要填数据不带公式那可以不装这个包减少体积。如果项目是Vue或Reactuniver官方也提供了对应的封装包但我这边是直接用了原生方法逻辑完全在JS里控制反而更透明。顺带说一句univer要求浏览器支持Canvas和ResizeObserver目前主流浏览器都没有问题。2.2 创建表格并挂载到页面初始化univer实例的代码非常直白。我把整个流程写一遍方便第一次接触的人照抄import { Univer } from univerjs/core; import { defaultModule } from univerjs/core; import { UniverSheetsModule } from univerjs/sheets; import { UniverSheetsUIModule } from univerjs/sheets-ui; import { UniverUIModule } from univerjs/ui; const container document.getElementById(my-table); const univerInstance univer.createNewUniver({ locale: zhCN, modules: [ defaultModule, UniverSheetsModule, UniverSheetsUIModule, UniverUIModule, ], configurations: { ui: { container, toolbar: true, statusbar: true, formulaBar: true, }, }, });跑起来之后页面上会出现一个完整的表格界面默认带一个sheet。不过这里只是能跑真正要做业务配置还要通过univer自己的Command API去操作workbook和worksheet。我印象特别深的是univer默认生成的表格任何单元格都可以编辑这跟我们的需求差得远。所以光会初始化还不够下一步就要落到控制单元格读写这件事上。2.3 基础配置工作表、字段、样式为了满足运营自己定义表格的需求我第一步是做一个后台配置页让运营通过界面设定表头、列数、行数、列宽然后把这些配置保存到后端。前端加载配置后动态构建workbook。univer创建workbook的数据结构大致是一个JSON描述各个sheet的单元格值、样式、合并单元格、行高列宽等信息。比如一个最简单的三行两列表格核心数据长这样{ id: sheet-1, name: 周报表, cellData: { 0: { 0: { v: 姓名 }, 1: { v: 本周完成 } }, 1: { 0: { v: 张三 }, 1: { v: 完成A项目 } } }, rowCount: 3, columnCount: 2 }不过在实际项目中我不会手动写这种JSON而是通过univer的API来创建和填充。这样能保证数据格式始终与引擎内部一致避免出现某个字段不生效的问题。样式方面可以通过setRangeValues批量设置单元格的背景色、字体、对齐方式运营在配置后台选的表头颜色会被映射成这些样式指令。有了这些基础能力后我就在后台做了一个表格模板设计器运营可以像画表格一样设计好表头然后设定哪几列是可填写的。这个配置最终决定了前端渲染出来的页面长什么样以及接下来的权限逻辑怎么写。3. 让用户填写需要的单元格其他格子保持只读3.1 官方保护机制工作表保护与单元格锁定这是我实现用户可以填的格子就那些其他格子谁都别想改需求的核心章节。univer内置了类似Excel的保护机制支持对工作表设置保护也支持对单个单元格锁定。两者的关系我们需要先理清楚单元格有一个属性叫锁定默认是false还是true取决于引擎的设计。在Excel中单元格默认是锁定的但如果工作表未开启保护锁定了也没用。工作表保护开启后锁定的单元格就不允许用户编辑了。univer的做法类似但API表达略有不同。我们需要两步走第一步把可填写区域的单元格锁定状态设为false其他单元格设为true。第二步对整张工作簿或工作表开启保护。这样只有我们显式标记为不锁定的单元格可以编辑其余全部只读。在实际编码时我封装了一个函数来应用可编辑白名单区域import { UniverInstanceType } from univerjs/core; export function setEditableRange(workbook, sheetId, editableRanges) { // 假设 workbook 为当前 univer 实例获取到的 workbook const sheet workbook.getSheetBySheetId(sheetId); const maxRow sheet.getRowCount(); const maxCol sheet.getColumnCount(); // 第一步全部单元格设置为锁定并关闭锁定的样式效果 sheet.getRange(0, 0, maxRow, maxCol).setLocked(true); // 第二步对可编辑区域设置为非锁定 editableRanges.forEach(({ startRow, startCol, endRow, endCol }) { sheet.getRange(startRow, startCol, endRow, endCol).setLocked(false); }); // 第三步开启工作簿保护 workbook.enableProtection(); }这里有个小细节默认单元格的样式可能没有锁定这层含义所以设置locked时同时要注意单元格的样式状态是否需要刷新。我当时第一次写完发现不生效排查后才知道需要主动调用一次UI刷新或者通过sheet.getRange().setLocked()会返回对应的command我们需要把command分发到引擎里不能直接改数据。建议你直接使用univer提供的CommandService来派发设置锁定的命令而不是绕开数据流直接set。我在项目里的做法是引用univerjs/sheets的SetRangeValuesCommand用Command的方式修改单元格的锁定属性。这样引擎才能完整地感知变化UI也会同步更新。3.2 通过配置实现白名单可编辑有了基础锁定函数接下来就是把它和后台配置接起来。我们的数据模型大概是每个模板包含一个editableAreas数组里面存的是类似[{ sheetId: xxx, startRow: 1, startCol: 1, endRow: 10, endCol: 2 }]这样的区域。加载模板时把区域传入上面的setEditableRange就可以精确控制。这种方案最让我满意的一点是它天然支持非连续区域。比如运营让用户填写B2、C2、D2三个单元格但E列是只能看的备注而F列是系统自动计算的结果不允许人改。我们可以定义两个白名单区域一次调用搞定。另外univer对保护开启后的交互也有细节处理只读单元格选中后工具栏里的编辑删除等操作会被禁用或忽略但用户仍然可以选中、复制、查看公式结果。这对于数据展示类的场景很实用——用户能自由阅读整张表只是改不了不该改的地方。如果你还想进一步限制用户不能选中特定区域就要另辟蹊径了比如监听SelectionChange事件一旦发现选中的范围超出了白名单就用setSelection强制拽回某个合法区域。这个逻辑我后来只在少数严格场景下用了因为大多数用户不会故意去选只读区域他们只是填写的角色。3.3 动态控制权限根据用户身份切换可编辑范围我的项目里还有另一层需求同一张表不同身份的人看到的可编辑区域不同。比如填写人只能编辑自己的周报内容而管理者可以编辑所有人或者管理者可以修改表头。univer的数据流是响应式的所以我可以非常方便地在用户切换角色时重新执行setEditableRange。具体做法是每个用户登录后后端返回当前用户在该模板下的权限范围前端只把对应范围传给setEditableRange然后调用一次工作簿保护刷新。这里有几个要注意的点如果当前用户拥有整张表的编辑权限就不要开启保护或者把editableRanges设为全部单元格否则一些用户操作如插入行会被误伤。如果多个协作者同时在线需要约定保护状态以谁为准我的建议是把可编辑区域作为模板级配置而不是某个会话级的临时配置这样大家看到的规则是统一的。动态切换时如果用户正在编辑的单元格突然变成了只读可能会造成输入中断。所以最好在切换前做一次输入校验确保没有未提交的修改。我在实际项目中用了简单的用户角色方案访客、填写者、管理员。访客不能编辑任何单元格填写者能编辑白名单区域管理员可以编辑所有区域并且可以修改模板结构。角色的变化通过applyEditablePermission()函数统一处理这样权限逻辑只收敛在一个函数里排查问题非常方便。4. 实际业务场景中的细节设计与联动4.1 用表格做收集表单校验、提交、重置表格并不是表单用户填完数据后我们得想办法把数据取出来同时做基本的校验。我最初的设想是监听cellChanged事件每次编辑都同步到前端状态。但这样容易产生大量的中间状态反而麻烦。后来改成提交时统一读取的模式。univer提供了获取当前sheet数据快照的API我写了一个getFormData函数遍历所有可编辑区域取出每个单元格的值。因为白名单区域是已知的所以我不需要遍历全表只用遍历用户有权编辑的那些区域。每个单元格的ID我用row:col来命名形成一个扁平的对象const formData {}; editableRanges.forEach(({ startRow, startCol, endRow, endCol }) { for (let r startRow; r endRow; r) { for (let c startCol; c endCol; c) { const value sheet.getCell(r, c).getValue(); formData[${r}:${c}] value ?? ; } } });校验逻辑也挂在这个函数后面。比如某列要求必填某列要求是数字某列要求是手机号形状。遇到不满足时我们用univer的RangeSelector把焦点定位到对应单元格并且用样式高亮标红然后提示用户具体哪里有问题。这个体验很像表格里的数据有效性校验但完全由我们控制更灵活。这里要提醒一句univer的单元格值拿到手后可能包含富文本对象并不是所有时候都是字符串。我建议统一用cell.getValue()如果返回的是对象就取它的text或value字段。不同版本可能略有差异需要看对应版本的接口说明。4.2 与后端交互保存、回填、协作保存数据没有太多神秘的地方。当用户点击提交时前端组装好formDataPOST到后端接口后端把这份数据作为一条记录存起来。如果运营想查看多条记录可以在后台打开一个管理页面用同一份模板但把数据源切换为某条记录。回填则是相反过程后端返回这条记录的数据前端在初始化workbook时直接把这些值填到对应单元格里。由于univer的整个数据快照都支持序列化所以我可以把一整份记录直接映射为新sheet的cellData。但要注意如果后端返回的字段与模板中的单元格并不是一一对应需要先做一次映射。协作方面univer官方有一些协作能力但我没有用到。因为我们这个场景本质上是多个用户填各自的表格副本而不是多个人同时编辑同一张表。如果你确实需要多人实时协作可以去了解univer的协作方案但要做好存储、消息同步、冲突处理等一系列工程化准备复杂度会上升一个量级。4.3 扩展利用univer的插件能力定制按钮和事件用户填写完数据后我们得给他一个提交按钮。我不想把这个按钮放在表格外面那样会破坏填写的沉浸感。univer的UI模块允许我们往工具栏注入自定义按钮也可以注册右键菜单项。我选择了在工具栏右侧加一个提交按钮。通过univer的插件机制我可以注册一个MenuItem然后绑定到自己的command上。这个command执行时会触发上面的校验、组装、提交动作。无论用户修改了哪些单元格最终提交时都从当前表格中读取数据不会丢失。另一个有用的扩展点是提交成功后的重置。用户填完提交后我们可能会希望清空可编辑区域的内容保留表头和公式。我封装了一个resetEditableAreas函数遍历白名单区域并清空值。同时用setRangeFormattedValues把样式恢复成初始状态防止上次提交的残留样式影响下一遍输入。这些自定义能力如果不用插件机制而是直接改univer内部代码会非常脆弱。所以我建议所有扩展都尽量走官方暴露的API和事件通道。univer的命令系统跟redux有点像所有操作都要派发command这让行为可追踪、可撤销但也需要时间去适应。5. 集成univer时踩过的坑与优化建议5.1 按需加载避免首屏体积过大univer全家桶加下来构建时体积可能会让你吓一跳。我第一次做包体分析发现主包达到1.2MB以上gzip后大约200KB对于一个小型产品页面来说这个代价偏高。好在我们有条件做代码分割。我的做法是把univer相关的依赖单独打成chunk通过动态import加载只有进入有表格的页面时才下载。这样首屏就不受影响。如果你还需要进一步压缩可以去掉暂时用不到的模块比如不启动公式功能就不引入univerjs/sheets-formula不需要评论功能就不引入评论区插件。univer的模块化设计允许我们做这种裁剪。另外univer在初始化时会创建一些Worker来做计算本地开发时没有明显感觉但在生产环境要确保Worker的静态资源路径配置正确。我遇到过部署后公式一直转圈的情况排查到最后是CDN路径没有设对。5.2 性能优化渲染大数据量时的取舍表格引擎最怕的是大表格。我在测试中用univer渲染了1000行、20列的表格初始化速度还在可接受范围但滚动时的流畅度会随着单元格样式复杂度下降。如果你只需要展示和填写白名单区域完全可以缩窄实际的工作表大小。我的建议是如果不是业务需要把表格的行列数控制在一个合理范围内。比如默认20行、10列而不是无限行列。univer虽然支持稀疏数据但越大的行列区域滚动渲染时的计算量就越高。如果你的产品确实需要大数据量表格那么可以把虚拟渲染开启univer默认就有虚拟滚动能力但对复杂合并单元格、边框样式的开销依然存在。最好做一次基准测试确定自己的性能红线。我在项目中采用了模板限制行数的办法比如最多200行超过后提示用户优化模板用实际数据换体验。5.3 版本和生态什么时候该自己维护源码univer的迭代速度很快我进入项目时使用的0.1.x版本一个多月后官方就发布了0.2.x接口变化不小。如果你在社区里搜到一些示例代码很可能是旧版本写法直接复制会报错。我的建议是以官方文档和对应版本的d.ts为准不要依赖旧文章。另外univer的生态插件还不像成熟商业产品那么多。官方有的UI能力我们可以直接享用但一些特殊交互比如合并单元格时自动跳过只读区域这类功能官方没有只能自己写。如果确实遇到阻塞问题可以考虑fork源码但那是下下策。更好的方式是通过univer的issue区提需求或者在社区里寻求协助。至少我在使用过程中社区响应速度还算积极常见问题能搜到答案。还有一个小提醒univer的许可证是Apache 2.0这意味着你可以自由商用但要保留版权声明。我一般会在README里注明使用了univer并在构建打包时保留许可证文件这样既合规也让团队知道我们依赖了哪个版本的引擎。5.4 若干实战心得最后分享几个我在实际开发中总结的经验。第一一定要在初始化univer之前准备好模板数据结构。如果你临时修改工作表结构比如插入行删除列很可能把已经设置的锁定期覆盖掉。我在后台模板设计器里是先把表单结构设计好再生成一份初始快照存到后端。前端渲染时严格使用这份快照避免中途改结构。第二保护功能开启后对于用户而言可编辑单元格的视觉提示非常重要。我把可编辑格子设置为白底、加细黑边框只读格子设置为浅灰底这样用户一眼就能看出来哪里能填。样式可以在模板配置时一并设定也可以在setEditableRange里统一应用。第三不要忘记处理粘贴行为。即使单元格是只读的浏览器自身的粘贴事件仍然可能把数据粘到受保护区域。我在监听beforePaste事件时做了拦截检查粘贴目标是否在白名单内。如果不在就取消粘贴并给出提示。这一点容易被忽略但用户一旦发现能粘贴进去就会认为系统有漏洞。第四版本升级时要格外小心。我升级到0.2.x后之前的setLocked写法变了需要改用新的API。在这方面我建议关注univer的CHANGELOG和迁移指南每次升级都在一个分支里先跑通核心用例再合入主开发线。现在我团队的这块功能已经上线稳定运行了小半年。从开发到交付univer整体给我最深的感受是它把表格引擎的能力开放出来了但把业务逻辑的控制权也交到了开发者手里。如果你手头正需要一套可在Web中自由定制的表格方案并且愿意花一点时间搞清楚它的数据结构univer是一个很值得投入的选择。至少对我来说它把那个用户自定义表格、指定可编辑单元格的需求从设想变成了可交付的现实。