☰
Canvas表格引擎与xlsx双向转换:Excel在线编辑实践
2026/11/2 17:40:56 网站建设 项目流程

简介:面向前端开发者的 HTML 页面集成 Excel 在线编辑示例包,适合需要在企业后台、数据填报和表格管理页面中嵌入类表格编辑能力的人员参考。压缩包共 41 个文件,约 1.44MB,以 CSS 样式、JS 脚本和 HTML 页面为主,另含图片、字体及配置文件;其中样式文件数量较多,提供多套浅色、深色表格主题与检查器界面外观,脚本部分则覆盖表格控件调用、界面交互和文件保存等依赖。包内示例展示了表格渲染、格式设置、界面组织与前端资源加载方式,读者可据此搭建可运行的演示环境,并进一步扩展数据编辑、导出、主题适配、授权访问与资源组织等功能。需要注意旧版浏览器兼容性有限,示例更适合作为方案选型、功能验证与降级处理的参考。已有 5081 人学习,适合作为 HTML 表格编辑方案的入门参考。

1. 一个表格页面,为什么最后都绕不开「在线编辑」

上周有个做后台的同学来问:客户在系统里看报价单,只想改一个单价,流程却是导出 Excel、本地改完、再传回附件,来回三趟,改错一次还得重来。他想要的效果很朴素——在 HTML 页面里直接做 Excel 在线编辑,打开就能改,改完点保存,服务器上那份文件就更新了。这份资源解决的就是这一件事:把表格引擎嵌进页面,前端负责渲染和交互,xlsx 解析库负责读进来、写回去,后端只当文件柜。它适合做后台管理系统、报价单、排班表、数据填报这类「表格即业务」的场景,不适合做几十万行的大数据报表。下面按我拆这份代码的顺序讲:架构怎么分层、初始化到保存的完整链路、公式和合并单元格这些会踩坑的地方,以及怎么把它改造成自己业务里的样子。

2. 整体架构:Canvas 渲染引擎加 xlsx 双向转换的两条链路

这份工程最值得先看清楚的一点,是它没有把表格当成 HTML 的<table>来用。页面里翻遍 DOM 也只有一个 div,真正的单元格是画在 Canvas 上的。这个选择决定了后面所有的写法,也决定了它能在浏览器里扛住几万单元格还不卡。

2.1 为什么用 Canvas 而不是 contenteditable 表格

用<table>加contenteditable做在线编辑,起步很快,一个下午就能写出能打字的表格。但只要业务方提三个需求,这条路就走到头了:冻结首行、合并单元格、公式联动重算。contenteditable的输入行为由浏览器接管,你在里面插一个不可编辑的行号列,光标就会跨区选中;你给单元格加边框,合并之后边框错位;几万个 td 的 DOM 节点,滚动一次就掉帧。

Canvas 方案把这些全部收回到自己手里:视口内只画看得见的那几十行几十列,滚动时重绘;选中区域、编辑器位置、行列头都是自绘的。代价是你要自己处理输入法、复制粘贴、无障碍,所以社区里成熟的 Canvas 表格引擎基本都把这些做完了。我一般会直接拿一个带公式、筛选、冻结、打印的 Canvas 引擎当底座,业务代码一层都不碰它的渲染逻辑。

提示:判断一个 Canvas 表格引擎能不能上生产,看它有没有暴露celldata这种扁平的单元格数组结构。有这层结构,你才能自己写导入导出;只有内部私有格式的,接后端时会非常难受。

2.2 三条数据链路:编辑态、序列化态、存储态

整个项目的复杂度其实集中在三次数据形态转换上,把这三种形态分清楚,代码就不会乱:

形态载体谁产生特点
编辑态引擎内部对象表格引擎有选区、滚动位置、撤销栈,不能直接持久化
序列化态celldata数组导入器 / 引擎导出[{r, c, v}],纯 JSON,可存可传
存储态xlsx 二进制导出器兼容 Excel,给非技术用户下载

编辑态的引擎对象里塞了大量 UI 状态,你把它JSON.stringify存进数据库,下次读出来会因为版本差异直接崩。序列化态才是前后端之间该传的东西,它的每个元素是行号、列号加一个值对象,跟渲染无关。存储态则是给业务方交付的最终文件。三条链路的方向是:xlsx → celldata → 引擎 → celldata → xlsx。

2.3 目录结构:一份能跑的最小工程长什么样

这份代码没有把所有东西塞进一个index.html,它按职责切了四个目录。引擎和解析库放public/vendor直接静态引入,不走打包——这类库体积大、版本升级少,塞进 bundle 只会拖慢构建。

excel-online/ ├── public/ │ ├── vendor/ # 表格引擎、xlsx 解析库,静态引入 │ └── template.xlsx # 新建空白表格用的模板 ├── src/ │ ├── engine/ │ │ ├── create.js # 引擎初始化封装,业务层只调这一个 │ │ ├── importer.js # xlsx -> celldata │ │ ├── exporter.js # celldata -> xlsx │ │ └── hook.js # 编辑事件、脏标记、自动保存 │ ├── api/ │ │ └── workbook.js # 后端读写、版本号校验 │ ├── ui/ │ │ ├── toolbar.js # 自定义按钮:提交、撤回、导出 │ │ └── upload.js # 拖拽上传、进度、错误提示 │ └── main.js # 组装:初始化 -> 拉数据 -> 渲染 ├── server/ │ └── index.js # 可选,只做文件落盘 + 版本号 └── index.html

这个划分的好处是engine/下面的三个文件可以单独拿出来测:给importer.js一个 xlsx 的 ArrayBuffer,它应该返回结构确定的数组,不需要起浏览器也不需要连后端。我在别的项目里就是靠这个特性写的单元测试,比手动点页面靠谱得多。

3. 核心代码拆解:从一个空 div 到能编辑的表格

这一章按真实运行顺序走一遍:页面挂载、初始化引擎、把文件读进来、改完之后写回去、最后处理保存按钮的可用状态。每一步都有对应文件,你可以对着代码看。

3.1 容器初始化与引擎配置项

页面 HTML 里只留一个容器,宽高必须显式给死,这是 Canvas 方案最常见的第一个坑——容器高度是 0,引擎量出来画布高度 0,页面一片空白。

<!-- index.html:容器宽高给死,不要靠内容撑开 --> <div id="sheet" style="width:100%;height:calc(100vh - 56px)"></div>
// src/engine/create.js —— 对引擎做一层薄封装,业务层只认这四个方法 import { Engine } from '../vendor/sheet-engine'; export function createSheet(container, onDirty) { const engine = new Engine({ container, // 容器 id 或 DOM,二选一,别混用 lang: 'zh', showToolbar: true, // 自带工具栏,先开着,后面再替换 showFormulaBar: true, // 公式栏,财务类表格必开 showinfobar: false, // 顶部文件名栏,接自己业务时关掉 allowCopy: true, enableAddRow: true, column: 60, // 默认列宽,单位 px rowHeight: 24, // 初始工作簿:一个 sheet 也要给 status,1 表示当前激活 data: [{ name: 'Sheet1', order: 0, status: 1, celldata: [], config: {} }], hook: { cellUpdated: () => onDirty(), // 任一单元格内容变化 sheetActivated: () => {}, // 切换 sheet rangePasteBefore: () => {} // 粘贴前拦截,做权限校验用 } }); return { engine, load: (sheets) => engine.setData(sheets), dump: () => engine.getAllSheets(), // 导出序列化态 setReadonly: (flag) => engine.setAllowEdit(!flag) }; }

参数里最关键是data的结构。celldata是扁平数组,每个元素形如{ r: 0, c: 0, v: { v: '单价', m: '单价', ct: { fa: 'General', t: 's' } } };r/c是零基行列下标,v.v是真实值,v.m是显示文本,v.ct.fa是格式串。config里放合并区域merge、行高rowlen、列宽columnlen、冻结frozen。三个东西分开存,是因为它们的更新时机完全不同:改格子不动 config,改列宽不动 celldata。

hook.cellUpdated是后面脏标记的唯一入口,所有「保存」按钮的可用状态都挂在这上面,别在别处再监听一遍 DOM 事件,两套状态一定会打架。

3.2 读 xlsx 进来:文件到 celldata 的转换

用户选文件之后,拿到的是File对象。这里必须用arrayBuffer()读二进制,用FileReader.readAsText读出来是乱码,因为 xlsx 本质是个 zip 包。

// src/engine/importer.js import * as XLSX from 'xlsx'; export async function xlsxToSheets(file) { const buf = await file.arrayBuffer(); // 二进制直读,别用 readAsText const wb = XLSX.read(buf, { type: 'array', cellFormula: true, // 保留公式原文,否则被解析成计算结果 cellStyles: true, // 读取样式,不给的话导入后全是白底 cellDates: true, // 日期转成 Date 对象,避免显示成 45123 sheetStubs: true // 空单元格也占位,合并区域才不会错位 }); return wb.SheetNames.map((name, idx) => { const ws = wb.Sheets[name]; // !ref/!merges/!cols/!rows 是元信息,要单独剥出来转成 config const { '!ref': ref, '!merges': merges, '!cols': cols, '!rows': rows, ...cells } = ws; return { name, order: idx, status: idx === 0 ? 1 : 0, // 只激活第一个 celldata: cellsToCelldata(cells), config: { merge: toMergeMap(merges), columnlen: toColumnLen(cols), rowlen: toRowLen(rows) } }; }); }

cellsToCelldata要处理地址转换,因为 xlsx 用A1这种字母数字地址,引擎只认行列下标:

// A1 -> {r:0,c:0};AB12 -> {r:11,c:27} function a1ToRC(a1) { const m = /^([A-Z]+)(\d+)$/.exec(a1); let c = 0; for (const ch of m[1]) c = c * 26 + (ch.charCodeAt(0) - 64); return { r: Number(m[2]) - 1, c: c - 1 }; } function cellsToCelldata(cells) { return Object.keys(cells).map((addr) => { const { r, c } = a1ToRC(addr); const cell = cells[addr]; return { r, c, v: { v: cell.v, // 真实值 m: cell.w ?? String(cell.v ?? ''), // 显示文本 f: cell.f ? `=${cell.f}` : undefined, // 公式,注意补等号 ct: { fa: cell.z || 'General', t: cell.t } // 格式串 + 类型 } }; }); }

两个细节值得单独说。第一,公式要补=,xlsx 里存的是SUM(A1:A9),引擎认的是=SUM(A1:A9),漏了等号这一格会被当纯文本。第二,cell.t是 xlsx 的类型码,n数字、s字符串、b布尔、d日期,直接透传给引擎的ct.t即可,中间不要自己猜。

3.3 存回 xlsx:把引擎数据还原成工作簿

保存是反向操作,比导入更容易出错,因为引擎返回的 celldata 里带着一堆 UI 字段,直接写进 xlsx 会报「Unsupported field」。

// src/engine/exporter.js import * as XLSX from 'xlsx'; export function sheetsToXlsx(sheets) { const wb = XLSX.utils.book_new(); sheets.forEach((s) => { const ws = {}; s.celldata.forEach(({ r, c, v }) => { if (v == null) return; const addr = XLSX.utils.encode_cell({ r, c }); ws[addr] = { t: pickType(v), // n / s / b / d,四选一 v: v.v ?? '', f: v.f ? v.f.replace(/^=/, '') : undefined, // 写回去要去掉等号 z: v.ct?.fa && v.ct.fa !== 'General' ? v.ct.fa : undefined }; }); ws['!ref'] = rangeOf(s.celldata); // 范围必须有,否则 Excel 打开是空表 ws['!merges'] = mergeMapToArray(s.config?.merge); ws['!cols'] = rowLenToArray(s.config?.columnlen); wb.SheetNames.push(s.name); wb.Sheets[s.name] = ws; }); // 关键:让 Excel 打开时全量重算,否则公式格显示的是旧缓存值 wb.Workbook = { CalcPr: { fullCalcOnLoad: true } }; return XLSX.write(wb, { bookType: 'xlsx', type: 'array', compression: true }); } function pickType(v) { if (v.ct?.t === 'd' || v.v instanceof Date) return 'd'; if (typeof v.v === 'number') return 'n'; if (typeof v.v === 'boolean') return 'b'; return 's'; }

!ref这一行是血泪经验。少了它,Excel 打开文件会提示「发现不可读取的内容」然后自动修复,修完一片空白——因为解析器不知道数据范围在哪。fullCalcOnLoad也一样重要,前端引擎算出来的公式结果只是个缓存,写进文件时如果带上了旧缓存,用户打开看到的还是改之前的值,得点一下单元格才刷新。

常见做法是写入时不带f的计算结果,只带公式原文,把重算交给 Excel。这样文件的正确性有保证,代价是首次打开稍慢一点。

3.4 编辑钩子与脏数据标记

用户改了但没保存就关页面,是这类系统最常见的投诉。这份代码在hook.js里用最土但最有效的办法兜住:

// src/engine/hook.js let dirty = false; export function markDirty() { if (dirty) return; // 只在首次变脏时更新 UI,避免频繁重排 dirty = true; document.getElementById('save-btn').disabled = false; document.title = '* ' + document.title.replace(/^\* /, ''); } export function clearDirty() { dirty = false; document.getElementById('save-btn').disabled = true; document.title = document.title.replace(/^\* /, ''); } // 离开前拦截,只在脏状态下触发 window.addEventListener('beforeunload', (e) => { if (!dirty) return; e.preventDefault(); e.returnValue = ''; });

markDirty里那个提前 return 是有意为之:cellUpdated在粘贴一大片区域时会连续触发几百次,每次都去改 DOM 标题会明显卡顿。加了判断之后,只有第一次要动 UI。

保存动作本身走PUT传二进制,不要传 base64——base64 会让体积涨三分之一,还多一次编解码:

// src/api/workbook.js export async function saveWorkbook(id, buffer, version) { const res = await fetch(`/api/workbook/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', 'X-Workbook-Version': String(version) // 乐观锁,后端比对后再落盘 }, body: new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) }); if (res.status === 409) throw new Error('文件已被他人修改,请刷新后重试'); return res.json(); }

X-Workbook-Version这个头就是为了对付多人同时改同一份表格。后端拿到版本号和库里比对,不一致直接返回 409,前端弹提示。没有这层,两个人先后保存,后保存的把先保存的整份覆盖掉,数据没了还查不出来。

4. 公式、合并单元格与样式:真正会翻车的三块

前面那套流程跑通一个纯文本表格只要半天,但从纯文本到「能替代 Excel」,剩下的 80% 工作量全在这一章。这三块内容在导入导出时各有各的坑,而且报错信息都极其模糊。

4.1 公式重算与缓存值

引擎负责显示公式的计算结果,但它的计算能力和 Excel 有差距。VLOOKUP跨表引用、SUMIFS多条件、数组公式这几类,引擎算出来的值和 Excel 对不上是常事。所以我的做法是:不依赖引擎的计算结果做业务判断,只把它当展示用。

保存时的处理顺序很讲究:先取引擎的 celldata,再把带f的单元格的值清掉,最后交给 xlsx 写文件。这样文件里只有公式没有缓存,Excel 打开会强制重算一遍,结果一定是准的。

// 保存前清洗:有公式的格子丢掉缓存值,交给 Excel 重算 function stripFormulaCache(celldata) { return celldata.map(({ r, c, v }) => { if (!v?.f) return { r, c, v }; return { r, c, v: { ...v, v: undefined, m: undefined } }; // 只留公式 }); }

如果不做这一步,会出现一种很诡异的现象:用户在页面里改了 A1,公式栏里 B1 显示的还是旧结果,保存后再打开 Excel 也还是旧结果,非得双击一下 B1 回车才更新。原因就是缓存值被原样写进了文件,Excel 信任了它。

4.2 合并单元格的 mc 结构

合并是导入时最容易丢数据的地方。xlsx 里合并信息存在!merges数组,形如[{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }],表示 A1 到 C1 合并;但cells里只有 A1 有值,B1、C1 压根不存在。

引擎这边需要每个被合并的格子都知道自己属于哪个主格,所以要补mc字段:

// 把 xlsx 的 !merges 展开成引擎的 merge 对象 + 给子格补 mc function applyMerge(celldata, merges = []) { const merge = {}; merges.forEach(({ s, e }) => { merge[`${s.r}_${s.c}`] = { r: s.r, c: s.c, rs: e.r - s.r + 1, cs: e.c - s.c + 1 }; // 被合并的每一个子格都要指向主格,否则选中/复制行为异常 for (let r = s.r; r <= e.r; r++) { for (let c = s.c; c <= e.c; c++) { if (r === s.r && c === s.c) continue; const exist = celldata.find((x) => x.r === r && x.c === c); const mc = { r: s.r, c: s.c }; if (exist) exist.v.mc = mc; else celldata.push({ r, c, v: { mc } }); } } }); return { celldata, merge }; }

漏掉补mc这一步的后果是:页面上看着合并了,但用户选中合并区域按删除,只有左上角被清空,右边几格还残留着看不见的旧值,保存回 xlsx 之后那些值又冒出来了。

4.3 样式与列宽行高的映射

样式是导出时最容易被忽略的。xlsx 的样式不写在单元格上,而是存在styles.xml里,单元格通过s索引指过去;引擎这边则是每个单元格自带bg(背景)、fc(字体色)、ff(字体)、fs(字号)、bl(加粗)、ht/vt(对齐)。

两边的映射没有捷径,只能一个个对:

xlsx 字段引擎字段说明
z(数字格式)ct.fa0.00%、yyyy-mm-dd这类格式串
s.patternFillv.bg背景色,注意 ARGB 转 RGB
s.font.colorv.fc字体色
s.font.boldv.bl1 加粗,0 常规
!cols[].wchconfig.columnlen[i]字符宽转像素,约wch * 7 + 5

颜色要特别注意:xlsx 存的是FFFF0000这种 ARGB 八位串,引擎要的是#FF0000,直接透传会显示成透明。我一开始就是漏了这个转换,导进来整张表白底黑字,业务方以为我把格式吃了。

5. 常见问题排查:五个我真实踩过的坑

下面这几条都是上线后用户报过来的,每条按「现象、原因、解决」记录,你照着排查能省不少时间。

5.1 打开十几兆的文件,页面白屏十几秒

现象:用户上传一个 15MB 的排班表,进度条走完了,页面卡死,浏览器弹「页面无响应」。

原因:导入器把工作簿里所有 sheet 一次性全部转成 celldata,然后一次性塞给引擎渲染。一个工作簿八个 sheet、每个两万行,转出来的对象有几百万个,主线程直接堵死。

解决:改成懒加载,只转当前激活的第一个 sheet,其余保留原始SheetJS的 worksheet 对象,等用户点 sheet 标签时再按需转换。同时把XLSX.read挪进 Web Worker,主线程只收结果。改完之后同样的文件首屏 1.5 秒出来。

5.2 保存后重新打开,公式全变成数字

现象:页面里=B2*C2显示正常,保存下载后打开,那一格变成了一个固定数字,改 B2 也不再联动。

原因:导出时把v.v(引擎算出来的结果)和v.f(公式)一起写进了单元格。xlsx 里v优先于f显示,Excel 认为你已经算好了,不再重算。

解决:按 4.1 里的stripFormulaCache清掉带公式单元格的缓存值,同时给工作簿加CalcPr: { fullCalcOnLoad: true }。

5.3 日期列全变成 45123 这种数字

现象:导入一份带日期的合同台账,日期列显示成五位数字,格式刷也刷不回来。

原因:xlsx 里日期本质是序列号,靠单元格的数字格式z渲染成日期。读取时没开cellDates,拿到的是裸数字;或者开了但没把z透传给ct.fa,引擎就按常规数字显示。

解决:XLSX.read加cellDates: true,同时在cellsToCelldata里保证ct.fa用的是cell.z,别自己硬编码成General。

5.4 中文输入法打字,第一个字总是丢

现象:双击单元格进入编辑,用拼音输入法打「测试」,上屏后只剩「试」。

原因:Canvas 上叠的是一个绝对定位的input,引擎在compositionstart期间收到input事件就把值提交了,输入法还没完成组词。这类问题在自研编辑器里几乎必然出现。

解决:不要自己造编辑器,用引擎自带的编辑组件;如果一定要自定义,监听compositionstart设标志位,compositionend之后再提交值,中间的所有input事件全部忽略。

5.5 上传大文件后端直接 413

现象:本地跑一切正常,部署到服务器上超过 5MB 的文件全部保存失败,控制台报 413。

原因:不是前端的问题。常见的反向代理默认请求体上限是 1MB,应用框架自己的 multipart 上限也常在 10MB 左右,两层任意一层没过都会 413。

解决:反向代理调client_max_body_size,应用层调上传大小限制,同时前端在saveWorkbook里判断响应状态,413 时给出「文件过大,请拆分」这种能看懂的提示,而不是把原始错误弹给用户。

注意:这几条里 5.1 和 5.2 是最容易被忽略的,因为它们在小文件、纯文本表格上完全不出现,等真实业务数据进来才爆。

6. 二次开发:把这张表格接进自己的业务

代码跑起来只是开始,真正花时间的是把它改成业务需要的样子。这里说我改过的三处,都是改动量小、收益明显的。

第一处是只读模式。业务里大部分用户只能看不能改,与其做两套页面,不如在初始化后调一次setReadonly(true),再把工具栏里编辑类按钮隐藏掉。判断依据来自后端返回的权限字段,别放在前端硬编码。这里有个细节:只读模式下hook.cellUpdated依然会触发(有些引擎的选区变化也算更新),所以脏标记要在只读时直接短路掉,否则用户只是点了几下格子,关页面时也会被beforeunload拦一次。

第二处是替换自带工具栏。自带工具栏功能全但样式和业务系统对不上,常见做法是用 CSS 把它隐藏,自己在上面加一排按钮,通过引擎暴露的 API 调对应动作:

// src/ui/toolbar.js —— 自己画的工具栏,只留业务真正用得到的动作 const actions = { undo: () => sheet.engine.undo(), redo: () => sheet.engine.redo(), bold: () => sheet.engine.setRangeStyle({ bl: 1 }), merge: () => sheet.engine.mergeCell(), export: async () => { const buf = sheetsToXlsx(sheet.dump()); download(new Blob([buf]), '导出.xlsx'); } };

第三处是版本号闭环。后端每次保存把version加一,前端拿到新版本号存进内存,下次保存带上去。这一套加上 409 处理之后,多人同时改一份表的冲突从「数据莫名消失」变成了「弹个提示让用户刷新」,投诉量直接归零。

验证改造有没有改坏,我一般不看页面,看文件:同一份原始 xlsx,经过「导入、不做任何修改、导出」这一圈,用文本 diff 工具比对解压后的sheet1.xml,除了calcChain和个别顺序字段,其余应该完全一致。不一致的地方就是你丢信息的地方,比肉眼比对靠谱得多。

从那以后我每次接这类表格项目,都强制走一遍「空跑导出比对」这一步,哪怕需求再急也不跳。很多看起来是引擎 bug 的问题,最后都发现在自己的导入导出映射里。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询