vercel/ms 源码阅读分享

227 阅读4分钟

vercel/ms

概述

在阅读源码时,我的一贯思路是这样的。

  • 先明白这个工具是解决什么问题的
  • 为了解决该问题开放了哪些接口
  • 见微知著。这些接口在源码中是怎么实现的
  • 为实现接口,做了哪些转换和处理
  • 用到了哪些工具函数
  • 有哪些奇技淫巧

vercel/ms 是一个小巧的毫秒转换工具,可以将带单位的时间转换为毫秒数或者将毫秒转换为带单位的时间。

截屏2022-06-15_上午7.18.23.png

结构图

%%{init: {'theme': 'forest'}}%%
flowchart TB
	a["msFn(value, options)"] ---> b["parse(str)"] & c["fmtShort(ms)"] & d["fmtLong(ms)"] ---> value
	u["plural(ms, msAbs, n, name)"] --utils--> d
	a --error---> E["isError(value)"]

功能分析

主函数(msFn()

主函数 msFn() 用于解析和格式化给定的值,我们来看看它的函数签名。

function msFn(value: StringValue, options?: Options): number;
function msFn(value: number, options?: Options): string;
function msFn(value: StringValue | number, options?: Options): number | string;function parse(str: string): number

它的形式参数有两个,value 是一个类型为指定字符串或任意数字的必选值,options 是一个可选的配置项。它的返回值为字符串或者数字。

具体的判断流程如下。

%%{init: { 'theme': 'forest'}}%%
flowchart LR
	value --> s{Stirng?} --> a["parse(value)"]
	value --> v{Number?} --> b["fmtShort(value)"] & c["fomLong(value)"]

如果是字符串且长度不为 0,调用 parse 解析器;如果是数字且有限,根据是否开启长属性而调用不同的 format 转换器。

到这里,接口就已经分析完成了。

解析器(parse()

解析器 parse() 用于解析给定的字符串,并返回毫秒数。我们来看看它的函数签名。

function parse(str:string): number;

它只有一个 str 的形式参数,是一个用于转化为毫秒数的字符串。

它的返回值是一个表示为毫秒数的数字,如果字符串无法解析则返回 NaN

%%{init: {'theme': 'forest'}}%%
flowchart LR
	str --> match --> groups --> type & value --> v["value * n"]

解析器做两件事,第一件是对传入的 str 做拆分,拆出数值和单位;第二件是将数值乘以对应的倍数 n ,得到最终的毫秒数。

拆分字符串

这里用了一个复杂的正则表达式来做字符串的拆分,根据功能我们可以推测出正则匹配两部分内容,任意实数和指定类型的单位,让我们来详细分解一下。

/^(?<value>-?(?:\d+)?\.?\d+) *(?<type>milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i
  • 回顾一下这里出现的正则表达式特殊符号的含义。
    • ^ 匹配输入的开始
    • $ 匹配输入的结束
    • * 匹配前一个表达式 0 次或多次,等价于 {0,}
    • ? 匹配前一个表达式 0 次或 1 次,等价于 {0,1}
    • . 匹配除了换行符外的任何单个字符
    • + 匹配前一个表达式 1 次或多次,等价于 {1,}
    • \d 匹配一个数字,等价于 [0-9]
    • x|y 匹配 x 或者 y
    • (x) 匹配 x 并记住匹配项,其中括号被称为捕获括号
    • (?<Name>x) 匹配 x 并将其存储在返回的匹配项的 groups 属性中,该属性位于 <name> 指定的名称下
    • (?:x) 匹配 x 但是不记住匹配项,其中括号被称为非捕获括号,通常用于定义子表达式

拆解来看。

  • 这一段 (?<value>-?(?:\d+)?\.?\d+) 匹配实数。
    • (?<value>) 将匹配表达式 -?(?:\d+)?\.?\d+ 并将得到的值保存到 groups.value 中;
    • -? 匹配 0 个或 1 个负号;
    • (?:\d+) 匹配 1 次或多次 0 ~ 9 ,即任意自然数,(?:) 用于定义子表达式;
    • \.? 匹配 0 个或 1 个小数点;
    • \d+ 匹配任意自然数。
  • * 匹配任意多个空格。
  • 这一段 (?<type>milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y) 匹配指定单位。
    • (?<type>) 匹配后面的表达式并将得到的值保存到 groups.type 中;
    • 后面这一串匹配具体单位,有 ? 表示匹配 0 个或 1 个。

整体来看,以一个实数开头(^),跟任意个数的空格,再接指定的单位,并且后面没有其他任何东西($)。$ 前的 ? 表示可以传一个空字符串。最后的修饰符 i 表示匹配忽略大小写。

从下面的例子可以验证我们的分析结果。

const strs = ['', '2 days', '1d', '10h', '2.5 hrs', '2h', '1m', '5s', '1y', '100', '-3 days', '-1h', '-200'];
const regexp = /^(?<value>-?(?:\d+)?\.?\d+) *(?<type>milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i;

strs.forEach(str => {
  const match = regexp.exec(str);
	const groups = match?.groups as { value: string; type?: string } | undefined;
  console.log(match, groups);
});

// null undefined
// Array ["2 days", "2", "days"] Object { value: "2", type: "days" }
// Array ["1d", "1", "d"] Object { value: "1", type: "d" }
// Array ["10h", "10", "h"] Object { value: "10", type: "h" }
// Array ["2.5 hrs", "2.5", "hrs"] Object { value: "2.5", type: "hrs" }
// Array ["2h", "2", "h"] Object { value: "2", type: "h" }
// Array ["1m", "1", "m"] Object { value: "1", type: "m" }

格式化工具( fmtShort()fmtLong()

和解析器相反,格式化工具将给定的毫秒数转换为对应的字符串,有两种转换形式,格式化为短单位的字符串和长单位的字符串。两者的函数签名是一致的。

function fmtShort(ms: number): StringValue;

function fmtLong(ms: number): StringValue;

两者都是传入一个毫秒数的数字参数,并返回指定类型为 StringValue 的字符格式。

接下来我们看看格式化的条件,首先对传入的数字取绝对值,然后和指定的由大到小的倍数 n 比较,大于该倍数则转化为对应的值,并加上对应的单位。

%%{init: {'theme': 'forest'}}%%
flowchart LR
	ms --> msAbs --> n{>=n, n is d,h,m,s,ms}
	n --> _d["${ms/d}d"]
	n --> _h["${ms/h}h"]
	n --> _m["${ms/m}m"]
	n --> _s["${ms/s}s"]
	n --> _ms["${ms}ms"]
	

长单位的复数处理

对于长单位,需要考虑是否为复数,所以单独拿一个 plural 的方法来处理,该方法的签名如下。

function plural(
  ms: number,
  msAbs: number,
  n: number,
  name: string,
): StringValue;

传入的四个形式参数中,ms 表示毫秒数,msAbs 表示毫秒数的绝对值,n 表示转换倍数,name 表示单位名称。返回指定类型为 StringValue 的字符格式。

由于采用了 Math.round(x) 方法返回四舍五入的结果,故可以使用 msAbs >= n * 1.5 来判断是否为复数。如果为 true 则给单位加上 s

const isPlural = msAbs >= n * 1.5;

技巧拓展

TypeScript 模版字面量类型(Template Literal Types)

Template Literal Types 基于字符串字面量,可以很方便的拓展 types。

当一个联合类型位于字符串插值位置是,types 类型就会是一系列类型叉乘的集合,举例来看:

type ColorsType = 'gold' | 'black' | 'white';

type SizesType = 'large' | 'middle' | 'small';

type DogsType = `${SizesType}_${ColorsType}_dog`;
// type DogsType = "large_gold_dog" | "large_black_dog" | "large_white_dog" | "middle_gold_dog" | "middle_black_dog" | "middle_white_dog" | "small_gold_dog" | "small_black_dog" | "small_white_dog"

此外, TypeScript 还提供了一些内置的字符串操作类型。

  • Uppercase<StringType> 转换为大写
  • Lowercase<StringType> 转换为小写
  • Capitalize<StringType> 首字母转换为大写
  • Uncapitalize<StringType> 首字母转换为小写

ms 巧妙的使用这种方法来保证单位的兼容性。

可以看到对用户输入的内容做了 3 层兼容,

  • 第一层定义 Unit 满足了不同长度的写法;

    type Unit = 'Years' | 'Year' | 'Yrs' |'Yr' | 'Y';

  • 第二层定义 UnitAnyCase 再做一次大小写转换; type UnitAnyCase = Unit | Uppercase<Unit> | Lowercase<Unit>;

  • 第三层定义 StringValue 加上数值。

    export type StringValue =
      | `${number}`
      | `${number}${UnitAnyCase}`
      | `${number} ${UnitAnyCase}`;
    

如此便可以满足尽可能多的类型。

错误信息的处理(throw new Error

try...catch 语句抛出的错误有很多种,为了防止额外问题,源码采用了一个简易的类型守卫来处理错误。判断 value 是否为 object 类型且存在 message 属性。

/**
 * A type guard for errors.
 *
 * @param value - The value to test
 * @returns A boolean `true` if the provided value is an Error-like object
 */
function isError(value: unknown): value is Error {
  return typeof value === 'object' && value !== null && 'message' in value;
}

try...catch 语句就可以这样使用了。

try {
//...
} cache (error) {
	const message = isError(error)
		? `${error.message}. value=${JSON.stringify(value)}`
		: 'An unknown error has occurred.';
	throw new Error(message);
}