# 前言

本文将简要介绍前端常用日期处理库官方停止维护的 moment.js无缝代替 moment.js 的 day.js逐渐流行的 date-fns,最后基于 date-fns 封装常用日期处理的 utils

如果项目中有用 moment.js 的可以用 day.js 代替减少体积做优化,新项目可以直接选择 date-fns

# 一、常用日期处理库

# 1.1 官方停止维护的 moment.js

github:https://github.com/moment/moment

moment.js 是一个大而全的时间日期库,极大方便了我们在 JavaScript 中计算时间和日期,每周下载量超过 1200 万,已成功用于数百万个项目中。

但是,作为一个诞生于 2011 年的元老级明星项目,以现在的眼光来看 moment.js 并非完美无缺,官方总结了两大问题:

(1) 可变对象
moment 对象是 可变对象(mutable) ,简单点说,任何时间上的加减等计算都改变了其本身。这种设计让代码变得十分不可控,而且很容易带来各种隐蔽且难以调试的 bug。以至于我们在每步修改之前,都要先调用 .clone 克隆一次才能放心操作。

(2)包体积过大
因为 Momnet.js 将全部的功能和所有支持的语言都打到一个包里 ,包的大小也是到了 280.9 kB 这样一个夸张的数字,而且 对于Tree shaking无效 。如果要使用时区相关的功能,包体积更是有 467.6 kB 的大小。简单点说,我们可能只需要一个 .format 格式化时间的方法,用户就需要加载数百 kB 的库,这是十分不划算的。

2020 年 9 月,moment.js 官方宣布停止开发,进入维护状态 (如下图),后续不会再为其增加新功能,并建议新项目不要使用 moment.js,推荐使用更现代的库或 JavaScript 目前的实验性提案 Temporal 。moment 团队提供的替代方案包括: LuxonDay.jsdate-fnsjs-Joda 。他们还说,希望未来有一天能够完全不需要 JavaScript 的日期和时间库,而是使用语言本身的功能。所以他们还推荐了尚处于实验性阶段的 Temporal

# 1.2 无缝代替 moment.js 的 day.js

github:https://github.com/iamkun/dayjs

day.js 基本用法如下:

dayjs().startOf('month').add(1, 'day').set('year', 2018).format('YYYY-MM-DD HH:mm:ss');

(1)和 moment.js 相同的 API
和 moment.js 相同的 API、相同的链式操作。

(2)不可变
上面提到,可变性是使用 momentJs 时最大的问题之一。在 day.js 完全消除了这个问题,它支持不变性。

(3)体积小
Day.js 虽然仅有 2kb 大小,但是功能一点都没有阉割,包含了时间处理的全部常用方法。

# 1.3 逐渐流行的 date-fns

github:https://github.com/date-fns/date-fns

(1)模块化、按需引入
date-fns 库包含多个函数,有 200 多种功能,适用于几乎所有场合。并且是模块化的,可以根据需要单独导入这些函数。适用于 webpackBrowserifyRollup ,还支持 tree-shaking
例如,如果要计算 2 个日期之间的差值,只需要导入 formatDistancesubDays 函数。

import { formatDistance, subDays } from ‘date-fns’
formatDistance(subDays(new Date(), 3), new Date())

(2)不可变
上面提到,可变性是使用 momentJs 时最大的问题之一。在 date-fns 完全消除了这个问题,它支持不变性。

const startDate = new Date();
const endDate = add(startDate, {years: 2});
console.log(startDate) // 2022-03-13T13:39:07+01:00
console.log(endDate)   // 2024-03-13T13:39:07+01:00

(3) 同时支持 Flow 和 TypeScript

# 二、基于 date-fns 封装 utils

为什么要封装?一般 library 都是提供了基础实现

1、日期这种需要跟业务的 UI 规范结合起来
比如我们默认的日期格式是 yyyy-MM-dd HH:mm ,而不是 yyyy/MM/dd HH:mm:ss
比如日期间隔的默认格式是为 yyyy/MM/dd \- yyyy/MM/dd ,而不是 yyyy-MM-dd ~ yyyy-MM-dd 等,提供一个日期间隔的 utils,而不是每次自己拼装
...
在封装的时候写好默认格式(其实真正考虑多语言场景是不能写死格式的,因为不同国家日期的表达其实是不一样的,后面解决了这个问题,再写文章补充吧),在使用的时候就可以避免不同的人写出来的格式不一样

2、日期涉及到多语言
比如时长: 22小时17分钟1秒22h17m1s22시간17분1초
比如多久之前: 5 天前5 days ago
...
date-fns 提供的多语言不一定能百分百契合,在封装的时候处理好

# 2.1 getTimestamp

获取当前时间或者某个时间的秒级时间戳

import { getUnixTime } from 'date-fns';
/**
 * @author zxyue25
 * @desc 获取当前时间或者某个时间的秒级时间戳;
 * 如果入参是毫秒秒级时间戳(13 位),则去除最后三位 1000 返回毫秒级(13 位)时间戳;主要场景在前端需要入参拿秒级时间戳给后端作为入参
 * @param date - Date | Number
 * @returns 返回格式化后的秒级时间戳 - Number
 * @example
 * ```
 * getTimestamp () // 1658320260
 * getTimestamp (new Date ()) // 1658320260
 * getTimestamp (new Date ().getTime ()) // 1658320260
 * getTimestamp (Date.parse (new Date ())) // 1658320260
 * getTimestamp (1658312707) // 1658312
 * getTimestamp (1) // 0
 * ```
 */
export const getTimestamp = (date: number | Date = new Date()): number => getUnixTime(date);

# 2.2 getMilliTimestamp

获取当前时间或者某个时间的毫秒级时间戳

import { getTime } from 'date-fns';
/**
 * @author zxyue25
 * @desc 获取当前时间或者某个时间的毫秒级时间戳;
 * 如果入参是秒级时间戳(10 位),则乘以 1000 返回毫秒级(13 位)时间戳;主要场景在 server 返回了秒级时间戳,前端先乘以 1000 转换成日期展示
 * TODO:这里传入了其他数字怎么处理?直接返回还是补齐 13 位返回?date-fns 是直接返回,建议直接返回
 * @param date - Date | Number
 * @returns 返回格式化后的毫秒级时间戳 - Number
 * @example
 * ```
 * getMilliTimestamp () // 1658320372160
 * getMilliTimestamp (new Date ()) // 1658320372160
 * getMilliTimestamp (new Date ().getTime ()) // 1658320372160
 * getMilliTimestamp (Date.parse (new Date ())) // 1658320372000
 * getMilliTimestamp (1658312707) // 1658312707000
 * getMilliTimestamp (1) // 1
 * ```
 */
export const getMilliTimestamp = (date?: number | Date): number => {
  if (!date) {
    return getTime(new Date());
  } else {
    if (date instanceof Date) {
      return getTime(date);
    } else {
      if (date.toString().length === 10) {
        return getTime(date * 1000);
      } else {
        return date;
      }
    }
  }
};

# 2.3 formatDate

将时间戳转换为指定格式的日期;入参可以是秒级时间戳、毫秒级时间戳;

import { format } from 'date-fns';
/**
 * @author zxyue25
 * @desc 将时间戳转换为指定格式的日期;入参可以是秒级时间戳、毫秒级时间戳;
 * 如果入参是秒级时间戳(10 位),会乘以 1000 转换;格式默认为 'yyyy-MM-dd HH:mm';
 * 主要场景:通常情况下时间戳转换日期不传格式,而是用默认格式;后端返回的时间戳一般是秒级时间戳,如果直接用 date-fns 需要自己乘 1000 传入
 * @param date - Date | Number
 * @param formatStr - String
 * @returns 返回格式化后的秒级时间戳 - Number
 * @example
 * ```
 * formatDate (1658320372161) // 2022-07-20 20:32
 * format (1658320372, 'yyyy-MM-dd HH:mm') // 1970-01-20 12:38
 * formatDate (1658320372) // 2022-07-20 20:32
 * formatDate (new Date ()) //  2022-07-21 11:28
 * formatDate (1658320372000, 'yyyy/MM/dd HH:mm:ss') // 2022/07/20 20:32:52
 * ```
 */
export const formatDate = (date: number | Date, formatStr = 'yyyy-MM-dd HH:mm') => {
  if (typeof date === 'number' && date.toString().length === 10) {
    return format(date * 1000, formatStr);
  } else {
    return format(date, formatStr);
  }
};

# 2.4 formatDateRange

将两个时间戳或者 Date 日期转换为指定格式的日期,并用指定连接符连接;

import { formatDate } from './formatDate';
/**
 * @author zxyue25
 * @desc 将两个时间戳或者 Date 日期转换为指定格式的日期,并用指定连接符连接;
 * 入参可以是秒级时间戳、毫秒级时间戳或者 Date 日期;如果入参是秒级时间戳(10 位),会乘以 1000 转换;
 * 格式默认为 `${yyyy/MM/dd} -`${yyyy/MM/dd}';如果想更改格式,可传入第三个参数(日期的格式),第四个参数(连接符的格式)
 * @param startDate - Date | Number
 * @param endDate - Date | Number
 * @param formatStr - String 默认格式 'yyyy/MM/dd'
 * @param joinStr - String 默认连接符 '-'
 * @returns 返回格式化后的日期区间字符串
 * @example
 * ```
 * formatDateRange (1658320372161, 1658717927699) // 2022/07/20 - 2022/07/25
 * formatDateRange (1658320372, 1658717927) // 2022/07/20 - 2022/07/25
 * formatDateRange (1658320372, 1658717927, '', '~') // 2022/07/20 ~ 2022/07/25
 * formatDateRange (1658320372, 1658717927, 'yyyy/MM/dd HH:mm', '~') // 2022/07/20 20:32 ~ 2022/07/25 10:58
 * ```
 */
export const formatDateRange = (
  startDate: number | Date,
  endDate: number | Date,
  formatStr = 'yyyy/MM/dd',
  joinStr = '-',
) => `${formatDate(startDate, formatStr || 'yyyy/MM/dd')} ${joinStr} ${formatDate(endDate, formatStr || 'yyyy/MM/dd')}`;

# 2.5 formatDateDistance

获取指定时间距离当前时间或者指定时间多远

import { formatDistance } from 'date-fns';
import { getMilliTimestamp } from './getMilliTimestamp';
import { LANGUAGE_DATE_FNS_MAP } from './locale';
/**
 * @author zxyue25
 * @desc 获取指定时间距离当前时间或者指定时间多远;
 * @param date - Date | Number
 * @param baseDate - Date | Number,默认为当前时间
 * @param options - 扩展项,可以配置语言,有两种方式:传入语言类型 lang;或者直接传入 Locale;
 * @returns 返回描述 “指定时间距离当前时间或者指定时间多远” 的字符串,有多语言处理
 * @example
 * ```
 * formatDateDistance (1658320372161, 1658717927699, { lang: 'zh-CN' }) // 5 天前
 * formatDateDistance (1658320372161, 1658717927699, { lang: 'zh-CN', addSuffix: 'false' }) // 5 天
 * formatDateDistance (1658320372, 1658717927) // 5 days ago'
 * formatDateDistance (new Date ('2022-07-12'), new Date ('2022-07-17')) // 5 days ago
 * formatDateDistance (new Date ('2022-07-05'), new Date ('2022-07-12')) // 7 days ago
 * formatDateDistance (new Date ('2022-06-12'), new Date ('2022-07-12')) //about 1 month ago
 * formatDateDistance (new Date ('2021-07-12'), new Date ('2022-07-12')) //about 1 year ago
 * formatDateDistance (1658320372, 1658717927, { locale: ko }) // 5일 전
 * ```
 */
type OptionType = {
  locale?: Locale;
  addSuffix?: boolean;
  lang?: keyof typeof LANGUAGE_DATE_FNS_MAP;
};
export const formatDateDistance = (
  date: Date | number = 0,
  baseDate: Date | number = new Date(),
  options?: OptionType,
): string => {
  const initOptions = {
    addSuffix: options?.addSuffix || true,
    locale: options?.locale || LANGUAGE_DATE_FNS_MAP[options?.lang || 'en'],
    ...options,
  };
  if (
    (typeof date === 'number' && date.toString().length === 10) ||
    (typeof baseDate === 'number' && baseDate.toString().length === 10)
  ) {
    return formatDistance(getMilliTimestamp(date), getMilliTimestamp(baseDate), initOptions);
  }
  return formatDistance(date, baseDate, initOptions);
};

# 2.6 formatDateDuration

将指定秒转为‘H 小时 M 分钟 S 秒’,H、M、S 为 0 时,默认不展示;如果想更改格式可传入第二个扩展参数 options

import { formatDuration } from 'date-fns';
import { LANGUAGE_DATE_FNS_MAP } from './locale';
/**
 * @author zxyue25
 * @desc 将指定秒转为‘H 小时 M 分钟 S 秒’,H、M、S 为 0 时,默认不展示;如果想更改格式可传入第二个扩展参数 options
 * @param second - Number,多少秒
 * @param options - 扩展项,可以配置语言,有两种方式:传入语言类型 lang;或者直接传入 Locale;
 * @returns 返回描述 “H 小时 M 分钟 S 秒” 的字符串,有多语言处理
 * @example
 * ```
 * formatDateDuration (71) // 1minute11seconds
 * formatDateDuration (71, { lang: 'zh-CN' }) // 1 分钟 11 秒
 * formatDateDuration (3604, { lang: 'zh-CN' }) // 1 小时 4 秒
 * formatDateDuration (80221, { lang: 'zh-CN' }) // 22 小时 17 分钟 1 秒
 * formatDateDuration (80221, { locale: ko }) // 22시간17분1초
 * formatDateDuration (80221, { lang: 'zh-CN', delimiter: ',' }) // 22 小时,17 分钟,1 秒
 * formatDateDuration (80221, { lang: 'zh-CN', format: ['hours', 'minutes'] }) // 22 小时 17 分钟
 * formatDateDuration (80220, { lang: 'zh-CN' }) // 22 小时 17 分钟
 * formatDateDuration (80220, { lang: 'zh-CN', zero: true }) // 22 小时 17 分钟 0 秒
 * formatDateDuration (880220, { lang: 'zh-CN' }) // 244 小时 30 分钟 20 秒
 * ```
 */
type OptionType = {
  locale?: Locale;
  zero?: boolean;
  delimiter?: string;
  format?: Array<string>;
  lang?: keyof typeof LANGUAGE_DATE_FNS_MAP;
};
export const formatDateDuration = (second: number, options?: OptionType) => {
  const hours = Math.floor(second / 3600);
  const minutes = Math.floor((second % 3600) / 60);
  const seconds = Math.floor(second % 60);
  return formatDuration(
    {
      hours,
      minutes,
      seconds,
    },
    {
      zero: options?.zero || false,
      locale: options?.locale || LANGUAGE_DATE_FNS_MAP[options?.lang || 'en'],
      ...options,
    },
  ).replace(/ /g, '');
};