
你没看错一个简单的“JS获取当前年月日”居然能在项目里翻车。前两周我还在帮同事查一个线上 bug表单里默认时间是“2025-9-5”用户没手动改就提交后端解析时以为中间是两位月份结果日期错乱。你说这算程序员的疏忽吗是也不是。因为 JavaScript 里getMonth()返回的是从 0 开始计数的索引9 月实际上是8而不少新手甚至老手都会在字符串拼接时漏掉这一层转换。再加上月份和日期如果是单数还要考虑补零补零的方式又五花八门。这篇文章不聊框架不聊 Vue/React 的封装就老老实实把 JavaScript 获取当前年月日、输出YYYY-mm-dd和YYYY年mm月dd日这件事讲透——包括每种写法的原理、隐藏的边界情况、项目里真正该注意的坑我会按自己的实操经验一步步拆给你看。适用读者刚接触 JavaScript 的前端新手、写工具函数想复用一段时间的开发者、以及对日期处理“总觉得哪里不对但说不清”的中间水平同学。如果你已经熟练到闭眼能写出toISOString().slice(0,10)也不妨读读时区那段说不定能帮你规避几个线上隐患。1. 先拆开 Date 对象为什么 getMonth() 会少 1 个月1.1 四个“老演员”getFullYear、getMonth、getDate、getDayJavaScript 里所有日期相关操作追根溯源都来自内置的Date对象。创建一个表示当前时刻的实例很简单const now new Date(); console.log(now); // 输出类似Thu Jan 11 2025 09:30:00 GMT0800 (中国标准时间)这个now对象身上带着年月日时分秒、毫秒、星期几、时区等一堆信息。我们最常用的四个方法分别是getFullYear()返回四位数的年份比如 2025。注意不是getYear()getYear()实际返回的是“当前年份减 1900”早就被弃用了别被老代码带沟里。getMonth()返回月份范围是 0-11也就是 0 代表 1 月11 代表 12 月。getDate()返回月份中的第几天范围 1-31。这个没什么幺蛾子但注意方法名是getDate不是getDay。getDay()返回星期几范围 0-60 是周日。这个今天用不到但经常被拿来和getDate()混淆我顺便提一嘴。我见过无数次这种情况// 错误示范 const month now.getMonth(); // 如果今天是 9 月返回的是 8 console.log(现在是 ${month} 月); // 输出现在是 8 月只要没做 1那么 9 月 1 日输入到表单里就会变成 8 月 1 日全部错位一个月。轻则表单显示不对重则业务统计月报对不上这种情况在月初尤其隐蔽因为 1 月的返回值是 0反而不会引起注意。1.2 一个“看起来没问题”的错误示例再给大家看一个更阴间的写错姿势。有些人知道要加 1也写了补零但还是错了const now new Date(2025-01-05T00:00:00); function formatDateWrong(date) { const year date.getFullYear(); const month date.getMonth() 1; const day date.getDate() 1; // 这里画蛇添足 return ${year}-${month.toString().padStart(2, 0)}-${day.toString().padStart(2, 0)}; } console.log(formatDateWrong(now)); // 2025-01-06看到没有问题出在getDate() 1。有些同学可能把“月份索引从 0 开始”这件事记混了连带着觉得“日期也要加 1”结果就是 5 号变成 6 号。这个错误如果不配合日期断言测试根本发现不了还特别容易在月末、季度末这种时间节点酿成大祸。所以在真正写封装之前先把Date这几位“老演员”的脾气摸清楚年份四位数直接拿月份必须加 1日期按字面拿星期索引另算。这样后面写格式化函数时你才不会被突如其来的“偏移 1”搞崩溃。2. 两种目标格式的完整实现从字符串拼接到防坑封装2.1 最容易理解的字符串拼接版既然要输出YYYY-mm-dd和YYYY年mm月dd日最朴素也最直观的思路就是先把年月日分别取出来再用模板字符串拼起来function formatDateSlash(date new Date()) { const year date.getFullYear(); const month date.getMonth() 1; const day date.getDate(); return ${year}-${month}-${day}; } function formatDateChinese(date new Date()) { const year date.getFullYear(); const month date.getMonth() 1; const day date.getDate(); return ${year}年${month}月${day}日; } console.log(formatDateSlash()); // 2025-9-5 console.log(formatDateChinese()); // 2025年9月5日到这里已经能跑了。但你想过没有——标题里写的格式是YYYY-mm-dd也就是月份要两位2025-09-05而不是2025-9-5。上面这版直接拼接单数月份和单数日期都不会补零。如果只是给人看2025年9月5日完全没问题中文习惯本来就不要求补零。但如果是给机器看、存数据库、做日期字符串比较2025-9-5就统一不了你还要在别处再判断长度。所以接下来必须处理补零。2.2 用 padStart 做两位数补零为什么它是最稳妥的字符串方法padStart是从 ES2017 开始普及的作用是让一个字符串填充到指定长度。它接收两个参数目标长度和填充字符。5.padStart(2, 0); // 05 12.padStart(2, 0); // 12 2025.padStart(2, 0); // 2025长度已经超过 2不生效用它做单数月和单数日期补零几乎是当前最优雅的方案。因为月份最大 12日期最大 31目标长度都是 2填充字符是0天然合适function padZero(num) { return num.toString().padStart(2, 0); } function formatDateWithDash(date new Date()) { const year date.getFullYear(); const month padZero(date.getMonth() 1); const day padZero(date.getDate()); return ${year}-${month}-${day}; } function formatDateChineseCN(date new Date()) { const year date.getFullYear(); const month padZero(date.getMonth() 1); const day padZero(date.getDate()); return ${year}年${month}月${day}日; } console.log(formatDateWithDash()); // 2025-09-05 console.log(formatDateChineseCN()); // 2025年09月05日这里有个小分叉YYYY年mm月dd日这种格式月份和日期到底该不该补零我翻了大量国内站点两种写法的支持者都有。但既然格式定义里写明了mm和dd我倾向于补零。原因很简单——格式的意义就是长度统一否则你没法保证后续做字符串排序或者正则匹配时不会出问题。你可以在项目内部统一定义但最好一开始就跟格式描述保持一致。另外提一个更老的补零技巧(0 month).slice(-2)。它在 ES5 时代很流行现在也能用。原理是把字符串左边拼一个0再从右边截两位。比如月份是 5(05).slice(-2)得到05月份是 12(012).slice(-2)得到12。判断逻辑完全均匀没有任何边界问题。如果你的项目还在兼容非常老的环境或者你干脆想炫技这种写法仍然成立。const month String(date.getMonth() 1); const paddedMonth (0 month).slice(-2);但坦白讲项目里没有硬性兼容 IE 的话我用padStart更多语义更清晰别人接手代码时一眼就能读懂意图。2.3 把两种格式合并一次封装自由切换实际工作中你可能不止需要两种固定格式。比如接口要求YYYY-mm-dd页面展示要求YYYY年mm月dd日还有导出 CSV 时要求YYYY/mm/dd。与其写三个几乎一样的函数我习惯封装成一个带格式化参数的通用函数function formatDate(date new Date(), pattern YYYY-mm-dd) { const year date.getFullYear(); const month padZero(date.getMonth() 1); const day padZero(date.getDate()); return pattern .replace(YYYY, year) .replace(mm, month) .replace(dd, day); } console.log(formatDate(new Date(), YYYY-mm-dd)); // 2025-09-05 console.log(formatDate(new Date(), YYYY年mm月dd日)); // 2025年09月05日 console.log(formatDate(new Date(), YYYY/mm/dd)); // 2025/09/05这个封装的巧妙之处在于它没有用switch去枚举所有格式而是利用字符串替换把模式里的占位符按顺序换掉。只要pattern里包含YYYY、mm、dd这三个占位符任意组合都能输出。以后产品有新格式要求比如YYYY年dd日mm月虽然不太可能你也不需要动函数体只传新的 pattern 就行。不过要提醒一句.replace不加正则全局标志时只替换第一个匹配项。所以模式里别出现两个YYYY或两个mm否则第二个不会被替换。如果要支持YYYY年mm月dd日这种正常场景完全没问题。真要支持重复占位符那就需要replaceAll或者正则/YYYY/g写法return pattern .replaceAll(YYYY, year) .replaceAll(mm, month) .replaceAll(dd, day);replaceAll是 ES2021 的方法现代浏览器和 Node.js 15 都支持。从我的经验看字符串替换版本已经足够应付 90% 以上的项目需求不需要再引入额外依赖。3. 时区、UTC、本地时间getFullYear 系列背后的大坑3.1 为什么toISOString().slice(0, 10)可能在下午 8 点变成“明天”网上流传着一种极简写法const today new Date().toISOString().slice(0, 10); // 得到 2025-09-05原理是toISOString()会把当前时间转换为 ISO 8601 格式的 UTC 字符串类似2025-09-05T08:30:00.000Z然后从开头截 10 个字符正好是YYYY-MM-DD。这段代码在纯前端、位于 UTC8 时区的中国浏览器里绝大多数情况运行正常但有个隐藏雷点toISOString()永远返回UTC 时间而不是用户的本地时间。假设你在北京时间UTC8的晚上 20:00 运行new Date()本地时间其实是2025-09-05 20:00但 UTC 时间才2025-09-05 12:00截出来的日期还是 9 月 5 日没问题。可如果你在晚上 23:30 运行本地时间2025-09-05 23:30UTC 时间则是2025-09-05 15:30也没问题。但是假设你在UTC8 时区的凌晨 6:30运行本地时间2025-09-05 06:30UTC 时间却是2025-09-04 22:30截出来就是2025-09-04——日期整整倒退一天。同理你在西半球比如 UTC-5 的纽约晚上 20:00 运行这段代码UTC 时间已经是第二天的凌晨 01:00截出来就是“明天”的日期。所以toISOString().slice(0, 10)这个写法本身没问题问题是它代表的是 UTC 日期不是用户所在时区的本地日期。中国用户还好只有凌晨那几个小时会出偏差但如果你面向全球用户或者你的服务器日志用这段代码记录日期那排查问题时会怀疑人生。3.2 getFullYear 系列为什么没有这个问题getFullYear()、getMonth()、getDate()这一组方法返回的都是基于本地时区的值。也就是说new Date()内部的时刻值包含了时间戳当你调用getFullYear()时JavaScript 会先根据运行时环境通常是操作系统的时区设置把这个时刻换算成本地日历时间再返回年份。举例北京时间 2025-09-05 06:30本地时刻的new Date().getFullYear()返回2025。同一个时刻在纽约时区如果你在本地运行new Date()得到的也是本地时间可能已经是 2025-09-04 的时候了因为时区差getFullYear()返回2025但如果跨年边界它就返回2024。所以这两类 API 的本质区别是API 系列基准时区适用场景getFullYear()/getMonth()/getDate()运行时本地时区业务展示、表单提交、本地日期逻辑getUTCFullYear()/getUTCMonth()/getUTCDate()UTC 通用协调时服务器时间戳、日志、跨时区对比toISOString()/toJSON()UTC网络传输、存储标准格式如果你的目标是“用户看到的日期就是本地日期”那安心用getFullYear()系列这篇文章后面的代码也都是基于这一组方法。如果后端接口和前端约定用 ISO 标准时间传输那toISOString()反而是更好的选择只是要清楚它的 UTC 属性别误拿来做表单默认值。3.3 夏令时会不会影响日期格式化夏令时DST主要影响的是“当天的小时数”比如某些地区春夏时会少一个小时导致当天只有 23 小时。但注意getFullYear()、getMonth()、getDate()这些方法反映的是日历日期不受“一小时偏移”的影响。因为Date对象在内部存储 UTC 时间戳而本地化方法在执行时已经做了时区换算。夏令时只会让“当天有多少小时”变化不会让日期本身跳变。唯一需要小心的是夏令时切换日的午夜可能有 00:30 之类的时间偏移但如果你只取年月日不取时分秒基本不会碰到这个坑。4. 我踩过的坑从“表单默认值自动变前一天”到“月份没补零导致后端报错”4.1 排查链路一表单默认日期的时区陷阱有一年我维护一个后台管理系统用户反馈“新增订单时订单日期默认显示的是昨天”。我第一反应是代码里哪里写死了减一天结果翻遍了整个表单组件都没找到。后来我仔细看初始值const orderDate new Date().toISOString().slice(0, 10); this.formData.order_date orderDate;表面看没有任何问题对吧但用户环境是北京时间而且他们在晚上 8 点以后打开表单。按照前面讲的时区逻辑21:00 的 UTC 时间是当天 13:00还没跨日但23:00 的 UTC 时间是当天 15:00也还没跨日。等等那什么时候会变昨天我重新算了一下北京时间凌晨 0 点到 7:59 之间UTC 时间还在前一天。用户通常不会凌晨 7 点前去打开后台所以这个 bug 理论上不太容易复现。但我还是决定不赌直接把代码改成getFullYear()系列。改为本地时间后不管几点打开日期都是当天的。这种问题不出现则已一出现就是大规模反馈而且非常难定位根源。后来我在团队里立了个规矩业务系统里凡是“用户视角的日期”一律不准用toISOString()取年月日全用本地时间方法。如果你接手了类似代码可以先在控制台跑一下console.log(new Date().toISOString().slice(0, 10)); console.log(${new Date().getFullYear()}-${String(new Date().getMonth() 1).padStart(2, 0)}-${String(new Date().getDate()).padStart(2, 0)});对比输出再结合当前时区判断就能确认是不是时区问题。4.2 排查链路二从getMonth()少一个月到埋点数据错月另一个项目里的问题更隐蔽数据看板里“本月新增用户数”总是比预期少一点。我一开始以为 SQL 写错了后来查到这里const startOfMonth ${new Date().getFullYear()}-${new Date().getMonth()}-01;注意这里没有 1。也就是说9 月 15 日运行时startOfMonth是2025-8-01等于把整个 8 月当成“本月”的开头拉取的数据自然不对。这种 bug 在高流量的仪表盘页面上会直接影响业务决策比表单展示严重得多。排查过程倒不复杂在控制台打印new Date().getMonth()发现输出是8再对照文档确认索引从 0 开始所有疑惑瞬间就通了。这个坑给我留下的教训是凡是涉及月份的输出第一件事写注释// getMonth() 从 0 开始所以 1。别小看这一行注释两个月后再看代码你十有八九会感谢它。4.3 排查链路三字符串比较时2025-9-30排在2025-10-01后面还有一个我记忆犹新的案例是关于“补零”的。那时项目里有一个日期排序功能前端把日期字符串交给后端排序。本来以为后端能处理结果发现某几个订单排序总是不对。查下来发现前端提交的日期字段是2025-9-30这种非补零格式后端按字符串排序2025-9-30和2025-10-01比较时因为9的字符编码比1大所以 9 月 30 日反而排在 10 月 1 日后面。解决方法就是前端统一用padStart补零让所有日期字符串都是定长的YYYY-MM-DD。这个案例说明一个道理格式定义里的mm、dd不是摆设它是为了让字符串排序、正则匹配、存储统一而存在的。如果你输出的日期长度不一后续处理方很可能埋雷。5. 在真实项目里怎么落地默认值、日志、上报、校验5.1 表单默认值用户看到今天就是今天说回开头那个表单场景正确的做法是把上面封装的函数放进一个公共utils/date.js文件// utils/date.js export function padZero(num) { return String(num).padStart(2, 0); } export function getTodayDash(date new Date()) { return ${date.getFullYear()}-${padZero(date.getMonth() 1)}-${padZero(date.getDate())}; } export function getTodayChinese(date new Date()) { return ${date.getFullYear()}年${padZero(date.getMonth() 1)}月${padZero(date.getDate())}日; }然后在组件里这样用import { getTodayDash } from /utils/date; this.formData.date getTodayDash();这段代码没有任何时区依赖用户在任何时区打开这个页面看到的都是他自己的本地日期。对表单类需求来说这是最符合直觉的表现。5.2 日志时间戳毫秒级唯一标识与日期分开如果你写前端日志埋点需要给每一条日志打时间戳我建议把“日期”和“完整时间戳”分开处理。日期用getTodayDash()完整时间戳用Date.now()或者new Date().toISOString()传给后端。为什么这样分因为日志排查经常需要按天分目录一个纯日期字段可以方便地做聚合而完整时间戳保留着毫秒级精度能精确到单条请求的前后顺序。两者混在一个字符串里虽然也能看但查询时往往还要再 split反而麻烦。const logPayload { date: getTodayDash(), timestamp: new Date().toISOString(), message: user clicked submit };我之前碰到过一个日志平台它的索引按天滚动如果前端上报的date字段不是严格YYYY-MM-DD格式会生成错误的索引分区查日志时怎么都查不到当天记录。最后回溯才发现是前端某个地方用了new Date().toLocaleDateString(zh-CN)在 Chrome 下输出的是2025/9/5斜杠分隔、没有补零完全不符合日志平台约定。这类问题不是“跑不跑得通”而是“隐式地不兼容”最容易坑到人。5.3 跨时区对比什么时候用 ISO什么时候用本地日期后端如果要求你这样传{ date: 2025-09-05T16:00:00.000Z }这就不是年月日展示问题了而是完整的时间点。这时候前端可以直接用new Date()传给后端后端自行转换时区。如果你的业务是按“用户本地日期”去查询某一天的数据那后端应该接收YYYY-MM-DD并且接口文档里明确“该字段代表用户本地日期”。在这个前提下前端用getTodayDash()完全合理。反之如果你要记录“操作发生的绝对时间点”请用 ISO 字符串不要用本地日期拼出来的字符串。我建议在任何传日期的接口旁边写清楚语义哪怕只是注释// 这个字段表示订单创建当天的用户本地日期不是 UTC orderDate: getTodayDash()这种细节是避免“前后端互相甩锅”的良药。6. 从格式化到日期工具库该不该用 dayjs 或 date-fns6.1 只做一件事原生就够如果只是获取当前年月日、转两种格式我个人不会引入任何第三方库。原因很简单原生Date的三四个方法足以实现而且代码短、无依赖、不会出现库版本升级导致的 breaking change。打包体积也少那么几十 KB对首屏性能有一点帮助。但如果你的项目和日期打交道特别多——比如要做日期加减、两个日期相差天数、每月的第几个星期几、按周聚合、农历转换——那我建议直接上dayjs。它体积只有 2KB 左右gzip 后API 与moment.js高度一致但不再有moment.js那种“全部打包进来”的沉重感。示例import dayjs from dayjs; dayjs().format(YYYY-MM-DD); // 2025-09-05 dayjs().format(YYYY年MM月DD日); // 2025年09月05日 dayjs().subtract(7, day).format(YYYY-MM-DD); // 上周同一天dayjs内部也严格遵循本地时区format(YYYY-MM-DD)不会出现 UTC 偏移问题。所以如果你要频繁做日期运算它确实是更省心的选择。但注意dayjs的format并不在默认核心包里不对其实默认核心包已经包含基础格式化能力不需要额外插件。真正需要插件的是weekday、advancedFormat、duration这些扩展能力。6.2 我的取舍经验三行原生函数远好过一百行“通用工具”有些团队喜欢把日期格式化函数做成一个超大的formatDate(date, YYYY年MM月DD日 HH:mm:ss)万能方法支持各种占位符。做得好确实好用但做不好就变成“需求一变不敢动”的代码。我自己经历过的最糟糕的版本是一个大约 200 行的格式化工具支持YY、YYYY、M、MM、D、DD、H、HH、m、s等十几种占位符还支持相对时间输出。最后项目交接时新同事完全不敢改因为每个分支相互嵌套太多。后来我把它拆成三四个简单函数getTodayDash()、getTodayChinese()、formatTimestamp()每个函数心智负担都低反而用得更顺手。所以我的建议是分几步走项目初期只要getTodayDash()和getTodayChinese()两个函数精确满足当前需求。需求扩展后出现多种模式时再引入带 pattern 参数的formatDate()仿照上面的字符串替换版本。需要大量日期运算时才考虑dayjs或date-fns但用法要统一封装到utils/date.js里避免业务代码到处引。6.3 一个容易被忽略的隐藏接口Intl.DateTimeFormat最后分享一个我最近偏爱的现代写法它也不依赖第三方库而是利用 JavaScript 内置的国际化 APIfunction getTodayDash(date new Date()) { const parts new Intl.DateTimeFormat(zh-CN, { year: numeric, month: 2-digit, day: 2-digit, }).formatToParts(date); const map {}; parts.forEach((part) { if (part.type ! literal) map[part.type] part.value; }); return ${map.year}-${map.month}-${map.day}; }这里借助Intl.DateTimeFormat的能力让浏览器根据 locale 和指定选项来格式化和分割日期。它会自动补零也严格使用本地时区。好处是不用手动padStart缺点是对新手而言formatToParts的返回值结构需要一点理解成本。我在项目里常用它处理“多语言格式化需求”因为只需要替换 locale 字符串就能拿不同的本地化输出比写一堆替换规则更可靠。对比一下四种常见输出的差异实现方式输出示例时区基准推荐场景原生getFullYear()拼接2025-09-05本地业务表单、展示padStart补零封装2025年09月05日本地要求定长字符串toISOString().slice(0,10)2025-09-04UTC服务器时间、日志标准格式Intl.DateTimeFormat2025-09-05本地多语言国际化这几条路没有绝对的对错关键是团队约定和接口契约。无论如何我建议把日期格式化收敛在一个文件里所有同事共同使用而不是每个页面写一遍自己的拼接逻辑——这是我踩过无数坑之后最想对你说的经验。我自己在实际业务里写日期格式化最频繁的场景反而不是输出到页面上而是给生成的文件命名、给批量任务生成批次号。比如导出报表时文件名往往会拼上日期这时候同一个函数就能派上用场const fileName 订单数据_${getTodayDash()}.xlsx; // 订单数据_2025-09-05.xlsx这类需求看起来不起眼但一个稳定、无时区坑、定长格式的日期函数会让它们变得极其省心。如果你再往深走一步可以把getTodayChinese()用在页面标题、浏览器document.title里直接展示给用户看。代码都不长但细节里的坑是实打实的。希望这篇整理能帮你避开我当年走过的弯路。