本文内容基于day.js的v1.10.4版本,将主要从基础理念、工程架构、源码解析来分析dayjs源码。
对时间相关的概念和API不熟悉的推荐先阅读上一篇文章时间。
基础理念
dayjs是国内饿了么团队iamkun大佬开发,同时他也是Element UI的核心开发者。dayjs首次发布在2018年4月,开发初衷就是为了对标momentjs从而取代它,因此API的设计与momentjs完全一致。
而官网上明确的告诉了我们dayjs本身的亮点:
为什么使用 Day.js ?
2kB
下载、解析和执行更少的 JavaScript,为您的代码留出更多时间。
简易
Day.js 是一个轻量的处理时间和日期的 JavaScript 库,和 Moment.js 的 API 设计保持完全一样。
如果您曾经用过 Moment.js, 那么您已经知道如何使用 Day.js 。
不可变的
所有的 API 操作都将返回一个新的 Dayjs 对象。
这种设计能避免 bug 产生,节约调试时间。
国际化
Day.js 对国际化支持良好。但除非手动加载,多国语言默认是不会被打包到工程里的
那这些亮点是如何在代码中实现体现的呢?看完全文你应该就能有所体会。
工程架构
兼容性
通过package.json可以看到项目引入了babel用于兼容性转化,使用karma和SauceLabs进行浏览器兼容性测试,确保浏览器兼容性。另外使用cross-env确保npm scripts运行命令的兼容性。
大小
dayjs为了确保最终打包大小在宣传的2kb,在项目引入了size-limit和gzip-size-cli,用于死守2.99kb大小的底线。当然,确保大小更重要的还是代码层面的设计,将国际化体系和插件体系独立在核心之外,这个在后面会有介绍。
Typescript支持
dayjs使用javascript进行开发,为了支持Typescript,单独书写了各种类型和接口定义,放置在types文件夹下。
单元测试
dayjs主要使用jest进行单元测试,需要关注的是开发依赖里面包含moment和moment-timezone,安装这两个包主要是为了进行两者API一致性的单元测试。另外mockdate这个包是用于修改当前时间进行单元测试使用。
打包
dayjs使用rollup进行打包压缩,并使用ncp进行代码拷贝输出结果。
规范
代码规范使用eslint和prettier来保障,eslint主要采用airbnb规范以优化js代码格式规范,prettier用于优化markdown文档格式规范。在提交代码时,使用pre-commit进行规范检测,确保提交的代码符合规范。
发布
版本发布使用Travis CI进行,在提交代码时将安装codecov,执行规范检测,单元测试和代码代码覆盖率检测。发布时需要将代码合并到master分支,安装@semantic-release/changelog 、@semantic-release/git、semantic-release用于生成CHANGELOG.md代码变更记录,同时会执行单元测试,打包发布等流程。
源码解析
dayjs的特点就是小而全,它的代码结构也非常简单,主要包含以下几个部分:
src
├── constant.js // 常量定义
├── index.js // 入口文件
├── locale // 国际化配置
├── plugin // 插件
└── utils.js // 工具函数入口文件index.js前三行代码引入了constant.js、locale/en.js、utils.js,因此我们先看看这几个文件:
常量定义: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
// 单位名称
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 8601默认时间字符串:2021-05-18T13:56:28Z
export const FORMAT_DEFAULT = 'YYYY-MM-DDTHH:mm:ssZ'
// 无效时间返回值,new Date('Invalid Date')的返回值
export const INVALID_DATE_STRING = 'Invalid Date'
// 解析ISO 8601时间字符串,根据match捕获的索引可以获取年月日时分秒毫秒等信息
export const REGEX_PARSE = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[^0-9]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/
// 解析时间格式字符串,通过String.replace将所有捕获的格式替换为实际值
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国际化配置:locale
使用
dayjs会在打包的时候生成locale.json文件,存储语言包的key和name组成的数组。
注册使用语言包:
import * as dayjs from 'dayjs';
import 'dayjs/locale/zh-cn'; // 全局注册语言包
dayjs.locale('zh-cn'); // 全局启用
dayjs().locale('zh-cn').format(); // 当前实例启用en.js
由于默认使用en作为locale,原生API也是使用en输出,因此en.js只配置了部分参数:
// English [en]
// We don't need weekdaysShort, weekdaysMin, monthsShort in en.js locale
export default {
name: 'en',
weekdays: 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'),
months: 'January_February_March_April_May_June_July_August_September_October_November_December'.split('_')
}zh-cn.js
// Chinese (China) [zh-cn]
import dayjs from 'dayjs'
const locale = {
// 语言名
name: 'zh-cn',
// 星期
weekdays: '星期日_星期一_星期二_星期三_星期四_星期五_星期六'.split('_'),
// 短的星期
weekdaysShort: '周日_周一_周二_周三_周四_周五_周六'.split('_'),
// 最短的星期
weekdaysMin: '日_一_二_三_四_五_六'.split('_'),
// 月份
months: '一月_二月_三月_四月_五月_六月_七月_八月_九月_十月_十一月_十二月'.split('_'),
// 短的月份
monthsShort: '1月_2月_3月_4月_5月_6月_7月_8月_9月_10月_11月_12月'.split('_'),
// 序号生成工厂函数
ordinal: (number, period) => {
switch (period) {
case 'W':
// W返回周序号
return `${number}周`
default:
// 默认返回日序号
return `${number}日`
}
},
// 一周起始日,设置为1定义星期一是开始
weekStart: 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'
},
// 相对时间格式
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 年'
},
// 时间段
meridiem: (hour, minute) => {
const hm = (hour * 100) + minute
if (hm < 600) {
return '凌晨'
} else if (hm < 900) {
return '早上'
} else if (hm < 1100) {
return '上午'
} else if (hm < 1300) {
return '中午'
} else if (hm < 1800) {
return '下午'
}
return '晚上'
}
}
// 全局注册语言包
dayjs.locale(locale, null, true)
// 导出语言配置,用于启用
export default locale工具函数:utils.js
// ES Module引入常量,短名减少代码量
import * as C from './constant'
/**
* @description: 在 string 的开头填充 pad 字符,直到长度为 length,相当于`string.padStart(length, pad)`,空字符串也不做填充
* @param {String} string 被填充的字符串
* @param {Number} length 需要扩充到的长度
* @param {String} pad 填充字符
* @return {String} 填充后的字符串
*/
const padStart = (string, length, pad) => {
// 只做了string的类型兼容判断,length,pad都没有,有风险
// 可参考:https://github.com/lodash/lodash/blob/4.17.15/lodash.js#L14443
// 但也有一个想法:判断是否够用就行,不用做到极致,否则也是一种浪费?
const s = String(string)
if (!s || s.length >= length) return string
// Array((length + 1) - s.length).join(pad)
// 可简化为pad.repeat(length - s.length)
// dayjs打包直接就体现:从2.6KB转成了2.59KB
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) => {
// 这里逻辑取反,而后面判断<=0为正,是否有必要呢?改为以下代码可读性感觉更好:
// const minutes = instance.utcOffset()
// const absMinutes = Math.abs(minutes)
// const hourOffset = Math.floor(absMinutes / 60)
// const minuteOffset = absMinutes % 60
// return `${minutes >= 0 ? '+' : '-'}${padStart(hourOffset, 2, '0')}:${padStart(minuteOffset, 2, '0')}`
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: 返回两个Dayjs实例的月份差
* @param {Dayjs} a Dayjs的实例
* @param {Dayjs} b Dayjs的实例
* @return {Number} 返回两个实例的月份差
*/
const monthDiff = (a, b) => {
// 来自moment.js的函数,确保两者返回相同的结果
// 使用简单的反向逻辑递归调用,大大降低代码逻辑复杂度,减少代码量
// 但是另外一方面转成了-(b-a),不知道为什么要做这个反转,同上面的padZoneStr
// 关于monthDiff算法的讨论:https://stackoverflow.com/questions/2536379/difference-in-months-between-two-dates-in-javascript
// moment讨论结果:https://github.com/moment/moment/pull/571
// 以下解析按照不反转逻辑来解析即a-b
// 算法主要分为两部分,两部分相加即可
// 1. 不考虑日期,计算相差的月份数,即:总月差 = 年差值 * 12 + 月差值
// 2. 计算日期差值转成的小数,这部分逻辑就是算法差异,dayjs和momentjs的逻辑如下:
// 2.1 获取锚点1 = b日期实例 + 总月差
// 2.2 获取a日期与锚点1的差值 = a日期 - 锚点1
// 2.3 判断a日期与锚点1的大小,即如果a日期小于锚点1,说明差距没这么大,需要减去多出来的部分,反之需要多加一部分
// 2.4 根据2.3的判断生成锚点2 = b日期实例 + 总月差+/-1,如此a日期实例必然落在锚点1和锚点2之间
// 2.5 获取锚点区间长度 = 锚点2 - 锚点1
// 2.6 计算a日期占据比例 = 锚点区间占据长度(a日期与锚点1的差值) / 锚点区间长度
// 3. 计算最终月差 = 第一步计算的总月差 + a日期占据比例
// 正向逻辑写法:
// if (a.date() < b.date()) return -monthDiff(b, a)
// const wholeMonthDiff = ((a.year() - b.year()) * 12) + (a.month() - b.month())
// const anchor1 = b.clone().add(wholeMonthDiff, 'month')
// const l1 = a - anchor1
// const anchor2 = b.clone().add(wholeMonthDiff + (l1 < 0 ? -1 : 1), 'month')
// const l2 = Math.abs(anchor2 - anchor1);
// return (wholeMonthDiff + l1 / l2) || 0
// 原反向逻辑写法-(b - a):
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
// || 0 是为了确保不会出现-0,Math.ceil(-0.1) => -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
}
// 返回special定义的单位,或将自定义的单位转为小写并去除结尾s字符的单数形式的单位
return special[u] || String(u || '').toLowerCase().replace(/s$/, '')
}
/**
* @description: 判断是否为undefined
* @param {Any} s
* @return {Boolean} 返回是否为undefined:true/false
*/
const isUndefined = s => s === undefined
// index.js使用Utils空间,babel无法mangle
// 缩短为了极致优化大小,实际开发不太推荐这样做
export default {
s: padStart,
z: padZoneStr,
m: monthDiff,
a: absFloor,
p: prettyUnit,
u: isUndefined
}入口文件:index.js
index.js是dayjs的核心,为了优化大小,作者在写代码的时候精简了部分参数,包括导入参数、工具函数等等。
为了方便阅读源码,我把有影响阅读的方法参数都重新补全。核心入口文件主要分为以下几部分:
- 引入常量、语言配置、工具函数模块
- 初始化配置语言环境为en,定义全局环境变量
- 定义使用全局变量的工具方法,并统一放到Utils命名空间
- 核心Dayjs类
- 工厂函数对象dayjs
- 为工厂函数对象添加原型方法和静态方法,挂载环境变量
- 返回工厂函数dayjs
// 引入常量、语言配置、工具函数模块
import * as CONSTANT from './constant'
import en from './locale/en'
import UTILS from './utils'
// 初始化配置语言环境为en,定义全局环境变量
// 存储当前语言环境
let LOCALE = 'en' // global locale
// 存储已导入的语言包配置
const LoadedLocales = {} // global loaded locale
// 将en语言包配置添加到环境变量对象进行存储
LoadedLocales[LOCALE] = en
/**
* @description: 判断是否为Dayjs实例
* @param {Any} d
* @return {Boolean} 返回是否为Dayjs实例:true/false
*/
const isDayjs = d => d instanceof Dayjs
/**
* @description 解析语言配置,导入并启用语言包
* @param {String|Object} preset 语言包名称或语言包配置对象
* @param {Object} object 语言包配置对象或null
* @param {Boolean} isLocal 是否为本地语言
* @return {String} 返回解析到的环境语言名称
*/
const parseLocale = (preset, object, isLocal) => {
let locale
// 不传参数则直接返回当前启用的语言包名
if (!preset) return LOCALE
if (typeof preset === 'string') {
// 判断是否preset语言是否已导入到本地
if (LoadedLocales[preset]) {
// 已导入则保存解析语言为preset
locale = preset
}
// 判断传入语言配置对象
if (object) {
// 传入则直接导入语言配置并保存语言名称为preset
LoadedLocales[preset] = object
locale = preset
}
} else {
// 若preset是完整的语言配置对象,则获取语言名称,存储导入并保存解析语言名称
const { name } = preset
LoadedLocales[name] = preset
locale = name
}
// 未传入isLocal或isLocal为false时,将启用语言包为环境语言
if (!isLocal && locale) LOCALE = locale
// 返回解析到的环境语言名称,未成功解析则返回当前环境语言
return locale || (!isLocal && LOCALE)
}
/**
* @description 工厂函数对象dayjs
* @param {Any} date Dayjs对象实例或Date对象实例或可以转换为Date的字符串、时间戳等
* @param {Object} c 配置对象
* @return {Dayjs} 返回Dayjs实例对象
*/
const dayjs = function (date, c) {
// 若传入Dayjs实例对象,则直接返回对象的拷贝
if (isDayjs(date)) {
return date.clone()
}
// 获取初始化构建参数,生成Dayjs实例对象
const config = typeof c === 'object' ? c : {}
config.date = date
config.args = arguments
return new Dayjs(config)
}
/**
* @description 获取dayjs实例的环境配置结合Date实例对象生成新的Dayjs实例
* @param {Date} date 日期实例对象
* @param {Dayjs} instance Dayjs实例对象
* @return {Dayjs} 返回新的Dayjs实例
*/
const wrapper = (date, instance) =>
dayjs(date, {
// 语言环境配置
locale: instance.$LOCALE,
// UTC配置
utc: instance.$utc,
// 时区配置
timezone: instance.$timezone,
// 偏移配置,未启用,忽略
$offset: instance.$offset // todo: refactor; do not use this.$offset in you code
})
// 定义使用全局变量的工具方法,并统一放到Utils命名空间
const Utils = UTILS // for plugin use
Utils.parseLocale = parseLocale
Utils.isDayjs = isDayjs
Utils.wrapper = wrapper
/**
* @description 通过参数生成Date实例对象
* @param {Object} config 时间参数,包含date:时间参数,utc:Boolean,是否启用UTC
* @return {Date} 返回Date实例对象
*/
const parseDate = (config) => {
const { date, utc } = config
// date不能传入null,将返回Invalid Date
if (date === null) return new Date(NaN) // null is invalid
// 未传入date时,则使用当前时间
if (Utils.isUndefined(date)) return new Date() // today
// 确保数据不可变性,传入为Date实例时,重新通过new Date生成一个新的实例
if (date instanceof Date) return new Date(date)
// date是字符串且未使用z字符结尾
if (typeof date === 'string' && !/Z$/i.test(date)) {
// 使用正则捕获日期对应的每个值,如2021-05-19 17:16:15.123,d可得到:
// [
// '2021-05-19 17:16:15.123', // 原始值,d[0]
// '2021', // 年,d[1]
// '05', // 月,d[2]
// '19', // 日,d[3]
// '17', // 时,d[4]
// '16', // 分,d[5]
// '15', // 秒,d[6]
// '123', // 毫秒,d[7]
// index: 0,
// input: '2021-05-19 17:16:15.123',
// groups: undefined
// ]
const d = date.match(CONSTANT.REGEX_PARSE)
if (d) {
// 需关注new Date时传入的月份应为monthIndex即月份的索引=月份值-1
// 月份可不传,则匹配值为undefined,undefined - 1返回NaN,需要转为1月份即月份索引值为0
const m = d[2] - 1 || 0
// 毫秒可能未传入则匹配值为undefined,或者传入过长,因此取三位
const ms = (d[7] || '0').substring(0, 3)
// 由于至少匹配到年,因此除了年份值之外都需要做兼容处理:
// 即往1月1日0时0分0秒0毫秒做兼容
// 使用兼容性最强的初始化Date对象方式,即传入所有参数或传入时间戳,而不是使用字符串
// utc用于判断是否是UTC时间
if (utc) {
// Date.UTC返回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)
}
}
// 这里覆盖其他类型,Dayjs本身会使用timestamp的类型,在add()方法里面使用
// 其他类型的字符串将尝试使用Date解析,无法确保能解析成功:
// https://star.qingzz.cn/2021/05/14/shi-jian/#toc-heading-16
return new Date(date) // everything else
}
// 核心Dayjs类
class Dayjs {
/**
* @description 构造函数
* @param {Object} config locale,date,utc,timezone等参数
*/
constructor(config) {
// 初始化语言环境
this.$LOCALE = parseLocale(config.locale, null, true)
// 初始化时间参数
this.parse(config) // for plugin
}
/**
* @description 初始化时间相关参数
* @param {Object} config 构造函数的参数对象
*/
parse(config) {
// 初始化时间对象,使用原生Date对象大大降低代码量和复杂度
this.$date = parseDate(config)
// 初始化时区配置
this.$timezone = config.timezone || {}
// 初始化计算各项时间参数
this.init()
}
/**
* @description 初始化年、月、日、星期、时、分、秒、毫秒属性
*/
init() {
const { $date } = this
this.$year = $date.getFullYear()
this.$Month = $date.getMonth()
this.$Date = $date.getDate()
this.$WeekDay = $date.getDay()
this.$Hour = $date.getHours()
this.$minute = $date.getMinutes()
this.$second = $date.getSeconds()
this.$millisecond = $date.getMilliseconds()
}
/**
* @description 将工具函数通过方法懒挂载到实例上,而不是直接赋值到某个属性,简化实例对象,更轻量且能复用同一份方法,值得学习
*/
$utils() {
return Utils
}
/**
* @description 判断是否合法时间
* @return {Boolean} 返回是否时间是否合法,true/false
*/
isValid() {
// Date非法时,转为字符串为Invalid Date,可根据此进行判断
// 可以直接用!==,不但减少了一次转换,而且实际上!==的性能比===性能好
// 大家可以使用下面代码测试一下:
// // ===
// console.time();
// console.log("1 === 1", 1 === 1); // 1 === 1 true
// console.timeEnd(); // default: 5.81ms
// console.time();
// console.log("1 === '1'", 1 === '1'); // 1 === '1' false
// console.timeEnd(); // default: 0.109ms
// // !==
// console.time();
// console.log("1 !== 1", 1 !== 1); // 1 !== 1 false
// console.timeEnd(); // default: 0.039ms
// console.time();
// console.log("1 !== '1'", 1 !== '1'); // 1 !== '1' true
// console.timeEnd(); // default: 0.063ms
// 应改为:return CONSTANT.INVALID_DATE_STRING !== this.$date.toString()
return !(this.$date.toString() === CONSTANT.INVALID_DATE_STRING)
}
/**
* @description 判断当前实例是否与另外一个实例在某个单位层级内相等
* @param {Dayjs} that Dayjs比较实例
* @param {String} units 单位层级字符串
* @return {Boolean} 返回是否相等,true/false
*/
isSame(that, units) {
const other = dayjs(that)
// 使用夹逼定理,可确定other落在units层级区间里,即在上一层级必然相等
// 若不传units,则startOf和endOf都是返回拷贝,直接根据相等判断即可
return this.startOf(units) <= other && other <= this.endOf(units)
}
/**
* @description 判断当前实例时间是否在另外一个实例的时间之后
* @param {Dayjs} that Dayjs比较实例
* @param {String} units 单位层级字符串
* @return {Boolean} 返回是否在that之后,true/false
*/
isAfter(that, units) {
return dayjs(that) < this.startOf(units)
}
/**
* @description 判断当前实例时间是否在另外一个实例的时间之前
* @param {Dayjs} that Dayjs比较实例
* @param {String} units 单位层级字符串
* @return {Boolean} 返回是否在that之前,true/false
*/
isBefore(that, units) {
return this.endOf(units) < dayjs(that)
}
/**
* @description 私有方法,用于分发获取或设置各项时间参数
* @param {Number} input 设置值
* @param {String} get 获取参数键值
* @param {String} set 设置参数键值
* @return {Dayjs|Number} 获取则返回获取值,设置则返回this实例用于链式调用
*/
$getterSetter(input, get, set) {
// 可跳转到下面调用的地方一起看
// 不传input,则是获取方法,直接获取属性值,在init()方法初始化和更新
if (Utils.isUndefined(input)) return this[get]
// 传入input,则是设置方法
return this.set(set, input)
}
/**
* @description 返回秒级别的时间戳
* @return {Number} 秒级时间戳
*/
unix() {
return Math.floor(this.valueOf() / 1000)
}
/**
* @description 返回时间值
* @return {Number} 非法时间将返回NaN,合法时间则返回时间戳
*/
valueOf() {
// timezone(hour) * 60 * 60 * 1000 => ms
return this.$date.getTime()
}
/**
* @description 根据单位将实例设置到一个时间段的开始
* @param {String} units 单位字符串
* @param {Boolean} startOf 可选,true或不传使用开始值,false使用结束值
* @return {Dayjs} 返回某个时间段起始或结束的实例
*/
startOf(units, startOf) { // startOf -> endOf
// 不传或true则计算startOf,否则为endOf
const isStartOf = !Utils.isUndefined(startOf) ? startOf : true
// 通过prettyUnit扩展单位支持范围,接受普通值、缩写、复数,对大小写不敏感
const unit = Utils.prettyUnit(units)
/**
* @description 根据月日(参数)和年份(实例)创建新的Dayjs实例
* @param {Number} d 日值
* @param {Number} m 月值,为索引
* @return {Dayjs} 返回新实例,环境参数使用原实例配置
*/
// 为什么要日,月呢?可读性不好,下面instanceFactorySet又从大到小,一致性不好。修改调整:
// const instanceFactory = (d, m) => {
const instanceFactory = (m, d) => {
const ins = Utils.wrapper(this.$utc ?
Date.UTC(this.$year, m, d) : new Date(this.$year, m, d), this)
// 如果 isStartOf 为 false,返回ins当天的 endOf
return isStartOf ? ins : ins.endOf(CONSTANT.D)
}
/**
* @description 根据传入的方法来返回新的Dayjs实例
* @param {String} method 设置方法,如setHours,setMinutes等
* @param {Number} slice 截取参数
* @return {Dayjs} 返回新实例
*/
const instanceFactorySet = (method, slice) => {
// [时,分,秒,毫秒]起始区间
const argumentStart = [0, 0, 0, 0]
const argumentEnd = [23, 59, 59, 999]
// 这里使用apply主要是想利用它接收数组参数的效果,也可以使用扩展运算符实现:
// return Utils.wrapper(this.toDate()[method](
// ...(isStartOf ? argumentStart : argumentEnd).slice(slice)
// ), this)
return Utils.wrapper(this.toDate()[method].apply(
// 这里多了无用参数's',应该是个bug
// this.toDate('s'),
this.toDate(),
(isStartOf ? argumentStart : argumentEnd).slice(slice)
), this)
}
// 以下代码调整了instanceFactory参数的顺序:
// 获取星期,月,日值
const { $WeekDay, $Month, $Date } = this
// 原生设置方法UTC需要多加UTC字符
const utcPad = `set${this.$utc ? 'UTC' : ''}`
switch (unit) {
// 年:起始值为1-1 0:0:0,结束值为12-31 23:59:59.999(当天的endOf)
case CONSTANT.Y:
return isStartOf ? instanceFactory(0, 1) :
instanceFactory(11, 31)
// 月:起始值为月-1 0:0:0,结束值为下一月-0 23:59:59.999
// 骚操作:设置为下一个月,且日期为0,相当于上一个月的最后一天,这样完全不需要判断上个月是28、29、30、31天
case CONSTANT.M:
return isStartOf ? instanceFactory($Month, 1) :
instanceFactory($Month + 1, 0)
// 周:起始值为周日或周一的 0:0:0,结束值为周一或周日的 23:23:59.999
case CONSTANT.W: {
const weekStart = this.$locale().weekStart || 0
const gap = ($WeekDay < weekStart ? $WeekDay + 7 : $WeekDay) - weekStart
return instanceFactory($Month, isStartOf ? $Date - gap : $Date + (6 - gap))
}
// 日:起始值为0:0:0.0,结束值为23:59:59.999
case CONSTANT.D:
case CONSTANT.DATE:
return instanceFactorySet(`${utcPad}Hours`, 0)
// 时:起始值为0:0:0.0,结束值为23:59:59.999
case CONSTANT.H:
return instanceFactorySet(`${utcPad}Minutes`, 1)
// 分:起始值为0:0.0,结束值为59:59.999
case CONSTANT.MIN:
return instanceFactorySet(`${utcPad}Seconds`, 2)
// 秒:起始值为0.0,结束值为59.999
case CONSTANT.S:
return instanceFactorySet(`${utcPad}Milliseconds`, 3)
// 默认返回一个拷贝
default:
return this.clone()
}
// // 获取星期,月,日值
// const { $WeekDay, $Month, $Date } = this
// // 原生设置方法UTC需要多加UTC字符
// const utcPad = `set${this.$utc ? 'UTC' : ''}`
// switch (unit) {
// case CONSTANT.Y:
// return isStartOf ? instanceFactory(1, 0) :
// instanceFactory(31, 11)
// case CONSTANT.M:
// return isStartOf ? instanceFactory(1, $Month) :
// instanceFactory(0, $Month + 1)
// case CONSTANT.W: {
// const weekStart = this.$locale().weekStart || 0
// const gap = ($WeekDay < weekStart ? $WeekDay + 7 : $WeekDay) - weekStart
// return instanceFactory(isStartOf ? $Date - gap : $Date + (6 - gap), $Month)
// }
// case CONSTANT.D:
// case CONSTANT.DATE:
// return instanceFactorySet(`${utcPad}Hours`, 0)
// case CONSTANT.H:
// return instanceFactorySet(`${utcPad}Minutes`, 1)
// case CONSTANT.MIN:
// return instanceFactorySet(`${utcPad}Seconds`, 2)
// case CONSTANT.S:
// return instanceFactorySet(`${utcPad}Milliseconds`, 3)
// default:
// return this.clone()
// }
}
/**
* @description 获取某个单位层级范围空间的结束值
* @param {String} arg 单位字符串
* @return {Dayjs} 返回某个单位层级范围空间的结束值实例
*/
endOf(arg) {
return this.startOf(arg, false)
}
/**
* @description 私有方法,设置某个单位层级的值
* @param {String} units 单位
* @param {Number} int 设置值
* @return {Dayjs} 返回this,方便链式调用
*/
$set(units, int) { // private set
const unit = Utils.prettyUnit(units)
const utcPad = `set${this.$utc ? 'UTC' : ''}`
const name = {
[CONSTANT.D]: `${utcPad}Date`,
[CONSTANT.DATE]: `${utcPad}Date`,
[CONSTANT.M]: `${utcPad}Month`,
[CONSTANT.Y]: `${utcPad}FullYear`,
[CONSTANT.H]: `${utcPad}Hours`,
[CONSTANT.MIN]: `${utcPad}Minutes`,
[CONSTANT.S]: `${utcPad}Seconds`,
[CONSTANT.MS]: `${utcPad}Milliseconds`
}[unit]
// 由于使用setDate设置星期几,因此只需要将设置值减去当前值,即可知道需要在本周调整几天
// 然后和this.$Date即日期相加进行调整就可以了
// 其他参数都有自己原生的设置方法,直接调用即可
const arg = unit === CONSTANT.D ? this.$Date + (int - this.$WeekDay) : int
// 如果是年/月
// 重新设置年和月可能影响月份天数,例如当前是5月31日,月份改成4月,只能改成30日
// 或者当前是2000年2月29日,年份改成2001年,2月只有28天,只能改成28日
if (unit === CONSTANT.M || unit === CONSTANT.Y) {
// clone is for badMutable plugin
// 首页拷贝一份并设置日期为1号
const date = this.clone().set(CONSTANT.DATE, 1)
// 设置年或月
date.$date[name](arg)
// 重新初始化拷贝实例对象
date.init()
// 获取当月最长天数,如果当前实例日期超过则使用当月最长天数,否则为安全日期,直接使用即可
this.$date = date.set(CONSTANT.DATE, Math.min(this.$Date, date.daysInMonth())).$date
} else if (name) {
// 其他方法直接调用Date本身的方法进行设置即可
this.$date[name](arg)
}
// 更新各项参数值
this.init()
// 返回this,用于链式调用
return this
}
/**
* @description 设置值
* @param {String} string 设置键值
* @param {Number} int 值
* @return {Dayjs} 返回对象,用于链式调用
*/
set(string, int) {
return this.clone().$set(string, int)
}
/**
* @description 获取某个单位级别的值
* @param {String} unit 单位
* @return {Number} 通过下面getterSetter分发挂载的原型方法获取某个单位级别的值
*/
get(unit) {
return this[Utils.prettyUnit(unit)]()
}
/**
* @description 获取当前日期增加一段时间后的实例对象
* @param {Number} number 增加的数量
* @param {String} units 增加的单位
* @return {Dayjs} 返回增加一段时间后的实例
*/
add(number, units) {
// 确保为数字
number = Number(number)
// 获取统一单位
const unit = Utils.prettyUnit(units)
/**
* @description 工厂函数,计算天数相关的增加,使用四舍五入
* @param {Number} n 1或7对应天和周
* @return {Dayjs} 返回增加时间后的实例
*/
const instanceFactorySet = (n) => {
// 是否使用this.clone更语意化呢?
// const d = this.clone()
const d = dayjs(this)
return Utils.wrapper(d.date(d.date() + Math.round(n * number)), this)
}
// 代码顺序可调整:年月日周时分秒毫秒更整齐
// 月
if (unit === CONSTANT.M) {
return this.set(CONSTANT.M, this.$Month + number)
}
// 年
if (unit === CONSTANT.Y) {
return this.set(CONSTANT.Y, this.$year + number)
}
// // 月
// if (unit === CONSTANT.M) {
// return this.set(CONSTANT.M, this.$Month + number)
// }
// 日
if (unit === CONSTANT.D) {
return instanceFactorySet(1)
}
// 周
if (unit === CONSTANT.W) {
return instanceFactorySet(7)
}
const step = {
[CONSTANT.MIN]: CONSTANT.MILLISECONDS_A_MINUTE,
[CONSTANT.H]: CONSTANT.MILLISECONDS_A_HOUR,
// [CONSTANT.MIN]: CONSTANT.MILLISECONDS_A_MINUTE,
[CONSTANT.S]: CONSTANT.MILLISECONDS_A_SECOND
}[unit] || 1 // ms
// 通过时间戳生成最终实例
const nextTimeStamp = this.$date.getTime() + (number * step)
return Utils.wrapper(nextTimeStamp, this)
}
/**
* @description 获取当前日期减少一段时间后的实例对象
* @param {Number} number 减少的数量
* @param {String} string 减少的单位
* @return {Dayjs} 返回减少一段时间后的实例
*/
subtract(number, string) {
return this.add(number * -1, string)
}
/**
* @description 获取格式化的时间字符串
* @param {String} formatStr 时间字符串格式
* @return {String} 返回格式化的时间字符串
*/
format(formatStr) {
// 非法时间直接返回Invalid Date
if (!this.isValid()) return CONSTANT.INVALID_DATE_STRING
// 不传格式则使用默认IOS 8601格式:'YYYY-MM-DDTHH:mm:ssZ'
const str = formatStr || CONSTANT.FORMAT_DEFAULT
// 时区偏移部分字符串
const zoneStr = Utils.padZoneStr(this)
// 获取当前语言配置
const locale = this.$locale()
// 获取时分月值
const { $Hour, $minute, $Month } = this
// 获取语言包里面的星期、月份、时间范围配置
const {
weekdays, months, meridiem
} = locale
/**
* @description: 返回对应缩写的字符串,可自适应
* @param {Array|Function} arr 星期和月份的缩写数组,也可以是函数
* @param {Number} index 索引
* @param {Array} full 星期和月份的非缩写数组
* @param {Number} length 返回结果的字符长度
* @return {String} 对应缩写的字符串
*/
const getShort = (arr, index, full, length) => (
(
arr && (arr[index] || arr(this, str)) // 通过缩写数组和索引获取
) || full[index].substr(0, length) // 截取完整字符串的前面几个字符作为缩写,如Monday截取前3个字符:Mon
)
/**
* @description 获取12小时制的小时数
* @param {Number} num 格式长度
* @return {String} 返回使用0补足num长度的小时数
*/
const get$Hour = num => (
// 这里关注|| 12,一般使用取余操作将小于除数,即0-11,使用 || 12,之后,0点和12点都会返回12
Utils.padStart($Hour % 12 || 12, num, '0')
)
/**
* @description 定义时间返回字符串的函数,默认取语言包定义的,否则使用默认en环境的
* @param {Number} hour 时
* @param {Number} minute 分
* @param {Boolean} isLowercase 是否要转为小写
* @return {String} 返回语言包定义的时间范围,默认返回AM/PM,传入小写则返回am/pm
*/
const meridiemFunc = meridiem || ((hour, minute, isLowercase) => {
const m = (hour < 12 ? 'AM' : 'PM')
return isLowercase ? m.toLowerCase() : m
})
/**
* 定义格式对应的值
*/
const matches = {
YY: String(this.$year).slice(-2),
YYYY: this.$year,
M: $Month + 1,
MM: Utils.padStart($Month + 1, 2, '0'),
MMM: getShort(locale.monthsShort, $Month, months, 3),
MMMM: getShort(months, $Month),
D: this.$Date,
DD: Utils.padStart(this.$Date, 2, '0'),
d: String(this.$WeekDay),
dd: getShort(locale.weekdaysMin, this.$WeekDay, weekdays, 2),
ddd: getShort(locale.weekdaysShort, this.$WeekDay, weekdays, 3),
dddd: weekdays[this.$WeekDay],
H: String($Hour),
HH: Utils.padStart($Hour, 2, '0'),
h: get$Hour(1),
hh: get$Hour(2),
a: meridiemFunc($Hour, $minute, true),
A: meridiemFunc($Hour, $minute, false),
m: String($minute),
mm: Utils.padStart($minute, 2, '0'),
s: String(this.$second),
ss: Utils.padStart(this.$second, 2, '0'),
SSS: Utils.padStart(this.$millisecond, 3, '0'),
Z: zoneStr // 'ZZ' logic below
}
// 解析时间格式字符串,通过String.replace将所有捕获的格式替换为实际值
// 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
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/String/replace#%E6%8C%87%E5%AE%9A%E4%B8%80%E4%B8%AA%E5%87%BD%E6%95%B0%E4%BD%9C%E4%B8%BA%E5%8F%82%E6%95%B0
return str.replace(CONSTANT.REGEX_FORMAT, (match, $1) => $1 || matches[match] || zoneStr.replace(':', '')) // 'ZZ'
}
/**
* @description 获取分钟级的UTC偏移量,精度为15分钟
* @return {Number} 返回UTC偏移量
*/
utcOffset() {
// Because a bug at FF24, we're rounding the timezone offset around 15 minutes
// https://github.com/moment/moment/pull/1871
return -Math.round(this.$date.getTimezoneOffset() / 15) * 15
}
/**
* @description 获取当前实例与另外一个日期之间的差异
* @param {Any} input Dayjs实例、或可以转换为Date的参数,如Date实例、时间戳、ISO 8601字符串等
* @param {String} units 单位
* @param {Boolean} float 是否保留小数
* @return {Number} 返回差别的单位数
*/
diff(input, units, float) {
// 单位
const unit = Utils.prettyUnit(units)
// 使用input定义实例
const that = dayjs(input)
// 获取UTC差别的分钟数转为毫秒数,解决DST造成的时差问题,由于DST某些天不会是完整的24小时
// https://github.com/moment/moment/issues/831
// https://github.com/moment/moment/issues/2361
const zoneDelta = (that.utcOffset() - this.utcOffset()) * CONSTANT.MILLISECONDS_A_MINUTE
// 获取直接相减的毫秒数
const diff = this - that
// 获取差别的月份数
let result = Utils.monthDiff(this, that)
// 这里可能会做一些无谓计算,但是如果使用判断未必能更快
// 这样的写法更简洁
result = {
// 年
[CONSTANT.Y]: result / 12,
// 月
[CONSTANT.M]: result,
// 季
[CONSTANT.Q]: result / 3,
// 周
[CONSTANT.W]: (diff - zoneDelta) / CONSTANT.MILLISECONDS_A_WEEK,
// 日
[CONSTANT.D]: (diff - zoneDelta) / CONSTANT.MILLISECONDS_A_DAY,
// 时
[CONSTANT.H]: diff / CONSTANT.MILLISECONDS_A_HOUR,
// 分
[CONSTANT.MIN]: diff / CONSTANT.MILLISECONDS_A_MINUTE,
// 秒
[CONSTANT.S]: diff / CONSTANT.MILLISECONDS_A_SECOND
}[unit] || diff // milliseconds
// 返回保留小数或取整的结果
return float ? result : Utils.absFloor(result)
}
/**
* @description 获取月份包含天数
* @return {Number} 返回月底日期
*/
daysInMonth() {
return this.endOf(CONSTANT.M).$Date
}
/**
* @description 私有方法,用于获取当前语言环境配置对象
* @return {Object} 返回当前语言环境配置对象
*/
$locale() { // get locale object
return LoadedLocales[this.$LOCALE]
}
/**
* @description 获取语言环境或启用语言包
* @param {Any} preset 语言环境字符串或语言配置对象
* @param {Object} object 一般本地语言包会传入null来启用,也可以通过object直接将语言配置传入
* @return {String|Dayjs} 不传参数则返回语言环境,传入参数则启用语言包并返回新Dayjs拷贝
*/
locale(preset, object) {
if (!preset) return this.$LOCALE
// 保持不可变性,重新生成拷贝进行环境配置
const that = this.clone()
const nextLocaleName = parseLocale(preset, object, true)
if (nextLocaleName) that.$LOCALE = nextLocaleName
return that
}
/**
* @description 生成拷贝
* @return {Dayjs} 复制当前环境参数和Date对象生成一个新的实例
*/
clone() {
return Utils.wrapper(this.$date, this)
}
/**
* @description 转化为Date对象
* @return {Date} 返回Date对象
*/
toDate() {
// 体现不可变性,不返回this.$date,而是重新生成Date对象
return new Date(this.valueOf())
}
/**
* @description 返回JSON格式字符串
* @return {String|Null} 非法返回null,合法返回类似"2021-05-19T09:47:35.970Z"格式的字符串
*/
toJSON() {
return this.isValid() ? this.toISOString() : null
}
/**
* @description 转为ISO 8601格式字符串
* @return {String} 非法报错,合法返回类似"2021-05-19T09:47:35.970Z"格式的字符串
*/
toISOString() {
// ie 8 return
// new Dayjs(this.valueOf() + this.$date.getTimezoneOffset() * 60000)
// .format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')
return this.$date.toISOString()
}
/**
* @description 转为字符串
* @return {String} 非法返回Invalid Date,合法返回类似"Wed, 19 May 2021 09:45:51 GMT"格式的字符串
*/
toString() {
return this.$date.toUTCString()
}
}
// 为工厂函数对象添加原型方法和静态方法,挂载环境变量
const proto = Dayjs.prototype
dayjs.prototype = proto;
[
['$millisecond', CONSTANT.MS],
['$second', CONSTANT.S],
['$minute', CONSTANT.MIN],
['$Hour', CONSTANT.H],
['$WeekDay', CONSTANT.D],
['$Month', CONSTANT.M],
['$year', CONSTANT.Y],
['$Date', CONSTANT.DATE]
].forEach((g) => {
// 使用分发快速挂载一种类型的方法,值得学习
// 注册原型方法:year,month,date,week,hour,minute,second,millisecond
// input不传则是获取,否则是设置,这种getter和setter的区别非常常见,可关注
proto[g[1]] = function (input) {
return this.$getterSetter(input, g[0], g[1])
}
})
/**
* @description 导入并启用插件
* @param {Object} plugin 插件对象
* @param {Object} option 插件配置
* @return {dayjs} 返回已安装插件的dayjs对象,用于链式调用
*/
dayjs.extend = (plugin, option) => {
// 防止多次注册
if (!plugin.$install) { // install plugin only once
// 调用函数全局注册插件
// 插件主要通过在Dayjs添加prototype方法,或者在dayjs添加属性和静态方法来扩展功能
plugin(option, Dayjs, dayjs)
// 添加安装标记
plugin.$install = true
}
return dayjs
}
// 解析语言配置,导入并启用语言包
dayjs.locale = parseLocale
// 判断是否是Dayjs实例对象
dayjs.isDayjs = isDayjs
// 通过秒级的时间戳生成dayjs实例
dayjs.unix = timestamp => (
dayjs(timestamp * 1e3)
)
// 定义默认语言en的环境
dayjs.en = LoadedLocales[LOCALE]
// 注册语言包
dayjs.LoadedLocales = LoadedLocales
// 定义插件对象
dayjs.plugins = {}
// 返回工厂函数dayjs
export default dayjs总结
通过分析,我们可以得出dayjs是如何践行自己的基础理念的:
- 2kb:从工程设计上通过工具检测死守底线,仅保留核心功能,将国际化语言包按需引入,非必要的功能全部迁到插件体系中。甚至使用不太推荐的短字符串变量用于精简大小。
- 简易:站在巨人的肩膀上也是
dayjs成功的原因,从momentjs汲取大量营养,从完整成熟的API体系设计,到通过一些技巧解决月份比较、DST造成每天未必有24小时问题等,快速催熟了dayjs的代码生态质量体系。为了对标momentjs,甚至有专门的单元测试来确保表现一致,而且能快速替换Element UI、Antd等使用momentjs的库,从而快速成长扩张自己的生态。 - 不可变性:
dayjs既能链式调用,又随时判断是否需要保持不可变性,本身实现了快速拷贝功能。通过不可变性,增强代码的可控性,而且可以保持一份Date各项数据的属性值,而不需要一直更新,只有在设置的时候才需要更新,减少了复杂度。同时,通过不可变性,插件体系更加可控,减少不必要的代码成本。 - 国际化:国际化包是
momentjs大小的痛点之一,通过按需加载,将国际化包独立,dayjs能大大减小最终大小,同时语言包模版固定,能快速实现扩展,现在国际化生态已基本覆盖常用的语言。