万万没想到,dayjs源码居然只有三个文件!(源码逐行解读)

266 阅读7分钟

1 为什么要学习dayjs

dayjs作为当前最流行的日期处理javascript库之一。

无论是ElementUIAntDesign都选择它作为日期组件的默认库,足以得见它在大家心目中的地位!

废话不多说,进入正题。

2 文件目录分析

源码地址:我这里提供的是gitee地址,访问会快一些

打开代码的src文件夹,映入眼帘的是如下精简的目录:

localeplugin分别代表多语言和插件,有使用需求才引入,所以可以先当做不存在。

剩下的三个文件就是dayjs的核心源码,我真的是一点都没有标题党!!

src
│  constant.js  // 常量文件
│  index.js   // 入口文件
│  utils.js   // 工具类库
│
├─locale
└─plugin
    

3 源码分析

鉴于该项目源码真的不多,我就把这三个文件逐一分析一下。

3.1 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时间格式,中间带T,结尾带Z
// T表示时间的起始点,Z表示标准时区
export const FORMAT_DEFAULT = 'YYYY-MM-DDTHH:mm:ssZ'

// 当new Date('error') 传入非标准日期参数时
// 会返回Invalid Date
export const INVALID_DATE_STRING = 'Invalid Date'

// 正则表达式
// 这个是用来字符串转日期的正则表达式,
// 用括号包裹的部分分别是年、月、日、时、分、秒、毫秒
export const REGEX_PARSE = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\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

正则表达式可能大家看的会有点懵,下面举两个简单的例子,大家可能就明白了:

const REGEX_PARSE = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/

const matches='2023-10-01T10:10:10.100'.match(REGEX_PARSE);

console.log(matches);
// 返回的matches是一个数组
[
  '2023-10-01T10:10:10.100',  //第一个元素代表参与匹配的字符串
  '2023', // 这个是正则中第一个括号匹配到的内容,也就是年,下面同理
  '10',   // 这个是月
  '01',   // 这个是日
  '10',   // 这个是时
  '10',   // 这个是分
  '10',   // 这个是秒
  '100',  // 这个是毫秒
  index: 0,
  input: '2023-10-01T10:10:10.100',
  groups: undefined
]
// 有了这些信息,就能够生成一个dayjs对象了
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
const matches='[YY]YYYY-MM-DDTHH:mm:ss.SSS'.match(REGEX_FORMAT)
console.log(matches);
// 返回的结果是一个数组,
// 代表的是哪些片段符合该正则
// dayjs中逻辑是遇到中括号包裹的内容,
// 会将中括号去掉,里面的内容原样返回
// 其余的就会转成对应的年月日等信息
[
  '[YY]', 'YYYY',
  'MM',   'DD',
  'HH',   'mm',
  'ss',   'SSS'
]

console.log(dayjs().format('[YY]YYYY-MM-DDTHH:mm:ss.SSS'));
//输出 YY2023-10-04T17:46:41.184

3.2 utils.js

import * as C from './constant.js'

// 给字符串补充前置内容
// 比如给月份补充0,
// padStart(1,2,'0')--->'01'
const padStart = (string, length, pad) => {
  const s = String(string)
  if (!s || s.length >= length) return string
  return `${Array(length + 1 - s.length).join(pad)}${string}`
}

// 返回时区字符串
// 入参是一个dayjs对象
// 如果是中国时区,会
// 返回+08:00这样的结果
const padZoneStr = instance => {
  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')}`
}

// 比较两个日期月份的差
// 会精确到小数
const monthDiff = (a, b) => {
  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
  )
}

// 正数向下取整
// 负数向上取整
const absFloor = n => (n < 0 ? Math.ceil(n) || 0 : Math.floor(n))

// 将缩写转为单词
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 (
    special[u] ||
    String(u || '')
      .toLowerCase()
      .replace(/s$/, '')
  )
}

// 判断是否undefined
const isUndefined = s => s === undefined

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

3.3 index.js

接下来就是最重要的入口文件了,让我们来看看吧!

由于多语言不是我们关心的重点,部分代码我会省略掉

import * as C from './constant.js'
import en from './locale/en.js'
import U from './utils.js'

// 默认语言为英文
let L = 'en' 
const Ls = {} 
// 设置多语言翻译内容
Ls[L] = en  

// 函数,用来判断是不是Dayjs对象
const isDayjs = d => d instanceof Dayjs

// 获取语言环境信息:
// 返回'en','zh'这样的字符串
const parseLocale = (preset, object, isLocal) => {
  // ....省略
}

// 函数:创建dayjs实例
// 我们最常用的dayjs()就是这个函数
const dayjs = function(date, c) {
  // date如果传入的是dayjs实例
  // 就返回一个新实例
  // clone是用来复制dayjs
  if (isDayjs(date)) {
    return date.clone()
  }
  // 否则new一个dayjs实例
  // 具体的看下面解析Dayjs类
  const cfg = typeof c === 'object' ? c : {}
  cfg.date = date
  cfg.args = arguments 
  return new Dayjs(cfg) 
}

// 也是创建一个dayjs实例
// 不同的是在第二个参数传入一个dayjs实例
// 会使用第二个参数的语言时区等信息
const wrapper = (date, instance) =>
  dayjs(date, {
    locale: instance.$L,
    utc: instance.$u,
    x: instance.$x,
    $offset: instance.$offset
  })

// 封装工具类,也可以给插件使用
const Utils = U 
Utils.l = parseLocale
Utils.i = isDayjs
Utils.w = wrapper

// 返回一个Date
// 可以根据字符串,时间戳等信息
// 创建Date对象
const parseDate = cfg => {
  const { date, utc } = cfg
  // null是无效的
  if (date === null) return new Date(NaN) 
  // 不传默认是当天
  if (Utils.u(date)) return new Date() 
  // 传入Date则返回一个新的一样的Date
  if (date instanceof Date) return new Date(date)
  // 传入的是字符串,并且不带'Zz'
  // 会根据REGEX_PARSE正则获取
  // 字符串的年月日等信息,
  // 新建一个Date
  if (typeof date === 'string' && !/Z$/i.test(date)) {
    const d = date.match(C.REGEX_PARSE)
    if (d) {
      const m = d[2] - 1 || 0
      const ms = (d[7] || '0').substring(0, 3)
      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)
    }
  }
  // 其他情况,比如时间戳,带Zz的字符串
  return new Date(date) 
}

// 重点,Dayjs类
class Dayjs {
  // 构造函数
  // 确定实例的语言环境
  constructor(cfg) {
    // $L会是en,zh这样的字符串,
    this.$L = parseLocale(cfg.locale, null, true)
    // 见下一行
    this.parse(cfg) 
  }

  parse(cfg) {
    // $d存放的是Date对象
    this.$d = parseDate(cfg)
    this.$x = cfg.x || {}
    // 见下一行
    this.init()
  }

  // 初始化,dayjs其实就是
  // 这一个个部分组成的
  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()
  }
  // 到这里为止,
  // dayjs实例的属性都已经初始化好了
  // 其实就这些:
  // $L 语言
  // $d Date对象
  // $y 年
  // $M 月
  // $D 日
  // $W 周几
  // $H 时
  // $m 分
  // $s 秒
  // $ms 毫秒
  // console.log(dayjs()); 大家可以打印看看
  
  // 剩下的部分都是dayjs的方法
  // 比如 dayjs().format()
  //      dayjs().isBefore()
  // 这里没写到的方法,
  // dayjs都通过插件的形式提供
  

  // 获取工具函数
  $utils() {
    return Utils
  }

  // 是否是有效的日期对象
  isValid() {
    return !(this.$d.toString() === C.INVALID_DATE_STRING)
  }

  // 是否相同,
  // 可以指定单位
  // 只对比到单位
  isSame(that, units) {
    const other = dayjs(that)
    return this.startOf(units) <= other && other <= this.endOf(units)
  }

  // 是否在传入的日期之后
  isAfter(that, units) {
    return dayjs(that) < this.startOf(units)
  }

  // 是否在传入的日期之前
  isBefore(that, units) {
    return this.endOf(units) < dayjs(that)
  }

  // 日期的get和set函数
  // 举个例子:
  // 获取秒数:$g(,'second') =dayjs().second()
  // 设置秒数:$g(1,'','second') =dayjs().second(1)
  $g(input, get, set) {
    if (Utils.u(input)) return this[get]
    return this.set(set, input)
  }

  // 返回毫秒时间戳
  unix() {
    return Math.floor(this.valueOf() / 1000)
  }

  // 返回微秒时间戳
  valueOf() {
    return this.$d.getTime()
  }

  // 获取对应单位的最近时间
  // 单位传月份,返回本月第一天
  // 单位传天,返回今天零点
  // 单位传小时,返回当前小时零分零秒
  startOf(units, startOf) {
    // 第二个参数startOf传false代表取endOf
    const isStartOf = !Utils.u(startOf) ? startOf : true
    
    // 单位转换:比如M转为month
    const unit = Utils.p(units)
    
    // 创建只精确到日的dayjs对象,
    // 参数是日和月
    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)
    }
    // 用来设置日期的时分秒毫秒信息
    // method 是方法名:比如setHours
    // slice 是argument截取起始点
    // 连起来就是setHours(0,0,0,0)
    //          setMinutes(59,59,999)
    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'),
          (isStartOf ? argumentStart : argumentEnd).slice(slice)
        ),
        this
      )
    }
    const { $W, $M, $D } = this
    const utcPad = `set${this.$u ? 'UTC' : ''}`
    switch (unit) {
      case C.Y:
        // 如果是年,返回1月1日,否则返回12月31日
        return isStartOf ? instanceFactory(1, 0) : instanceFactory(31, 11)
      case C.M:
        // 如果是月,返回1号,否则返回本月最后一天
        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()
    }
  }

  // 与startOf相对
  // 获取对应单位的最远时间
  // 单位传月份,返回本月最后一天
  // 单位传天,返回今天23:59:59
  // 单位传小时,返回当前小时59:59
  endOf(arg) {
    return this.startOf(arg, false)
  }

  // 给内部使用的set方法
  $set(units, int) {
    const unit = Utils.p(units)
    const utcPad = `set${this.$u ? 'UTC' : ''}`
    // 拼接方法名称
    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) {
      // 设置年月
      const date = this.clone().set(C.DATE, 1)
      date.$d[name](arg)
      date.init()
      this.$d = date.set(C.DATE, Math.min(this.$D, date.daysInMonth())).$d
    } else if (name) this.$d[name](arg)

    this.init()
    return this
  }

  // 设置
  set(string, int) {
    return this.clone().$set(string, int)
  }

  // 获取
  get(unit) {
    return this[Utils.p(unit)]()
  }

  // 给日期增加
  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)
    }
    // 年月 使用set方法相加
    if (unit === C.M) {
      return this.set(C.M, this.$M + number)
    }
    // 年月 使用set方法相加
    if (unit === C.Y) {
      return this.set(C.Y, this.$y + number)
    }
    // 天、周使用instanceFactorySet相加
    if (unit === C.D) {
      return instanceFactorySet(1)
    }
    // 天、周使用instanceFactorySet相加
    if (unit === C.W) {
      return instanceFactorySet(7)
    }
    // 时分秒使用毫秒数相加
    const step =
      {
        [C.MIN]: C.MILLISECONDS_A_MINUTE,
        [C.H]: C.MILLISECONDS_A_HOUR,
        [C.S]: C.MILLISECONDS_A_SECOND
      }[unit] || 1 // ms

    const nextTimeStamp = this.$d.getTime() + number * step
    return Utils.w(nextTimeStamp, this)
  }

  // 减少日期
  subtract(number, string) {
    return this.add(number * -1, string)
  }

  // 格式化日期
  // 这里是用到string.replace(regExp,func)
  // 使用正则匹配要替换的项,然后替换
  // 比如正则匹配到YYYY,替换成年份
  format(formatStr) {
    const locale = this.$locale()
    //判断是否有效
    if (!this.isValid()) return locale.invalidDate || C.INVALID_DATE_STRING
    // 默认格式为YYYY-MM-DDTHH:mm:ssZ
    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
      })

    // 列出所有可能匹配到的项
    const matches = {
      YY: String(this.$y).slice(-2),
      YYYY: Utils.s(this.$y, 4, '0'),
      M: $M + 1,
      MM: Utils.s($M + 1, 2, '0'),
      MMM: getShort(locale.monthsShort, $M, months, 3),
      MMMM: getShort(months, $M),
      D: this.$D,
      DD: Utils.s(this.$D, 2, '0'),
      d: String(this.$W),
      dd: getShort(locale.weekdaysMin, this.$W, weekdays, 2),
      ddd: getShort(locale.weekdaysShort, this.$W, weekdays, 3),
      dddd: weekdays[this.$W],
      H: String($H),
      HH: Utils.s($H, 2, '0'),
      h: get$H(1),
      hh: get$H(2),
      a: meridiemFunc($H, $m, true),
      A: meridiemFunc($H, $m, false),
      m: String($m),
      mm: Utils.s($m, 2, '0'),
      s: String(this.$s),
      ss: Utils.s(this.$s, 2, '0'),
      SSS: Utils.s(this.$ms, 3, '0'),
      Z: zoneStr // 'ZZ' logic below
    }
    // 核心其实就这一句,替换文本
    return str.replace(
      C.REGEX_FORMAT,
      (match, $1) => $1 || matches[match] || zoneStr.replace(':', '')
    ) 
  }
  // 时区偏移分钟数
  // 东八区就是480分钟
  utcOffset() {
    return -Math.round(this.$d.getTimezoneOffset() / 15) * 15
  }

  // 日期差,float代表是否保留小数
  diff(input, units, float) {
    const unit = Utils.p(units)
    const that = dayjs(input)
    // 时区差
    const zoneDelta =
      (that.utcOffset() - this.utcOffset()) * C.MILLISECONDS_A_MINUTE
    // 微秒数
    const diff = this - that
    let result = Utils.m(this, that)

    result =
      {
        [C.Y]: result / 12,
        [C.M]: result,
        [C.Q]: result / 3,
        [C.W]: (diff - zoneDelta) / C.MILLISECONDS_A_WEEK,
        [C.D]: (diff - zoneDelta) / C.MILLISECONDS_A_DAY,
        [C.H]: diff / C.MILLISECONDS_A_HOUR,
        [C.MIN]: diff / C.MILLISECONDS_A_MINUTE,
        [C.S]: diff / C.MILLISECONDS_A_SECOND
      }[unit] || diff // milliseconds

    return float ? result : Utils.a(result)
  }

  // 本月有几天
  daysInMonth() {
    return this.endOf(C.M).$D
  }

  // 获取多语言配置
  $locale() {
    return Ls[this.$L]
  }
  
  // 设置多语言环境
  locale(preset, object) {
    if (!preset) return this.$L
    const that = this.clone()
    const nextLocaleName = parseLocale(preset, object, true)
    if (nextLocaleName) that.$L = nextLocaleName
    return that
  }

  // 复制
  clone() {
    return Utils.w(this.$d, this)
  }

  // 转为Date对象
  toDate() {
    return new Date(this.valueOf())
  }

  // 转ISO字符串
  toJSON() {
    return this.isValid() ? this.toISOString() : null
  }

  // 转ISO字符串
  toISOString() {
    return this.$d.toISOString()
  }
  // 转UTC字符串
  toString() {
    return this.$d.toUTCString()
  }
}

// 让dayjs函数拥有Dayjs类里的方法
const proto = Dayjs.prototype
dayjs.prototype = proto

// 提供修改年月日时分秒的函数
// 比如dayjs().second()获取秒数
//     dayjs().second(10)设置秒数
;[
  ['$ms', C.MS],
  ['$s', C.S],
  ['$m', C.MIN],
  ['$H', C.H],
  ['$W', C.D],
  ['$M', C.M],
  ['$y', C.Y],
  ['$D', C.DATE]
].forEach(g => {
  proto[g[1]] = function(input) {
    return this.$g(input, g[0], g[1])
  }
})

// 扩展插件
// 可以看出来插件都是一个函数
// 插件的回调参数有Dayjs, dayjs
dayjs.extend = (plugin, option) => {
  // 防止重复安装插件,用$i标记
  if (!plugin.$i) {
    plugin(option, Dayjs, dayjs)
    plugin.$i = true
  }
  return dayjs
}

// dayjs的一些属性
dayjs.locale = parseLocale

dayjs.isDayjs = isDayjs

dayjs.unix = timestamp => dayjs(timestamp * 1e3)

dayjs.en = Ls[L]
dayjs.Ls = Ls
dayjs.p = {}
export default dayjs

这个入口文件还是比较长的,总结一下:

dayjs其实没什么特别的,它只是重新封装了日期类,并且附上常用的日期处理方法,另外提供了扩展插件的方法。

4 结束

希望大家看完本篇文章能够有所收获!

如有疑问,欢迎评论区沟通交流!