1. Excel 里的"日期"从来不是日期,它只是一个数字
第一次在浏览器里用 xlsx(SheetJS)解析 Excel,看到日期列输出44197这种五位数的时候,我人是懵的。明明在 Excel 里看着是2021-05-01,为什么读出来变成一个整数?后来才明白,Excel 文件格式(OOXML 里的xl/worksheets/sheet1.xml)根本不存储"日期"这个概念,所有日期时间在文件里都是双精度浮点数,也就是所谓的序列号(serial number)。你看到的2021-05-01,只是 Excel 在渲染时根据单元格的 numFmt(数字格式)把这个浮点数"画"成了日期而已。
这个认知是后面所有问题的根。如果你也卡在"前端 xlsx 解析 Excel 日期格式数据转化"这件事上,先把这一层想通,剩下的时区偏移、少一天、YYYY-MM-DD拼不对,全都是这个前提下的衍生问题。
1.1 序列号:44000 背后的换算规则
Excel 的日期系统以1900-01-01作为起点,但它不是从 0 开始计数,而是从1开始。也就是说 1900-01-01 的序列号是 1,1900-01-02 是 2,往后一天加一。小数部分代表一天之内的时间,0.5就是中午 12:00,0.25是早上 6:00。
拿44197举例,你自己心算一下就知道它对应哪天:从 1899-12-30 作为基准日加上 44197 天,正好落在 2021-05-01。这个1899-12-30的基准点非常反直觉——为什么不是 1900-01-01?原因就在下一节。
换算成代码,最朴素的写法是:
// 注意:这是"朴素版",暂时忽略 1900 闰年 bug const EXCEL_EPOCH = Date.UTC(1899, 11, 30); // 1899-12-30 const ONE_DAY = 24 * 60 * 60 * 1000; function serialToDate(serial) { return new Date(EXCEL_EPOCH + serial * ONE_DAY); } console.log(serialToDate(44197)); // 2021-05-01T00:00:00.000Z这里我特意用了Date.UTC而不是new Date(1899, 11, 30)。因为new Date(y, m, d)是按本地时区构造的,在 UTC+8 环境下它会得到1899-12-30T00:00:00+08:00,再叠加序列号之后,时间部分就会带上 8 小时的偏移。用Date.UTC把整个计算锁定在 UTC 上,输出的绝对时间点才是干净的。这一步很多人第一次写都会写错,然后发现所有日期都莫名其妙地差了几个小时。
序列号里的小数部分也好处理,乘以一天的毫秒数之后,Math.round稍微处理一下浮点误差就行。真正恶心的是整数部分的两个例外情况。
1.2 1900 年的那个假闰日,会让你的算法差一天
1900 年不是闰年,这是常识。但 Lotus 1-2-3 在 1983 年发布时犯了个错,把 1900 年当成了闰年,于是它的日期序列里凭空多出了一个1900-02-29。微软为了保证和 Lotus 的文件兼容,在 Excel 里原样保留了这个 bug,一直到今天都没修。
这个 bug 的具体表现是:序列号 59 对应1900-02-28,序列号 60 对应那个不存在的1900-02-29,序列号 61 对应1900-03-01。也就是说,从序列号 61 开始,Excel 的序列号比"真实经过的天数"多了一天。
所以上面那段"朴素版"代码在序列号小于 60 的时候是对的,一旦大于等于 60 就会算成前一天。修正版本必须分两段处理,基准日不同:
function excelSerialToUTC(serial, date1904 = false) { let s = serial; if (date1904) s += 1462; // 1904 系统转成 1900 系统 const base = s < 60 ? Date.UTC(1899, 11, 31) : Date.UTC(1899, 11, 30); return new Date(base + Math.round(s * 86400000)); } console.log(excelSerialToUTC(1).toISOString()); // 1900-01-01 console.log(excelSerialToUTC(59).toISOString()); // 1900-02-28 console.log(excelSerialToUTC(61).toISOString()); // 1900-03-01 console.log(excelSerialToUTC(44197).toISOString()); // 2021-05-01顺便说一句,44197这种量级的序列号早就越过 60 了,所以你在实际业务里基本只会走1899-12-30这条分支。但"为什么基准日是 12-30 而不是 01-01"这个问题,面试里被问到的概率不低,能答出 1900 假闰日这条线,比背公式有说服力得多。
1.3 1904 日期系统:老 Mac 版 Excel 埋的雷
还有一个更冷门的东西:1904 日期系统。早期 Mac 版 Excel 出于历史原因,把起始日定成了 1904-01-01,序列号从 0 开始。这个设置在文件里体现为工作簿属性date1904,二者之间的差值正好是 1462 天。
现在的 Windows 和 Mac 版 Excel 默认都用 1900 系统了,但只要用户是从很老的 Mac 文件一路继承下来的,或者手动勾选过"使用 1904 日期系统",你就会踩到。症状非常明显:所有日期整体往后差 4 年零 1 天左右(1462 天,约 4 年)。
SheetJS 在工作簿对象上会暴露这个标记,读取时可以直接检测:
const wb = XLSX.read(arrayBuffer, { type: 'array' }); const is1904 = !!(wb.Workbook && wb.Workbook.WBProps && wb.Workbook.WBProps.date1904); console.log('当前文件使用 1904 日期系统:', is1904);我自己的处理习惯是:读到一个文件先打这一行日志,成本几乎为零,但能省掉一次深夜排查。真要遇到 1904 的文件,把所有序列号统一加 1462 再走正常流程即可,不要去改序列号之外的东西。
2. xlsx(SheetJS)读日期的三条路线,各自会在哪一步翻车
搞清楚底层是数字之后,用 SheetJS 读日期就有三条路可选。三条路我都跑过,也都在生产环境里出过问题,这里把每一段的坑说清楚。
2.1 路线一:什么都不配,拿到一个裸数字
最简单的读法:
const wb = XLSX.read(arrayBuffer, { type: 'array' }); const ws = wb.Sheets[wb.SheetNames[0]]; const rows = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true }); console.log(rows[1]); // [ '张三', 44197, '研发部' ]raw: true表示不做格式化,直接拿单元格的原始值(.v)。好处是速度极快,坏处就是日期变成了一串数字,你得自己判断哪一列是日期。
这里有个隐藏难点:你怎么知道44197是日期,而不是某个员工的工号或者金额?肉眼看到五位数觉得像日期,但10000以内的序列号同样合法(对应 1927 年),而金额 44197 也完全不奇怪。唯一的可靠依据是单元格的 numFmt 是不是日期格式,不是数值范围。
要拿到 numFmt,得直接遍历单元格对象:
const addr = XLSX.utils.encode_cell({ r: 1, c: 1 }); const cell = ws[addr]; console.log(cell); // { t: 'n', v: 44197, w: '5/1/21', z: 'm/d/yy' } console.log(cell.z); // 'm/d/yy' —— 这就是判断依据cell.z是该单元格的数字格式串(来自 styles.xml 的 numFmt),cell.w是 SheetJS 按这个格式渲染出来的文本。判断一个格子是不是日期,最靠谱的做法就是检查z里有没有y、m、d、h、s这些日期时间占位符——当然m单独出现时要小心,它既可能是月份也可能是分钟。
这条路的完整逻辑是"先读裸值,再按 numFmt 判定,最后自己转换"。控制力最强,代码也最长。
2.2 路线二:cellDates: true,拿到 Date 但要小心它的时区语义
这是大部分教程推荐的写法:
const wb = XLSX.read(arrayBuffer, { type: 'array', cellDates: true }); const ws = wb.Sheets[wb.SheetNames[0]]; const rows = XLSX.utils.sheet_to_json(ws, { header: 1 }); console.log(rows[1]); // [ '张三', 2021-05-01T00:00:00.000Z, '研发部' ]开启之后,SheetJS 会用内部的 SSF 格式引擎判断这个数字是不是日期,是的话直接给你一个Date对象(单元格类型变成'd')。看起来一步到位,但它埋了两个雷。
第一个雷:这个 Date 的时区语义在不同版本、不同环境里并不完全一致。SheetJS 是把解析出来的年/月/日/时/分/秒按本地时间构造 Date 的(类似new Date(2021, 4, 1)),所以在你机器上getFullYear()返回 2021、getMonth()返回 4、getDate()返回 1,看起来完全正确。可一旦你随手写了toISOString().slice(0, 10),在 UTC+8 环境下这个 Date 的 UTC 时间点是2021-04-30T16:00:00Z,切出来就变成了2021-04-30——整整少一天。
第二个雷:cellDates: true会让 xlsx 在读取时多做一层格式判断和 Date 构造,几万行的大表上,耗时能明显感觉到差异。纯数字解析是直读,构造 Date 对象是有成本的。
提示:如果你打算用
cellDates: true,那就只用getFullYear / getMonth / getDate去取值,绝对不要碰toISOString。这个约定比任何补丁都管用。
2.3 路线三:raw: false,直接读 w 字段
第三条路是让 SheetJS 帮你格式化,raw: false就会输出.w字段的内容:
const rows = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false }); console.log(rows[1]); // [ '张三', '5/1/21', '研发部' ]省事是真省事,可控性差也是真差。原因是w完全取决于单元格自己的格式:这一列有人设成5/1/21,有人设成2021年5月1日,有人设成2021/05/01 00:00,你就得写一堆正则去兜。而且raw: false会触发整张表的格式化流程,性能上比raw: true差一大截,几万行数据能差出好几倍。
不过w在一种场景下特别有用:当你需要保留用户原本看到的展示样式时。比如做数据预览,想原样还原 Excel 里的观感,那w是最贴近的。
另外提一个配置项dateNF:
const wb = XLSX.read(arrayBuffer, { type: 'array', dateNF: 'yyyy-mm-dd' });它会给日期单元格的w指定一个统一格式。但要提醒一句,它的生效条件是单元格本身没有可用的日期格式定义,或者格式解析失败——不要指望它能把所有日期列统一成yyyy-mm-dd,实测中经常有列不听话。把它当辅助手段,别当主力方案。
2.4 三条路的对比与选型
| 维度 | 路线一:raw: true + 自己转 | 路线二:cellDates: true | 路线三:raw: false 读 w |
|---|---|---|---|
| 拿到的东西 | 数字序列号 | Date 对象 | 已格式化的字符串 |
| 时区风险 | 无(纯数字) | 有,取决于后续取值方式 | 无 |
| 格式可控性 | 完全可控 | 可控 | 不可控,随单元格格式变 |
| 性能 | 最好 | 中等 | 最差 |
| 代码量 | 多 | 少 | 最少 |
| 适用场景 | 导入、入库、批量处理 | 快速原型、简单展示 | 数据预览、样式还原 |
我现在的默认选择是路线一。原因很直接:YYYY-MM-DD这种格式本质上是一个"字符串需求",既然最终要拼字符串,就不要绕道 Date 对象。序列号到年月日的换算完全可以纯算术完成,全程不构造任何 Date,时区问题连出现的机会都没有。
3. 时区这一刀:为什么你和同事看到的是两个日期
"我这边跑出来是 2021-05-01,同事那边跑出来是 2021-04-30"——这个问题几乎每个用前端处理过 Excel 日期的人都遇到过至少一次。它的根子就在Date这个对象身上:Date本质上是一个绝对时间点(从 1970-01-01T00:00:00Z 起的毫秒数),而 Excel 里存的日期是一个没有时区的墙上时间。两者语义不同,硬要转换就必然出问题。
3.1 toISOString 的陷阱
toISOString()永远按 UTC 输出。如果你手里的 Date 是按本地时间构造的2021-05-01 00:00:00 +08:00,它的 UTC 表示就是2021-04-30 16:00:00,切前 10 位必然是2021-04-30。
const d = new Date(2021, 4, 1); // 本地时间 2021-05-01 00:00:00 console.log(d.getFullYear(), d.getMonth() + 1, d.getDate()); // 2021 5 1 ✅ console.log(d.toISOString()); // 2021-04-30T16:00:00.000Z 在 UTC+8 下 console.log(d.toISOString().slice(0, 10)); // '2021-04-30' ❌而如果你的用户分布在不同时区,这个偏移量还会变:UTC-5 的用户跑出来反而是2021-05-01T05:00:00Z,切出来是对的。于是你会收到"只有部分用户反馈日期错误"这种最难查的 bug。我在一个项目里就被这个坑过——测试同学在 UTC+7 的环境下测,怎么都复现不出来。
3.2 我为什么最后放弃了 Date 对象
解决时区问题有两种思路:一是继续用 Date,但取值全部改成getUTCFullYear / getUTCMonth / getUTCDate;二是彻底不用 Date,纯算术转换。
第一种思路的前提是:你拿到手的 Date 必须是"按 UTC 构造"的。但正如 2.2 节说的,SheetJS 给的是按本地时间构造的 Date,你直接用getUTC*取值反而会错。要修正就得自己把本地偏移补回去:
function fixTimezoneOffset(date) { const offset = date.getTimezoneOffset(); // 分钟,UTC+8 下是 -480 return new Date(date.getTime() + offset * 60 * 1000); }这段代码能跑,但它依赖"Date 是按本地时间构造的"这个前提,一旦 SheetJS 版本升级改了行为,它就静默失效了——这种静默失效是最可怕的,测试环境一切正常,线上才炸。
所以我最后选了第二种思路:从序列号直接算出年月日,全程不经过 Date。逻辑短、依赖少、行为在任何时区下完全一致。这也是我现在项目模板里固定的写法。
3.3 纯计算方案:从序列号直接拼字符串
核心就是把序列号拆成"天数 + 小数",然后用一套不依赖 Date 的日历算法把天数转成年月日。下面这份实现我用了两年多,跨时区、跨年份、包含 1900 假闰日和 1904 系统都测过:
function serialToYMD(serial, date1904 = false) { let s = Number(serial); if (isNaN(s)) return null; if (date1904) s += 1462; const dayPart = Math.floor(s); // 1900-01-01 的序列号是 1,这里对齐成"距 1900-01-01 的天数" let days = dayPart - 1; // 越过假闰日之后要退回一天 if (dayPart >= 61) days -= 1; // 以 1900-01-01 为起点做纯加法 let y = 1900, m = 1, d = 1 + days; const isLeap = (yy) => (yy % 4 === 0 && yy % 100 !== 0) || yy % 400 === 0; const monthLen = (yy, mm) => [31, isLeap(yy) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][mm - 1]; while (true) { const len = monthLen(y, m); if (d <= len) break; d -= len; m += 1; if (m > 12) { m = 1; y += 1; } } return { y, m, d }; } console.log(serialToYMD(44197)); // { y: 2021, m: 5, d: 1 }注意这里判断闰年用的是真实的公历规则,不是 Excel 那套带 bug 的规则——因为我们已经在dayPart >= 61那里把假闰日扣掉了,后面走的完全是真实日历。
这个函数的执行速度也值得一提。它的while循环平均只转两三次(跨月才会进循环),单次调用是微秒级,十万行数据撑死几百毫秒,比构造十万个 Date 对象轻得多。
4. 手写 YYYY-MM-DD:三行代码里的五个坑
拿到{y, m, d}之后拼字符串看起来是最没技术含量的部分,但恰恰是这里栽的人最多。我见过的写法里,十有八九有一个到两个隐藏问题。
4.1 零填充:padStart 之前先确认它的第二个参数是字符串
const pad = (n) => String(n).padStart(2, '0'); console.log(`${y}-${pad(m)}-${pad(d)}`); // 2021-05-01padStart(2, '0')是标准写法。有人会写成padStart(2, 0),多数浏览器会容忍并隐式转成字符串,但这是没必要的风险。另外注意String(n)这层显式转换——万一m是undefined(比如上游序列号解析失败),undefined.padStart会直接抛错,而String(undefined)至少能拼出一个看得见的undefined,方便定位。
4.2 别用 toLocaleDateString 拼日期
toLocaleDateString('zh-CN')输出是2021/5/1,不是2021-05-01;toLocaleDateString('sv-SE')倒是能输出2021-05-01(北欧格式恰好是 ISO 风格),但依赖这种"格式巧合"非常脆弱,换个 Node 版本或者 ICU 数据不完整的构建环境就可能变。要拼固定格式就自己拼,别指望 locale。
4.3 需要时分秒的时候,把小数部分单独处理
如果单元格里带时间(比如2021-05-01 14:30:00,序列号是44197.604166...),小数部分需要单独换算:
function serialTimePart(serial) { const frac = Math.abs(serial - Math.floor(serial)); let totalSeconds = Math.round(frac * 86400); if (totalSeconds >= 86400) totalSeconds = 86399; // 防止进位到第二天 const H = Math.floor(totalSeconds / 3600); const M = Math.floor((totalSeconds % 3600) / 60); const S = totalSeconds % 60; return { H, M, S }; }这里totalSeconds >= 86400那个兜底不是多余的。浮点数乘法的误差足以让0.9999999这类值算出 86400 秒,然后你拼出来一个24:00:00,直接把下游的数据库字段撑爆。我确实在线上见过这个报错。
另外要不要输出时间,判断标准不是"序列号有没有小数"(很多纯日期的格子会因为浮点误差带上0.0000001这种尾巴),而是看 numFmt 里有没有h或s。这个判断更靠谱。
4.4 一个完整的 formatter
把前面的东西组装起来,就是我在项目里直接复制粘贴的版本:
function formatExcelDate(serial, opts = {}) { const { date1904 = false, withTime = false, placeholder = '-' } = opts; if (serial === null || serial === undefined || serial === '') return placeholder; if (typeof serial === 'string' && !/^-?\d+(\.\d+)?$/.test(serial.trim())) { return normalizeTextDate(serial, placeholder); // 文本日期走兜底 } const num = Number(serial); if (!isFinite(num) || num < 0) return placeholder; const { y, m, d } = serialToYMD(num, date1904); const pad = (n) => String(n).padStart(2, '0'); const dateStr = `${y}-${pad(m)}-${pad(d)}`; if (!withTime) return dateStr; const { H, M, S } = serialTimePart(num); return `${dateStr} ${pad(H)}:${pad(M)}:${pad(S)}`; }placeholder参数是我后来加的。空单元格、脏数据、解析失败统一返回-,比返回null或者Invalid Date友好太多——前端渲染时不用再写一层判空,表格里也不会出现NaN-NaN-NaN这种尴尬东西。
5. 完整流程:把一张 Excel 洗成干净的 JSON
单个函数解决不了业务问题。下面把整条链路串起来,这也是我在实际项目里跑通的流程。
5.1 读文件、定位 sheet、处理表头
浏览器里读文件有两种入口:<input type="file">和拖拽。无论哪种,最后都拿到一个File对象,用arrayBuffer()读成二进制:
async function readWorkbook(file) { const buf = await file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array', cellDates: false, cellNF: true }); return wb; }注意我关掉了cellDates,反而打开了cellNF: true。cellNF的作用是让每个单元格保留z(数字格式串),这是后面判断"哪一列是日期"的唯一可靠依据。很多人不知道这个选项,不开的话cell.z可能是空的。
sheet_to_json我也不建议直接用默认配置。默认配置会把第一行当表头,遇到重名的表头会自动加_1后缀,遇到空表头会变成__EMPTY。这些隐式行为在业务里是个麻烦。我更习惯header: 1拿到二维数组,自己控制哪一行是表头:
const ws = wb.Sheets[wb.SheetNames[0]]; const matrix = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true, defval: null }); const headerRowIndex = findHeaderRow(matrix); // 见下findHeaderRow的逻辑是:从第 0 行开始往下扫,找到第一个"非空单元格数量 >= 3 且其中字符串占比超过一半"的行。这个启发式规则在真实业务表上命中率很高——因为业务表前面经常有标题行、说明行、空行。
5.2 列类型识别:怎么判定一列是日期列
这是整条链路里最关键的一步判断,判错了后面全错。我的做法是统计 + 阈值,而不是看第一行:
const DATE_FMT_RE = /(^|[^\\])([ymdhs]{1,4})/i; function isDateCell(cell) { if (!cell) return false; if (cell.t === 'd') return true; if (cell.t === 'n' && cell.z && DATE_FMT_RE.test(cell.z)) return true; if (cell.t === 'n' && isDateLikeNumber(cell.v)) return true; // 兜底 return false; }isDateLikeNumber是个兜底启发式:数值在 20000 到 60000 之间(大致对应 1954 到 2064 年),并且这一列里超过 70% 的单元格都落在这个区间。加这个兜底是因为现实中大量 Excel 是从别的系统导出来的,numFmt 信息丢了或者被写成了通用格式,这时候只能靠数值分布猜。
阈值我定的是70%,不是 100%。因为业务表里日期列总有几个空值或者写着"待定"这样的文本,卡 100% 会导致整列识别失败,然后所有日期都以数字形式流进数据库——这个事故我见过一次,修复成本相当高。
5.3 逐格转换与文本日期兜底
识别出日期列之后,就按列遍历转换:
function normalizeTextDate(raw, placeholder = '-') { if (raw === null || raw === undefined) return placeholder; const s = String(raw).trim(); if (!s) return placeholder; // 统一各种分隔符和中文单位 const cleaned = s .replace(/[年月]/g, '-') .replace(/日/g, '') .replace(/[./]/g, '-') .replace(/\s+/g, ' ') .trim(); const m = cleaned.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/); if (!m) return placeholder; const pad = (n, len = 2) => String(n).padStart(len, '0'); const y = pad(m[1], 4), mo = pad(m[2]), d = pad(m[3]); if (m[4] === undefined) return `${y}-${mo}-${d}`; return `${y}-${mo}-${d} ${pad(m[4])}:${pad(m[5])}:${pad(m[6] || 0)}`; }这段代码有两个设计取舍值得说。第一,我不做日期合法性校验(比如 2 月 30 日),只保证格式统一。因为业务上"发现脏数据"和"阻止脏数据入库"是两件事,前者应该上报给用户而不是静默丢弃。第二,不做月日互换猜测。2021/5/1我按年月日解释,5/1/2021这种我就直接返回-并计入错误列表。猜错一次比留空危险得多——用户看到空值会去核对,看到错误的日期可能直接当成正确的用。
5.4 大文件放 Worker,别卡住主线程
XLSX.read加sheet_to_json在一个 5 万行的表上,主线程能卡住两三秒。这两三秒里页面完全无响应,用户会以为崩了然后疯狂点击。
我的做法是把整个解析流程丢进 Web Worker:
// main.js const worker = new Worker('./excel.worker.js', { type: 'module' }); worker.postMessage({ file }); worker.onmessage = (e) => { const { rows, errors } = e.data; renderTable(rows); if (errors.length) showErrorPanel(errors); }; // excel.worker.js importScripts('xlsx.full.min.js'); self.onmessage = async (e) => { const buf = await e.data.file.arrayBuffer(); const wb = XLSX.read(buf, { type: 'array', cellNF: true }); const result = transform(wb); self.postMessage(result); };Worker 里做解析 + 转换,主线程只负责渲染,进度条用postMessage分段上报(每处理 5000 行报一次)。这个改造上线之后,"页面卡死"的反馈直接归零。
有个细节提醒:Worker 里没法直接用File.arrayBuffer()的某些 polyfill 版本,我在 Safari 上遇到过。稳妥做法是主线程先把 ArrayBuffer 读出来,再把 ArrayBuffer 的所有权转移给 Worker(postMessage(buf, [buf])),这样还能避免一次大内存拷贝。
6. 排错实录:五个真实场景的定位过程
6.1 合并单元格只有左上角有值
用户反馈"部门这一列一半是空的"。打开源文件一看,部门列是纵向合并单元格——A2:A10 合并成一个"研发部"。
Excel 的合并单元格在 XML 里只在左上角那个地址存值,其余地址在ws对象里根本不存在。SheetJS 不会帮你填充。处理方式有两种:一是读ws['!merges'],手动把值铺开;二是接受空值,交给下游用"上一个非空值"填充。
function expandMerges(ws, matrix) { const merges = ws['!merges'] || []; merges.forEach(({ s, e }) => { const v = matrix[s.r] && matrix[s.r][s.c]; for (let r = s.r; r <= e.r; r++) { for (let c = s.c; c <= e.c; c++) { if (!matrix[r]) matrix[r] = []; matrix[r][c] = v; } } }); return matrix; }注意合并单元格里的日期一样会遇到序列号问题,铺开之后照样要过一遍转换。
6.2 公式格读到的是缓存值
日期列是用公式算出来的(比如=A2+30),SheetJS 读到的是cell.f里存公式文本,cell.v里存上次 Excel 保存时算出的缓存值。正常情况下这个缓存值是对的,但如果文件是用程序生成、没经过 Excel 打开过,v可能是空的。
判断方式:
if (cell.f && (cell.v === undefined || cell.v === null)) { // 公式没算过,需要提醒用户用 Excel 打开另存一次 errors.push(`单元格 ${addr} 是公式且无缓存值`); }我处理这类问题的策略是报错而不是尝试计算。前端去实现 Excel 公式引擎是个无底洞,不如明确告诉用户"请用 Excel 打开文件另存为 xlsx 后再上传",一句话解决 90% 的问题。
6.3 日期列被识别成数字或文本
症状:日期列的值在页面上一部分是44197,一部分是2021-05-01。
这不是 bug,是同一列里混了两种类型。用户可能先输入了日期,后来又手打了字符串。Excel 允许列内类型不一致。所以转换逻辑必须是逐格判断,不能按列一刀切。我在 5.3 节写的那段就是逐格的:先看是不是数字、再看是不是文本,两条路分开走。
6.4 导回去之后又变成了 44000
这是个反向的坑:你把处理好的 JSON 用XLSX.utils.json_to_sheet写成 Excel,日期列变成了一串数字。
原因是你往 sheet 里塞的是字符串'2021-05-01'(或者 Date 对象),但 SheetJS 写出去的时候不会自动给这一列设置日期格式,Excel 打开时就按默认格式渲染。解决办法是写完之后手动给这些单元格设置z属性:
const ws = XLSX.utils.json_to_sheet(rows); // 给第 2 列(索引 1)设置日期格式 const range = XLSX.utils.decode_range(ws['!ref']); for (let r = 1; r <= range.e.r; r++) { const addr = XLSX.utils.encode_cell({ r, c: 1 }); if (ws[addr] && ws[addr].t === 'n') ws[addr].z = 'yyyy-mm-dd'; }注意条件t === 'n'——只有数值才能套日期格式,字符串套了没用。所以正确做法是往 sheet 里写序列号(数字),再设z,而不是写字符串。写的时候用XLSX.write(wb, { bookType: 'xlsx', type: 'array', cellDates: false }),让数字原样出去。
6.5 只有某几个用户看到日期少一天
这个就是第 3 节讲的时区问题,但排查过程值得记一下。当时的表现是:测试环境全对,生产环境部分用户错一天。
定位步骤是这样走的:先确认这些用户的共同点——发现都是对应同一批浏览器版本;然后怀疑是解析层,加了一行日志把原始序列号和最终字符串都打出来,结果显示原始序列号是对的,最终字符串少了一天;顺着字符串反查,发现中间某处用了toISOString().slice(0, 10)。
从发现到定位花了两天,从定位到修复花了十分钟。教训是:在日期处理链路上,任何一个toISOString都值得被 code review 时单独拎出来问一句。后来我把这条写进了团队的前端规约。
7. 我在项目里固定下来的几条约定
踩了这么多坑之后,我在现在带的项目里定了几条硬约定,执行下来效果挺好,直接列出来供参考。
关于对象类型:日期在传输层必须统一成YYYY-MM-DD(或带时间的YYYY-MM-DD HH:mm:ss)字符串,不允许在组件之间传 Date 对象。原因很实在——Date 对象跨时区、跨序列化(JSON.stringify 会转成 ISO 字符串再带一次时区偏移)都不稳定,而字符串的语义永远明确。需要做日期计算时,在函数内部临时转 Date,算完立刻转回字符串。
关于解析库:只用一个日期处理方式,要么全用纯算术,要么全用 dayjs 之类的库,不要混。混用是 bug 的重灾区,因为每个库对"日期"的默认语义理解都不一样。
关于测试用例:日期解析的单元测试里,我固定放这几个边界值——序列号1(1900-01-01)、59、60、61、44197(2021-05-01)、44197.5(带时间)、45000.9999999(浮点尾巴)。这组数据能把假闰日、时间进位、浮点误差三个坑一次性全覆盖。
关于时区验证:跑测试的时候记得用TZ环境变量切几个时区跑一遍。
TZ=UTC node date.test.js TZ=Asia/Shanghai node date.test.js TZ=America/New_York node date.test.js这三行如果都能过,时区这块基本就稳了。我以前只在本机跑,结果部署到 UTC 的服务器上才暴露问题,返工了一轮。
关于 numFmt:判断日期永远看cell.z,不要看数值范围,不要看表头文字。表头写"日期"的那一列,可能是文本;表头写"编号"的那一列,可能真的存了日期。
最后说个我自己的小习惯:每次接到"Excel 导入"这类需求,我会先跟业务方要至少 5 份真实文件,而不是等他们描述字段。这五份文件里通常能覆盖掉合并单元格、公式、文本日期、1904 系统这四类问题中的两三类,比写十页需求文档有用得多。日期这块尤其如此——用户永远会告诉你"日期就是 2021-05-01 这种格式",然后给你一份全是5/1/21的文件。