三种时间处理库时间格式化方法源码阅读

本文就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.jssrc/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,发现一开始的formatFunctionsformatTokenFunctions都是空的。那么,它们在哪里赋值呢?随后的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');

yearproto上的映射:

// ./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是正常处理日历年的方式,底层使用getUTCFullYeargetFullYear

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)。

v2才是当前的主流版本

源码地址: 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()方法实现的取年份方法,需要格外注意时差问题
  • yyyyYYYY是不同的含义。也是唯一符合Unicode对于yyyyYYYY定义的库
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容