news 2026/10/11 16:11:37

js-xlsx实战:Excel导入导出与日期精度避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
js-xlsx实战:Excel导入导出与日期精度避坑指南

简介:在前端处理Excel文件时,解析与生成的底层逻辑都围绕工作簿(workbook)和工作表(worksheet)展开。SheetJS的js-xlsx库提供了read/write两条核心链路,能够将表格数据与JSON互相转换。实际工程中,日期序列号(如45123)、长数字科学计数法、公式单元格结构等问题往往比API本身更棘手。理解这些数据类型的存储原理,配合FileReader、json_to_sheet等工具,可以有效规避导入导出时的精度丢失与数据错乱。本文从概念到工程实践,梳理Excel导入导出的完整流程与高频踩坑点,帮助开发者构建可靠的表格数据处理方案。

1. js-xlsx 是什么:一个 demo 能覆盖的导入导出场景

接到「把这张 Excel 里的几百行数据导进系统」的需求时,前端最容易想到的就是 js-xlsx。它是 SheetJS 社区版对 XLSX 解析/生成能力的封装,浏览器里用 import 或 script 引一下,导入、导出、修改单元格都能做。做 demo 最容易走偏的一点是:上来就找「读 Excel 的函数」,其实 js-xlsx 的核心 API 只有 read 和 write 两条线,其余都是围绕 workbook、worksheet 结构转来转去。本文用一个可复现的本地 demo 把这两条线讲透,覆盖导入解析、导出文件、日期精度、长数字精度这些最容易翻车的点。适合刚接触 SheetJS 的读者,也适合已经写了 demo、但一上线就踩数据格式坑的开发者。

2. 从 Excel 导入数据:FileReader 读取、表头映射和多 Sheet 处理

导入的完整链路是:拿到 File 对象 → FileReader 读成 ArrayBuffer → XLSX.read 解析出 workbook → 取出 worksheet → sheet_to_json 转成前端好用的 JSON。demo 里最容易忽略的是type参数,不传的话 read 会按默认字符串处理,二进制内容直接乱掉。

2.1 用 FileReader 异步读文件:demo 里的标准姿势

用 input[type=file] 选文件,这是浏览器端最可靠的方式。不要用 ajax 去拉本地路径,浏览器出于限制拿不到本地磁盘路径。直接看代码:

<input type="file" id="excelInput" accept=".xlsx,.xls" /> <script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script> <script> document.getElementById('excelInput').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const buffer = await file.arrayBuffer(); const workbook = XLSX.read(buffer, { type: 'array' }); const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; const rows = XLSX.utils.sheet_to_json(firstSheet, { defval: '' }); console.log(rows); }); </script>

这里用file.arrayBuffer()替代了老版 demo 里的 FileReader.onload 写法,代码更短且不会丢this上下文。type: 'array'告诉解析器输入的是 ArrayBuffer,这是浏览器端最常见的输入类型;如果是 Node 端读文件,应改用type: 'buffer'。defval: ''让空白单元格返回空字符串而不是 undefined,后续做非空校验和渲染表格时少写很多判空逻辑。

2.2 sheet_to_json 的表头映射:默认模式与二维数组模式

sheet_to_json 第一参数是 worksheet,第二个参数支持不少选项。最常用的是默认模式:把第一行当成表头,后面的行按「表头字段 → 单元格值」映射成对象数组。这有个隐含约定:表头必须唯一。如果表格里有两列都叫「金额」,后面的会覆盖前面的,数据直接丢列。下表是几个高频选项:

选项作用常见误用
header: 1返回二维数组,不解析表头以为返回的是对象数组,下标取错
defval空单元格的默认值不设置时返回 undefined
raw: false尝试把富文本转成可读字符串影响日期/数字的格式化行为
range只读取指定范围范围写错会漏行多列
// demo:读取所有 sheet,第二个 sheet 拿原始二维数组 const allData = {}; workbook.SheetNames.forEach(name => { const ws = workbook.Sheets[name]; // 默认模式:第一行表头映射为对象 allData[name] = XLSX.utils.sheet_to_json(ws, { defval: '' }); }); // demo:header:1 拿二维数组,适合没有表头或需要手拼数据的场景 const matrix = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });

header: 1模式返回的是[[第一行], [第二行], ...],第一行是否表头由你自己决定,适合表头不在 A1、标题占了前两行这类脏表格。默认模式下如果第一行是「序号、名称、数量」这样的表头,字段名会直接是中文;想换英文键名,要么改 Excel 表头,要么拿到对象后自己map一遍做重命名。demo 阶段我一般不做复杂映射,先把数据原样打印出来核对列数,再决定重命名的逻辑。

2.3 多 Sheet 导入与列顺序校验

真实业务里的 Excel 很少只有一个 sheet。常见的有「汇总表」「明细表」「说明」三个 sheet,或者一个模板文件里按月份分 sheet。上面代码里用workbook.SheetNames遍历,就是标准的全量读取姿势。还有两点容易被 demo 漏掉:

第一,sheet 顺序不可靠。用户重命名、拖动 sheet 标签后,SheetNames数组的顺序会变化。不要用「第几个 sheet 一定是汇总表」这种假设,而是按 sheet 名匹配。找不到目标 sheet 时直接给用户提示。

function pickSheet(workbook, wantedName) { const idx = workbook.SheetNames.indexOf(wantedName); if (idx < 0) { throw new Error(`未找到 Sheet: ${wantedName},实际包含: ${workbook.SheetNames.join(', ')}`); } return workbook.Sheets[workbook.SheetNames[idx]]; }

第二,列顺序和 demo 里不一致。用户可能把「备注」插到「数量」前面,也可能删掉某一列。导入前先取出表头行做一次校验,比导入完再发现数据对不上要省事得多。校验逻辑见后面「常见问题与避坑」章节。

3. 从数据导出 Excel:json_to_sheet、book_new 与 writeFile 的最小闭环

导出方向正好反过来:前端 JSON 数据 →XLSX.utils.json_to_sheet生成 worksheet →XLSX.utils.book_new建 workbook →XLSX.writeFile触发浏览器下载。这个闭环 5 行代码能跑通,但实际项目里总会追加「要合并单元格」「要设置列宽」「要导出多个 sheet」的需求。

3.1 json_to_sheet 一键导出:最小可运行代码

function exportDemo() { const rows = [ { 姓名: '张三', 部门: '研发部', 工龄: 3 }, { 姓名: '李四', 部门: '市场部', 工龄: 5 }, ]; const ws = XLSX.utils.json_to_sheet(rows); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, '人员名单'); XLSX.writeFile(wb, '人员名单.xlsx'); }

json_to_sheet会按对象的 key 顺序生成列,说白了就是Object.keys的顺序。中文 key 没问题,但如果你要求列顺序固定,例如「姓名、部门、工龄」而不是「部门、姓名、工龄」,建议先对数据做一层字段重排。常见做法是传入一个有序数组,再配合aoa_to_sheet:

const header = ['姓名', '部门', '工龄']; const body = rows.map(r => [r.姓名, r.部门, r.工龄]); const ws = XLSX.utils.aoa_to_sheet([header, ...body]);

aoa_to_sheet接受二维数组,第一行当表头,完全由你控制顺序。项目里导出带格式的表格,我基本都会切到这种写法,因为json_to_sheet的字段顺序不够直观。

3.2 列宽、合并单元格与多 Sheet:导出 demo 的进阶配置

用户拿到导出的文件后,第一反馈永远是「列宽太窄,文字挤在一起」「标题跑到第二页」「怎么只有第一个 sheet」。这些都能在 workbook 生成阶段做掉。

设置列宽需要在 worksheet 的!cols属性上做文章:

ws['!cols'] = [ { wch: 10 }, // 姓名列宽 10 个字符 { wch: 12 }, // 部门列宽 12 { wch: 8 }, ];

wch是「字符宽度」的单位,中文按全角字符算,英文按半角。不是像素,也不是磅值,调的时候按「最多能容纳几个字」估。合并单元格用!merges:

ws['!merges'] = [ { s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }, // 第 1 行第 1 列到第 1 行第 3 列合并 ];

s是起始单元格,e是结束单元格,r是行号(0 开始),c是列号(0 开始)。这里特别容易踩坑:行列都是 0 索引,而 Excel 界面里显示的行号从 1 开始、列号是字母。写错一格,合并区域就偏移了。写 demo 时可以用一个辅助函数:

function merge(ws, r1, c1, r2, c2) { if (!ws['!merges']) ws['!merges'] = []; ws['!merges'].push({ s: { r: r1, c: c1 }, e: { r: r2, c: c2 } }); }

多 sheet 导出的逻辑和导入对称:book_new创建空 workbook,每调用一次book_append_sheet就往后追加一个 sheet。注意第二个参数 sheet 名不能重复,也不能包含: \ / ? * [ ]这些非法字符。demo 里建议对 sheet 名做一次清理:

const safeName = rawName.replace(/[:\\/?*\[\]]/g, '_').slice(0, 31);

Excel 的 sheet 名上限是 31 个字符,超长会写入失败或弹修复提示,这是我自己实际遇到过的问题。

4. 常见问题与避坑:日期精度、科学计数法和单元格玄学

demo 跑通不意味着能用,真正的问题都在「看着成功、打开一看不对」的场景里。下面 4 条是 js-xlsx 使用中最常见的血泪经验,每一条都按「现象 → 原因 → 解决」梳理。

4.1 日期列变成了 45123

现象:Excel 里明明是「2023-08-15」,导出的 JSON 里变成了 45123 或 45123.0。原因:Excel 的日期本质是序列号,从 1900-01-01 开始按天计数,js-xlsx 本身不会主动把它转成可读日期,只有当你读取时声明需要日期解析才行。解决:读入时加cellDates: true:

const workbook = XLSX.read(buffer, { type: 'array', cellDates: true });

加上之后,日期类型单元格会直接解析成 JS 的Date对象。但如果你的表格里既有真日期又有「2023/8/15」这种文本型日期,文本不会被转换,输出就混着 Date 对象和字符串,处理逻辑要分开写。稳妥做法是拿到行数据后统一格式化:

const rows = XLSX.utils.sheet_to_json(ws, { cellDates: true, defval: '' }); rows.forEach(row => { if (row['入职日期'] instanceof Date) { row['入职日期'] = formatDate(row['入职日期']); } });

formatDate自己写getFullYear()/getMonth()+1/getDate()拼字符串,不要直接toLocaleDateString(),不同浏览器的本地化输出格式不同,线上会翻车。另外导出方向也有坑:如果你用JSON.stringify把 Date 对象转成 ISO 字符串再json_to_sheet,Excel 打开会显示英文日期格式,需要在生成的单元格里指定t: 'd'或把日期先转成new Date()再交给json_to_sheet——直接传字符串是不会被识别成日期的。

4.2 19 位订单号变成科学计数法

现象:Excel 里的订单号是 6222020200012345678 这种纯数字,导入后变成 6.222020200012345e+18,精度全丢。原因:js-xlsx 按数字解析单元格,JS 的 Number 对超过 2^53 的整数无法精确表示。解决思路有两个方向:导入时让 Excel 把文本当文本读。

造成「改类型」这个事,常见做法是在源数据侧把长数字写成字符串。比如导出时就把订单号拼成带前缀的文本,或者在aoa_to_sheet之前,把每一项都String()一下:

const body = rows.map(r => [String(r.orderId), r.name]);

更细一点:js-xlsx 判断一个单元格是不是文本,看的是数据的类型和单元格的t字段。json_to_sheet默认把字符串写为t: 's',所以导出的文件里订单号是文本型,Excel 打开不会变科学计数法。但如果你从 Excel 读入的是「数字型」长尾,再导出时它仍然是数字型,精度已经丢了,不可逆。所以长数字的底线是:源头能做成文本就做文本,导入后立即转成字符串缓存,不要中途做加减乘除。真实项目里遇到过一个场景:订单号导出到 CSV 直接被 Excel 吞掉尾数,后来改成 xlsx 导出加文本格式才解决。

4.3 公式单元格只拿到计算值

现象:Excel 里某个单元格是=SUM(B2:B10),导入后sheet_to_json直接给了求和结果,不是公式字符串。原因:默认情况下 js-xlsx 会把公式解析为值类型。这其实符合大部分人的预期——你要的是「结果」不是公式。但有些场景(比如审计、模板回填)确实需要原样保留公式。

解法是读取时加cellFormula: true:

const workbook = XLSX.read(buffer, { type: 'array', cellFormula: true });

此时单元格对象多一个f字段,即公式文本。sheet_to_json默认输出仍走值,你要拿到公式得遍历worksheet的单元格对象:

for (const key in ws) { if (key.startsWith('!')) continue; // 跳过 !ref !cols !merges 这些内部属性 const cell = ws[key]; if (cell && cell.f) { console.log(key, cell.f); } }

注意cell.f是未计算的公式字符串,cell.v才是上次计算的结果。两者同时存在,别混用。还有一点:js-xlsx 不会重新计算公式,如果你用代码修改了某个参与计算的单元格,旁边用 SUM 合计的结果不会自动更新。demo 阶段发现「我改了 A1 但总价没变」先别怀疑库有问题,这本来就是预期行为,要重新计算得引入公式引擎或从 xlsx 重新解析。

4.4 空行、合并单元格与 sheet_to_json 的静默丢数据

现象:导入的行数比 Excel 里看到的数据行数少,尤其表格中间有空行时,后面的行突然断了。原因:sheet_to_json以「第一行为表头」的规则遍历,遇到连续空行会提前结束,而且它不做「空行跳过继续」的处理。解决:先转成header: 1二维数组,手动过滤空行和全空列,再转换:

function sheetToRows(ws) { const matrix = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }); return matrix.filter(row => row.some(cell => String(cell).trim() !== '')); }

row.some(...)表示「这一行至少有一个非空单元格」才保留。过滤之后再决定表头行是哪一行、从哪一行开始读数据。合并单元格也在这类问题中出现,被合并的空白单元格在header: 1模式下是空字符串,只有合并区域的左上角有值。如果业务上必须要读取合并后的所有行都带同一个值,需要在读取后做一次前向填充:遍历时记住「上一个非空单元格的值」,遇到空值就填上一个值。这个逻辑不要写进循环里的if判断,单独抽一个函数,否则多 sheet 时到处复制粘贴,线上出问题不好排查。

5. 本地 demo 上线前:数据校验、参数护栏与性能观测

demo 能跑只证明链路通了,上线意味着要面对真实数据:几万行的大表、脏数据、命名不规范的 sheet。这里聊三个我常用的加固手段。

5.1 性能与规模护栏

导入大文件时,read是一次性把整个文件解析进内存,sheet_to_json又是全量转换。十万行级别的 xlsx,解析耗时基本在秒级到十几秒,取决于单元格数量和公式复杂度。常见处理是分页导入:先读一个 sheet,切片展示前 200 行,确认数据正确后再全量入库。切片用数组方法就行,不用重新读文件:

const page = rows.slice(start, start + 200);

内存方面,type: 'array'读入的 ArrayBuffer 本来就是文件大小,解析后 workbook 对象会比原文件大好几倍,因为每个单元格的键值对展开成对象了。demo 里无所谓,线上要留意。如果只是预览数据不想全保留,用完 workbook 后手动置空引用,别挂在全局变量上。

5.2 read 参数调优与字段类型护栏

read 的可配参数不止type和cellDates,还有raw和dense。raw: true会保留原始值类型,文本仍是文本、数字仍是数字;raw: false会把数字按 Excel 显示格式格式化一次。看起来 raw:false 更友好,但它会把「金额」这类数字变成带千分位分隔的字符串,前端再排序就出问题了。我的习惯是读取时用raw: true,格式化留在 UI 层做。dense: true是另一种存储形式,用数组代替对象存单元格,大文件解析更快、内存更低,代价是单元格取数方式不同。demo 阶段不用开,文件超过 5MB 时可以试。

上线前的字段类型护栏应该写成独立函数:

function validateRows(rows, requiredFields) { const errors = []; rows.forEach((row, i) => { requiredFields.forEach(field => { if (row[field] === '' || row[field] == null) { errors.push(`第 ${i + 2} 行缺少 ${field}`); } }); }); return errors; }

行号写i + 2是因为第一行是表头,人眼看到的行号从 2 开始。这个很容易错,我第一版写i + 1,用户拿着报错去对 Excel 老是对不上。数字字段还可以加typeof row[field] !== 'number'的检查,防止文本型数字混进来。

最后提一个我自己的习惯:本地 demo 里始终放一份「带故意脏数据」的测试文件,里面包含长数字订单号、中文日期、合并单元格、空行、多 sheet,每次改完解析逻辑都拿它回归一遍。这个习惯帮我挡掉过至少三次上线事故。希望这篇文章的思路能帮你把一个能跑的 demo 变成能抗真实数据的方案,也希望你踩坑时能想起「先看单元格类型,再怪库」。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 16:10:44

开发者自建ADHD外部大脑:命令行任务管理与时间盒系统

“i-have-adhd”这个项目名第一次出现在我屏幕上时&#xff0c;我就愣住了。这不是矫情&#xff0c;是一个做了很多年开发的人&#xff0c;终于肯给自己贴上的标签。ADHD&#xff0c;注意力缺陷多动障碍&#xff0c;听起来像小孩才有的毛病&#xff0c;但成年开发者里带着它工作…

作者头像 李华
网站建设 2026/10/11 16:09:40

知乎评论爬取实战:x-zse-96签名逆向与Node.js/Python混合抓取方案

简介&#xff1a;知乎评论爬取中的x-zse-96参数逆向分析&#xff0c;是爬虫开发者绕开知乎反爬机制、获取高质量评论数据的关键突破口。这份代码包面向中高级爬虫工程师与逆向分析爱好者&#xff0c;聚焦x-zse-96参数的生成逻辑与验证机制&#xff0c;帮助读者理解参数如何构造…

作者头像 李华
网站建设 2026/10/11 16:06:29

工具退役≠知识消亡:面向继承者的废弃工具归档策略

我在团队里处理过一个非常典型的场景&#xff1a;一套内部构建工具用了快六年&#xff0c;某天被正式宣判退役&#xff0c;原维护者三个月后就要离岗。交接时我们打开它的文档&#xff0c;发现除了安装步骤和几段配置说明&#xff0c;没有任何人写清楚“为什么当时放弃开源方案…

作者头像 李华