本文就moment
, dayjs
, date-fns
三个JS中常用的时间处理库时间格式化方法源码解析,以探索时间库是如何处理大写的YYYY
及小写yyyy
的。
moment
源码地址: moment-github
源码位置:
./moment.js
src/moment.js
src/lib/moment/format.js
src/lib/format/format.js
src/lib/units/era.js
src/lib/units/year.js
根目录下的moment.js
是由分析src/moment.js
的引用关系打包而来,所以直接跳到src/moment.js
下观察,发现src/moment.js
全部引用自src/lib/*
,因此可推论src/lib/*
是所有moment
核心逻辑片断所在。因此,将所有与format
有关的文件查找后,发现与format
直接相关的是src/lib/moment/format.js
与src/lib/format/format.js
。
在写代码时,调用format
方法的示例代码:
import moment from 'moment';
// format是在moment()实例创建后才能调用
const formattedTime = moment().format('YYYY-MM-DD');
经分析,moment().format(...)
实际上调用的是src/lib/moment/format.js
中的方法。源码节选如下:
// '../format/format' 即 `src/lib/format/format.js`
import { formatMoment } from '../format/format';
export function format(inputString) {
// ... 省略部分不太相关的逻辑
var output = formatMoment(this, inputString);
return this.localeData().postformat(output);
}
据此,我们可以看出format
方法最为核心的逻辑是调用src/lib/format/format.js
中的formatMoment
方法。继续节选源码:
// format date using native date object
export function formatMoment(m, format) {
// ... 省略部分不太相关的逻辑
formatFunctions[format] =
formatFunctions[format] || makeFormatFunction(format);
return formatFunctions[format](m);
}
makeFormatFunction
节选:
function makeFormatFunction(format) {
var array = format.match(formattingTokens),
i,
length;
for (i = 0, length = array.length; i < length; i++) {
if (formatTokenFunctions[array[i]]) {
// 核心:读取formatTokenFunctions
array[i] = formatTokenFunctions[array[i]];
} else {
array[i] = removeFormattingTokens(array[i]);
}
}
return function (mom) {
var output = '',
i;
for (i = 0; i < length; i++) {
output += isFunction(array[i])
? array[i].call(mom, format)
: array[i];
}
return output;
};
}
如果再回看src/lib/format/format.js
,发现一开始的formatFunctions
与formatTokenFunctions
都是空的。那么,它们在哪里赋值呢?随后的addFormatToken
给出了答案:
// 暂时不用特别关注细节
// 只需知道这个函数可以注册格式化函数到 formatTokenFunctions
export function addFormatToken(token, padded, ordinal, callback) {
var func = callback;
if (typeof callback === 'string') {
func = function () {
return this[callback]();
};
}
if (token) {
formatTokenFunctions[token] = func;
}
if (padded) {
formatTokenFunctions[padded[0]] = function () {
return zeroFill(func.apply(this, arguments), padded[1], padded[2]);
};
}
if (ordinal) {
formatTokenFunctions[ordinal] = function () {
return this.localeData().ordinal(
func.apply(this, arguments),
token
);
};
}
}
这个函数在哪里被使用了呢?继续全局搜索可以发现,均在src/lib/units/*
中。继续全局搜索,可以发现:
-
src/lib/units/era.js
注册了yyyy
-
src/lib/units/year.js
注册了YYYY
代码片断如下:
// src/lib/units/era.js
addFormatToken('y', ['y', 1], 'yo', 'eraYear');
addFormatToken('y', ['yy', 2], 0, 'eraYear');
addFormatToken('y', ['yyy', 3], 0, 'eraYear');
addFormatToken('y', ['yyyy', 4], 0, 'eraYear');
根据addFormatToken
函数执行逻辑可看出,yyyy
注册名为eraYear
的格式化方法。而通过./moment.js
中源码可以看出, eraYear
实际上是getEraYear
函数:
// ./moment.js
// ...省略不相关代码
// line 4956
proto.eraYear = getEraYear;
那么,这个getEraYear
函数是怎么执行的呢?代码节选:
export function getEraYear() {
var i,
l,
dir,
val,
// 依赖于localeData的eras运行结果
eras = this.localeData().eras();
// 如有eras,则处理eras
// 暂时忽略下方细节
for (i = 0, l = eras.length; i < l; ++i) {
dir = eras[i].since <= eras[i].until ? +1 : -1;
// truncate time
val = this.clone().startOf('day').valueOf();
if (
(eras[i].since <= val && val <= eras[i].until) ||
(eras[i].until <= val && val <= eras[i].since)
) {
return (
(this.year() - moment(eras[i].since).year()) * dir +
eras[i].offset
);
}
}
// 如果没有eras字段, 则直接返回this.year();
return this.year();
}
依照相同的方式,我们看YYYY
的注册及调用:
注册YYYY
:
// src/lib/units/year.js
// ...省略无关代码
addFormatToken('Y', 0, 0, function () {
var y = this.year();
return y <= 9999 ? zeroFill(y, 4) : '+' + y;
});
addFormatToken(0, ['YY', 2], 0, function () {
return this.year() % 100;
});
addFormatToken(0, ['YYYY', 4], 0, 'year');
addFormatToken(0, ['YYYYY', 5], 0, 'year');
addFormatToken(0, ['YYYYYY', 6, true], 0, 'year');
year
在proto
上的映射:
// ./moment.js
// ...省略不相关代码
// line 4957
proto.year = getSetYear;
// src/lib/units/year.js
export var getSetYear = makeGetSet('FullYear', true);
// makeGetSet函数: src/lib/moment/get-set.js
export function makeGetSet(unit, keepTime) {
return function (value) {
if (value != null) {
set(this, unit, value);
hooks.updateOffset(this, keepTime);
return this;
} else {
return get(this, unit);
}
};
}
// getSetYear无参数调用时是获取当前年份
// 会走到 get(this, unit)分支上
// get(this, unit)两个参数的含义:
// this: moment实例
// unit: 传入makeGetSet的第一个参数。在getSetYear中为'FullYear'
// 所以,看一下相关的get函数执行逻辑
// 位置: src/lib/moment/get-set.js
export function get(mom, unit) {
// 省略无关逻辑
switch (unit) {
// ... 省略无关
case 'FullYear':
// 因此看出, 获取年份是使用的getUTCFullYear 及 getFullYear
return isUTC ? d.getUTCFullYear() : d.getFullYear();
}
}
综上可得出结论:
-
yyyy
视为处理eras(纪年)。如当前locale
无纪年,视为处理日历年 -
YYYY
是正常处理日历年的方式,底层使用getUTCFullYear
及getFullYear
dayjs
源码地址: dayjs-gitee
源码位置: src/index.js
dayjs
相对直观许多,节选format
方法如下:
// line 262
format(formatStr) {
// ... 省略不相关细节
// 靠其中的matches内部函数实现
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')
// ... 省略不相关细节
default:
break
}
return null
}
// ... 省略不相关细节
}
可以看出,dayjs
仅处理了大写的YYYY
,而完全没有处理小写的yyyy
。而处理年份则是靠this.$y
来达成,源码节选:
// line 100
// $d即Date对象
this.$y = $d.getFullYear()
总而言之,dayjs
并没有处理小写yyyy
,只是将大写的YYYY
处理为日历年,且底层实现是getFullYear
。
此外,luxon
的处理策略与dayjs
正好相反,仅处理yyyy
而不处理YYYY
, 可从luxon源码中看出,且luxon
的底层是getUTCFullYear;
date-fns
切记要看v2
源码,目前v3
不常用 (2024/1/5)。
源码地址: date-fns v2
源码位置: src/format/index.ts
date-fns
源码非常好的一点是代码中注释即运行原理文档,光看文档就知道怎么回事。以下是部分节选:
从注释中可以看出, date-fns
对大写YYYY
小写yyyy
做了明确区分。YYYY
指的是基于周的年份,即容易产生跨年Bug的表示法;yyyy
即日历年。
向下翻源码,节选如下(依旧屏蔽及删除关联不大的部分,只保留最核心逻辑):
// ... 暂时屏蔽无关
import formatters from '../_lib/format/formatters/index'
// 正则
const formattingTokensRegExp = /[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g
export default function format(
dirtyDate: Date | number,
formatStr: string,
options?: LocaleOptions &
WeekStartOptions &
FirstWeekContainsDateOptions &
AdditionalTokensOptions
): string {
// ... 暂时屏蔽无关
const result = formatStr
// 进行正则匹配
.match(formattingTokensRegExp)!
.map(substring => {
const firstCharacter = substring[0]
// 有删节
// formatters即格式化函数表
const formatter = formatters[firstCharacter]
if (formatter) {
// !!! 如果没有设置options.useAdditionalWeekYearTokens === true
// !!! 并且 substring isProtectedWeekYearToken
// !!! 那么会报错
if (
!options?.useAdditionalWeekYearTokens &&
isProtectedWeekYearToken(substring)
) {
throwProtectedError(substring, dirtyFormatStr, String(dirtyDate))
}
// 有删除
// 用formatter函数实现格式化
return formatter(utcDate, substring, locale.localize, formatterOptions)
}
return substring
})
.join('')
return result
}
代码中最重要的就两段,其中一个是命中proctedWeekYearToken的报错机制,另一个是formatter
函数。
首先看protectedWeekYearToken
部分源码:
// src/_lib/protectedTokens/index.ts
const protectedDayOfYearTokens = ['D', 'DD']
const protectedWeekYearTokens = ['YY', 'YYYY']
export function isProtectedDayOfYearToken(token: string): boolean {
return protectedDayOfYearTokens.indexOf(token) !== -1
}
export function isProtectedWeekYearToken(token: string): boolean {
return protectedWeekYearTokens.indexOf(token) !== -1
}
如果options.useAdditionalWeekYearTokens === false
(默认值)并且用YYYY
取基于周的年份,那么会直接报错。换句话说,date-fns
从机制上为用YYYY
格式化上设置了壁垒。
然后看formatter
源码:
// src/_lib/format/formatters/index.ts
// Year
// 处理小写y
y: function (date, token, localize) {
// Ordinal number
if (token === 'yo') {
// 底层使用的是getUTCFullYear
const signedYear = date.getUTCFullYear()
// Returns 1 for 1 BC (which is year 0 in JavaScript)
const year = signedYear > 0 ? signedYear : 1 - signedYear
return localize.ordinalNumber(year, { unit: 'year' })
}
return lightFormatters.y(date, token)
},
// Local week-numbering year
// 处理大写Y
Y: function (date, token, localize, options) {
// !!! 忽略细节,是库自己实现的取基于周的年份的方法,不是JS原生方法
const signedWeekYear = getUTCWeekYear(date, options)
// Returns 1 for 1 BC (which is year 0 in JavaScript)
const weekYear = signedWeekYear > 0 ? signedWeekYear : 1 - signedWeekYear
// Two digit year
if (token === 'YY') {
const twoDigitYear = weekYear % 100
return addLeadingZeros(twoDigitYear, 2)
}
// Ordinal number
if (token === 'Yo') {
return localize.ordinalNumber(weekYear, { unit: 'year' })
}
// Padding
return addLeadingZeros(weekYear, token.length)
},
可以得出:
-
date-fns
使用的是getUTCFullYear()
方法实现的取年份方法,需要格外注意时差问题 -
yyyy
与YYYY
是不同的含义。也是唯一符合Unicode对于yyyy
与YYYY
定义的库