Typescript学习(十五)模版字符串进阶

75 阅读7分钟

前面介绍了模版字符串的基本用法, 而这一节, 我们将用模版字符串和模式匹配相互配合使用, 来实现诸多处理字符串的工具类型, 比如: Include, trim等等

Include

首先来看看Include工具, Include, 顾名思义就是判断一个字符串字面量类型中是否包含某个另一个字符串字面量类型

// 是否包含某个字符串
type Include<STR extends string, KEYWORD extends string> = STR extends `${infer R1}${KEYWORD}${infer R2}` ? true : false
type Result1 = Include<'helloword', 'word'> // true

type Result2 = Include<'123', '1'> // true
type Result3 = Include<'json', 'kkk'> // false
type Result4 = Include<'', ''> // false
type Result5 = Include<' ', ''> // true

在这里我们的工具类型Include接受两个泛型参数, STR就是原始的字符串字面量类型; KEYWORD就是需要寻找的字符串字面量类型; 在这里, STR extends ${infer R1}${KEYWORD}${infer R2}中, 我们匹配了R1和R2, 但是却没有去消费它们, 其实, 它们只是用来表达'其他部分', KEYWORD才是我们想要的, ${infer R1}${KEYWORD}${infer R2}的意思就是一个包含KEYWORD的字符串字面量类型, 类似于一个正则表达式, 以抽象的方式描述出了一个字符串的样子; 但是, 我们发现Result4好像有问题, 因为按道理, 空字符串字面量类型, 应该是包含了另一个相同的空字符串字面量类型的! 因此, 还要进一步优化;

type doInclude<STR extends string, KEYWORD extends string> = STR extends `${infer R1}${KEYWORD}${infer R2}` ? true : false

type Include<STR extends string, KEYWORD extends string> = STR extends '' ? KEYWORD extends '' ? true : false : doInclude<STR, KEYWORD>

type Result1 = Include<'', ''> // true

我们将原来的Include逻辑放进doInclude中, 重新定义了一个Include工具类型, 泛型参数不变, 但是, 如果泛型参数都为'', 则直接返回true, 否则false; 只有字符串不为空的时候, 才会进入真正的doInclude逻辑!

Trim

再来看看Trim方法, 即去除空格, 这个其实可以分开来处理, 即去除左侧空格和去除右侧空格; 根据前面Include工具类型的思路, 我们也可以使用模版字符串先将目标类型描述出来

type trimLeft<STR extends string> = STR extends ` ${infer R}` ? `${R}` : STR
type Result1 = trimLeft<' hello'> // 'hello'

type trimRight<STR extends string> = STR extends `${infer R} ` ? `${R}` : STR
type Result2 = trimRight<'world '> // 'world'

type Trim<STR extends string> = trimLeft<trimRight<STR>>

type Result3 = Trim<' hello world '> // 'hello world'

我们首先使用trimLeft去掉字符串字面量类型左侧的空格, 和之前的逻辑一样, 使用 ${infer R}来表达这是一个左侧有空格的字符串字面量类型, 如果符合, 则返回左侧无空格的${R}, 否则返回STR泛型参数本身, 即 不做任何处理! trimRight也是同样的道理; 最后, 通过组合调用的方式, 将它们合并在Trim工具类型的逻辑中; 但是, 我们条件类型匹配的 ${infer R}${infer R} 都只有一个空格, 那万一开头和结尾有好几个空格,该如何处理?

type Result4 = Trim<'   hello world   '> // '  hello world  '

可见, 我们只是去掉了左侧各一个空格, 剩下的空格还在! 所以, 必须找到一个方法能够将所有空格都'匹配殆尽'才行! 没错, 可以使用前面工具类型进阶中, 属性修饰工具类型, 深度修饰的思路: 递归!

type trimLeft<STR extends string> = STR extends ` ${infer R}` ? trimLeft<R> : STR

type trimRight<STR extends string> = STR extends `${infer R} ` ? trimRight<R> : STR

type Trim<STR extends string> = trimLeft<trimRight<STR>>

type Result1 = Trim<'   hello world   '> // 'hello world'

我们在trimLeft和trimRight工具类型中使用了递归逻辑, 确保了字符串中的空格被全部清除殆尽!

StartsWith和EndWith

经过前面的学习, 我们知道, 要实现StartsWith和EndWith的逻辑, 无非就是要先用模版字符串将它们描述出来, 这里, 可以说和前面的Include很像!

type doStartsWith<STR extends string, KEYWORD extends string> = STR extends `${KEYWORD}${infer R}` ? true : false

type StartsWith<STR extends string, KEYWORD extends string> = STR extends '' ? KEYWORD extends '' ? true : false : doStartsWith<STR, KEYWORD>

type Result1 = StartsWith<'hello', 'h'> // true
type Result2 = StartsWith<'123', '1'> // true
type Result3 = StartsWith<'123', '3'> // false

为了防止前面空字符串带来的问题我们这次直接将判断起始字符串字面量的逻辑放进doStartsWith中, 然后StartsWith则负责判断空字符串情况, 如果STR不是空字符串, 则调用doStartsWith, 来进行真正的判断, 这里的判断同样适用模版字符串${KEYWORD}${infer R}, 将想要匹配的字符串字面量类型描述出来; endWith也是相同的原理

type doEndWith<STR extends string, KEYWORD extends string> = STR extends `${infer R}${KEYWORD}` ? true : false

type EndWith<STR extends string, KEYWORD extends string> = STR extends '' ? KEYWORD extends '' ? true : false : doEndWith<STR, KEYWORD>

type Result1 = EndWith<'hello', 'o'> // true
type Result2 = EndWith<'123', '3'> // true
type Result3 = EndWith<'123', '1'> // false

Replace&ReplaceAll

说到replace, 我们应该很容易想到思路了, 即 使用模版字符串匹配成功后, 再用模版字符串, 描述出新字符串的样子:

type Replace<STR extends string, KEYWORD extends string, REPLACEMENT extends string> = 
STR extends `${infer Head}${KEYWORD}${infer Footer}`
  ? `${Head}${REPLACEMENT}${Footer}` : STR

type Result1 = Replace<'hello', 'o', 'ddd'> // 'hellddd'
type Result2 = Replace<'121', '2', '0'> // '101'

这里可以看出, 我们先使用模版字符串${infer Head}${KEYWORD}${infer Footer}匹配到对应的字符串字面量类型, 然后将其重新组合为${Head}${REPLACEMENT}${Footer}, 即用REPLACEMENT代替原来的KEYWORD部分!

现在, 我们可以替换掉一个字符串中的某一个关键字, 那如果我们想要匹配多个相同关键字呢?

type Result3 = Replace<'1000000001', '0', '1'> // '1100000001'

很明显, 只匹配到第一个0, 并将其转换为了1, 其他的0还是老样子! 所以, 我们再次希望能够将目标字符串字面量'匹配殆尽'! 没错, 又是递归!

type ReplaceAll<STR extends string, KEYWORD extends string, REPLACEMENT extends string> =
STR extends `${infer Head}${KEYWORD}${infer Footer}`
  ? ReplaceAll<`${Head}${REPLACEMENT}${Footer}`, KEYWORD, REPLACEMENT> : STR

type Result1 = ReplaceAll<'1000000001', '0', '1'> // '1111111111'
type Result2 = ReplaceAll<'羊在山上吃草, 山上羊在吃草, 吃草羊在山上', '羊', '牛'>
// '牛在山上吃草, 山上牛在吃草, 吃草牛在山上'

当然, 我们也可以将ReplaceAll的逻辑融入Replace之中, 通过一个boolean参数来控制是否全部替换

type Replace<STR extends string, KEYWORD extends string, REPLACEMENT extends string, isReplaceAll extends boolean = false> =
STR extends `${infer Head}${KEYWORD}${infer Footer}`
? isReplaceAll extends true
  ? Replace<`${Head}${REPLACEMENT}${Footer}`, KEYWORD, REPLACEMENT, isReplaceAll>
  : `${Head}${REPLACEMENT}${Footer}`

: STR


type Result1 = Replace<'1000000001', '0', '1'> // '1111111111'
type Result2 = Replace<'1000000001', '0', '1', true> // '1111111111'

这里, 其实只是加了一个isReplaceAll作为是否全部替换的控制标示, 然后, 依然是使用递归来将所有符合条件的字符串字面量类型进行替换!

Split

在Javascript中, split方法, 可以将一个字符串根据特定的分隔符拆分为一个数组, 我们也来实现一个工具类型版本的split吧, 还是老套路, 先使用模版字符串, 将要匹配的字符串字面量描述出来

type Split<STR extends string, DELIMITERS extends string> = 
STR extends `${infer HEAD}${DELIMITERS}${infer FOOTER}`
  ? [HEAD, FOOTER]
  : [STR]

type Result1 = Split<'1,1', ','> // ["1", "1"]

当然, 我们不可能永远只处理这么简单的字符串字面量类型, 其实字符串中delimiters标识的数量, 肯定是不确定的! 所以, 还是要用到递归!

type Split<STR extends string, DELIMITERS extends string> = 
STR extends `${infer HEAD}${DELIMITERS}${infer FOOTER}`
  ? [HEAD, ...Split<FOOTER, DELIMITERS>]
  : [STR]

type Result1 = Split<'1,2,3,4', ','> // ["1", "2", "3", "4"]

这样, 我们就可以处理更复杂的字符串字面量类型了, 我们先使用模版字符串工具类型, 将期望的字符串描述出来:

${infer HEAD}${DELIMITERS}${infer FOOTER}, 然后在匹配成功后将首个元素放进一个数组中, 剩余部分继续递归匹配...Split<FOOTER, DELIMITERS>, 直到全部匹配结束. 在Javascript中, 我们有时候也会这样调用: str.split(''), 以此来将一个字符串转为一个数组:

const arr = 'hello'.split('') // ['h', 'e', 'l', 'l', 'o']

那如果用我们的类型版Split, 分隔符是空字符串, 会怎样呢?

type arrType = Split<'123456', ''> // ["1", "2", "3", "4", "5", "6", ""]

我们发现, 竟然多了一项! 分析下原因, 无非就是最后一个空字符串, 也被当作了一个元素!走进了[STR]的逻辑! 所以这里我们要增加一个判断, 当元素和分隔符一样的时候, 应该返回[]

type Split<STR extends string, DELIMITERS extends string> = 
STR extends `${infer HEAD}${DELIMITERS}${infer FOOTER}`
  ? [HEAD, ...Split<FOOTER, DELIMITERS>]
  : STR extends DELIMITERS
  ? []
  : [STR]

我们再来试试

type arrType = Split<'123456', ''> // ["1", "2", "3", "4", "5", "6"]

上面的案例中, 我们将一个字符串转为数组, 分隔符是空字符串, 此时, arrType的length不就是传入Split的字符串的length吗?所以, 我们还可以基于Split写一个求字符串字面量长度的工具类型

type StrLength<T extends string> = Split<Trim<T>, ''>['length']
type len = StrLength<'123456'> // 6

Join

在Javascript中, join方法和split方法其实是相反的运算逻辑, split将字符串转为数组, join则是将数组转为字符串, 我们已经实现了类型版的split, 现在再来试着执行类型版的join吧; 基本思路还是老样子先描绘出我们想要匹配的数据的样子, 然后使用模版字符串类型将其拼接完成!

type Join<LIST extends Array<unknown>, DELIMITERS extends string> =
LIST extends [string | number, ...infer REST]?
`${LIST[0]}${DELIMITERS}${Join<REST, DELIMITERS>}` : ''

我们描述我们希望匹配的数据结构为[string | number, ...infer REST], 说白了就是一个数组, 然后将第一项拼接进一个字符串内, 后接分隔符, 然后继续对剩余的部分递归处理, 如最后是一个空数组, 则返回空字符串字面量类型; 好, 理论说完了, 来看看行不行吧

type Result = Join<[1,2,3], '-'> // '1-2-3-'

有问题! 最后多了个分隔符! 这怎么回事? 其实也好理解, 就是当递归到最后一个元素的时候, 它仍然符合条件, 就像这样:

type bool = [1] extends [string | number, ...infer REST] ? true : false // true

既然是true, 那在我们的Join工具类型的逻辑中, 就是继续拼接为${LIST[0]}${DELIMITERS}${Join<REST, DELIMITERS>}, 这里的LIST[0]就是最后一个元素! 但是, 它后面还有个${DELIMITERS}, 这就导致了最后多了一个分隔符! 那么怎么解决? 我们是否应该针对这一个元素的情况进行特殊处理?

type Join<LIST extends Array<unknown>, DELIMITERS extends string> =
LIST extends [string | number] ? `${LIST[0]}`: // 增加只有一个元素的判断
LIST extends [string | number, ...infer REST]?
`${LIST[0]}${DELIMITERS}${Join<REST, DELIMITERS>}` : ''

看看结果:

type Result = Join<[1,2,3], '-'> // 1-2-3

Case转换

在我们日常开发中, 字符串有驼峰写法, 即 camelCase, 也有kebab-case, snake_case等等, 这些不同的case, 在Javascript中, 我们通常会用一些方法将其进行转换, 例如, Vue源码中, 就有诸多case转换的方法

export const cached = (fn) => {
  const cache = Object.create(null)
  return function (str) {
    const hit = cache[str]
    return hit ||( cache[str] = fn(str))
  }
}

// kebab-case转为camelCase
const camelizeRE = /-(\w)/g
export const camelize = cached((str) => {
  return str.replace(camelizeRE, (_, r) =>(r ? r.toLowerCase() : ''))
})

我们先来看看kebab-case如何转换为camelCase

type KebabCase2CamelCase<T extends string> =
T extends `${infer Head}-${infer Footer}`
  ? `${Head}${KebabCase2CamelCase<Capitalize<Footer>>}`
  : T

其实依然是匹配重组这种老套路了, 看看结果, 就不再赘述了

type Result1 = KebabCase2CamelCase<'hello-world-hi'> // helloWorldHi

同理snake_case转camelCase

type SnakeCase2CamelCase<T extends string> =
T extends `${infer Head}_${infer Footer}`
  ? `${Head}${SnakeCase2CamelCase<Capitalize<Footer>>}`
  : T

type Result2 = SnakeCase2CamelCase<'hello_world_hi'> // helloWorldHi

综合上面两个案例, 我们发现不同点就是一个分隔符, 所以, 这个分隔符也能抽出来作为一个泛型的入參, 重新实现一个针对不同分隔符转换为camelCase的工具类型

type DelimiterCase2CamelCase<T extends string, delimiters extends string> =
T extends `${infer Head}${delimiters}${infer Footer}`
  ? `${Head}${DelimiterCase2CamelCase<Capitalize<Footer>, delimiters>}`
  : T

type Result1 = DelimiterCase2CamelCase<'hello~world~hi', '~'> // helloWorldHi
type Result2 = DelimiterCase2CamelCase<'hello<world<hi', '<'> // helloWorldHi

上面的案例中, 我们封装了一个DelimiterCase2CamelCase工具类型, 它接受一个字符串字面量类型, 和一个分隔符, 那能否再进一步, 将分隔符部分给省略了? 或者说, 我们将分隔符写成一个联合类型; 我们期待的效果如下:

type Delimiters = '-' | '_' | ' '

type Result1 = CamelCase<'hell_world_hi'> // hellWorldHi
type Result2 = CamelCase<'hell-world-hi'> // hellWorldHi
type Result3 = CamelCase<'hell world hi'> // hellWorldHi

先来理下思路

  1. 将一个字符串分隔为数组
  2. 数组首个成员提取出来, 不做处理
  3. 数组首个之外的成员全部开头字母大写
  4. 拼接成字符串字面量类型;

首先来看下分隔, 这个分隔也很有讲究, 要联合类型中的都能作为分隔符, 那么,我们 是不是可以利用Split工具类型

type Delimiters = '-' | '_' | ' '
type Split<str extends string, delimiters extends string> =
	str extends `${infer Head}${delimiters}${infer Footer}`?
		[Head, ...Split<Footer, delimiters>]:
    str extends delimiters ?
    []:
		[str]
// 效果如下:
type Result1 = Split<'hell_world_hi', Delimiters> // ["hell", "world", "hi"]
type Result2 = Split<'hell-world-hi', Delimiters> // ["hell", "world", "hi"]
type Result3 = Split<'hell world hi', Delimiters> // ["hell", "world", "hi"]

可以看到, 我们的Split已经可以利用模版字符串类型分发特性, 做到匹配不同分隔符;

接着, 我们想想, 如果让一个数组所有成员都首字母大写?

type CapitalizeCamel<ARR extends Array<string>> =
ARR extends [infer First, ...infer Rest] ?
// @ts-expect-error
`${Capitalize<First>}${CapitalizeCamel<Rest>}`:
''
// 效果如下:
type Result1 = CapitalizeCamel<Split<'hell_world_hi', Delimiters>> // HellWorldHi
type Result2 = CapitalizeCamel<Split<'hell-world-hi', Delimiters>> // HellWorldHi
type Result3 = CapitalizeCamel<Split<'hell world hi', Delimiters>> // HellWorldHi

实现很简单, 就是每次取出首个元素, 执行原生的Capitalize方法!

但是, 我们要的是首个单词或者说数组的元素的首个字母不大写, 那么, 我们就可以这样处理

type CamelStrArray<ARR extends Array<string>> = 
ARR extends [infer First, ...infer Rest] ?
// @ts-expect-error
`${First}${CapitalizeCamel<Rest>}` : never

这样, 就可以将First之外的所有属性, 都执行CapitalizeCamel, 让剩余元素首字母全部大写!

完整代码如下:

// 分隔符
type Delimiters = '-' | '_' | ' '

// 分割字符串方法
type Split<str extends string, delimiters extends string> =
str extends `${infer Head}${delimiters}${infer Footer}`?
[Head, ...Split<Footer, delimiters>]:
str extends delimiters ?
[]:
[str]

// 数组所有成员首字母都改为大写
type CapitalizeCamel<ARR extends Array<string>> =
ARR extends [infer First, ...infer Rest] ?
// @ts-expect-error
`${Capitalize<First>}${CapitalizeCamel<Rest>}`:
''

// 分离出首个字母
type CamelStrArray<ARR extends Array<string>> = 
ARR extends [infer First, ...infer Rest] ?
// @ts-expect-error
`${First}${CapitalizeCamel<Rest>}` : never

// 最终封装的工具类型
type CamelCase<str extends string> = CamelStrArray<Split<str, Delimiters>>

type Result1 = CamelCase<'hell_world_hi'> // hellWorldHi
type Result2 = CamelCase<'hell-world-hi'> // hellWorldHi
type Result3 = CamelCase<'hell world hi'> // hellWorldHi