dayjs 分析
dayjs 介绍
dayjs 是一个热门的时间日期库,npm 每周下载量超过千万。
dayjs 的卖点是:
- 代码体积小;打包后仅 2kb
- 简单;API 和 Moment.js 保持一致
- 不可变数据;每次操作都会返回新的对象,避免 bug 产生,方便调试
- 国家化支持良好;
工作中时常使用 dayjs,十分好奇其中的实现细节,趁着有时间,赶紧做一期源码分析,here we go!
dayjs debug
要调试 dayjs 代码,首先要在 github 上把代码拉取下来。
然后安装依赖,构建代码
npm install
npm run build
用 vscode 打开,创建一个 js 文件和调试配置
touch debug.js .vscode/launch.json
// debug.js
const dayjs = require("./dayjs.min");
const d1 = dayjs();
// launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/debug.js"
}
]
}
这样就可以调试了,但是设置断点查看 dayjs 代码,我们看到的都是混淆后的代码,不利于阅读和分析,所以我们需要 sourcemap 文件映射回原来的代码。
改一下构建的配置,配置文件在 build 目录的 rollup.config.js 文件中:
// rollup.config.js
module.exports = (config) => {
...
return {
...
output: {
...
// 其它代码不用修改,加上下面这一行
sourcemap: true
}
}
再构建一次:
npm run build
最后,就可以愉快地进行调试了。
dayjs 分析
解析
调用 dayjs 时,支持用多种类型初始化
// 不传参数,表示当前时间
dayjs();
// 使用 ISO 8601 格式的字符串
dayjs("2018-04-13 19:18:17.040+02:00");
// 使用字符串 + 格式 (依赖 CustomParseFormat 插件)
dayjs("12-25-1995", "MM-DD-YYYY");
// 使用时间戳
dayjs(1318781876406);
// JS Date对象
dayjs(new Date(2024, 1, 15));
我们来分析一下 dayjs 这个方法:
const dayjs = function (date, c) {
// 首先判断是否为Dayjs实例,如果是,直接返回一份克隆(深拷贝)
if (isDayjs(date)) {
return date.clone();
}
// 参数统一化
const cfg = typeof c === "object" ? c : {};
cfg.date = date;
cfg.args = arguments;
// 返回dayjs的实例
return newdayjs(cfg);
};
dayjs 方法的逻辑比较简单,规范了输入参数,返回一份 Dayjs 实例。这里要强调一下,要区分开 dayjs 和 Dayjs:
1.dayjs 是对外暴露的调用入口,用于生成 Dayjs 实例,除此之外,dayjs 上也挂载了一系列辅助方法。 2.Dayjs 才是真正的日期时间对象,对日期时间的各种操作都是基于 Dayjs 的实例。
我们来分析 Dayjs 对象(由于代码比较长,采取分段分析,并省略一部分代码):
class Dayjs{
constructor(cfg) {
// 解析地区
this.$L = parseLocale(cfg.locale, null, true)
// 开始解析
this.parse(cfg);
this.$x = this.$x || cfg.x || {};
// 标识这是一个Dayjs实例
this[IS_DAYJS] = true;
}
parse(cfg) {
// 解析参数,获取Date实例
this.$d = parseDate(cfg);
// 初始化
this.init();
}
init() {
// $d是Date是实例
const { $d } = this;
// 初始化年、月、日、星期、小时、分、秒、毫秒等信息
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 方法,该方法将输入参数转化为 Date 实例的,我们来分析一下:
const parseDate = (cfg) => {
const { date } = cfg;
// 非法参数
if (date === null) return new Date(NaN);
// Utils.u是判断值是否为undefined的。传入undefeind将返回当前时间
if (Utils.u(date)) return new Date();
// 传入Date实例
if (date instanceof Date) return new Date(date);
// 传入字符串
if (typeof date === "string" && !/Z$/i.test(date)) {
// REGEX_PARSE = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/
// 用上面的正则表达式,匹配特定格式的字符串
const d = date.match(C.REGEX_PARSE);
if (d) {
const m = d[2] - 1 || 0;
const ms = (d[7] || "0").substring(0, 3);
return new Date(d[1], m, d[3] || 1, d[4] || 0, d[5] || 0, d[6] || 0, ms);
}
}
return new Date(date);
};
parseDate 方法将解析输入的参数,生成对应的 Date 实例。因此,整体解析流程是:
flowchart TB A[dayjs] --> B{isDayjs} B -->|Yes| C[Dayjs clone] B -->|No| D[new Dayjs] D --> E[parse] E --> F[parseDate] F --> G[init]
显示
解析完成后,我们通常需要将 Dayjs 实例展示成特定的格式,最常用的就是 format 方法,这个方法根据传入的占位符返回格式化后的日期
dayjs("2019-01-25").format("DD/MM/YYYY"); // '25/01/2019'
format 方法代码分析:
class Dayjs {
...
format(formatStr) {
const locale = this.$locale()
// 设置默认参数
const str = formatStr || C.FORMAT_DEFAULT
const zoneStr = Utils.z(this)
// 初始化时得到的时间信息
const { $H, $m, $M } = this
// 不同的地区,周、月和上下午,定义或顺序不一样
const {
weekdays, months, meridiem
} = locale
const getShort = (arr, index, full, length) => (
(arr && (arr[index] || arr(this, str))) || full[index].slice(0, length)
)
const get$H = num => (
Utils.s($H % 12 || 12, num, '0')
)
const meridiemFunc = meridiem || ((hour, minute, isLowercase) => {
const m = (hour < 12 ? 'AM' : 'PM')
return isLowercase ? m.toLowerCase() : m
})
// 这个方法是format实现的关键,将会匹配占位符,替换成真实日期
const matches = (match) => {
switch (match) {
// 年
case 'YY':
return String(this.$y).slice(-2)
case 'YYYY':
return Utils.s(this.$y, 4, '0')
// 月份
case 'M':
return $M + 1
case 'MM':
return Utils.s($M + 1, 2, '0')
case 'MMM':
return getShort(locale.monthsShort, $M, months, 3)
case 'MMMM':
return getShort(months, $M)
// 天
case 'D':
return this.$D
case 'DD':
return Utils.s(this.$D, 2, '0')
// 星期
case 'd':
return String(this.$W)
case 'dd':
return getShort(locale.weekdaysMin, this.$W, weekdays, 2)
case 'ddd':
return getShort(locale.weekdaysShort, this.$W, weekdays, 3)
case 'dddd':
return weekdays[this.$W]
// 小时
case 'H':
return String($H)
case 'HH':
return Utils.s($H, 2, '0')
// 小时(12小时制)
case 'h':
return get$H(1)
case 'hh':
return get$H(2)
// am pm
case 'a':
return meridiemFunc($H, $m, true)
// AM PM
case 'A':
return meridiemFunc($H, $m, false)
// 分钟
case 'm':
return String($m)
case 'mm':
return Utils.s($m, 2, '0')
// 秒
case 's':
return String(this.$s)
case 'ss':
return Utils.s(this.$s, 2, '0')
case 'SSS':
return Utils.s(this.$ms, 3, '0')
// UTC 的偏移量
case 'Z':
return zoneStr // 'ZZ' logic below
default:
break
}
return null
}
// C.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
// 用正则匹配 formatStr,匹配项将会由matches方法处理
return str.replace(C.REGEX_FORMAT, (match, $1) => $1 || matches(match) || zoneStr.replace(':', '')) // 'ZZ'
}
...
}
上面的代码比较繁琐,但是逻辑比较简单,简而言之就是用一个正则表达式匹配输入的占位符,分别用数据来填充。比方说,我们这样格式化 dayjs().format('YYYY-MM-DD')
,处理过程将是:
flowchart LR A[YYYY-MM-DD] A --> B[YYYY] B --> B1["String(this.$y).slice(-2)"] B1 --> B2[2024] A --> C[MM] C --> C1["Utils.s($M + 1, 2, '0')"] C1 --> C2[01] A --> D[DD] D --> D1["Utils.s(this.$D, 2, '0')"] D1 --> D2[15] B2 --> E[2024-01-15] C2 --> E D2 --> E
除了格式化字符串之外,dayjs 还支持显示 unix 时间戳
// 毫秒
dayjs("2019-01-25").valueOf() + // 1548381600000
dayjs(1548381600000); // 1548381600000
// 秒
dayjs("2019-01-25").unix();
相关代码:
class Dayjs {
...
valueOf() {
// 直接调用date实例getTime方法,返回unix时间戳(毫秒)
return this.$d.getTime()
}
unix() {
// 返回unix时间戳(秒)
return Math.floor(this.valueOf() / 1000)
}
...
}
操作
dayjs 支持对 Dayjs 实例进行操作,修改日期
// 增加1天
dayjs().add(1, "day");
// 先增加1天,然后减少一个月
dayjs().add(1, "day").subtract(1, "month");
// 日期设置为当月的开始
dayjs().startOf("month");
// 日期设置为当周的末尾
dayjs().endOf("week");
这些方法也在 Dayjs 类中:
class Dayjs{
...
// 内部方法,用于设置新数据
$set(units, int) {
// 这里会处理一下数据单位的缩写,比如将d转化为day,w转化为week,诸如此类
const unit = Utils.p(units)
// 不考虑用UTC时间的情况,this.$u === false,utcPad === ''
const utcPad = `set${this.$u ? 'UTC' : ''}`
// 获取数据在Date实例上的字段名
const name = {
[C.D]: `${utcPad}Date`,
[C.DATE]: `${utcPad}Date`,
[C.M]: `${utcPad}Month`,
[C.Y]: `${utcPad}FullYear`,
[C.H]: `${utcPad}Hours`,
[C.MIN]: `${utcPad}Minutes`,
[C.S]: `${utcPad}Seconds`,
[C.MS]: `${utcPad}Milliseconds`
}[unit]
// 获取新数据
const arg = unit === C.D ? this.$D + (int - this.$W) : int
if (unit === C.M || unit === C.Y) {
// 设置年或月
// clone is for badMutable plugin
const date = this.clone().set(C.DATE, 1)
// 修改数据
date.$d[name](arg)
date.init()
// 年月变更后,天数也要修改(月份总天数不一样,有30天的,有31天的,还有28、29天的)
this.$d = date.set(C.DATE, Math.min(this.$D, date.daysInMonth())).$d
} else if (name) {
// 其它情况,直接操作Date实例修改数据
this.$d[name](arg)
}
// 因为Date实例被修改了,所以重新初始化
this.init()
// 返回实例自身,方便链式操作
return this
}
// 对$set方法的封装
set(string, int) {
// 每次操作都会返回一个新的实例,保证数据的不可变性
return this.clone().$set(string, int)
}
add(number, units) {
number = Number(number)
const unit = Utils.p(units)
// 修改天和周的逻辑
const instanceFactorySet = (n) => {
const d = dayjs(this)
return Utils.w(d.date(d.date() + Math.round(n * number)), this)
}
// 设置月份
if (unit === C.M) {
return this.set(C.M, this.$M + number)
}
// 设置年份
if (unit === C.Y) {
return this.set(C.Y, this.$y + number)
}
// 设置天数
if (unit === C.D) {
return instanceFactorySet(1)
}
// 设置周数
if (unit === C.W) {
return instanceFactorySet(7)
}
// 获取对应单位的基准毫秒
const step = {
[C.S]: C.MILLISECONDS_A_SECOND // 1000
[C.MIN]: C.MILLISECONDS_A_MINUTE, // 1000 * 60
[C.H]: C.MILLISECONDS_A_HOUR, // 1000 * 60 * 60
}[unit] || 1 // ms
// 计算新时间戳(毫秒)
const nextTimeStamp = this.$d.getTime() + (number * step)
// 以新时间戳为输入参数,生成新的Dayjs实例
return Utils.w(nextTimeStamp, this)
}
subtract(number, string) {
// 相当于add的反操作
return this.add(number * -1, string)
}
startOf(units, startOf) {
// 是否为调用 startOf,默认为true,除非手动传入false
const isStartOf = !Utils.u(startOf) ? startOf : true
const unit = Utils.p(units)
// 修改天数和月数
const instanceFactory = (d, m) => {
// 返回新的实例
const ins = Utils.w(this.$u ?
Date.UTC(this.$y, m, d) : new Date(this.$y, m, d), this)
return isStartOf ? ins : ins.endOf(C.D)
}
// 修改一天内的时间
const instanceFactorySet = (method, slice) => {
// 分别对应天、小时、分钟和秒
const argumentStart = [0, 0, 0, 0]
const argumentEnd = [23, 59, 59, 999]
// 返回新的实例
return Utils.w(this.toDate()[method].apply(
this.toDate('s'),
// 天 -> [0,0,0,0] or [23,59,59,999]
// 小时 -> [0,0,0] or [59,59,999]
// ...
(isStartOf ? argumentStart : argumentEnd).slice(slice)
), this)
}
const { $W, $M, $D } = this
// 不考虑用UTC时间的情况,this.$u === false,utcPad === ''
const utcPad = `set${this.$u ? 'UTC' : ''}`
switch (unit) {
// 年
case C.Y:
// startOf 修改为当年的第一个月的第一天,endOf 修改为最后一个月的最后一天
return isStartOf ? instanceFactory(1, 0) :
instanceFactory(31, 11)
// 月
case C.M:
// startOf 修改当月的第一天,endOf 修改当月的最后一天
return isStartOf ? instanceFactory(1, $M) :
instanceFactory(0, $M + 1)
// 周
case C.W: {
// 有的地区第一天是周日
const weekStart = this.$locale().weekStart || 0
const gap = ($W < weekStart ? $W + 7 : $W) - weekStart
return instanceFactory(isStartOf ? $D - gap : $D + (6 - gap), $M)
}
// 天
case C.D:
case C.DATE:
return instanceFactorySet(`${utcPad}Hours`, 0)
// 小时
case C.H:
return instanceFactorySet(`${utcPad}Minutes`, 1)
// 分钟
case C.MIN:
return instanceFactorySet(`${utcPad}Seconds`, 2)
// 秒
case C.S:
return instanceFactorySet(`${utcPad}Milliseconds`, 3)
default:
return this.clone()
}
}
endOf(arg) {
// 表示调用endOf
return this.startOf(arg, false)
}
...
}
上面的代码比较复杂,但是大部分都是转换单位和计算变更数值,case by case。本质上就是根据单位和数值生成新的 Date 实例,然后用新 Date 实例生成新的 Dayjs 实例。
flowchart TB A[add、subtract、startOf、endOf] --> B[转换单位] B --> C[计算变更后的数据] C --> D[用新数据生成新的 Date 实例] D --> E[生成新的 Dayjs 实例]
查询
Day.js 对象还有很多查询的方法
// 表示 Day.js 对象是否在另一个提供的日期时间之前(默认毫秒)
dayjs().isBefore(dayjs("2011-01-01"));
// 还支持传入单位,在这种情况下,会使用传入的单位以及比其范围大的单位进行比较
dayjs().isBefore("2011-01-01", "month");
// 表示 Day.js 对象是否在另一个提供的日期时间之后
dayjs().isAfter(dayjs("2011-01-01"));
// 表示 Day.js 对象是否和另一个提供的日期时间相同
dayjs().isSame(dayjs("2011-01-01"));
我们来分析一下其中实现:
class Dayjs {
...
isBefore(that, units) {
// 1. 生成新的Dayjs对象,调用endOf将日期设置到该单位的末尾,相当于将时间调后(当前日期 <= 当前日期endOf)
// 2. 用要对比日期生成新的Dayjs对象
// 3. 调用valueOf方法获取时间戳,对比大小,时间戳小的在前(JS在进行数值操作或隐式转换时会自动调用对象的valueOf方法)
return this.endOf(units) < dayjs(that)
}
isAfter(that, units) {
// 与isBefore逻辑类似,区别是调用startOf将日期设置到该单位的开始,如果这个日期都在对比日期后面,那么当前日期必然也在对比日期后面
// 对比日期 < 当前日期startOf <= 当前日期
return dayjs(that) < this.startOf(units)
}
isSame(that, units) {
const other = dayjs(that)
// isBefore和isSame的反操作,同时成立
return this.startOf(units) <= other && other <= this.endOf(units)
}
...
}
插件
dayjs 自身代码比较精简,只保留了核心逻辑,不超过 1000 行。除此之外的功能都通过插件来实现,内置了 20 多个插件。 插件使用非常简单:
// 引入插件(判断日期的年份是不是闰年)
import isLeapYear from "dayjs/plugin/isLeapYear";
// 安装插件
dayjs.extend(isLeapYear);
dayjs("2024-01-01").isLeapYear(); // true
如果要自定义插件,dayjs 插件模版如下:
// option是插件选项
// dayjsClass是Dayjs对象
// dayjsFactory是dayjs方法
export default (option, dayjsClass, dayjsFactory) => {
// extend dayjs()
// e.g. add dayjs().isSameOrBefore()
dayjsClass.prototype.isSameOrBefore = function (arguments) {};
// extend dayjs
// e.g. add dayjs.utc()
dayjsFactory.utc = (arguments) => {};
// overriding existing API
// e.g. extend dayjs().format()
const oldFormat = dayjsClass.prototype.format;
dayjsClass.prototype.format = function (arguments) {
// original format result
const result = oldFormat.bind(this)(arguments);
// return modified result
};
};
我们可以分析一下 isLeapYear 插件的实现方式
export default (o, c) => {
// 获取Dayjs对象的原型
const proto = c.prototype;
// 在原型上加一个方法,这样所有的Dayjs实例都可以使用
proto.isLeapYear = function () {
// 获取年份判断是否为闰年
return (this.$y % 4 === 0 && this.$y % 100 !== 0) || this.$y % 400 === 0;
};
};
可以看出来,dayjs 插件机制十分简单粗暴,直接在原型上面进行操作,扩展或者重写方法。
dayjs.extend 实现分析:
dayjs.extend = (plugin, option) => {
if (!plugin.$i) {
// 调用插件函数,传入参数,Dayjs对象和dayjs方法
plugin(option, Dayjs, dayjs);
// 避免重复安装插件
plugin.$i = true;
}
// 方便链式操作 dayjs.extend(plugin1).extend(plugin2)...
return dayjs;
};