day.js 源碼解析

679 阅读3分钟

Day.js 是一个轻量的处理时间和日期的 JavaScript 库,和 Moment.js 的 API 设计保持完全一样. 如果您曾经用过 Moment.js, 那么您已经知道如何使用 Day.js

这是day.js的官方介绍,它的代码容量只有2kb,小而精巧,是非常适合新手阅读的源码库。

阅读本文前,请读者先去看官方文档,对库的用法有基本了解

文档地址

本文主要解析day.js的主要api源码,读者要是对其他部分感兴趣,可以自行阅读,或与我交流也可以。

工厂函数

dayjs('2018-08-08')
/*
{
  '$L': 'en',
  '$d': 2018-08-07T16:00:00.000Z,
  '$x': {},
  '$y': 2018,
  '$M': 7,
  '$D': 8,
  '$W': 3,
  '$H': 0,
  '$m': 0,
  '$s': 0,
  '$ms': 0 }
*/

dayjs() // 返回当天日期
/*
{
  '$L': 'en',
  '$d': 2021-06-15T07:24:27.814Z,
  '$x': {},
  '$y': 2021,
  '$M': 5,
  '$D': 15,
  '$W': 2,
  '$H': 15,
  '$m': 24,
  '$s': 27,
  '$ms': 814 }
*/

dayjs通过工厂函数来创建实例,而不是直接new一个实例,这样做的好处是一方面方便用户构造实例,另一方面是方便dayjs进行扩展。我们通过看文档,知道dayjs函数可以传入不同类型数据,可以通过插件来扩大允许传入的数据类型。

而且通过插件机制,dayjs可以大大减少它代码体积,只存放主要功能代码,其他的用插件扩展。

现在来看看dayjs的工厂函数:

const dayjs = function (date, c) {
  if (isDayjs(date)) {
    return date.clone()
  }
  // eslint-disable-next-line no-nested-ternary
  const cfg = typeof c === 'object' ? c : {}
  cfg.date = date
  cfg.args = arguments// eslint-disable-line prefer-rest-params
  return new Dayjs(cfg) // eslint-disable-line no-use-before-define
}

const isDayjs = d => d instanceof Dayjs // eslint-disable-line no-use-before-define

date是用户传入的数据,而 c 则是自定义的设置。如果 date 本身是 Dayjs 实例,则去拷贝一份,否则则生成一个设置对象,传给 Dayjs 创建新实例。

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

// 重新生成一个日期对象
const wrapper = (date, instance) =>
  dayjs(date, {
    locale: instance.$L,
    utc: instance.$u,
    x: instance.$x,
    $offset: instance.$offset // todo: refactor; do not use this.$offset in you code
  })

const Utils = U // for plugin use
Utils.w = wrapper

拷贝方法好简单,就是把Dayjs的时间属性 $d 传给dayjs工厂函数,重新构造一个新对象。

如果看文档,在解析部分我们看到:

dayjs.extend(arraySupport)
dayjs([2010, 1, 14, 15, 25, 50, 125]); // February 14th, 3:25:50.125 PM

看一下相关源码:

dayjs.extend = (plugin, option) => {
  if (!plugin.$i) { // install plugin only once
    plugin(option, Dayjs, dayjs)
    plugin.$i = true
  }
  return dayjs
}

所谓扩展,就是看一下到底有没有安装插件,没有则调用插件,把 Dayjs类和 dayjs 函数传入。

Dayjs 构造函数

class Dayjs {
  constructor(cfg) {
    this.$L = parseLocale(cfg.locale, null, true)
    this.parse(cfg) // for plugin
  }

  parse(cfg) {
    this.$d = parseDate(cfg)
    this.$x = cfg.x || {}
    this.init()
  }

  init() {
    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的实例,首先获取本地化属性,然后去解析传入参数,这里的重点是parseData,它会返回一个 Date对象,是的,Dayjs库就是对 Date 对象的封装。

const parseDate = (cfg) => {
  const { date, utc } = cfg
  if (date === null) return new Date(NaN) // null is invalid
  if (Utils.u(date)) return new Date() // today
  if (date instanceof Date) return new Date(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)
    }
  }

  return new Date(date) // everything else
}

// utils.js

const isUndefined = s => s === undefined

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

根据传入不同的数据类型进行处理,主要是要看传入字符串时的情况,用正则表达式把字符串的信息提取出来,最后利用它们来创建新的 Date 对象。所以我们得要看正则表达式:

// constant.js

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

这正则表达式不算是太难的,如果看得吃力,可以参考下面的资源,多看多调试就好了:

前端进阶高薪必看-正则篇

format

dayjs().format('{YYYY} MM-DDTHH:mm:ss SSS [Z] A') // 展示

/*
{2021} 06-15T17:03:45 468 Z PM
*/

格式化是dayjs另一个重要的api,它的实现也不是太难,就是把传入的格式用正则表达式匹配,如果有匹配到的关键字,则替换成 Dayjs 对象的相关属性。

  format(formatStr) {
    const locale = this.$locale()

    if (!this.isValid()) return locale.invalidDate || C.INVALID_DATE_STRING

    const str = formatStr || C.FORMAT_DEFAULT // 'YYYY-MM-DDTHH:mm:ssZ'
    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].substr(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: this.$y,
      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(':', '')) // 'ZZ'
  }

看起来好多内容,基本都是对Dayjs的属性进行处理,以让它们能替换格式字符串formatStr的关键词,这里就不解析了,主要看最后一句:

str.replace(C.REGEX_FORMAT, (match, $1) => $1 || matches[match] || zoneStr.replace(':', '')) // 'ZZ'

// constant.js
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

replace的第一个参数是正则表达式,第二个是函数,第一个参数是匹配的字符串,第二个是括号捕获的字符串。所以就是要不匹配普通字符串,要不是关键字,则替换成相关属性,要不是对时间区域的替换。

getter/setter

dayjs的getter和setter是很巧妙的设计,值得一看:

dayjs().second(30).valueOf() // => new Date().setSeconds(30)
dayjs().second() // => new Date().getSeconds()

传参是setter,不传则是getter。dayjs是怎样在js做重载?

import * as C from './constant'

...
const proto = Dayjs.prototype
dayjs.prototype = proto;
[
  ['$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])
  }
})

$g(input, get, set) {
  if (Utils.u(input)) return this[get]
  return this.set(set, input)
}

// constant.js
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'

dayjs是通过柯里化实现重载,用一个函数对 $g 进行封装,如果有传 input 是getter,不然是setter。

看一下set方法的实现:

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

$set是一个私有方法:

  $set(units, int) { // private set
    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]
    // 处理date日期
    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()
      this.$d = date.set(C.DATE, Math.min(this.$D, date.daysInMonth())).$d
    } else if (name) this.$d[name](arg)

    this.init()
    return this
  }

简单来说就是先对传入的参数进行处理,然后修改 Dayjs实例的 $d属性,也就是 Date实例。