moment时间处理常用方法与现代替代方案
后台管理系统的筛选栏最容易碰到这类需求。产品要「今天、昨天、近 7 天、近 30 天、本月」五个快捷按钮,每个按钮点下去要给后端一对 YYYY-MM-DD 的起止日期。用原生 Date 手写,光是「上个月的今天」就得先判断上个月有多少天,代码写到第三个按钮就开始打结。moment 把这类事情压成了一行。
这篇是我当年用 moment 时攒的一份速查笔记,这次回头重新整理,顺手把里面几处写错的地方改了,也补上 2018 年之后发生的变化。moment 官方现在已经把项目置于维护模式,新项目该怎么选,最后一节会说。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- moment 对象是可变的,这个特性带来的坑
format占位符里MM和mm、HH和hh的区别- 取年月日、取周几,以及
day和weekday到底差在哪 add/subtract做时间加减startOf/endOf拿一天、一月的起止时刻fromNow和diff算相对时间与时间差- 把这些拼成筛选栏的日期常量表
- moment 进入维护模式之后的替代方案与迁移思路
# 一、先记住一件事,moment 对象是可变的
这是 moment 最容易翻车的地方,也是我建议放在最前面讲的原因。
const base = moment('2018-12-06');
const next = base.add(1, 'days');
console.log(base.format('YYYY-MM-DD')); // 2018-12-07
console.log(next.format('YYYY-MM-DD')); // 2018-12-07
add 不返回新对象,它直接改掉了 base 自己,然后把 base 返回给你。所以 base 和 next 是同一个对象。你要是把一个 moment 对象存进 state 或者当成常量复用,某个地方不小心 add 了一下,其他所有地方跟着一起偏。
这个坑我踩过,排查了半天才反应过来是同一个引用。
绕开的办法只有一个,每次都从 clone() 出发:
const base = moment('2018-12-06');
const next = base.clone().add(1, 'days');
console.log(base.format('YYYY-MM-DD')); // 2018-12-06
console.log(next.format('YYYY-MM-DD')); // 2018-12-07
或者干脆每次都重新 moment() 一个新的。下面所有示例都写成 moment().xxx() 这种一次性的链式调用,就是因为一次性对象没有共享,也就没有这个问题。
# 二、format 的占位符,MM 和 mm 是两回事
最基础的用法是取当前时间,按中文格式输出。
var now = moment().format("YYYY年MM月DD日");
任意时间戳格式化,注意 moment 接收的是毫秒级时间戳:
var t1 = moment(1411641720000).format('YYYY-MM-DD HH:mm:ss');
如果你手上是后端给的秒级时间戳(10 位),得先乘 1000:
// 格式化秒级时间戳为 2018-12-06 12:21:00
moment(unixSeconds * 1000).format('YYYY-MM-DD HH:mm:ss')
这里有个坑要注意,占位符是区分大小写的,而且区分得很较真:
| 占位符 | 含义 | 常见误用 |
|---|---|---|
YYYY |
四位年 | yyyy 在 moment 里不是年 |
MM |
两位月份 | 被当成分钟 |
DD |
两位日期 | dd 是星期几的缩写 |
HH |
24 小时制小时 | hh 是 12 小时制 |
mm |
两位分钟 | 被写成 MM |
ss |
两位秒 | 被写成 SS,S 是毫秒的小数位 |
我那份老笔记里就有这么一行:
// 这行是错的
now = moment().format('YYYY-MM-DD:HH:MM:SS');
HH:MM:SS 里的 MM 输出的是月份,SS 输出的是毫秒小数位,所以拿到的时间部分完全不对。正确写法是:
now = moment().format('YYYY-MM-DD HH:mm:ss');
这个错误特别隐蔽,因为月份和分钟在某些时刻碰巧看起来都像合法数字,测试的时候未必一眼看得出来。
除了自己拼占位符,moment 还提供了几个本地化的短名:
now = moment().format('L'); // 10/22/2016,随 locale 变化
now = moment().format('LL'); // October 22, 2016,随 locale 变化
L 和 LL 输出什么完全取决于当前加载的 locale。默认是 en,所以是美式日期。要中文得先引入并切换到 zh-cn。这类跟 locale 绑定的格式,我一般只在给用户看的展示层用,传给后端的参数永远写死成 YYYY-MM-DD,免得换个环境就变样。
# 三、取年月日和周几
单独取某个部分,moment 提供了一组同名方法。
var t14 = moment().year();
var t15 = moment().month(); // 此处月份从 0 开始,当前月要 +1
var t16 = moment().date();
month() 从 0 开始这件事跟原生 Date.prototype.getMonth() 是一致的,属于历史包袱。要拿到人看的月份记得加一,或者干脆用 format('MM'),那个是从 1 开始的。
date() 是「几号」,别和 day() 搞混,day() 是「星期几」。
顺着这个说到 day 和 weekday 的差别,这是我老笔记里另一处错误:
// 老笔记写的是「获取前一天日期」,其实不是
var t11 = moment().day(0).format('YYYY-MM-DD');
day(0) 拿到的是本周的周日,不是前一天。moment 的 day() 固定以周日为 0、周六为 6,跟 locale 无关。要拿前一天,用后面讲的 subtract 才对。
var t12 = moment().weekday(5).format('YYYY-MM-DD');
var t13 = moment().weekday(-3).format('YYYY-MM-DD');
weekday() 和 day() 的区别在于,它是相对「本地化的一周第一天」来数的。在默认的 en locale 下一周从周日开始,weekday(5) 是周五;切到 zh-cn,一周从周一开始,同样的 weekday(5) 就变成周六了。负数会往前一周数,weekday(-3) 落在上一周,具体是周几同样看 locale。
所以老笔记里「获取本周五」「获取上周五」这两条注释,只在某个特定 locale 下成立。
你要是在做跨语言的项目,我的建议是别用 weekday,用 day() 加显式的加减,行为是确定的,不会因为某个地方 moment.locale('zh-cn') 一句就全变了。
# 四、加减时间用 add 和 subtract
这两个方法是 moment 用得最多的部分,签名都是 (数量, 单位)。
// 获取上个月今天的日期
var t18 = moment().subtract(1, 'months').format('YYYY-MM-DD');
// 获取上个月,只要年月
var t19 = moment().subtract(1, 'months').format('YYYY-MM');
// 获取前一天
var t20 = moment().subtract(1, 'days').format('YYYY-MM-DD');
// 获取两个小时之后
var t22 = moment().add(2, 'hours').format('YYYY-MM-DD HH:mm:ss');
// 获取五天前,例如今天 2018-7-23,拿到 2018-7-18
var t23 = moment().subtract(5, 'days').format('YYYY-MM-DD');
单位可以写单数、复数或者缩写,'day'、'days'、'd' 是同一个意思。add 传负数等价于 subtract,所以下面两行结果一样:
let beforeDay = moment().add(-5, 'day').toDate();
beforeDay = moment().subtract(5, 'day').toDate();
按月加减这块有个行为值得知道。moment 做月份加减时会保护「日」不溢出,比如 3 月 31 号减一个月,2 月没有 31 号,结果是 2 月 28 号(闰年 29 号),而不是滚到 3 月 3 号去。这跟你手写 setMonth 的默认行为是不一样的,setMonth 会溢出。
回到实际场景,这个差别决定了「上个月今天」这种需求能不能一行搞定。
toDate() 会把 moment 对象转回原生 Date,需要把值交给不认识 moment 的第三方库时会用到:
var now = moment().toDate();
# 五、startOf 和 endOf 拿区间端点
筛选栏要的是「这一天从 00:00:00 到 23:59:59」这种闭区间,手写会很啰嗦。
// 这个月月初
let startMonth = moment().startOf('month').toDate();
// 今天开始的时间
let dayOfStart = moment().startOf('day').toDate();
// 今天结束的时间
let dayOfEnd = moment().endOf('day').toDate();
startOf('day') 把时分秒毫秒全部清零,endOf('day') 设成 23:59:59.999。单位除了 day、month,还有 year、week、hour 这些。
注意 endOf 给的是 .999 毫秒而不是下一天的 00:00:00。如果后端那边是按左闭右开区间查的,直接把 endOf('day') 传过去会漏掉那一毫秒里的数据。这种边界差一点点的问题,量小的时候根本发现不了。稳妥的做法是和后端约定清楚区间是闭是开,然后统一用 startOf('day') 加上「下一天的 startOf('day')」这种左闭右开的写法。
startOf('week') 同样受 locale 影响,一周从周日还是周一开始,取决于当前 locale。
# 六、fromNow 和 diff
fromNow() 给的是「三天前」「两个月后」这种相对描述,列表页展示时间时很常用:
// 获取到现在的年限,如果不满一年显示出具体几个月
let years = moment('2020-12-31').fromNow();
输出的语言同样跟着 locale 走,默认英文的 in 2 years,切到 zh-cn 才是「2 年后」。
diff 算的是两个时间的差值,第二个参数是单位: