ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

在线表格编辑实战:Excel导入导出、公式计算与数据落库

在线表格编辑实战:Excel导入导出、公式计算与数据落库

简介:面向需要在网页端查看、编辑并保存 Excel 数据的前端开发者与企业应用团队,这份示例包围绕 HTML 页面集成在线表格编辑能力展开,涉及表格控件初始化、单元格交互、样式主题切换以及浏览器兼容性处理。压缩包共 41 个文件,约 1.44MB,包含 1 个 HTML 入口页、7 个 JavaScript 脚本、17 个 CSS 样式、9 张 PNG 图片及字体、Web.config 等配置资源;脚本涵盖 jQuery、Bootstrap、FileSaver 与表格控件库,样式覆盖多套 Excel 风格主题,便于直接运行和按需改造。已有 5080 人学习。通过 InspectorSample 示例,读者可研究在线编辑器的页面组织、资源引用关系和配置方式,并据此理解接口调用、数据保存与旧版浏览器降级思路;入口页面、主题样式和脚本依赖关系较为完整,能帮助定位表格渲染、编辑状态维护与文件导出相关实现,适合作为企业表格模块的原型参考与二次开发起点。

1. 一个带合并单元格和公式的报价表,卡住的从来不是编辑器

运营每周丢过来一份带合并单元格、条件着色和公式的报价表,要求在浏览器里直接改,改完导出的文件还得跟原文件长得一样。这类需求落到 HTML 页面上,多数人第一反应是找一个「Excel 在线编辑」组件塞进 div,跑起来才发现问题不在编辑器本身。单元格矩阵和业务数据是两套结构,前端渲染和 xlsx 解析是两条链路,公式到底由前端算还是后端算,又是第三个决策点。这三件事一旦混着做,后面每加一个字段都要返工。

这里拆的是一条能直接复用的落地路径:开源表格引擎接管渲染,SheetJS 负责文件进出,一版快照加一版结构化字段把数据存住。适合已经在做后台系统、被 Excel 导入导出折腾过一轮的前后端。下面按「先跑起来、再对得上、最后存得住」的顺序走。

2. 选型与最小可跑:把表格引擎塞进一个 div

2.1 先定公式归属,再挑库

选型时最容易踩的误区是拿「功能列表」对比,比完发现每个库都能编辑单元格,都能设背景色。真正决定后期返工量的是两个问题:公式在哪一端计算,以及数据量上限是多少。

把常见的几类方案摊开看:

方案类型公式计算万行级表现协同能力接入成本适合场景
前端轻量表格库(Luckysheet 这类)前端内置引擎千行尚可,万行吃力需自建低,一个 div 起步内部系统,编辑为主
极简表格库(x-spreadsheet 这类)基本没有一般无低只读展示、简单录入
商业表格组件(Handsontable 这类)需外挂计算引擎好,虚拟滚动成熟商业版内建中,要处理授权结构化数据录入
新一代表格引擎(Univer 这类)有,插件化较好需自建中,工程化程度高新项目、要长期维护
服务端文档套件服务端算好内建高,要独立部署和运维要求完全兼容 Office 格式

判断标准很简单:如果业务表里公式是运营手写的、随时会改,那公式必须在能编辑的一端算,前端库更合适;如果公式是产品定义死的、只用来算合计和税额,那公式就该在后端算,前端只负责显示值,选个轻量库足够,还能省掉一大坨公式兼容问题。

我一般会先问一句「这张表里有没有人手写公式」,答案是有,就走前端引擎;答案是没有,直接上结构化表单,别用表格引擎硬撑。

提示:选型前自己去仓库看一眼最近的提交和 issue 响应情况,「曾经很火」和「现在能维护」是两件事。

2.2 起一个二十行跑起来的最小页面

以 Luckysheet 为例,它需要一个尺寸明确的容器,并且依赖的样式和脚本必须按固定顺序加载,顺序错了就是白屏或者工具栏图标全丢。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8" /> <title>在线报价单</title> <!-- 顺序不能乱:pluginsCss 必须在 luckysheet.css 之前,否则工具栏图标错位 --> <link rel="stylesheet" href="./luckysheet/plugins/css/pluginsCss.css" /> <link rel="stylesheet" href="./luckysheet/plugins/plugins.css" /> <link rel="stylesheet" href="./luckysheet/css/luckysheet.css" /> <link rel="stylesheet" href="./luckysheet/assets/iconfont/iconfont.css" /> </head> <body> <div id="ss" style="position:absolute; left:0; top:56px; width:100%; height:calc(100% - 56px);"></div> <!-- 依赖脚本先加载,主包后加载 --> <script src="./luckysheet/plugins/js/plugin.js"></script> <script src="./luckysheet/luckysheet.umd.js"></script> <script src="./app.js"></script> </body> </html>

容器用position:absolute配合calc(100% - 56px)而不是height:100%,是因为表格引擎初始化时要读取容器的实际像素高度去算可视行数,height:100%在父级没定高的情况下算出来是 0,结果就是一片空白——这个坑后面第 5 章还会提到。

初始化脚本:

// 一份最小的初始化数据:一张工作表,两行表头 const sheetData = [{ name: '报价单', // 工作表名,导出时会直接作为 sheet 名 index: 'sheet_01', // 唯一标识,多表切换靠它,不要用中文 order: 0, status: 1, // 1 表示当前激活的工作表 row: 200, // 预留行数,不是实际数据行数 column: 26, celldata: [ { r: 0, c: 0, v: { v: '物料', m: '物料', ct: { fa: 'General', t: 'g' } } }, { r: 0, c: 1, v: { v: '单价', m: '单价', ct: { fa: 'General', t: 'g' } } } ], config: {} }]; luckysheet.create({ container: 'ss', data: sheetData, lang: 'zh', showinfobar: false, // 顶部文件名栏,内部系统一般关掉 showtoolbar: true, enableAddRow: true, hook: { // 单元格编辑结束触发,用来打脏标记,供自动保存用 cellUpdated: (r, c, oldValue, newValue) => { markDirty(); }, // 工作表切换也打标记,保证多表场景不漏存 sheetActivate: () => { markDirty(); } } });

几个参数值得单独说。container是容器的 id,初始化后不要再动这个节点的显隐,重新display:none再显示出来会导致画布尺寸失准,需要手动luckysheet.resize()。celldata是稀疏数组,只写有值的格子,r是行号c是列号,都从 0 开始。每个格子的v对象里,v是真实值,m是显示值,ct.fa是数字格式串,ct.t是类型(g通用、n数字、s字符串、d日期)。真实值和显示值分开存是这套结构的关键设计,后面导入导出全靠它。

row和column只是可视区域预留,不改数据,写 200 行不代表有 200 行数据。hook.cellUpdated是保存逻辑的入口,先记着。

2.3 判断「渲染成功」的三个可观测点

页面出来之后别急着接业务,先确认三件事:document.querySelector('#ss canvas')能拿到画布节点;控制台没有Cannot read properties of null;手动改一个格子,luckysheet.getCellValue(0, 0)能返回新值。三条都过,说明引擎和容器都正常,可以往下走文件链路。三条里任何一条不过,问题基本都在容器尺寸或者脚本加载顺序上,跟数据无关。

3. 打通导入导出:SheetJS 读 xlsx 的四个关键参数

3.1 读文件:为什么必须开 cellDates

先装依赖,浏览器端直接用打包工具引:

npm i xlsx

读文件的核心代码:

import * as XLSX from 'xlsx'; async function importXlsx(file) { const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array', cellDates: true, // 日期序列号直接转 Date 对象,不开会拿到 45000 这种数字 cellNF: true, // 保留数字格式串,百分比和小数位靠它还原 cellStyles: true, // 读样式信息(合并、字体色),xlsx 里支持有限 dense: false // 稀疏模式,大文件内存友好但取值方式要跟着改 }); // 只取第一张表做演示,多表场景遍历 wb.SheetNames const ws = wb.Sheets[wb.SheetNames[0]]; return sheetToCelldata(ws); }

cellDates是这四个参数里最不能省的一个。Excel 内部把日期存成从 1900 年 1 月 1 日起的天数,不开这个开关,读到的是45000这样的整数,你把它写进表格,用户看到的就是一串莫名其妙的数字,排查半天才发现是日期。

cellNF决定你能不能还原0.00%这种格式。cellStyles在 xlsx 格式下只能拿到一部分样式,颜色和字体能读,行高列宽要靠ws['!cols']和ws['!rows']单独取。

3.2 单元格转换:优先用 w 而不是 v

从 SheetJS 的 worksheet 转成表格引擎的 celldata,有个细节决定用户第一眼的观感:

function sheetToCelldata(ws) { if (!ws || !ws['!ref']) return []; const range = XLSX.utils.decode_range(ws['!ref']); const celldata = []; for (let r = range.s.r; r <= range.e.r; r++) { for (let c = range.s.c; c <= range.e.c; c++) { const cell = ws[XLSX.utils.encode_cell({ r, c })]; if (!cell) continue; // 空格子不写,保持稀疏结构 // w 是格式化后的显示值,v 是原始值。 // 金额和百分比优先用 w,避免 0.30000000000000004 这种浮点尾巴露到界面上 const display = cell.w ?? String(cell.v); celldata.push({ r, c, v: { v: cell.t === 'n' ? cell.v : display, m: display, ct: { fa: cell.z || 'General', t: mapType(cell.t) } } }); } } return celldata; } // SheetJS 的类型码映射到表格引擎的 ct.t function mapType(t) { switch (t) { case 'n': return 'n'; // 数字 case 'd': return 'd'; // 日期 case 'b': return 'b'; // 布尔 default: return 'g'; // 其余按通用处理 } }

这里的取舍是:数字类型保留原始值参与后续计算,展示用w;非数字类型直接用展示值。这样导进来用户看到的就是原来文件里的样子,不会出现小数位全变、百分比变成 0.15 这类问题。

3.3 写文件:合并区域和列宽的映射

导出比导入更容易出错,因为 Excel 对文件结构很挑,!merges的结束行列写错一个就会弹「文件已损坏,是否修复」。

function celldataToWs(sheet) { const rows = []; // 不同版本返回的字段名不一致,data 和 celldata 都兜一下 const celldata = sheet.celldata ?? sheet.data ?? []; for (const item of celldata) { const { r, c, v } = item; if (!v) continue; rows[r] = rows[r] || []; rows[r][c] = v.m ?? v.v ?? ''; } const ws = XLSX.utils.aoa_to_sheet(rows); ws['!merges'] = toMerges(sheet.config); ws['!cols'] = toCols(sheet.config); ws['!rows'] = toRows(sheet.config); return ws; } function toMerges(config) { if (!config?.merge) return []; // 引擎里存的是左上角坐标加跨行跨列数,xlsx 要的是起止坐标 // 结束坐标必须减 1,不减就是「合并区域越界」,Excel 直接报损坏 return Object.values(config.merge).map(m => ({ s: { r: m.r, c: m.c }, e: { r: m.r + m.rs - 1, c: m.c + m.cs - 1 } })); } function toCols(config) { if (!config?.columnlen) return []; // 引擎里存的是像素,xlsx 认 wpx 字段 return Object.entries(config.columnlen).map(([_, w]) => ({ wpx: w })); } function toRows(config) { if (!config?.rowlen) return []; return Object.entries(config.rowlen).map(([_, h]) => ({ hpx: h })); }

导出动作本身一行就够:

function exportWorkbook() { const sheets = luckysheet.getAllSheets(); const wb = XLSX.utils.book_new(); for (const s of sheets) { const ws = celldataToWs(s); // sheet 名在 Excel 里有 31 字符上限,超了会静默截断或直接报错 const name = (s.name || 'Sheet1').slice(0, 31); XLSX.utils.book_append_sheet(wb, ws, name); } XLSX.writeFile(wb, '报价单.xlsx', { bookType: 'xlsx', compression: true }); }

compression: true在大表上能把文件压到原来的三分之一左右,代价是多花几十毫秒 CPU,一般值得开。

3.4 公式字段:f 和 v 要一起写

如果原表里有公式,SheetJS 读出来的 cell 会同时带f(公式串)和v(计算结果)。只写v,导出的文件里公式就变成了死值,运营改一个单价,合计不会动,第二天就会来找你。

表格引擎这边存公式用的是格子的f属性,转回去时补齐:

// 写入格子时,公式和值要同时给 if (cell.f) { entry.v.f = '=' + cell.f; // 注意加等号,两边约定不一样 }

反过来的坑是:两边公式语法不完全一致,函数名大小写、参数分隔符、跨表引用写法都可能有差异。所以我的做法是公式只在「导入 → 编辑 → 导出」这条链上透传,不参与业务计算;真要算金额合计,后端拿值重算一遍,别指望前端引擎和 Excel 算得一模一样。

4. 数据落库:单元格矩阵怎么变成业务表

4.1 整表快照和结构化字段,各存一份

导入进来的一坨 celldata 直接丢进数据库 json 字段,是最省事的做法,但两天后产品要「按物料统计本月报价总额」,你就会发现这个结构根本没法查。

两种存法的差别:

维度整表快照结构化字段
存储形态一个大 JSON 字段一行一条明细记录
还原格式完整,合并、颜色、列宽都在会丢格式
查询统计基本没法查,要全表捞出来在内存算直接 SQL 聚合
写入成本一次写一条一次写 N 条,要事务
适合格式即业务(如排版类报表)数据即业务(如报价、库存)

实际项目里我一般两份都存:主表存结构化明细,负责查询和统计;附表存一份快照,负责「打开时长得跟上次一样」。快照只在保存时整份覆盖,不做增量,逻辑简单,出错也好回滚。

4.2 自动保存:防抖加版本号乐观锁

表格编辑是高频操作,不能每改一个格子就发一次请求。防抖之外还得处理并发覆盖,两个人同时打开一份表,后保存的会把前一个人的改动整个冲掉。

let saveTimer = null; let dirty = false; let version = 0; // 打开文档时从接口拿到,每次成功保存后 +1 function markDirty() { dirty = true; clearTimeout(saveTimer); // 800ms 是试出来的:比这个短,连续输入时会打出大量请求;比这个长,用户切走时容易丢最后一段 saveTimer = setTimeout(save, 800); } async function save() { if (!dirty) return; const payload = { docId: currentDocId, version, // 带上版本号,服务端比对 sheets: luckysheet.getAllSheets().map(s => ({ name: s.name, celldata: s.celldata ?? s.data, // 字段名兜底,见 3.3 的说明 config: s.config })) }; const res = await fetch('/api/sheet/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.status === 409) { // 服务端返回 409 表示版本号对不上,说明有人先存了 dirty = false; alert('这份表已被其他人修改,请刷新后再编辑'); return; } version += 1; dirty = false; }

版本号这段是血泪经验。早期版本没加锁,两个运营同时改同一份排班表,各自看到「保存成功」,回到列表页发现只剩一个人的数据,然后就是一轮扯皮。乐观锁的成本极低,服务端一条update ... where version = ?就够,返回影响行数 0 就抛 409。

4.3 多人实时编辑的最小可行版本

真要实时协同,方案从轻到重排:单元格级锁定、轮询增量、WebSocket 广播操作。

内部系统里我一般只做到第二档。打开文档时给每个格子加一个占用标记,用户点进某个格子时发一个lock请求,其他人再点同一个格子会收到提示「该单元格正在被编辑」。这个方案不需要 OT 或者 CRDT,实现量小,也不会出现「两个人的光标互相打架」这种需要专门解释的现象。

锁的粒度按需求定。按整行锁适合排班表,按单元格锁适合报价单,按整表锁适合导出前的定稿阶段。锁要有超时,用户关掉页面不释放是最常见的情况,服务端记一个lockedAt,超过 5 分钟自动放开。

5. 避坑排查:五个现场翻过的车

5.1 初始化与渲染

现象一:页面打开是白的一片,控制台没报错。

原因有两个。一是容器高度算出来是 0,引擎读不到可视尺寸就什么都不画;二是luckysheet.create在 DOM 还没挂载完就调用了,容器节点还不存在。

解决方式:容器用position:absolute加明确的height,不要指望百分比自动撑开。初始化放在DOMContentLoaded之后,或者用requestAnimationFrame推迟一帧。如果容器是从隐藏状态切换显示的(比如弹窗里嵌表格),显示后必须手动调一次luckysheet.resize(),让引擎重新量一遍尺寸。

现象二:表格出来了,但工具栏图标全是方块,或者工具栏压根不显示。

原因基本是样式加载顺序错了。这套引擎的样式分三层:插件样式、插件扩展样式、主样式,主样式必须最后加载,图标字体单独一份也不能漏。

解决方式:照 2.2 里的顺序引样式,别自己合并 CSS 文件,也别用构建工具做 CSS 抽取把顺序打乱。打包配置里给这几份样式加sideEffects白名单,否则可能被 tree-shaking 掉。

5.2 文件读写

现象三:导入后所有日期变成 45000 这样的整数,百分比变成 0.15。

原因:读文件的参数没开cellDates,日期没被转成 Date;同时写入格子时用了原始值v而不是显示值w,格式串也没带过来。

解决方式:三个地方一起改。XLSX.read加cellDates: true和cellNF: true;格子的m字段用cell.w;ct.fa用cell.z填进去。改完再验证一次,导入后随便点一个日期格子看编辑栏里显示的是什么。

现象四:导出的文件 Excel 打开提示「发现部分内容有问题,是否修复」,点修复后格式全乱。

原因几乎都在!merges。结束坐标没减 1、区域之间互相重叠、合并区域超出!ref声明的范围,这三种任意一种都会触发。

解决方式:写一个校验函数,在writeFile之前把 merges 全过一遍,检查起始行列小于等于结束行列、任意两个区域不相交、所有坐标不超范围。这类结构性错误一定要在浏览器里拦住,等用户下载下来才发现就晚了。

现象五:几千行的表导出要转十几秒,页面直接卡住不动。

原因:XLSX.writeFile是同步的,几万个格子拼字符串会占满主线程,浏览器渲染全停。

解决方式:超过 5000 行就切到XLSX.write拿到 ArrayBuffer,再交给 Worker 或者用Blob下载,主线程只负责发请求和收结果。同时把compression关掉,压缩在 JS 里是纯 CPU 活,对大文件来说省下的那点传输体积不值这个延迟。

5.3 保存与并发

现象六:用户改完直接关标签页,最后几个格子的修改没保存。

原因:防抖的 800ms 还没到,请求没发出去。beforeunload里发异步请求大多数情况下不可靠。

解决方式:监听visibilitychange,页面切到后台时立即 flush 一次;beforeunload里如果dirty为真,弹一个确认框拦住,别指望sendBeacon能保证送达。更稳的做法是保存时在localStorage留一份草稿,下次打开时对比服务端版本,提示「有一次未提交的修改,是否恢复」。这个后悔药花不了多少代码,能省掉大量「我明明改了」的争论。

6. 进阶:把校验放到导出之前

表格引擎给的是自由编辑,业务字段有约束,两者之间需要一层拦截。我在导出和保存这两个动作之前都会跑一遍结构校验,把问题拦在浏览器里,而不是等用户打开 Excel 才发现第 37 行的金额对不上。

// 导出前跑一遍:合计校验 + 必填校验 function validateSheet(sheet) { const errors = []; const celldata = sheet.celldata ?? sheet.data ?? []; // 建一个坐标索引,避免在循环里反复 find const index = new Map(); for (const item of celldata) index.set(`${item.r}_${item.c}`, item.v); const cellOf = (r, c) => index.get(`${r}_${c}`); const numOf = (r, c) => { const raw = cellOf(r, c)?.v; const n = Number(raw); return Number.isFinite(n) ? n : NaN; }; // 数据行从第 2 行开始(下标 1),最多校验 200 行 for (let r = 1; r < 200; r++) { const qty = numOf(r, 2); const price = numOf(r, 3); const total = numOf(r, 4); if (Number.isNaN(qty) && Number.isNaN(price)) continue; // 整行空,跳过 if (Number.isNaN(qty) || Number.isNaN(price)) { errors.push(`第 ${r + 1} 行:数量或单价为空`); continue; } // 金额允许一分钱误差,浮点乘法不保证精确 if (Math.abs(qty * price - total) > 0.01) { errors.push(`第 ${r + 1} 行:金额 ${total} 与数量×单价 ${(qty * price).toFixed(2)} 不一致`); } } return errors; }

0.01这个容差不是随手写的。JS 里0.1 * 3得到0.30000000000000004,不留容差会在一堆正常数据上报错。行业里金额一般用整数分做计算,但用户手填的表不可能要求他按分填,所以校验环节放宽到一分,真正入库时再统一转成整数分。

调用位置放在导出按钮的点击回调最前面:

document.querySelector('#btn-export').addEventListener('click', () => { const errors = luckysheet.getAllSheets().flatMap(s => validateSheet(s)); if (errors.length) { // 这里不要用 alert 堆一大坨,取前三条提示即可 alert('发现 ' + errors.length + ' 处问题:\n' + errors.slice(0, 3).join('\n')); return; } exportWorkbook(); });

再往上一层是服务端复算。前端校验只是体验优化,绕过去太容易,接口收到数据后必须用同一套规则再算一遍,前后端共用一份规则定义(金额容差、必填列、数量上限)最省事,改规则只改一处。

最后一句话是我自己的习惯:每次接这类需求,先不写业务代码,先拿一份真实的、脏的、带合并单元格和手写公式的表导进去,走一遍导入、改三个格子、导出、用 Excel 打开看有没有修复提示。这四步过了,再谈功能。从那以后我每次上线前都强制走一遍这个流程,宁可前期慢半天,也不想半夜被「文件打不开」叫起来。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进