简介:面向需要在网页中直接查看、编辑和保存 Excel 数据的前端开发者与企业应用团队,这份资料围绕 HTML 页面集成 Excel 在线编辑展开,兼顾基础功能、浏览器兼容性与示例工程研究。压缩包共 41 个文件,约 1.44MB,以 17 个 CSS、7 个 JavaScript 和 9 个 PNG 为主,另有 HTML 入口、配置文件及字体资源,目录按页面、脚本、样式和图片分层,便于检索与拆解。已有 5080 人学习,说明该场景关注度较高。示例工程呈现了表格编辑器的界面组织、功能区与表格样式切换、文件保存等实现思路,并涉及旧版浏览器兼容与降级处理;其中还包含条件格式、边框与颜色选择、表格格式及多套主题样式等界面模块,能帮助观察编辑器工具栏和样式层的组织方式。读者可据此理解在线表格的交互流程、组件配置和前后端衔接方式,作为企业级 Web 表格功能原型的参考。
1. 从「下载改完再上传」到网页里直接改:这个 Excel 在线编辑项目解决了什么问题
月底对账那几天,业务同事的操作路径是这样的:从系统里导出报表,在本地改三列数字,另存成「v7_final_真的最终版.xlsx」,再传回系统。整套流程里真正需要人判断的只有那三列,剩下的全是搬运。我手上这个「在 HTML 页面里做 Excel 在线编辑」的模拟项目,干的就是把这段搬运砍掉——页面打开就是一张可编辑的表格,公式、合并单元格、拖拽行列、右键菜单都在,改完点保存,工作簿的 JSON 直接落到服务端。它适合两类人:一是要在后台系统里塞报表录入、配置表维护的前端,二是想搞清楚在线表格底层数据结构长什么样的后端。这个项目不追求把桌面版 Excel 一比一复刻,只追求「够用且能改」——这一点在后面每一章的取舍里都会反复出现。
2. 架构与选型:三条落地路线,我为什么选「开源表格引擎 + 自有后端」
2.1 三条路线的取舍:iframe 套件、开源引擎、自研 Canvas
如果需求是「在网页里有一张能改的表格,覆盖 Excel 的日常操作」,不要自己从零写,也不要一上来就部署一整套文档服务。我把能走的路归成三条,代价差别很大。
第一条是嵌入一套成品文档服务,前端只是一个 iframe。好处是 Word、PPT、Excel 全都有,公式覆盖接近桌面版,连打印排版都不用操心。代价是你要多维护一个服务和一套存储,前端能做的事被它的对外接口卡死,想在单元格上盖个业务徽标、或者把某一列替换成下拉选择器,基本没戏。
第二条是引入一个开源表格引擎,前端一个 JS 包,数据是纯 JSON,后端自己配。日常操作里八成左右它能覆盖:公式、合并、冻结、筛选、条件格式、复制粘贴、导入导出都有,剩下两成靠二次开发补。这个模拟项目走的就是这条。
第三条是自研 Canvas 表格。只有当你需要极致的虚拟滚动、或者表格交互本身就是产品卖点时才值得,否则光是公式解析和粘贴板兼容就能吃掉两个月。
| 路线 | 典型做法 | 上手成本 | 功能覆盖 | 主要代价 |
|---|---|---|---|---|
| 嵌入成品文档服务 | 独立部署文档服务,前端 iframe 挂 URL | 中 | 接近桌面版 | 多一套服务要运维,二次开发受接口限制 |
| 开源表格引擎 | 引入前端表格库,自己写数据层和后端 | 低 | 日常操作约八成 | 公式和超大表格有上限,需自己补业务能力 |
| 自研 Canvas 表格 | 自己画格子、自己做选区 | 高 | 看投入 | 合并、公式、粘贴要长期投入 |
选第二条的核心理由是数据可控。引擎的整份工作簿就是一个能直接JSON.stringify的普通对象,里面是每张 sheet 的celldata、config和行列信息。这意味着保存、比对、回滚、写审计日志,全都在我熟悉的后端里完成,不用去啃别人的二进制格式。换个引擎,数据结构大同小异,迁移成本可控。
提示:这类前端引擎在几千行、几十列以内很舒服,上万行就要开始做分片加载或者只读预览。选型前先拿真实数据量压一遍,比看功能列表靠谱。
2.2 目录结构:一个能跑的最小工程长什么样
工程刻意做得很薄,一共四层:页面、编辑器封装、格式适配、后端存储。分层的目的不是好看,而是把「换引擎」和「换存储」这两件事隔离开——adapter.js是唯一知道 xlsx 长什么样的文件,editor.js是唯一知道引擎 API 长什么样的文件,两者互不引用。
excel-online-demo/ ├── public/ │ ├── index.html # 唯一入口页,只负责挂载容器和引入资源 │ ├── js/ │ │ ├── editor.js # 初始化、钩子、防抖自动保存 │ │ ├── adapter.js # xlsx <-> 引擎 JSON 的双向转换,唯一格式边界 │ │ └── api.js # 后端接口封装,统一处理 409 和 5xx │ ├── css/app.css │ └── vendor/ │ ├── sheet/ # 表格引擎的静态资源(css/js/字体) │ └── xlsx/ # SheetJS,只在前端做解析和导出 ├── server/ │ ├── app.js # express 入口,静态目录 + 路由 │ ├── routes/workbook.js # 读、存、导入、导出四个接口 │ └── storage/ # demo 用内存 Map,生产换数据库或对象存储 ├── package.json └── README.mdvendor目录不建议走 CDN。表格引擎的字体图标和插件 css 是分文件引用的,一旦某个文件 404,页面不会报错,只会看到一堆空白方块,排查起来非常费劲。本地放一份,版本固定,是最省心的做法。
adapter.js单独成文件是有血泪教训的。最初我把格式转换写在editor.js里,后来想从引擎 A 换成引擎 B,发现转换逻辑和初始化逻辑缠在一起,改一处崩三处。抽出来之后,换引擎只需要重写editor.js里的四个钩子,adapter输出的celldata结构不动,后端完全无感。
storage目录放的是存储实现。demo 阶段用一个Map就够了,键是工作簿 id,值是{id, name, sheets, version, updatedAt}。上生产换成关系库时,把sheets整体序列化成 JSON 存一个字段,别急着拆成单元格表——按单元格存会让一次保存变成几千条 update,而且合并、行高列宽这些 sheet 级配置根本没法塞进单元格表里。
2.3 数据流:一份工作簿在前后端之间怎么走
打开一个工作簿的完整链路是这样的:浏览器请求GET /api/workbook/:id,服务端返回{id, name, sheets, version};sheets直接塞进引擎的data参数,页面渲染出表格。用户改一个格子,引擎内部更新celldata,钩子触发,前端把整份sheets防抖后PUT回去;服务端比对version,一致就 +1 落库,不一致返回 409,前端提示「有人刚改过,是否刷新」。
| 接口 | 方法 | 入参 | 返回 | 说明 |
|---|---|---|---|---|
/api/workbook/:id | GET | 路径参数 id | {id, name, sheets, version} | 首屏加载,直接喂给引擎 |
/api/workbook/:id | PUT | {sheets, baseVersion} | {version} | 整份覆盖写,带乐观锁 |
/api/workbook/:id/export | POST | {sheets} | xlsx 二进制流 | 服务端生成,保证与落库数据一致 |
/api/workbook/import | POST | multipart 文件 | {sheets} | 上传解析后回填编辑器,用户确认再存 |
导出单独走服务端而不是在前端直接XLSX.writeFile,是因为导出的表格往往要套模板:加表头、加签名行、改列宽、把某些列锁成文本格式。这些逻辑放在服务端,前端只负责把当前sheets发过去,两边永远对得上。前端的 SheetJS 仍然保留,但只用于「导入时解析」和「临时下载预览」两个场景。
version字段是整个数据流里最容易被砍掉、又最容易出事的字段。没有它,两个人同时打开同一张表,后保存的那个会把先保存的整体覆盖,而且完全静默——用户只会觉得「我明明改了啊」。整表覆盖写的方案里,乐观锁是唯一成本极低、又能兜住这个问题的补丁。
3. 页面接入:把一张可编辑的 Excel 塞进 HTML 的最小闭环
3.1 容器与初始化参数:create 里每个字段都要知道为什么
接入的第一步不是写配置,是把容器撑开。表格引擎普遍需要一个有确定宽高的 DOM 节点,父级是display:none或者高度为 0 的 flex 项时,初始化会静默失败,控制台一条报错都没有,页面就是一块白的。我一般直接把容器设成绝对定位铺满:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8" /> <link rel="stylesheet" href="/vendor/sheet/plugins/css/pluginsCss.css" /> <link rel="stylesheet" href="/vendor/sheet/css/sheet.css" /> <link rel="stylesheet" href="/vendor/sheet/assets/iconfont/iconfont.css" /> </head> <body> <!-- 关键:容器必须有真实宽高,inset:0 比 height:100% 更稳 --> <div id="sheet" style="position:absolute; inset:0;"></div> <script src="/vendor/sheet/plugins/js/plugin.js"></script> <script src="/vendor/sheet/sheet.umd.js"></script> <script src="/vendor/xlsx/xlsx.full.min.js"></script> <script type="module" src="/js/editor.js"></script> </body> </html>// public/js/editor.js import { loadWorkbook, saveWorkbook } from './api.js'; export async function mount(workbookId) { const { sheets, version } = await loadWorkbook(workbookId); window.sheetEngine.create({ container: 'sheet', // 注意是 DOM 的 id 字符串,不是元素本身,传错不报错 data: sheets, // 工作簿数组,每项对应一张 sheet lang: 'zh', showinfobar: false, // 顶部文件名信息条,业务系统一般已有面包屑 showtoolbar: true, showstatisticBar: false, // 底部统计条,关掉能省一次重绘 enableAddRow: true, enableAddBackTop: false, row: 60, // 新 sheet 默认行数,给太大会首屏卡顿 column: 20, autoFormatw: false, // 关掉「长数字自动转科学计数法」,订单号必备 allowEdit: true, hook: buildHooks(workbookId) }); window.__wbVersion = version; // demo 放全局,正式工程放模块作用域 }container传 id 字符串而不是document.getElementById的结果,是这类库的常见约定,传错元素对象不会抛异常,只会渲染不出来。autoFormatw一定要关:开着的时候,用户输入 18 位订单号,回车之后变成1.23E+15,再保存就永久丢精度了,这种数据事故回头找都找不回来。
row和column只影响「新建 sheet」的默认尺寸。首次渲染的尺寸由data里每张 sheet 自带的row、column决定,所以导入大表的时候要顺手把这两个值设成实际行数加一点余量,否则用户滚到第 61 行就滚不动了,会以为数据丢了。
3.2 工具栏与右键菜单:把用不上的按钮关掉
默认工具栏塞了将近六十个按钮,图表、批注、数据透视、截图这些,在一个内部报表系统里 90% 用不上,留着只会让用户点出问题来问你。配置的思路是「先全关,再按需开」,而不是反过来。
const toolbarConfig = { showtoolbarConfig: { undoRedo: true, paintFormat: true, currencyFormat: false, percentageFormat: false, // 财务格式走单元格预设,不让用户乱点 font: true, fontSize: true, bold: true, italic: true, underline: true, textColor: true, fillColor: true, border: true, mergeCell: true, horizontalAlignMode: true, verticalAlignMode: true, textWrapMode: true, freezeMode: true, filter: true, sortAndFilter: true, image: false, link: false, chart: false, postil: false, pivotTable: false, screenshot: false, splitColumn: false }, cellRightClickConfig: { copy: true, paste: true, insertRow: true, insertColumn: true, deleteRow: true, deleteColumn: true, hideRow: true, hideColumn: true, clear: true, matrix: false, sort: false, filter: false, image: false, chart: false }, // 自定义按钮:字符串形式的 HTML,会被插到工具栏右侧 functionButton: '<button id="btn-export" class="tool-btn">导出 Excel</button>' };这几个配置项要跟create的其它参数合并传入。functionButton接受的是 HTML 字符串,意味着按钮是后来插入的,直接document.getElementById('btn-export').onclick在初始化那一刻可能拿不到元素。稳妥写法是用事件委托挂到document上:
document.addEventListener('click', async (e) => { if (!e.target.closest('#btn-export')) return; const sheets = window.sheetEngine.getAllSheets(); // 走服务端导出,模板和样式在服务端统一处理 const resp = await fetch(`/api/workbook/${workbookId}/export`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sheets }) }); const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '导出.xlsx'; a.click(); URL.revokeObjectURL(url); // 不释放会一直占内存,长时间开着页面尤其明显 });clear这个右键项留着,但它的语义是「清除内容和格式」,用户很容易误点把整块数据抹掉。要么关掉,要么在钩子里监听并弹一次确认。后者麻烦一点,但内部系统里用户操作失误的概率远高于你的想象。
3.3 钩子函数:编辑、选区、保存三个关键事件
钩子是整个接入里最值钱的部分。表格引擎本身不知道什么是「保存」,你得从它的状态变化里自己判断。这个项目只关心三类事件:
let saveTimer = null; const dirty = new Set(); // 记录哪些 sheet 被改动过,导出时只发这些也行 function buildHooks(workbookId) { return { // 首次渲染完成,适合做只读区域着色、列宽修正 workbookCreateAfter() { markReadonlyColumns(); }, // 单元格内容变化,输入回车或失焦时触发 cellUpdated(r, c, oldValue, newValue) { dirty.add(currentSheetIndex()); scheduleSave(); }, // 粗粒度变更:增删行列、合并、切换 sheet 都会走这里 updated() { scheduleSave(); }, // 选区变化,用来更新右下角「已选 3 行 5 列」 rangeSelect(r1, c1, r2, c2) { updateStatusBar(r1, c1, r2, c2); } }; } function scheduleSave() { clearTimeout(saveTimer); saveTimer = setTimeout(async () => { // getAllSheets 返回的是内部引用,直接发出去可能被后续编辑改掉 const sheets = JSON.parse(JSON.stringify(window.sheetEngine.getAllSheets())); try { const { version } = await saveWorkbook(workbookId, sheets, window.__wbVersion); window.__wbVersion = version; toast('已保存'); } catch (err) { if (err.status === 409) toast('有人刚改过,刷新后再改'); else toast('保存失败,稍后重试'); } }, 800); }cellUpdated只覆盖「手动输入」这一种路径。用户从别处复制一整块粘进来、拖拽填充、删除行,走的都是updated。只监听cellUpdated是新手最常犯的错——测试的时候手打数据一切正常,上线第一天就有人粘贴了五十行,然后发现没保存上。
getAllSheets()返回的是引擎内部对象,序列化一份再发出去,是为了避免请求还在飞的时候用户继续编辑,把正在序列化的对象改掉。这个概率不高,但一旦发生就是「保存的数据和界面显示不一致」,属于最难受的那类 bug。
注意:800ms 防抖是经验值。太小会在连续输入时打出几十个请求,太大会让用户关页面时丢掉最后一段修改。稳妥做法是防抖之外再加一个
beforeunload里的同步sendBeacon兜底。
4. 导入导出:xlsx 与编辑器 JSON 的双向转换
4.1 上传 xlsx 转成引擎认识的 celldata
格式转换是这类项目里唯一必须精确的地方。引擎的单元格模型大致是{r, c, v: {v, m, ct}}——v是原始值,m是显示文本,ct是{fa: 格式串, t: 类型}。SheetJS 给你的是一张按 A1 引用索引的对象,中间这一层转换写不好,格式会大面积丢失。
// public/js/adapter.js import * as XLSX from 'xlsx'; export function workbookToSheets(arrayBuffer) { // cellDates 让日期变成 Date 对象,cellNF 保留数字格式串(就是那个 z) const wb = XLSX.read(arrayBuffer, { type: 'array', cellDates: true, cellNF: true }); return wb.SheetNames.map((name, index) => { const ws = wb.Sheets[name]; // header:1 输出二维数组,raw:false 让我们拿到格式化后的显示值 const aoa = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: '' }); const celldata = []; aoa.forEach((row, r) => { row.forEach((display, c) => { if (display === '' || display == null) return; const addr = XLSX.utils.encode_cell({ r, c }); const cell = ws[addr]; if (!cell) return; celldata.push({ r, c, v: { v: cell.v, // 原始值,公式计算靠它 m: String(display), // 显示值,先给个安全的字符串 ct: { fa: cell.z || 'General', // 数字格式串,丢了千分位就没了 t: cell.t === 'n' ? 'n' : cell.t === 'b' ? 'b' : 's' } } }); }); }); // 合并单元格:xlsx 是 {s:{r,c}, e:{r,c}},引擎要 {r, c, rs, cs} const merge = (ws['!merges'] || []).reduce((acc, m) => { acc[`${m.s.r}_${m.s.c}`] = { r: m.s.r, c: m.s.c, rs: m.e.r - m.s.r + 1, cs: m.e.c - m.s.c + 1 }; return acc; }, {}); return { name, index, order: index, status: index === 0 ? 1 : 0, // 只有第一张表是激活态 celldata, config: { merge, rowlen: {}, columnlen: {}, borderInfo: [] }, row: Math.max(aoa.length + 20, 60), column: Math.max(...aoa.map((r) => r.length), 20) }; }); }ct.fa这一行是不能省的。用户看到的1,234.50和12.5%,底层存的都是同一个数字,区别全在格式串上。导入时把cell.z丢掉,表格会显示成一堆裸数字,用户第一反应就是「你把我的格式弄没了」。
日期是第二个坑。SheetJS 的cellDates:true会把日期单元格变成 JSDate对象,引擎拿到这个对象不知道怎么办,常见表现是显示成Mon Jan 01 2024...这种长串。我一般的做法是在转换时判断cell.t === 'd',用XLSX.SSF.format(cell.z || 'yyyy-mm-dd', cell.v)先算出一个显示串填进m,同时保留原始的v和z。
config.merge的 key 用行_列拼接,是因为引擎内部按这个规则查合并区域,随便换个格式(比如r-c)会导致合并死活不生效,而控制台毫无提示。
4.2 从编辑器数据导出 xlsx
导出是反向操作,但有三个细节和导入不对称:列宽单位、sheet 名长度、空单元格。
export function sheetsToXlsx(sheets, filename = '导出.xlsx') { const wb = XLSX.utils.book_new(); sheets.forEach((sheet) => { // 先把稀疏的 celldata 摊平成二维数组 const aoa = []; (sheet.celldata || []).forEach(({ r, c, v }) => { aoa[r] = aoa[r] || []; aoa[r][c] = v?.m ?? v?.v ?? ''; // 优先给显示值,导出后和界面一致 }); const ws = XLSX.utils.aoa_to_sheet(aoa); // 合并区域要还原成 xlsx 的 {s, e} 结构 const merges = Object.values(sheet.config?.merge || {}).map((m) => ({ s: { r: m.r, c: m.c }, e: { r: m.r + m.rs - 1, c: m.c + m.cs - 1 } })); if (merges.length) ws['!merges'] = merges; // 引擎里列宽是像素,xlsx 认的是字符宽度,wpx 让 SheetJS 自己换算 const cols = Object.entries(sheet.config?.columnlen || {}).map(([, px]) => ({ wpx: px })); if (cols.length) ws['!cols'] = cols; // 名字超 31 字符或含 []:*?/\ 会让 Excel 打不开文件 const safeName = sheet.name.replace(/[\[\]:*?/\\]/g, '_').slice(0, 31) || `Sheet${sheet.index + 1}`; XLSX.utils.book_append_sheet(wb, ws, safeName); }); XLSX.writeFile(wb, filename); }v?.m ?? v?.v这个顺序很重要。导出时优先写显示值,用户看到的是2024-01-01,导出后不该变成一个五位数的序列号。代价是导出后再导入会丢失原始精度,所以服务端导出的正式文件,我一般会保留原始值并加格式串,前端这个函数只用于「快速下载看看」。
sheet 名长度和非法字符处理看着琐碎,实际翻车率极高。名字里带个斜杠,SheetJS 在前端不报错,文件也能生成,但用户用 Excel 打开会弹出「发现不可读取的内容,是否修复」,修完数据还在,格式全没了。用户会认为是你的系统把文件弄坏了。
4.3 后端保存:整表覆盖 + 版本号,别急着做增量
保存接口的重点不在性能,在不出错。demo 阶段我用的是整份覆盖加乐观锁,实测一张两千行、三十列的表序列化后大约 1.5MB,走内网完全无感。真正需要做增量的场景,是同一张表被多人高频编辑,那是协同编辑的范畴,不是保存接口该解决的问题。
// server/routes/workbook.js import { Router } from 'express'; const router = Router(); const store = new Map(); // demo 用内存,生产换库 router.get('/:id', (req, res) => { const doc = store.get(req.params.id); if (!doc) return res.status(404).json({ msg: 'not found' }); res.json(doc); }); router.put('/:id', express.json({ limit: '20mb' }), (req, res) => { const { sheets, baseVersion } = req.body; // limit 必须显式调大,默认 100kb,稍微大点的表就 413 if (!Array.isArray(sheets)) return res.status(400).json({ msg: 'sheets 必须是数组' }); const doc = store.get(req.params.id) || { id: req.params.id, version: 0 }; // 乐观锁:客户端版本落后就直接拒绝,让前端决定刷新还是覆盖 if (baseVersion !== undefined && baseVersion !== doc.version) { return res.status(409).json({ msg: '版本冲突', version: doc.version }); } doc.name = doc.name || req.params.id; doc.sheets = sheets; doc.version = doc.version + 1; doc.updatedAt = Date.now(); store.set(doc.id, doc); res.json({ version: doc.version }); }); export default router;express.json({ limit })那行是必须的。默认限制 100KB,一张稍大的表直接返回 413,前端只看到「保存失败,稍后重试」,查半天以为是接口挂了。生产环境还要考虑反向代理的 body 大小限制,两层都要调。
409 的处理也要想清楚。前端收到之后不能自动重试,那等于把乐观锁关掉了;也不能直接覆盖,那用户刚才的修改就没了。这个项目里的做法是弹一个二选一:刷新(丢掉本地修改)或者另存为新版本。后者的实现成本极低,就是给工作簿 id 加个时间戳后缀再POST一次,但对用户来说是救命功能。
5. 踩坑与排查:Excel 在线编辑上线后最容易翻车的五件事
5.1 渲染与初始化阶段:白屏、重影、格式乱
现象:页面打开是一片空白,控制台没有任何报错。原因是容器的实际宽高是 0。最常见的三种来源:父级是display:none的 tab 面板、flex 布局里没给flex:1、以及用了height:100%但html/body没有高度。解决方式是容器直接用position:absolute; inset:0,或者强制在 tab 切换之后再调用一次resize()。我现在的习惯是初始化前先打印一次container.getBoundingClientRect(),宽高为 0 就别往下走了。
现象:SPA 里来回切路由,切第二次表格就不出来了,或者出现两张表格叠在一起。原因是引擎在目标容器里插了一大堆 DOM 节点,路由离开时没有清理,再进来又创建了一个实例。解决方式是在组件卸载钩子里调用销毁方法,并在初始化前把容器innerHTML清空。稳妥一点的写法是给容器 id 加一个自增后缀,每次挂载都用一个全新的节点。
现象:单元格里输 18 位订单号,回车后变成1.23E+15,后面几位全变 0。原因是引擎把长数字当成数值型,按科学计数法渲染,而数值精度只有 15 位。解决办法是在初始化时关掉自动格式化(autoFormatw: false),并且在导入时把这类列显式设成文本格式ct: { fa: '@', t: 's' }。已经丢精度的是救不回来的,只能在入口拦住。
5.2 数据与导入导出阶段:合并丢失、覆盖写、文件打不开
现象:保存后重新打开,单元格内容和边框都在,合并单元格全散了,行高也回到默认。原因是保存时只挑了celldata存,把config.merge、config.rowlen、config.borderInfo丢了。这几个字段都是 sheet 级的,跟单元格内容一样重要。解决方式是整份 sheet 对象直接存,别手动挑字段——挑字段这件事,永远会在某次迭代里漏掉新加的那一个。
现象:两个人同时编辑同一张表,后保存的把先保存的整份覆盖了,而且没有任何提示。原因是接口是无条件的整表覆盖写。解决办法是 PUT 时带上客户端拿到的baseVersion,服务端比对不一致就返回 409,前端弹「有人刚改过」。这个补丁加起来不到二十行,但它能挡掉内部系统里绝大部分「我的修改没了」的投诉。
现象:导出的 xlsx 用 Excel 打开提示「发现不可读取的内容」,修复后格式全乱。排查下来通常是三个原因之一:sheet 名超过 31 个字符、名字里含[]:*?/\这类非法字符、或者二维数组里混进了undefined。前两个在sheetsToXlsx里做一次清洗就能解决,第三个要在摊平aoa的时候用?? ''兜住。校验的方法很简单:导出的文件在 Excel 里打开不弹任何提示,才算过。
提示:这三条排查建议做成一份固定的自检清单,每次改动数据层之后手动跑一遍,比等用户反馈快得多。
6. 二次开发:把通用表格改成业务表格的三个改造点
通用表格引擎解决的是「能编辑」,业务系统要的是「只能按规则编辑」。这个项目里我留了三个明确的扩展点,改动量从小到大排列:
| 改造点 | 改动位置 | 大致工作量 | 适用场景 |
|---|---|---|---|
| 单元格只读与着色 | editor.js的workbookCreateAfter | 半天 | 报表模板固定表头、锁定计算列 |
| 自定义校验与提交拦截 | hook.cellUpdated+beforeSubmit | 1~2 天 | 编号格式、必填列、跨列一致性 |
| 多人协同 | 后端加 WebSocket + 增量广播 | 1 周起 | 多人同时维护同一张配置表 |
第一个改造点是只读列。报表里通常有「序号」「合计」这类不该让用户改的列。做法是在workbookCreateAfter钩子里遍历这些列,给每个单元格塞上v.bg、v.fc的样式,同时在cellUpdated里判断当前列属于锁定列就回滚——把oldValue写回去并给个 toast。样式只是提示,钩子里的回滚才是真正的拦截,两者都要做,少一个都会有人绕过。
const READONLY_COLS = [0, 5]; // 第 1 列和第 6 列锁死 cellUpdated(r, c, oldValue, newValue) { if (READONLY_COLS.includes(c)) { window.sheetEngine.setCellValue(r, c, oldValue); // 回滚 toast('这一列不允许修改'); return; } scheduleSave(); }第二个改造点是校验。别指望引擎自带的数据校验能满足业务规则,跨列一致性(比如「结束日期不早于开始日期」)、跨 sheet 引用、编号格式,都只能自己写。我一般把规则抽成一个纯函数数组,每个函数接收(sheets, r, c)返回错误信息或null,在提交前跑一遍,把所有错误收集起来定位到具体单元格。
第三个改造点是协同。这里最容易上头——一上来就想上 OT 或者 CRDT,两周之后发现连最基本的同时输入都还没跑通。实操路径应该是:先用 WebSocket 做「谁正在编辑哪张表」的存在感提示,再做一个粗粒度的行级锁,最后才考虑操作转换。绝大多数内部系统的并发度根本到不了需要 OT 的程度,先做存在感和锁,能解决 90% 的冲突。
这套东西做完之后我最大的体会是:真正的成本从来不在接入那一步。接入一个表格引擎,一个下午就能跑出能看的 Demo;难的是保存策略、版本冲突、格式保真这三件事,它们都要等真实用户用起来才会暴露
本文还有配套的精品资源,点击获取