1. 从一个"日期变数字"的诡异 Bug 说起
上周三下午,运营同事甩过来一个 Excel 文件,说导入系统之后所有下单日期全变成了45291、45292这种五位数。我打开文件一看,Excel 里显示的明明是2023-12-05,导入到前端页面就成这样了。这种问题几乎每一个做过 Excel 导入功能的前端都踩过,尤其是用xlsx(SheetJS)这个库的时候,sheet_to_json出来的日期字段不是字符串也不是 Date 对象,而是一串让人摸不着头脑的数字。
这个现象背后其实一点都不神秘:Excel 内部根本不存"日期"这个概念,它存的是序列号(Serial Number)。Excel 把 1900 年 1 月 1 日(严格说是 1899 年 12 月 30 日,这里有个著名的历史 Bug)当作第 1 天,往后每过一天加 1。所以45291就是某个具体日期的第 45291 天。前端拿到这个数字,如果不做转换,直接渲染出来当然是一串乱码数字。
我写这篇东西的目的很明确:把前端解析 Excel 日期格式这一整套坑讲透,从xlsx库的原始行为,到手动转换序列号,再到把各种奇形怪状的日期统一成YYYY-MM-DD这种自定义格式。适合正在做管理后台、数据导入、报表导出这类功能的前端开发者,也适合刚开始接触xlsx库、被日期字段搞得一头雾水的同学。
内容会覆盖四个部分:先是整体设计思路和方案选型,然后是核心细节和实操要点,接着是完整可跑的代码实现,最后是我这两年攒下来的排查经验。每一段都有可以直接抄的代码,也有我踩过的真实坑。
2. 日期转换的整体设计思路与方案选型
2.1 为什么 Excel 里的日期会变成数字
要解决问题,先得理解问题。Excel 的日期系统是这样设计的:单元格本身只有一个数值,45291这种就是日期序列号。当你在单元格上设置"日期格式"时,Excel 只是把这个数字显示成日期样子,底层数据始终没变。这就像你给一个数字套了个 CSS 样式,看着是日期,实际还是数字。
前端用xlsx库解析时,XLSX.utils.sheet_to_json(worksheet)默认会把单元格的v属性(原始值 raw value)取出来放进 JSON,所以日期字段拿到的就是45291。如果你传了{ cellDates: true }这个参数,SheetJS 会尝试帮你把序列号转成 JS 的Date对象,看起来省事了,但这里又会引入新的坑,后面细说。
序列号的换算公式很简单,但有两个关键点。第一,Excel 的纪元是 1899 年 12 月 30 日,不是 1900 年 1 月 1 日,因为 Excel 为了兼容 Lotus 1-2-3 故意保留了"1900 年是闰年"的错误,所以 1900 年 3 月 1 日之前的日期都要减 1。第二,序列号还包含小数部分表示时间,45291.5就是当天中午 12 点。
// Excel 序列号转 JS Date 的核心逻辑 const EXCEL_EPOCH = Date.UTC(1899, 11, 30); // 1899-12-30 function excelSerialToDate(serial) { const utcDays = serial - 25569; // 25569 是 1970-01-01 的 Excel 序列号 const utcMs = utcDays * 86400 * 1000; return new Date(utcMs); }上面这段是最通用的写法,25569这个魔数来源于 1970-01-01 减去 1899-12-30 的天数差。用 UTC 计算是为了避开本机时区导致的偏移,这一条很重要,很多人转换出来的日期差一天就是这个原因。
2.2 三种转换方案的对比与选型
在实际项目里处理 Excel 日期,我试过至少三种方案,各有适用场景,不能无脑选一种。
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
方案 A:cellDates: true | 解析时让库自动转 Date | 代码最少,一行搞定 | 时区偏移、空值变Invalid Date、时间字段精度问题 | 简单列表、对日期精度要求低 |
| 方案 B:手动序列号转换 | 拿到数字自己算 | 完全可控,时区稳定 | 需要判断单元格是否为日期类型 | 生产环境、数据严谨的导入 |
| 方案 C:正则识别字符串 | 直接对字符串做正则 | 灵活、容错强 | 依赖 Excel 原始格式,数字型日期无能为力 | 混合数据、脏数据处理 |
我个人的选型原则是:如果是正式的导入功能,一律用方案 B,配合方案 C 做兜底。cellDates: true看着省事,但它在不同浏览器、不同时区下表现不一致,我用 Safari 测试时甚至遇到过Invalid Date直接崩掉渲染的情况,这个后面第七节会详细讲。
方案 C 里的正则是处理字符串型日期的利器,比如用户粘贴进来的2023/12/05、2023.12.05、2023年12月5日这些,统一用正则抽取出年月日,再重组成标准格式。这块的正则写法我会在第四节给出完整版本,区分不同分隔符和中文单位。
2.3 自定义 YYYY-MM-DD 格式化的核心考量
统一成YYYY-MM-DD这个格式,看起来就是字符串拼接,但真正的难点在于补零和边界处理。2023-1-5和2023-01-05在数据库里是两个不同的字符串,排序、筛选、比较都会出问题,所以补零是硬性要求。我见过太多项目因为没补零导致日期排序错乱,尤其是后台按字符串排序的时候。
另外要考虑的是时间部分要不要保留。如果 Excel 里的日期带时分秒,转成YYYY-MM-DD会丢失时间信息。我的做法是提供两个函数:formatDate只输出日期,formatDateTime输出YYYY-MM-DD HH:mm:ss。导入功能通常只需要日期,但报表导出往往需要精确到秒,这一点在设计阶段就要想清楚。
还有一个容易被忽视的点是空值处理。Excel 里的空单元格经过sheet_to_json之后可能是undefined、null或者空字符串,如果不做判断,格式化函数里new Date(undefined)会返回Invalid Date,拼出来就是NaN-NaN-NaN。这个必须在函数入口就拦住。
3. xlsx 库日期解析的核心细节与实操要点
3.1 sheet_to_json 的默认行为到底做了什么
很多人用xlsx库就是一句XLSX.utils.sheet_to_json(sheet),但根本没搞清楚它做了什么。它的默认行为是遍历工作表里的每个单元格,取单元格的v(原始值)和w(格式化后的文本 formatted text)。默认情况下它取的是v,也就是原始值,所以日期拿到的是序列号。
这里有个关键属性值得注意:每个单元格对象里其实有t属性表示类型(type)。t: 'n'是数字,t: 's'是字符串,t: 'd'是日期(只有cellDates: true时才会出现),t: 'b'是布尔值。如果你想知道某个单元格到底是不是日期,光看t不行,因为日期在原始数据里是t: 'n',你需要额外判断单元格的数字格式(number format)。
// 判断单元格是否为日期格式 function isDateCell(cell) { if (!cell || cell.t !== 'n') return false; // z 属性是数字格式字符串,如 'yyyy-mm-dd'、'm/d/yy' const fmt = cell.z || ''; return /[ymdhs]/i.test(fmt) && !/^[0#.,]+$/.test(fmt); }这个z属性是很多人忽略的宝藏。Excel 里每个单元格的显示格式都存成字符串,日期格式通常包含y、m、d、h、s这些字母,而纯数字格式只有0、#、.、,。用正则区分这两类,就能精准识别日期单元格,避免把金额、数量这种数字误判成日期。我一开始没做这个判断,结果把数量45291也转成了日期,闹了个大笑话。
如果你传了{ cellDates: true, raw: false },SheetJS 会优先取w属性,也就是 Excel 里显示的那个字符串。这样做的好处是能拿到用户实际看到的格式,坏处是格式五花八门,2023/12/5、23-12-5都可能有,还得再解析一遍。所以这个参数组合适合"用户怎么填我就怎么用"的场景,不适合需要统一格式的导入。
3.2 序列号时间部分的精度陷阱
Excel 的序列号小数部分表示时间,但它的精度是有限的。Excel 底层用的是双精度浮点数,一天是 1,一小时是1/24 ≈ 0.041666,一分钟是1/1440 ≈ 0.000694。当你把一个时间戳存进 Excel 再读出来,浮点误差可能导致时间差几秒。
我实测过一个案例:Excel 里填2023-12-05 10:30:00,序列号算出来是45291.4375,转回来刚好是 10:30:00。但如果填10:30:01,序列号是45291.437511574,浮点精度在处理时会有一点点误差,最后可能变成 10:30:00 或者 10:30:01。这种误差在做对账、计费类功能时是致命的。
处理办法是:在转换后对秒数做四舍五入,而不是直接截断。我一般会把毫秒数除以 1000 再Math.round(),这样能最大程度消除浮点误差。
function excelSerialToDate(serial) { const utcDays = serial - 25569; const utcMs = Math.round(utcDays * 86400 * 1000); // 关键:四舍五入到毫秒 return new Date(utcMs); }提示:如果你的业务只需要精确到分钟,直接对分钟四舍五入更稳,避免秒级误差带来的显示抖动。
3.3 时区问题的根源与规避
时区是 Excel 日期转换里最容易翻车的地方,没有之一。new Date(utcMs)创建的 Date 对象是 UTC 时间,但你用date.getFullYear()取出来的是本地时区的年份。如果你在东八区,2023-12-05 00:00:00 UTC取出来会变成2023-12-05 08:00:00,日期没变;但如果是2023-12-05 20:00:00 UTC,本地就是2023-12-06 04:00:00,日期直接多了一天。
这就是为什么很多人转换出来的日期会莫名其妙差一天。解决方案是全程用 UTC 方法取值,即用getUTCFullYear()、getUTCMonth()、getUTCDate()而不是getFullYear()那一套。
function formatDateUTC(date) { const y = date.getUTCFullYear(); const m = String(date.getUTCMonth() + 1).padStart(2, '0'); const d = String(date.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${d}`; }注意getUTCMonth()返回 0-11,所以要加 1,这个坑我踩过不止一次。用 UTC 取值之后,无论用户在哪个时区,转换出来的日期都是一致的,这对服务端统一存储非常重要。
如果你们公司业务强制要求用本地时区(比如考勤系统),那就反过来,全程用本地方法,但要在解析序列号时把时区偏移补回去。两种思路不能混用,混用必出 Bug。
4. 统一格式化成 YYYY-MM-DD 的完整实现
4.1 核心格式化函数与补零
把各种来源的日期统一成YYYY-MM-DD,核心就是一个健壮的格式化函数。这个函数要能接受 Date 对象、时间戳、日期字符串三种输入,并且做完整校验。
/** * 统一的日期格式化函数 * @param {Date|number|string} input - 日期来源 * @param {string} pattern - 输出格式,默认 YYYY-MM-DD * @returns {string} 格式化结果,无效输入返回空字符串 */ function formatDate(input, pattern = 'YYYY-MM-DD') { if (input === null || input === undefined || input === '') return ''; let date; if (input instanceof Date) { date = input; } else if (typeof input === 'number') { date = excelSerialToDate(input); } else { // 字符串先做归一化 date = parseDateString(input); } if (!date || isNaN(date.getTime())) return ''; const map = { YYYY: date.getUTCFullYear(), MM: String(date.getUTCMonth() + 1).padStart(2, '0'), DD: String(date.getUTCDate()).padStart(2, '0'), HH: String(date.getUTCHours()).padStart(2, '0'), mm: String(date.getUTCMinutes()).padStart(2, '0'), ss: String(date.getUTCSeconds()).padStart(2, '0'), }; return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (key) => map[key]); }这段代码的三个设计点值得说明。第一,入口空值判断放最前面,避免后续计算报错。第二,统一走 UTC,保证跨时区一致。第三,用map + replace的方式做模板替换,比一堆if-else拼接优雅得多,而且支持任意格式组合,比如YYYY年MM月DD日也能直接输出。
padStart(2, '0')是 ES2017 的方法,现代浏览器都支持,但如果你要兼容很老的 IE,得自己写一个补零函数。我现在基本不写 IE 兼容了,但见过一些老项目还在用,这里提一句。
4.2 字符串日期归一化与正则解析
Excel 里除了序列号,还有大量字符串日期,尤其是用户手动粘贴的、从其他系统导出的数据。这些字符串格式五花八门:2023/12/05、2023.12.5、2023年12月5日、12/05/2023,甚至还有带时间的2023-12-05 10:30:00。
处理这类数据,正则是最靠谱的工具。核心思路是:用一个正则把所有分隔符统一成-,识别中文单位,然后提取年月日。
function parseDateString(str) { if (typeof str !== 'string') return null; let s = str.trim(); // 中文单位替换 s = s.replace(/年|月/g, '-').replace(/日/g, ''); // 各种分隔符统一 s = s.replace(/[./]/g, '-').replace(/\s+/, ' '); // 匹配 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss const reg = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/; const match = s.match(reg); if (!match) return null; const [, y, m, d, hh = 0, mm = 0, ss = 0] = match; // 用 UTC 构造,与格式化函数保持一致 return new Date(Date.UTC(+y, +m - 1, +d, +hh, +mm, +ss)); }这个函数有几个细节要展开讲。第一,中文单位替换要放在分隔符统一之前,因为2023年12月5日里的"年""月"要先变成-,否则正则匹配不到。第二,月份和日期允许 1-2 位,\d{1,2}而不是\d{2},这样2023-1-5也能正确解析。第三,时间部分是可选的,用(?:\s+...)?包起来。第四,构造 Date 时用Date.UTC,和格式化函数保持一致。
注意:千万不要用
new Date(str)直接解析字符串,这是最不靠谱的方式。new Date('2023-12-05')在不同浏览器里可能被当作 UTC,也可能被当作本地时间,行为不一致,这在 MDN 上都有明确警告。
4.3 AM/PM 与英文月份的处理
还有一种让人头疼的情况:从英文版 Excel 或某些系统导出的日期是Dec 5, 2023或12/5/2023 10:30:00 AM这种格式。这种情况在跨国业务里很常见,尤其是 mac 版 Excel 默认语言是英文的时候,导出的日期格式和 Windows 版完全不一样。
处理这类数据要建立月份名映射和AM/PM 判断。
const MONTH_MAP = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }; function parseEnglishDate(str) { const reg = /^([A-Za-z]{3})\s+(\d{1,2}),?\s+(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?)?$/i; const m = str.match(reg); if (!m) return null; const month = MONTH_MAP[m[1].toLowerCase()]; if (!month) return null; let hour = +(m[4] || 0); const ampm = (m[7] || '').toUpperCase(); if (ampm === 'PM' && hour < 12) hour += 12; if (ampm === 'AM' && hour === 12) hour = 0; return new Date(Date.UTC(+m[3], month - 1, +m[2], hour, +(m[5] || 0), +(m[6] || 0))); }AM/PM 的处理有个经典陷阱:12 AM 是 0 点,12 PM 是 12 点。所以判断逻辑是 PM 且小时小于 12 才加 12,AM 且小时等于 12 要归零。我见过不少代码把 12 PM 处理成 24 点,直接报错。月份名映射要全部转小写再查,因为用户输入可能是Dec也可能是dec。
把parseDateString和parseEnglishDate组合起来,就能覆盖绝大多数字符串日期格式。我的做法是在formatDate的字符串分支里先试中文/数字格式,失败再试英文格式,双重兜底。
4.4 一个完整的导入处理函数
把上面的零件组装起来,就是一个可以处理真实 Excel 文件的导入函数。这个函数接收工作簿,遍历每一行,把所有日期字段统一格式化。
function parseExcelWorkbook(workbook, dateFields = []) { const sheetName = workbook.SheetNames[0]; const sheet = workbook.Sheets[sheetName]; // 关键:用 cellDates: false 拿原始值,自己控制转换 const rows = XLSX.utils.sheet_to_json(sheet, { raw: true, cellDates: false, defval: '' // 空单元格默认值,避免 undefined }); return rows.map(row => { const result = { ...row }; dateFields.forEach(field => { if (result[field] !== '' && result[field] !== undefined) { result[field] = formatDate(result[field]); } }); // 兜底:扫描所有字段,把看起来像日期的都转一遍 Object.keys(result).forEach(key => { if (!dateFields.includes(key) && isDateLike(result[key])) { result[key] = formatDate(result[key]); } }); return result; }); } // 启发式判断:数字在合理序列号范围内,或字符串符合日期特征 function isDateLike(val) { if (typeof val === 'number') { return val > 25569 && val < 80000; // 1970-01-01 到 2119 年左右 } if (typeof val === 'string') { return /^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}/.test(val) || /[年月日]/.test(val); } return false; }这个函数的几个设计决策说明一下。raw: true, cellDates: false保证拿到的是原始序列号,我完全掌控转换过程。defval: ''把空单元格统一成空字符串,避免后面undefined报错。指定dateFields是精准处理,兜底的isDateLike是防止漏网。序列号范围我卡的是25569到80000,对应 1970 年到 2119 年,这个范围能覆盖绝大多数业务场景,同时避免把数量、金额误判。
提醒:如果你拿到的是 ArrayBuffer 而不是已解析的 workbook,先用
XLSX.read(data, { type: 'array' })解析。用type: 'array'而不是type: 'binary',前者对二进制数据的处理更稳。
5. 常见问题排查与避坑经验实录
5.1 日期差一天的完整排查链路
日期差一天是最高频的问题,我把它整理成一个排查清单,按顺序过一遍基本能定位。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 所有日期差 1 天 | 用本地方法取 UTC 值 | 检查是否用getFullYear | 改用getUTCxxx系列 |
| 只有部分日期差 1 天 | 时区偏移叠加 | 看是否跨越 UTC 午夜 | 统一 UTC 计算 |
| 1900 年附近日期差 1 天 | Excel 闰年历史 Bug | 日期是否早于 1900-03-01 | 特殊处理减 1 |
| 日期变成 1900-01-00 | 序列号 0 或负数 | 检查原始值 | 判空返回空字符串 |
第一类是最常见的。我自己的经验是:只要用 UTC,就全部用 UTC,不要中途换本地方法。有一次我在中间某处用了getMonth()取值,结果整个批次的数据在晚上跑的时候全是前一天,白天跑又正常,排查了整整一下午才发现是时区问题。
1900 年闰年 Bug 相对少见,但如果你的业务涉及历史数据,就要注意。Excel 认为 1900 年 2 月 29 日存在(实际不存在),所以序列号 60 对应这个不存在的日期。序列号 60 之后的日期转换都要考虑这个偏差。日常业务基本用不到,但知道这个坑的存在能帮你在遇到怪异日期时快速定位。
5.2 cellDates 参数的坑与 Invalid Date
cellDates: true看起来是最省事的方案,但它有三个坑:时区不一致、空值变 Invalid Date、大量数据性能下降。第一个已经讲过,重点说后两个。
空单元格在 Excel 里没有任何值,但 SheetJS 在某些版本下会把它转成无效的 Date 对象,你在渲染时date.toISOString()直接抛错,整个表格白屏。这个在生产环境是灾难级的。我现在的做法是:永远不用cellDates: true,自己拿序列号转换,所有边界情况都在自己掌控中。
性能问题也值得提一句。cellDates: true会让 SheetJS 对每个数字单元格都做一次日期判断,数据量上万行的时候解析时间明显变长。我做过对比,一个 5 万行的文件,cellDates: true比cellDates: false慢了大概 40%。对于同步解析的场景,这个差距足以让页面卡死。
5.3 大文件解析的卡顿与分片处理
Excel 导入功能随着数据量增加,卡顿是必然的。前端解析大文件有几个方向可以优化:Web Worker、分片解析、按需解析。
Web Worker 是最有效的方案,把XLSX.read和sheet_to_json全部丢到 Worker 里跑,主线程只负责渲染,页面不会卡死。这块的正则匹配热词里提到了"前端使用 worker 上传大文件",方向是对的。Worker 里处理完返回 JSON 数组,主线程更新表格。
分片解析适合超大文件,SheetJS 支持通过sheetRows参数限制每次读取的行数,但它是从头读的,不好做真正的分片。更实际的做法是配合range参数指定读取范围,或者干脆拆分文件让用户分批上传。
我的经验是:1 万行以内的文件,主线程直接解析没问题;超过 1 万行,上 Worker;超过 10 万行,建议引导用户分批上传或者走后端解析。前端不是万能的,有些场景老老实实交给后端更省心。
5.4 空值与异常数据的兜底策略
真实业务数据脏得很,Excel 里什么奇怪的东西都有。我总结了几类需要兜底的情况:
- 空单元格:返回空字符串,不要返回
Invalid Date - 纯文本"暂无":用
isDateLike判断拦掉,原样保留 - 合法日期夹杂非法:逐行校验,非法的记为错误行,不阻断整体导入
- 合并单元格:只有左上角有值,其他是
undefined,需要业务层处理 - 公式单元格:
v是公式计算结果,f是公式本身,通常取v即可
处理原则是:宁可保留原值,不要转换出错的值。如果formatDate返回空字符串,但原始值不是空的,说明这个字段存在但格式不对,应该原样保留并标记出来提示用户,而不是悄悄丢成空。
function safeFormatDate(val) { const formatted = formatDate(val); if (!formatted && val !== '' && val !== null && val !== undefined) { return { value: String(val), error: '日期格式无法识别' }; } return { value: formatted, error: null }; }这样处理之后,前端可以把错误行高亮展示,用户自己对照着改,体验比默默失败要好得多。
5.5 我踩过的三个真实坑
第一个坑:月份忘记加 1。getUTCMonth()返回 0-11,我第一版代码忘了加 1,结果 1 月变成了 0 月,所有日期都是2023-00-xx,排查时盯着代码看了半天才反应过来。这个错误太隐蔽了,因为 2 月不会变 1 月,只有 1 月会变 0 月,平时测试不容易发现。
第二个坑:序列号判断范围设太宽。我一开始把isDateLike的数字范围设成0 到 100000,结果把员工的工号45291也当成日期转成了2023-12-05,导入之后工号全乱套。后来改成25569 到 80000才解决。这个教训是:启发式判断的范围一定要贴合业务,不能太宽。
第三个坑:Safari 下的正则兼容。我用了一个带命名捕获组的正则(?<year>\d{4}),在 Chrome 下跑得好好的,用户用 Safari 打开直接白屏。命名捕获组 Safari 支持得比较晚,老版本直接语法错误。后来全部改成普通捕获组加解构赋值,问题解决。写前端一定要考虑浏览器差异,尤其是兼容老设备的时候。
6. 完整实战:从选文件到渲染的闭环代码
6.1 文件读取与解析入口
把前面所有零件串起来,就是一个完整的 Excel 导入流程。入口是用户选择文件,通过FileReader读成 ArrayBuffer,再交给 SheetJS 解析。这里我用了 Promise 封装,方便配合 async/await。
async function importExcel(file, dateFields) { const buffer = await file.arrayBuffer(); // type: 'array' 处理二进制,比 binary 稳 const workbook = XLSX.read(buffer, { type: 'array', cellDates: false }); const rows = parseExcelWorkbook(workbook, dateFields); return rows; } // 配合 input 使用 document.getElementById('fileInput').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; try { const rows = await importExcel(file, ['下单日期', '发货日期']); renderTable(rows); } catch (err) { console.error('解析失败', err); alert('文件解析失败,请检查格式'); } });file.arrayBuffer()是现代浏览器的标准 API,比老的FileReader.readAsArrayBuffer简洁。XLSX.read的type参数选array是因为传入的是 ArrayBuffer,类型匹配。如果传的是二进制字符串要用binary,传 Base64 要用base64,类型不对会直接解析失败,这个错误信息很不友好,容易让人以为是文件问题。
6.2 参数选择的完整说明
SheetJS 的参数看起来多,实际常用的就几个,我列个表说清楚什么时候用什么。
| 参数 | 取值 | 作用 | 我的建议 |
|---|---|---|---|
type | array/binary/base64 | 输入数据类型 | ArrayBuffer 用 array |
cellDates | true/false | 是否自动转日期 | 一律 false,自己转 |
raw | true/false | 取原始值还是格式化值 | true,拿序列号自己处理 |
defval | 任意 | 空单元格默认值 | 设成 '' 避免 undefined |
sheetRows | number | 限制读取行数 | 大文件预览时用 |
cellDates和raw的关系要说清楚:cellDates: true时无论raw是什么,日期都会变成 Date 对象;cellDates: false, raw: true拿到序列号;cellDates: false, raw: false拿到格式化字符串。三种组合对应三种数据形态,选错了后面的处理逻辑全对不上。
我的固定配置是{ cellDates: false, raw: true, defval: '' },这个组合给我最原始的数据,转换完全由我控制,跨环境一致。这三年做过的所有导入功能,这个配置都没出过问题。
6.3 渲染层的展示与错误标记
解析出来之后要渲染,渲染层要做两件事:格式化展示和错误高亮。我用一个简单的表格渲染说明,配合 Element Plus 或 Ant Design 的表格组件思路是一样的。
function renderTable(rows) { const tbody = document.querySelector('#resultTable tbody'); tbody.innerHTML = rows.map(row => { const cells = Object.keys(row).map(key => { const val = row[key]; const isError = typeof val === 'object' && val?.error; const text = isError ? val.value : val; const cls = isError ? 'error-cell' : ''; return `<td class="${cls}">${escapeHtml(text)}</td>`; }).join(''); return `<tr>${cells}</tr>`; }).join(''); }escapeHtml是防 XSS 的必要步骤,Excel 里的内容不可信,直接拼进 innerHTML 有安全风险。错误单元格加error-cell类,前端用红色背景标出来,用户可以直观看到哪些字段有问题。这个交互细节看起来小,但对导入功能的成功率影响很大,用户能自己修的数据就不会来问你。
如果是 Vue 或者 React 项目,思路一样,把错误标记放到数据里,渲染时根据error字段决定样式。核心是解析结果和错误信息要一起返回,不要让渲染层再去猜。
6.4 导出场景的反向处理
有导入就有导出,导出时把YYYY-MM-DD写回 Excel 又是另一套逻辑。如果直接写字符串,Excel 里显示的是左对齐的文本,不能参与日期计算;如果要写真正的日期,得转成序列号写进去。
function dateToExcelSerial(dateStr) { const date = parseDateString(dateStr); if (!date) return ''; return date.getTime() / 86400000 + 25569; } // 导出时设置单元格格式为日期 const ws = XLSX.utils.json_to_sheet(data); // 遍历设置 z 属性 Object.keys(ws).forEach(key => { if (key.startsWith('!')) return; if (dateFields.includes(ws[key]?.v)) { ws[key].z = 'yyyy-mm-dd'; // 让 Excel 显示为日期 } });把YYYY-MM-DD字符串转成序列号再写入,同时设置z属性为日期格式,这样导出的 Excel 才是"真正"的日期,用户能排序、能计算。如果只是写字符串,用户拿到手会发现排序是乱的,体验差。这个反向转换的公式和正向是互逆的,date.getTime() / 86400000 + 25569就是逆运算。
7. 我的实战心得与扩展方向
做了这么多 Excel 导入导出的需求,我最深的一个体会是:日期处理没有银弹,只有一层层的兜底。你永远不知道用户会往 Excel 里填什么,也不知道用户用的是 Windows 还是 Mac、中文版还是英文版。所以代码里要有parseDateString处理数字格式,要有parseEnglishDate处理英文格式,要有isDateLike做启发式判断,要有safeFormatDate做错误标记。每一层看着都是多余的,但真到了线上,正是这些兜底让整个功能稳住了。
关于性能,再补一个实测数据。我用 3 万行的 Excel 做过测试,主线程直接解析用时要 2 秒左右,页面会明显卡顿一下;放到 Web Worker 里,主线程全程无感,总耗时 2.1 秒左右,多出来的 0.1 秒是数据传输开销。所以超过 1 万行就上 Worker,几乎没有副作用。这块如果后续要扩展,可以加上解析进度条,Worker 分片处理并实时回报进度,用户体验会更好。
最后说个小技巧:如果你不确定一个 Excel 单元格的原始结构,用console.log(JSON.stringify(sheet['A1']))打出来看看,t、v、w、z四个属性一目了然。我排查日期问题时第一步永远是打印单元格原始对象,比盲目猜快得多。这个习惯帮我省下了大量调试时间。
这套方案后续还可以往几个方向延伸:一是支持多种日期格式的输出配置,通过 pattern 参数适配不同后端要求;二是把日期处理逻辑抽成独立的 npm 包,团队内复用;三是结合后端做字段级校验,前端解析后先做一次格式校验,后端入库前再做一次业务校验,双重保险。这些都是实际项目里会遇到的进阶需求,等有精力的时候可以逐步加上。