dayjs源码解析(一):概念、locale、constant、utils

3,805 阅读12分钟

插播一个新系列:时间库 dayjs 的源码解析。

用官方的描述 “Day.jsMoment.js 的 2kB 轻量化方案,拥有同样强大的 API”。优点是如下三个:

  • 简易:Day.js 是一个轻量的处理时间和日期的 JavaScript 库,和 Moment.jsAPI 设计保持完全一样。
  • 不可变:所有的 API 操作都将返回一个新的 Dayjs 实例。这种设计能避免 bug 产生,节约调试时间。
  • 国际化:Day.js 对国际化支持良好。但除非手动加载,多国语言默认是不会被打包到工程里的。

总的来说,dayjs 的优点就是 pluginlocale 手动按需加载,减少打包体积。

dayjs 是饿了么的大佬 iamkun 开发维护的,大佬同时也是 ElementUI 的开发者。解析之前先从dayjs 源代码仓库 fork 了一份:github.com/MageeLin/da…

时间是 2020 年 12 月 7 日,commitIDeb5fbc4c。解析时从 master 分支拉了一个新分支 analysis

打算分五章完成,目录如下:

  1. dayjs 源码解析(一):概念、locale、constant、utils
  2. dayjs 源码解析(二):Dayjs 类
  3. dayjs 源码解析(三):插件(上)
  4. dayjs 源码解析(四):插件(中)
  5. dayjs 源码解析(五):插件(下)

代码结构

目录结构

源代码的目录结构如下所示:

dayjs
│  .editorconfig // 编辑器配置
│  .eslintrc.json // ESLint配置
│  .gitignore // git忽略配置
│  .npmignore // npm发布忽略配置
│  .travis.yml // 持续集成配置
│  babel.config.js // babel配置
│  CHANGELOG.md // 更新日志
│  CONTRIBUTING.md // 共建指南
│  karma.sauce.conf.js // karma测试配置
│  LICENSE // 许可声明
│  package.json
│  prettier.config.js // prettier 格式化配置
│  README.md
│
├─.github // github的一些配置
├─build // 构建打包
├─docs // 各语言的说明文档
├─src
│  │  constant.js // 常量
│  │  index.js // 主入口,定义Dayjs类
│  │  utils.js // 工具函数
│  │
│  ├─locale // 国际化
│  └─plugin // 插件
├─test // 测试
└─types // TypeScript

依赖结构

入口 src/index.js 的依赖如下所示:

依赖结构图

可以发现依赖链特别简单,没有依赖到 localeplugin 目录下的语言包和插件。这也就是 dayjs 的核心优点。

基础概念

在分析源码之前,先理解下一些相关的基础概念。

时间标准

几种时间标准的解释来自维基百科。

GMT

格林尼治平均时间(Greenwich Mean Time,GMT)是指位于英国伦敦郊区的皇家格林尼治天文台当地的平太阳时,因为本初子午线被定义为通过那里的经线。

自 1924 年 2 月 5 日开始,格林尼治天文台负责每隔一小时向全世界发放调时信息。

格林尼治标准时间的正午是指当平太阳横穿格林尼治子午线时(也就是在格林尼治上空最高点时)的时间。由于地球每天的自转是有些不规则的,而且正在缓慢减速,因此格林尼治平时基于天文观测本身的缺陷,已经被原子钟报时的协调世界时(UTC)所取代。

UTC

协调世界时(英语:Coordinated Universal Time,法语:Temps Universel Coordonné,简称 UTC)是最主要的世界时间标准,其以原子时秒长为基础,在时刻上尽量接近于格林威治标准时间。

协调世界时是世界上调节时钟和时间的主要时间标准,它与 0 度经线的平太阳时相差不超过 1 秒,并不遵守夏令时。

现行的协调世界时根据国际电信联盟的建议《Standard-frequency and time-signal emissions》(ITU-R TF.460-6)所确定。UTC 基于国际原子时,并在必要时通过不规则的加入闰秒来抵消地球自转变慢的影响。

如果本地时间比 UTC 时间快,例如中国、蒙古、菲律宾、新加坡、马来西亚、澳大利亚西部的时间比 UTC8 小时,就会写作 UTC+8,俗称东八区。相反,如果本地时间比 UTC 时间慢,例如夏威夷的时间比 UTC 时间慢 10 小时,就会写作 UTC-10,俗称西十区

ISO

国际标准 ISO 8601,是国际标准化组织的日期和时间的表示方法,全称为《数据存储和交换形式·信息交换·日期和时间的表示方法》。目前是 2004 年 12 月 1 日发行的第三版“ISO8601:2004”

在 Javascript 中的 Date.prototype.toISOString() 中,返回的是 YYYY-MM-DDTHH:mm:ss.sssZ格式的字符串,时区总是 UTC(协调世界时),加一个后缀“Z”标识。

Date 对象输出时间的格式

Javascript 的 Date.prototype 上有很多种方式可以输出时间,以时间戳 1607561462990 为例,在 Chrome87 中返回值如下表:

方法格式输出
valueOf时间戳1607561462990
getTimeGMT 时间戳1607561462990
toString英语格式的本地时间字符串Thu Dec 10 2020 08:51:02 GMT+0800 (中国标准时间)
toUTCString英语格式的 UTC 时间字符串Thu, 10 Dec 2020 00:51:02 GMT
toGMTString(标准已废弃)英语格式的 GMT 时间字符串Thu, 10 Dec 2020 00:51:02 GMT
toISOStringISO 格式的 UTC 时间字符串2020-12-10T00:51:02.990Z
toLocaleString字符串格式因不同语言而不同2020/12/10 上午 8:51:02
toJSONtoISOString 相同2020-12-10T00:51:02.990Z

语言(文化)代码

不同语言对事物的描述方式肯定不同,即使同一种语言由于文化地区差异,对相同事物的描述也有区别,所以国际上就形成了一套标准来识别各种语言。

先放一篇 Hax 的回答BCP47 规范,对于汉语代码来说,按照标准应该使用 zh-cmn-Hans-CNzh-cmn-Hant-HKzh-cmn-Hans-SGzh-cmn-Hant-TW。但是由于历史的原因,广泛应用的是zh-CNzh-HKzh-SGzh-TW

引用一个通用的语言列表

语言代码
国家|地区
"" (空字符串)   无变化的文化
af  公用荷兰语
af-ZA   公用荷兰语 - 南非
sq  阿尔巴尼亚
sq-AL   阿尔巴尼亚 -阿尔巴尼亚
ar  阿拉伯语
ar-DZ   阿拉伯语 -阿尔及利亚
ar-BH   阿拉伯语 -巴林
ar-EG   阿拉伯语 -埃及
ar-IQ   阿拉伯语 -伊拉克
ar-JO   阿拉伯语 -约旦
ar-KW   阿拉伯语 -科威特
ar-LB   阿拉伯语 -黎巴嫩
ar-LY   阿拉伯语 -利比亚
ar-MA   阿拉伯语 -摩洛哥
ar-OM   阿拉伯语 -阿曼
ar-QA   阿拉伯语 -卡塔尔
ar-SA   阿拉伯语 - 沙特阿拉伯
ar-SY   阿拉伯语 -叙利亚共和国
ar-TN   阿拉伯语 -北非的共和国
ar-AE   阿拉伯语 - 阿拉伯联合酋长国
ar-YE   阿拉伯语 -也门
hy  亚美尼亚
hy-AM   亚美尼亚的 -亚美尼亚
az  Azeri
az-AZ-Cyrl  Azeri-(西里尔字母的) 阿塞拜疆
az-AZ-Latn  Azeri(拉丁文)- 阿塞拜疆
eu  巴斯克
eu-ES   巴斯克 -巴斯克
be  Belarusian
be-BY   Belarusian-白俄罗斯
bg  保加利亚
bg-BG   保加利亚 -保加利亚
ca  嘉泰罗尼亚
ca-ES   嘉泰罗尼亚 -嘉泰罗尼亚
zh-HK   中国 -香港
zh-MO   中国 -澳门
zh-CN   中国 -中国
zh-CHS  中国 (单一化)
zh-SG   中国 -新加坡
zh-TW   中国 -台湾
zh-CHT  中国 (传统的)
hr  克罗埃西亚
hr-HR   克罗埃西亚 -克罗埃西亚
cs  捷克
cs-CZ   捷克 - 捷克
da  丹麦文
da-DK   丹麦文 -丹麦
div Dhivehi
div-MV  Dhivehi-马尔代夫
nl  荷兰
nl-BE   荷兰 -比利时
nl-NL   荷兰 - 荷兰
en  英国
en-AU   英国 -澳洲
en-BZ   英国 -伯利兹
en-CA   英国 -加拿大
en-CB   英国 -加勒比海
en-IE   英国 -爱尔兰
en-JM   英国 -牙买加
en-NZ   英国 - 新西兰
en-PH   英国 -菲律宾共和国
en-ZA   英国 - 南非
en-TT   英国 - 千里达托贝哥共和国
en-GB   英国 - 英国
en-US   英国 - 美国
en-ZW   英国 -津巴布韦
et  爱沙尼亚
et-EE   爱沙尼亚的 -爱沙尼亚
fo  Faroese
fo-FO   Faroese- 法罗群岛
fa  波斯语
fa-IR   波斯语 -伊朗王国
fi  芬兰语
fi-FI   芬兰语 -芬兰
fr  法国
fr-BE   法国 -比利时
fr-CA   法国 -加拿大
fr-FR   法国 -法国
fr-LU   法国 -卢森堡
fr-MC   法国 -摩纳哥
fr-CH   法国 -瑞士
gl  加利西亚
gl-ES   加利西亚 -加利西亚
ka  格鲁吉亚州
ka-GE   格鲁吉亚州 -格鲁吉亚州
de  德国
de-AT   德国 -奥地利
de-DE   德国 -德国
de-LI   德国 -列支敦士登
de-LU   德国 -卢森堡
de-CH   德国 -瑞士
el  希腊
el-GR   希腊 -希腊
gu  Gujarati
gu-IN   Gujarati-印度
he  希伯来
he-IL   希伯来 -以色列
hi  北印度语
hi-IN   北印度的 -印度
hu  匈牙利
hu-HU   匈牙利的 -匈牙利
is  冰岛语
is-IS   冰岛的 -冰岛
id  印尼
id-ID   印尼 -印尼
it  意大利
it-IT   意大利 -意大利
it-CH   意大利 -瑞士
ja  日本
ja-JP   日本 -日本
kn  卡纳达语
kn-IN   卡纳达语 -印度
kk  Kazakh
kk-KZ   Kazakh-哈萨克
kok Konkani
kok-IN  Konkani-印度
ko  韩国
ko-KR   韩国 -韩国
ky  Kyrgyz
ky-KZ   Kyrgyz-哈萨克
lv  拉脱维亚
lv-LV   拉脱维亚的 -拉脱维亚
lt  立陶宛
lt-LT   立陶宛 -立陶宛
mk  马其顿
mk-MK   马其顿 -FYROM
ms  马来
ms-BN   马来 -汶莱
ms-MY   马来 -马来西亚
mr  马拉地语
mr-IN   马拉地语 -印度
mn  蒙古
mn-MN   蒙古 -蒙古
no  挪威
nb-NO   挪威 (Bokm?l) - 挪威
nn-NO   挪威 (Nynorsk)- 挪威
pl  波兰
pl-PL   波兰 -波兰
pt  葡萄牙
pt-BR   葡萄牙 -巴西
pt-PT   葡萄牙 -葡萄牙
pa  Punjab 语
pa-IN   Punjab 语 -印度
ro  罗马尼亚语
ro-RO   罗马尼亚语 -罗马尼亚
ru  俄国
ru-RU   俄国 -俄国
sa  梵文
sa-IN   梵文 -印度
sr-SP-Cyrl  塞尔维亚 -(西里尔字母的) 塞尔
sr-SP-Latn  塞尔维亚 (拉丁文)- 塞尔维亚共
sk  斯洛伐克
sk-SK   斯洛伐克 -斯洛伐克
sl  斯洛文尼亚
sl-SI   斯洛文尼亚 -斯洛文尼亚
es  西班牙
es-AR   西班牙 -阿根廷
es-BO   西班牙 -玻利维亚
es-CL   西班牙 -智利
es-CO   西班牙 -哥伦比亚
es-CR   西班牙 - 哥斯达黎加
es-DO   西班牙 - 多米尼加共和国
es-EC   西班牙 -厄瓜多尔
es-SV   西班牙 - 萨尔瓦多
es-GT   西班牙 -危地马拉
es-HN   西班牙 -洪都拉斯
es-MX   西班牙 -墨西哥
es-NI   西班牙 -尼加拉瓜
es-PA   西班牙 -巴拿马
es-PY   西班牙 -巴拉圭
es-PE   西班牙 -秘鲁
es-PR   西班牙 - 波多黎各
es-ES   西班牙 -西班牙
es-UY   西班牙 -乌拉圭
es-VE   西班牙 -委内瑞拉
sw  Swahili
sw-KE   Swahili-肯尼亚
sv  瑞典
sv-FI   瑞典 -芬兰
sv-SE   瑞典 -瑞典
syr Syriac
syr-SY  Syriac-叙利亚共和国
ta  坦米尔
ta-IN   坦米尔 -印度
tt  Tatar
tt-RU   Tatar-俄国
te  Telugu
te-IN   Telugu-印度
th  泰国
th-TH   泰国 -泰国
tr  土耳其语
tr-TR   土耳其语 -土耳其
uk  乌克兰
uk-UA   乌克兰 -乌克兰
ur  Urdu
ur-PK   Urdu-巴基斯坦
uz  Uzbek
uz-UZ-Cyrl  Uzbek-(西里尔字母的) 乌兹别克
uz-UZ-Latn  Uzbek(拉丁文)- 乌兹别克斯坦
vi  越南
vi-VN   越南 -越南

locale

对于 day.js 来说,同样也是实现了很多种语言的国际化,都放置在 src/locale 目录下,跟语言代码稍微有点不同的就是命名全部小写。

由于 day.js 是按需加载的,所以在使用某种语言前需要提前引入:

import 'dayjs/locale/zh-cn';

dayjs.locale('zh-cn'); // 全局使用
dayjs().locale('zh-cn').format(); // 当前实例使用

其实 dayjs/locale/xxx.js 种保存的是对应语言的各种模板和配置,以 zh-cn.js 为例:

// Chinese [zh]
import dayjs from 'dayjs';

// locale 对象
const locale = {
  name: 'zh', // 对象的名,关键
  // 数组都是用 split 实现
  // weekdays 数组
  weekdays: '星期日_星期一_星期二_星期三_星期四_星期五_星期六'.split('_'),
  // 可选,简写 weekdays 数组,没有就用前 3 个字符
  weekdaysShort: '周日_周一_周二_周三_周四_周五_周六'.split('_'),
  // 可选,最简写 weekdays 数组,没有就用前 2 个字符
  weekdaysMin: '日_一_二_三_四_五_六'.split('_'),
  // months 数组
  months: '一月_二月_三月_四月_五月_六月_七月_八月_九月_十月_十一月_十二月'.split(
    '_'
  ),
  // 可选,简写 months 数组,没有就用前 3 个字符
  monthsShort: '1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月'.split('_'),
  /**
   * @description: 返回例如3周,2日
   * @param {Number} number 第几个
   * @param {String} period 单位标志
   * @return {String}
   */
  ordinal: (number, period) => {
    switch (period) {
      case 'W':
        return `${number}周`;
      default:
        return `${number}日`;
    }
  },
  // 可选,设置一周的开始,默认周日,1 代表周一
  weekStart: 1,
  // 可选,设置一年的开始周,包含1月4日的那一周作为第一周
  yearStart: 4,
  // 格式化模板
  formats: {
    LT: 'HH:mm',
    LTS: 'HH:mm:ss',
    L: 'YYYY/MM/DD',
    LL: 'YYYY年M月D日',
    LLL: 'YYYY年M月D日Ah点mm分',
    LLLL: 'YYYY年M月D日ddddAh点mm分',
    // 小写或者简写
    l: 'YYYY/M/D',
    ll: 'YYYY年M月D日',
    lll: 'YYYY年M月D日 HH:mm',
    llll: 'YYYY年M月D日dddd HH:mm',
  },
  // 相对时间的格式化模板,保正 %s %d 相同
  relativeTime: {
    future: '%s后',
    past: '%s前',
    s: '几秒',
    m: '1 分钟',
    mm: '%d 分钟',
    h: '1 小时',
    hh: '%d 小时',
    d: '1 天',
    dd: '%d 天',
    M: '1 个月',
    MM: '%d 个月',
    y: '1 年',
    yy: '%d 年',
  },
  /**
   * @description: 根据时和分返回当前的时间阶段
   * @param {Number} hour 时
   * @param {Number} minute 分
   * @return {String} 时间阶段
   */
  meridiem: (hour, minute) => {
    const hm = hour * 100 + minute;
    if (hm < 600) {
      return '凌晨';
    } else if (hm < 900) {
      return '早上';
    } else if (hm < 1130) {
      return '上午';
    } else if (hm < 1230) {
      return '中午';
    } else if (hm < 1800) {
      return '下午';
    }
    return '晚上';
  },
};

// 把 locale 对象加载到locale的 Ls 中
dayjs.locale(locale, null, true);

export default locale;

除了配置以外,可以发现,最后两步中首先把 locale 对象加载并保存,然后把 locale 对象默认导出。所以虽然官方没明说,但是也可以如下导入:

import dayjs from 'dayjs';
import zhCN from 'dayjs/locale/zh-cn';

dayjs.locale(zhCN); // 全局使用
dayjs().locale(zhCN).format(); // 当前实例使用

constant

src/constant.js 中存放的是一些常量和正则表达式。包括不同单位包含的秒数和毫秒数、标准的时间单位表达、默认格式化模板、无效时间和两个正则表达式。

// 计算几个常量
// 包含的秒数
export const SECONDS_A_MINUTE = 60;
export const SECONDS_A_HOUR = SECONDS_A_MINUTE * 60;
export const SECONDS_A_DAY = SECONDS_A_HOUR * 24;
export const SECONDS_A_WEEK = SECONDS_A_DAY * 7;

// 包含的毫秒数
export const MILLISECONDS_A_SECOND = 1e3;
export const MILLISECONDS_A_MINUTE = SECONDS_A_MINUTE * MILLISECONDS_A_SECOND;
export const MILLISECONDS_A_HOUR = SECONDS_A_HOUR * MILLISECONDS_A_SECOND;
export const MILLISECONDS_A_DAY = SECONDS_A_DAY * MILLISECONDS_A_SECOND;
export const MILLISECONDS_A_WEEK = SECONDS_A_WEEK * MILLISECONDS_A_SECOND;

// 标准的 unit 写法
export const MS = 'millisecond';
export const S = 'second';
export const MIN = 'minute';
export const H = 'hour';
export const D = 'day';
export const W = 'week';
export const M = 'month';
export const Q = 'quarter';
export const Y = 'year';
export const DATE = 'date';

// 默认时间格式是ISO 2020-12-06T20:11:43Z
export const FORMAT_DEFAULT = 'YYYY-MM-DDTHH:mm:ssZ';

// 无效时间
export const INVALID_DATE_STRING = 'Invalid Date';

// 正则表达式
export const REGEX_PARSE = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[^0-9]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?.?(\d+)?$/;
export const 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;

这两个正则表达式比较有意思,第一个正则表达式 REGEX_PARSE 是用来解析字符串格式的时间,便于生成 Dayjs 实例关联的 Date 对象;第二个正则表达式 REGEX_FORMAT 用于解析 format 参数,返回想要的时间格式。

utils

src/utils.js 中存放的是一些工具函数。其实在 index.js 中也放置了很多工具函数,只不过那些工具函数需要用到一些 index.js 的全局变量,所以不能定义在 utils.js 中。但是在 index.js 中最后还是把它们放在了一个 Utils 对象里共同管理。

// C 是定义的常量 constant
import * as C from './constant.js';

/**
 * @description: 在 string 的开头补充 pad,直到长度为 length,相当于`string.padStart(length, pad)`
 * @param {String} string 被补充的字符串
 * @param {Number} length 最后的长度
 * @param {String} pad 填充的内容
 * @return {String} 补充后的字符串
 */
const padStart = (string, length, pad) => {
  const s = String(string);
  if (!s || s.length >= length) return string;
  // 前面的数组join更简单的表示就是 `pad.repeat(length - string.length)`
  return `${Array(length + 1 - s.length).join(pad)}${string}`;
};

/**
 * @description: 返回实例的UTC偏移量(分钟)转化成的 [+|-]HH:mm的格式
 * @param {Dayjs} instance Dayjs的实例
 * @return {String} UTC偏移量 格式:[+|-]HH:mm
 */
const padZoneStr = (instance) => {
  // 这里感觉用Number(instance.utcOffset())会更易读
  const negMinutes = -instance.utcOffset();
  const minutes = Math.abs(negMinutes);
  const hourOffset = Math.floor(minutes / 60);
  const minuteOffset = minutes % 60;
  return `${negMinutes <= 0 ? '+' : '-'}${padStart(
    hourOffset,
    2,
    '0'
  )}:${padStart(minuteOffset, 2, '0')}`;
};

/**
 * @description: 求两个实例的月份差
 * @param {Dayjs} a Dayjs的实例
 * @param {Dayjs} b Dayjs的实例
 * @return {Number} 返回两个实例的月份差
 */
const monthDiff = (a, b) => {
  // 来自moment.js的函数,保证能返回相同的结果
  if (a.date() < b.date()) return -monthDiff(b, a);
  const wholeMonthDiff = (b.year() - a.year()) * 12 + (b.month() - a.month());
  const anchor = a.clone().add(wholeMonthDiff, C.M);
  const c = b - anchor < 0;
  const anchor2 = a.clone().add(wholeMonthDiff + (c ? -1 : 1), C.M);
  return +(
    -(
      wholeMonthDiff +
      (b - anchor) / (c ? anchor - anchor2 : anchor2 - anchor)
    ) || 0
  );
};

/**
 * @description: 向 0 取整
 * @param {Number} n 要取整的数字
 * @return {Number} 取整后的数字
 */
const absFloor = (n) => (n < 0 ? Math.ceil(n) || 0 : Math.floor(n));

/**
 * @description: 返回 u 对应的单位,能自动适配标准格式和缩写格式
 * @param {String} u M(month) y(year) w(week) d(day) D(date) h(hour) m(minute) s(second) ms(millisecond) Q(quarter) 或 其他字符串
 * @return {String} u 对应的单位
 */
const prettyUnit = (u) => {
  const special = {
    M: C.M,
    y: C.Y,
    w: C.W,
    d: C.D,
    D: C.DATE,
    h: C.H,
    m: C.MIN,
    s: C.S,
    ms: C.MS,
    Q: C.Q,
  };
  return (
    // 返回 u 对应的单位
    special[u] ||
    // 或者是把 u 结尾的 字符s 删除,当作单位
    String(u || '')
      .toLowerCase()
      .replace(/s$/, '')
  );
};

/**
 * @description: 判断是否为 undefined
 * @param {Any} s
 * @return {Boolean} true: 是, false: 否
 */
const isUndefined = (s) => s === undefined;

export default {
  s: padStart,
  z: padZoneStr,
  m: monthDiff,
  a: absFloor,
  p: prettyUnit,
  u: isUndefined,
};

本篇内容完成,下一篇文章来分析 day.js 的核心 src/index.js 文件,学习 Dayjs 类的实现。


前端记事本,不定期更新,欢迎关注!