dayjs如何实现的日期格式化、多语言?

5,731 阅读7分钟

前言

在平时开发的过程中,肯定会遇到需要操作日期、比较日期、格式化日期的情况的。
为了满足以上的需求,出现了很多的日期JS库,momentdayjsdate-fns等,这些都是比较流行的日期库。

但是moment已经停止维护, 而dayjs本身的api与moment基本相同, 因此可以比较方便的学习和迁移。

并且dayjs本身就具有轻量级国际化日期操作无副作用(即日期操作相关api都将返回新的实例)的特点,能够满足大部分的日期相关需求。

因此今天来看看dayjs是怎么实现的。

本文为了顾及不同水平的读者,因此写得可能会有点啰嗦,不过整体实现比较简单, 相信大部分读者都能很容易的理解。

dayjs 基本使用

在分析dayjs源码前,知道怎么使用dayjs能够更好的理解后续的内容。
因此为了方便没使用过dayjs的读者,这里列出了一些基本的使用例子。

1.解析ISO时间格式,然后格式化为YYYY/MM/DD形式

const dayjs = require('dayjs')

// 输出: 2023/01/12
dayjs('2023-01-12T08:00:00.000Z').format('YYYY/MM/DD') 

2.解析时间戳, 然后格式化为YYYY-MM-DD形式

const dayjs = require('dayjs')

// 输出:2023-01-12
dayjs(1673514666940).format('YYYY-MM-DD')

3.解析当前时间, 然后格式化为YYYY-MM-DD形式

const dayjs = require('dayjs')

dayjs().format('YYYY-MM-DD')

format方法传入格式化的占位符,format执行时,会将对应占位符替换为对应的数值。支持的占位符有:

标识示例描述
YY23年,两位数
YYYY2023年,四位数
M1-12月,从1开始
MM01-12月,两位数
MMMJan-Dec月,英文缩写
MMMMJanuary-December月,英文全称
D1-31
DD01-31日,两位数
d0-6一周中的一天,星期天是 0
ddSu-Sa最简写的星期几
dddSun-Sat简写的星期几
ddddSunday-Saturday星期几,英文全称
H0-23小时
HH00-23小时,两位数
h1-12小时, 12 小时制
hh01-12小时, 12 小时制, 两位数
m0-59分钟
mm00-59分钟,两位数
s0-59
ss00-59秒,两位数
S0-9毫秒(十),一位数
SS00-99毫秒(百),两位数
SSS000-999毫秒,三位数
Z-05:00UTC 的偏移量,±HH:mm
ZZ-0500UTC 的偏移量,±HHmm
AAM / PM上/下午,大写
aam / pm上/下午,小写

dayjs 源码分析

从上面的示例中,可以看到使用dayjs分为两步:
1.通过dayjs函数创建dayjs实例。
2.调用实例的format方法进行日期的格式化。

我们先从第一步开始,分析实例的创建过程中,都做了什么事情。

dayjs 源码分析-创建对象实例

var dayjs = function dayjs(date, c) {

  // 省略部分非核心代码

  /**
  * 初始化Dayjs类的构造函数中需要的配置参数cfg。
  * 将date添加到cfg配置参数后, 传递给Dayjs类的构造函数创建Dayjs实例。
  */
  var cfg = typeof c === 'object' ? c : {};
  cfg.date = date;
  cfg.args = arguments;

  return new Dayjs(cfg);
};

以上代码中,主要就是通过Dayjs类创建dayjs实例, 然后直接返回。
Dayjs类的构造函数实现如下:

function Dayjs(cfg) {

    // 省略部分非核心代码实现
    
    this.parse(cfg);
}

Dayjs.prototype.parse = function parse(cfg) {
    /**
    * 第一步:将传入的日期参数解析为js的Date对象。
    * 因为传入的日期参数存在多种类型, 如时间戳、日期格式字符串、原生Date实例等不同格式,
    * 因此parseDate函数内部处理各种不同类型的参数,然后返回Date对象。
    * 稍后再分析parseDate函数
    */
    this.$d = parseDate(cfg);
    
    /**
    * 第二步:通过parseDate获取到的Date实例, 将年月日时分秒等信息初始化到dayjs实例中。
    */
    this.init();
};

// 将年月日时分秒等信息初始化到dayjs实例中
Dayjs.prototype.init = function init() {
    var $d = this.$d;
    this.$y = $d.getFullYear();
    this.$M = $d.getMonth();
    this.$D = $d.getDate();
    this.$W = $d.getDay();
    this.$H = $d.getHours();
    this.$m = $d.getMinutes();
    this.$s = $d.getSeconds();
    this.$ms = $d.getMilliseconds();
}

parseDate函数实现如下

// 处理执行dayjs函数时的日期参数。
function parseDate(cfg) {
  var date = cfg.date,
      utc = cfg.utc;
      
  /**
  * 传入null时, 返回无效日期。
  * 如: dayjs(null)
  */
  if (date === null) return new Date(NaN);

  /**
  * 传空时, 返回当前日期。
  * 如: dayjs()。
  * Utils.u函数功能:判断date是否为undefined
  */
  if (Utils.u(date)) return new Date();

  /**
  * 传递原生Date日期实例, 创建新的Date实例然后返回(避免直接操作原数据源可能导致的副作用影响)。
  * 如: dayjs(new Date(1318781876406))。
  * Utils.u函数功能:判断date是否为undefined
  */
  if (date instanceof Date) return new Date(date);

  /**
  * 传递日期格式字符串时
  * 如:dayjs('2023-01-12T08:00:00.000') 或 dayjs('2023/01/12T08:00:00.000')
  * 
  */
  if (typeof date === 'string' && !/Z$/i.test(date)) {
     /**
     * 匹配类似‘2023-01-12T08:00:00.000’和‘2023/01/12T08:00:00.000’的日期格式。
     * 因为浏览器不支持2023/01/12T08:00:00.000斜杠形式的日期格式,
     * 因此dayjs中对斜杠形式的日期做了兼容处理。
     * 不同的dayjs版本的正则不同。
     * 旧版本可能不匹配类似‘2023/01/12T08:00:00.000’斜杠的日期格式
     */
    var d = date.match(/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/);

    if (d) {
      // 获取月份数值, Date中的月份从0开始, 因此需要减去1
      var m = d[2] - 1 || 0;
      
      // 获取毫秒数值
      var ms = (d[7] || '0').substring(0, 3);

      /**
      * 处理utc时间。
      * 如: dayjs('2023-01-12T08:00:00.000', { utc: true })。
      * utc时间为世界标准时间, 与我们的本地时间存在8个小时时差。
      * 更多详情可自行查阅, 这里不懂的话可先跳过utc的处理。
      */
      if (utc) {
        return new Date(Date.UTC(d[1], m, d[3] || 1, d[4] || 0, d[5] || 0, d[6] || 0, ms));
      }

      return new Date(d[1], m, d[3] || 1, d[4] || 0, d[5] || 0, d[6] || 0, ms);
    }
  }

  return new Date(date);
}

因此执行dayjs函数时, 函数内部主要是通过Dayjs类创建dayjs实例返回。
Dayjs类的构造函数主要只是做了以下两件事情。
1.执行parseDate,获取Date实例对象。由于执行dayjs时,传入的日期格式存在多种类型, 因此通过parseDate处理日期参数解析为Date实例返回, 并且进行了一些日期格式的兼容处理。
2.将年月日时分秒等信息初始化到dayjs实例中, 供后续使用。

dayjs 源码分析-format方法

format为格式化日期的方法,以下代码为了更容易理解进行了部分的简化和修改,但核心不变。

function format(formatStr) {
    // 获取当前语言的多语言资源, 稍后分析如何实现的多语言
    var locale = this.$locale();

    /**
     * 根据传入的hour小时, 返回当前时刻处于上午或下午
     * hour小于12则返回上午, 大于12返回下午
     * 
     * AM: 表示上午
     * PM: 表示下午
     * 
     * 可通过多语言中的locale.meridiem自定义逻辑(如中文时返回‘上午’、‘下午’, 而不是‘AM’、‘PM’)
    */
    var meridiemFunc = locale.meridiem || function (hour, isLowercase) {
      var m = hour < 12 ? 'AM' : 'PM';
      return isLowercase ? m.toLowerCase() : m;
    };

    /**
     * 第一步。
     * 从文章开头的基本使用中, 我们能看到format函数支持很多的占位符。
     * 而 matchs 中是存储不同占位符的对应数值的集合。
     *
     * this.$y、this.$M等在分析构造函数源码时有讲过,忘了可以回头看看。
     * 以 dayjs('2023-01-12T13:01:02.000').format('YYYY-MM-DD') 为例
     */ 
     var matches = {
       // 获取年份的后两位,即23
       YY: String(this.$y).slice(-2), 
       // 获取完整年份,即2023
       YYYY: this.$y,
       // 月份从0开始,因此需要加1
       M: this.$M + 1, 即1
       // 如果月份不足2位数,则在开头补0。如示例中为‘1’月份,不足2位数,则在开头补0,即01
       MM: padStart(this.$M + 1, 2, '0'),
       // 获取简写的月份名称,如在多语言为英语时, 即Jan
       MMM: getShort(locale.monthsShort, this.$M, locale.months, 3),
       // 获取全称的月份名称,如在多语言为英语时, 即January
       MMMM: getShort(locale.months, this.$M),
       // 月份的日期, 即12
       D: this.$D,
       // 如果日期不足2位数,则在开头补0。如示例中为‘12’日,满足两位,不需补0,即12
       DD: padStart(this.$D, 2, '0'),
       // 星期, 示例中为星期四,即4
       d: String(this.$W),
       // 星期的简写, 如在多语言为英语时, 即Th
       dd: getShort(locale.weekdaysMin, this.$W, locale.weekdays, 2),
       // 星期的简写, 如在多语言为英语时, 即Thu
       ddd: getShort(locale.weekdaysShort, this.$W, locale.weekdays, 3),
       // 星期的全称, 如在多语言为英语时, 即Thursday
       dddd: locale.weekdays[this.$W],
       // 24小时制的小时,即13
       H: String(this.$H),
       // 24小时制的小时,即13
       HH: padStart(this.$H, 2, '0'),
       // 12小时制的小时(超过12重新从1开始),即1
       h: padStart(this.$H % 12 || 12, 1, '0'),
       // 12小时制的小时(超过12重新从1开始),不满足2位, 在开头补0,即01
       hh: padStart(this.$H % 12 || 12, 2, '0'),
       // 上面有解释meridiemFunc的功能, 即‘am’
       a: meridiemFunc(this.$H, true),
       // 上面有解释meridiemFunc的功能, 即‘AM’
       A: meridiemFunc(this.$H, false),
       // 分钟,即1
       m: String(this.$m),
       // 分钟,即01
       mm: padStart(this.$m, 2, '0'),
       // 秒,示例中为0,即0
       s: String(this.$s),
       // 秒,示例中为0,即00
       ss: padStart(this.$s, 2, '0'),
       // 毫秒,示例中为0,即000
       SSS: padStart(this.$ms, 3, '0'),
    };

    /**
     * 第二步。
     * 通过正则列举所有支持的占位符, 然后进行匹配, 匹配到的占位符替换成matches中对应的值
     * 
     * 如执行: dayjs('2023-01-12T08:00:00.000').format('YYYY-MM-DD')
     * 则replace中的match分别为YYYY、MM、DD
     * 而YYYY、MM、DD在matches中分别对应2023、01、12
     * 因此结果为2023-01-12
    */
    var REGEX_FORMAT = /\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g;
    return formatStr.replace(REGEX_FORMAT, function (match, $1) {
      /**
       * 额外补充, 此处不感兴趣可跳过。
       * 如执行:dayjs('2023-01-12T08:00:00.000').format('[YYYYescape] YYYY-MM-DD')。
       * 中括号里的占位符不会被替换为对应数值。
       * 因此输出的结果为: 'YYYYescape 2023-01-12'
       * 
       * 而该特性的实现就是通过正则中的第一段'\[([^\]]+)]', 该段正则匹配中括号的内容。
       * 而$1则是中括号的内容, 因此会直接返回, 不会替换为对应matches中的数值
      */
      return $1 || matches[match];
    });

    /**
     * 当string的长度不足length时, 在开头以pad作为填充字符填充不足的位置
     * 如: padStart(1, 2, 0), 
     * 字符1的字符数量不足2,因此用0补充
     * 因此输出:01
    */
    function padStart(string, length, pad) {
        var s = String(string);
        if (!s || s.length >= length) return string;
        return "" + Array(length + 1 - s.length).join(pad) + string;
    };

    /**
    * 获取arr中的index位置元素
    * 如果arr不存在, 则获取full中的index位置元素, 并且截取该元素的length长度
    */
    function getShort(arr, index, full, length) {
        return (arr && arr[index]) || full[index].slice(0, length);
    };
}

以上代码主要分为两步:
1.获取不同日期占位符的数值到matches集合中。
2.通过正则匹配不同的日期占位符, 然后将占位符替换matches中对应的数值。

dayjs 源码分析-多语言实现

在分析format方法的时候, 我们看到了format方法执行过程中,调用了$locale方法获取当前语言的多语言资源。那么现在,我们来看看$locale方法是怎么实现的。

// 默认加载英语的语言包
const en  = require('./locale/en.js');

// 默认的语言语言环境。(创建dayjs实例的时候,如果未传递语言环境,则使用该默认的设置)
var L = 'en';

// 语言环境的语言包集合
var Ls = {};

// 将英语语言包添加到语言包集合中
Ls[L] = en;

function $locale() {
    return Ls[this.$L];
};

Ls为所有语言环境的语言包集合,Ls内默认只存在英语的语言包。
$L为当前实例的语言环境,默认en
$locale方法只是根据当前语言,从语言包集合中返回对应的语言包。

知道了Ls为语言包集合,$L为当前实例的语言环境。下一步我们还得知道如何扩展Ls中的语言包,以及$L语言环境是在什么时候设置、怎么改变的。

我们先看下$L是在什么时候设置的。在分析Dayjs类的构造函数的时候, 其实我省略了初始化多语言的处理。

更完整的Dayjs类的构造函数如下:

function Dayjs(cfg) {

    /**
    * 初始化当前的多语言环境。
    * 如果外部传递了多语言环境,则使用外部传递的, 如dayjs('2023-01-12T08:00:00.000', { locale: 'zh' })
    * 否则返回默认的‘en’
    * 稍后详情分析parseLocale
    */
    this.$L = parseLocale(cfg.locale, null, true);
    
    // 该函数在上面有分析过
    this.parse(cfg);
}

以上只是在创建dayjs实例时初始化的多语言环境, 但如果是执行过程中,想改变多语言环境,可以有两种方式:
1.全局改变, 即以后所有创建的dayjs实例都会使用改变后的多语言环境。

// 全局改变, 直接调用dayjs函数上的locale方法
dayjs.locale('zh')

然后我们看下全局的locale方法怎么实现, 其实全局的locale方法就是等于Dayjs构造函数时调用的parseLocale,我们等会再分析parseLocale

dayjs.locale = parseLocale;

2.只改变某个dayjs实例的多语言环境。

// 调用dayjs获取dajys实例后, 再调用实例的locale方法
const dayjs1 = dayjs().locale('zh')

再看下实例上的locale方法怎么实现。

Dayjs.prototype.locale = function locale(preset, object) {
    // 省略部分非核心代码
    
    /*
    * 第一步
    * 复制新的dayjs实例(因此设置的多语言环境,影响的是复制出的新实例,而非当前实例)
    */
    var that = this.clone();
    
    /*
    * 第二步(重点)
    * 其实还是通过调用parseLocale进行多语言的处理, 只是第三个参数设置为true, 稍后分析parseLocale
    */
    var nextLocaleName = parseLocale(preset, object, true);
    
    /*
    * 第三步
    * 将parseLocale返回的多语言环境设置到当前实例上
    */
    if (nextLocaleName) that.$L = nextLocaleName;
    
    /*
    * 第四步
    * 返回新实例
    */ 
    return that;
 };

看了全局设置局部设置的实现后, 可以看到最核心的还是parseLocale
那么现在,我们就来看看parseLocale做了什么事情。

// 处理语言环境或设置语言包资源
function parseLocale(preset, object, isLocal) {
  // 需要返回的语言环境结果。
  var l;

  /*
  * 第一步
  * 未传递多语言环境时, 则返回全局默认的多语言
  */ 
  if (!preset) return L;


  /*
  * 第二步
  * 处理语言环境、添加语言包到语言包集合中
  */
  if (typeof preset === 'string') {
    var presetLower = preset.toLowerCase();

    // 如果传递了多语言环境, 还需要在当前语言包集合中判断是否存在该语言环境的多语言, 存在才返回该语言环境
    if (Ls[presetLower]) {
      l = presetLower;
    }

    /**
     * 存在object则表示为语言环境设置对应的语言包
     * 
     * 如 parseLocale('zh', { 多语言数据 })
     */
    if (object) {
      Ls[presetLower] = object;
      // 返回设置的语言环境
      l = presetLower;
    }

    /**
     * 额外处理, 向下兼容语言包。
     * 
     * 如设置语言环境parseLocale('zh-cn')时,
     * 语言包中不存在'zh-cn', 那么将回退为使用‘zh’
    */
    var presetSplit = preset.split('-');
    if (!l && presetSplit.length > 1) {
      return parseLocale(presetSplit[0]);
    }
  } else {
    /**
     * preset对象, 则对象中必须存在name属性.
     * name为语言
     * preset则为该语言对应的语言包数据
     */
    var name = preset.name;
    Ls[name] = preset;
    l = name;
  }

  /*
  * 第三步
  * 处理是否全局设置
  * isLocal默认为false, 即默认是全局设置
  * 如果是全局,则将语言环境设置到全局的L变量中
  * 那么后续创建dayjs实例时,则会返回全局L变量的语言(第一步中未传自定义语言环境,直接返回全局L)
  */
  if (!isLocal && l) L = l;
  
  // 将语言结果l返回, 或语言结果l不存在, 且是全局时,则返回全局L变量的语言
  return l || !isLocal && L;
}

通过上面的代码, 其实可以看到,parseLocale中主要做了以下几件事:
1.处理语言环境, 然后返回语言环境。
2.为语言环境在Ls语言包集合中添加语言包。
3.如果是全局设置,则将语言环境设置到全局的L变量中,为后续创建的dayjs实例直接使用全局的L语言。

因为不管是全局的locale还是局部的locale方法, 都是基于parseLocale实现, 也就是locale不仅可以切换多语言环境,还可以为多语言环境指定对应的语言包

有了切换语言环境添加语言包这两步后, 后续只需要在需要用到多语言的地方调用$locale方法获取当前语言环境语言包进行使用就可以。

总结

最后来个小总结。

format日期格式化:
1.获取不同的日期格式的占位符数据, 然后将数据添加到matches集合中。 2.通过正则匹配不同的日期占位符,然后将占位符替换为matches集合中的数据。

多语言:
1.创建dayjs实例时,通过parseLocale获取当前的语言环境,然后设置到实例的$L实例中。
2.如果需要拓展多语言资源包, 可通过locale方法为不同的语言设置语言包(locale内调用的也是parseLocale)。
3.有了语言环境语言包集合, 后续就可以在需要用到多语言的地方调用$locale获取当前语言环境的语言包资源。

最后

除了日期格式化、多语言外,dayjs中比较常用到的还有日期的操作、日期比较的相关的功能,后面会再出一篇继续看看其他常见的功能实现,希望文章的内容对你有所帮助。

最后的最后,你可以从功能的实现、代码的组织、可读性等任何的角度思考下dayjs中做得比较好或者能够优化的地方吗?