vercel/ms
概述
在阅读源码时,我的一贯思路是这样的。
- 先明白这个工具是解决什么问题的
- 为了解决该问题开放了哪些接口
- 见微知著。这些接口在源码中是怎么实现的
- 为实现接口,做了哪些转换和处理
- 用到了哪些工具函数
- 有哪些奇技淫巧
vercel/ms 是一个小巧的毫秒转换工具,可以将带单位的时间转换为毫秒数或者将毫秒转换为带单位的时间。
结构图
%%{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);
}