dayjs如何实现的日期格式化、多语言?
前言
在平时开发的过程中,肯定会遇到需要操作日期、比较日期、格式化日期的情况的。
为了满足以上的需求,出现了很多的日期JS库,moment
、dayjs
、date-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
执行时,会将对应占位符替换为对应的数值。支持的占位符有:
标识 | 示例 | 描述 |
---|---|---|
YY | 23 | 年,两位数 |
YYYY | 2023 | 年,四位数 |
M | 1-12 | 月,从1开始 |
MM | 01-12 | 月,两位数 |
MMM | Jan-Dec | 月,英文缩写 |
MMMM | January-December | 月,英文全称 |
D | 1-31 | 日 |
DD | 01-31 | 日,两位数 |
d | 0-6 | 一周中的一天,星期天是 0 |
dd | Su-Sa | 最简写的星期几 |
ddd | Sun-Sat | 简写的星期几 |
dddd | Sunday-Saturday | 星期几,英文全称 |
H | 0-23 | 小时 |
HH | 00-23 | 小时,两位数 |
h | 1-12 | 小时, 12 小时制 |
hh | 01-12 | 小时, 12 小时制, 两位数 |
m | 0-59 | 分钟 |
mm | 00-59 | 分钟,两位数 |
s | 0-59 | 秒 |
ss | 00-59 | 秒,两位数 |
S | 0-9 | 毫秒(十),一位数 |
SS | 00-99 | 毫秒(百),两位数 |
SSS | 000-999 | 毫秒,三位数 |
Z | -05:00 | UTC 的偏移量,±HH:mm |
ZZ | -0500 | UTC 的偏移量,±HHmm |
A | AM / PM | 上/下午,大写 |
a | am / 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中做得比较好或者能够优化的地方吗?
转载自:https://juejin.cn/post/7188855653845172261