《 关于项目中的遇到的那些有趣的 ts 类型》

608 阅读6分钟

前言

能点开这篇文章的人,多少都有点ts 基础,当哩个当,当哩个当,闲言碎语不要讲,就不介绍 ts 带来的优势了
总之一句话,js 不能提示的 ts 能提示, js 不能静态发现的错误 ts 能发现,js 能干的 ts 能干,js 不能干的 ts 也能干 !

通用项目

注释

最基础 但最重要

使用 ts 注释 可以很轻松的表达出每个属性更具体的含义,特别是对于相似的属性来说,往往更有意义

/** 这是一个人 */
interface Person {
  /** This is name.表示一个人的名字 */
  name: string,
  // md 格式
  /** - 一个人的年龄 */
  age: number,

  /**
   * @property {object}  address           - 一个人的地址
   * @property {string}  address.city      - 城市
   * @property {string}  address.town      - 镇子
   */
  address: {
    city: string,
    town: string
  }
}

let m: Person = {
  name: 'zs',
  age: 26,
  address: {
    city: "",
    town: ""
  }
}

函数上定义其他属性

由于 js 中 函数可以当做 对象 使用,函数上是可以定义属性的

interface Counter {
  ():void,
  count:number
}
// 如果使用 let 定义 会报错
// let c:Counter = () =>{}
const c:Counter = () =>{}
c.count = 12

使用 let 定义 回报下面 👇 的这个错误

image.png

const 关键字确保不会发生对变量进行重新分配,并且只保证该字面量的严格类型
let 定义的是可变的 let a:string = 'str' 那么 a的类型 是 string
如果用 const 定义,那么 a 的类型 是 str字面量
let 扩展为更通用的类型,并允许将其重新分配给该类型的其他值
个人看法,如有不同,欢迎讨论

分发

🎈 在css 中,margin-leftmargin-right可以合并成margin-inline margin-topmargin-bottom 可以合并成 margin-block
同理 的padding 也可以合并为padding-inliinepadding-block

看这里👉 mdn-margin-inline
看这里👉 mdn-padding-inline

type  Contact<S1 extends string,S2 extends string> = `${S1}-${S2}` 

type F = Contact<'top'|'bottom',"left" | "right"> // 具有分发作用

image.png

那什么时候会有分发?🧐
需要满足以下的条件

  • 泛型传入
  • 裸类型传入

详细可以看这里的文档 👉 条件类型的分布式特性文档

在条件类型中,如果被检查的类型是一个 “裸” 类型参数,即没有被数组、元组或 Promise 等包装过,则该条件类型被称为分布式条件类型即裸类型

对于分布式条件类型来说,当传入的被检查类型是联合类型的话,在运算过程中会被分解成多个分支

// 分发
type Naked<T> = T extends boolean ? "Y" : "N";

// 非裸类型
type WrappedTuple<T> = [T] extends [boolean] ? "Y" : "N";
type WrappedArray<T> = T[] extends boolean[] ? "Y" : "N";
type WrappedPromise<T> = Promise<T> extends Promise<boolean> ? "Y" : "N";

// 进行分发
type T0 = Naked<number | boolean>; // "N" | "Y"
// 没有进行分发
type T1 = WrappedTuple<number | boolean>; // "N"
type T2 = WrappedArray<number | boolean>; // "N"
type T3 = WrappedPromise<true | false>; // "Y"

// 🔥🔥 重要 ,
// 如果没有 & {},这一步的话,下面的 s1 不会进行分发,是 boolean
type NoDistrubate<T> = T & {} 
type UnionAsset<T,U> =  NoDistrubate<T> extends U ? true :false 

type s1 =  UnionAsset<false | number, boolean> // false



type Letter = "a" | "b" | "c";

 // 原封不动的 Letter ,作为一个整体
// type Remove= Letter extends "c"  ? never : Letter // 没有分发

// 没有被包裹, 有分发
type Remove<T> = T extends "c"  ? never : T
type a = Remove<Letter> // 'a' | 'b'

断言 - assets 关键字

一个控制流,类似于 if else,只是更加优雅 🍷
assets(断言) 表示我知道这个值的类型,是联合类型的收紧
初始值可能是一个类型,后面你又修改成另一个类型,你很确定是你断言的这个类型

function assert(value: unknown, message?: string): asserts value {
  if (!value) {
    throw new Error(message);
  }
}
function assertNonNull<T>(obj: T): asserts obj is NonNullable<T>{
  if (!obj) {
    throw new Error('不能是 null 或者 是 undefined');
  }
}

function f1(n: number | string): number {
  assert(typeof n === 'string');
  return n.length; // 执行到这里 n 一定是 string 类型。
}

function f2(n: null | string) {
  assertNonNull(n);
  return n.length; // n -> string
}
f2("2")

在 vue 中,使用 ref 来引用一个dom 结构,往往初始值 是 HTMLElement | null

const domRef = ref<HTMLElement | null>(null)
// 你肯定知道 这个 domRef 是引用了一个dom,但是 ts 不知道
function assetsElement(ele: HTMLElement | null): asserts ele {
  if (!ele) {
    throw new Error(`${ele} is not exited`);
  }
}

assetsElement(domRef.value)
// 现在 domRef.value 就是 一个 HTMLElement 类型了

有大用 - is 关键字

他和上文的 assets 关键字一样,也是收紧语法

判断一个 unknown 类型 使用 if else 判断有什么问题呢?

function isString(s: unknown): boolean {
  return typeof s === 'string'
}

function toUpperCase(x: unknown) {
  if(isString(x)) {
    x.toUpperCase() // Error, Object is of type 'unknown'
  }
}

image.png

⭐ 虽然在上一行明明已经通过 isString() 函数确认参数 x 为 string 类型,但是由于函数嵌套 TypeScript 不能进行正确的类型判断

那么这种情况就是 is 出手的时候了

const isString = (s: unknown): s is string => typeof s === 'string'

function toUpperCase(x: unknown) {
  if(isString(x)) {
    x.toUpperCase()
  }
}

image.png 这时, x 已经被收紧为string类型了 常用的判断类型函数

const isNumber = (val: unknown): val is number => typeof val === 'number'
const isString = (val: unknown): val is string => typeof val === 'string'
const isSymbol = (val: unknown): val is symbol => typeof val === 'symbol'
const isFunction = (val: unknown): val is Function => typeof val === 'function'
const isObject = (val: unknown): val is Record<any, any> => val !== null && typeof val === 'object'

function isPromise<T = any>(val: unknown): val is Promise<T> {
  return isObject(val) && isFunction(val.then) && isFunction(val.catch)
}

const objectToString = Object.prototype.toString
const toTypeString = (value: unknown): string => objectToString.call(value)
const isPlainObject = (val: unknown): val is object => toTypeString(val) === '[object Object]'

新语法有奇效 satisfies

image.png

出现上图的原因 大家应该都可能遇到过,按理说,ts 应该可以判断出来 我妻子的心情是 happy

为啥还是 联合类型呢? 因为 ts 只能推倒出xcc是满足类型 wife 的,其他的推倒就无能为力了,这个时候,是satisfies 上场的时候了

interface Wife {
  mood: "happy" | "sad";
}

const xcc = {
  mood: "happy",
} satisfies Wife;

看效果

image.png

再来一个🌰

下面的写法你一定见过

interface IUser  {
  id:number
  image: string | {
    width: string,
    img:HTMLImageElement
  }
}

const badImage:IUser = {
  id:1,
  image:"aa"
}
// 只能获取字符串和 对象的公有方法
badImage.image 

你一定以为badImage.image 一定是个字符串了, 年轻人,还是太年轻了

image.png

是一个联合类型,使用 satisfies 试试?

const goodImage = {
  id:1,
  image:"aa"
} satisfies IUser

image.png

你以为satisfies 只有这些吗? nonono

type Person = {
  name:string,
  age:number
  [x:string]:any
}

let tsk:Person = {
  name:"tsk",
  age:26,
  address:"杭州"
}

那么 tsk 是否可以推到出 address 呢? 答案是:不能

image.png

还得是你 satisfies

image.png

筛选字符串

有时候一个 类型可以填写任何的字符串类型,但是你还有些常用的 字符串,但是还需要提示

type C = "sm" | "md" | Omit<string,"sm"|"md">; // 不能 string,否则 sm 和  md 不生效
let g:C = "aaa"
let f:C = "md"

image.png

部分可选属性

怎么做到只让 hobby变成可选的?

interface User {
  name: string;
  age: number;
  hobby: string;
}

思路是 忽略这个hobby 联合上 hobby单独可选

// 拿出其他值 & 让这两个值 变成可选
type C<T,S extends keyof T> = Omit<T,S> & Partial<Pick<T,S>>

type Computed<T> = {
  [ k in keyof T ] : T[k]
};
type d = Computed<C<User, 'hobby'>>

image.png

🔥🔥 获取对象的key值

fn 接收一个对象,返回一个函数 i
你的目的是 在函数 i 中传入 接收对象key
🧐 怎么实现这个 Path 类型?

function fn<S>(schema: S): (path: Path<S>) => void {
  return path => {};
}

const i = fn({
  home: {
    toolbar: {
      title: "title",
      welcome: "welcome",
    },
  },
  login: {
    userName: "用户名",
    age: 20,
  },
});

// 怎么实现 ?
i("home.toolbar.welcome");

📓 思路:
如果按照 js 的想法来说

  1. 首先是 遍历这个传入的对象,获取对象的 key,value
  2. 判断这个 value 的类型
  • 如果是字符串 那么就要收集自己和父亲的key 值
  • 如果是 对象,那么就要 继续 遍历
type Path<T, F extends string = "", K = keyof T> = 
K extends keyof T // 泛型无法保证类型
// 判断是否是 对象
  ? T[K] extends object
  // 如果是对象 就继续收集,F 如果没有,那么说明是第一层的对象,这个时候不需要用 .
    ? Path<T[K], `${F}${F extends "" ? "" : "."}${K & string}`, keyof T[K]>
    // 不是对象,就带上父亲 和 自己的 K 值
    : `${F}.${K & string}`
  : any;

image.png

🚀 url 的 search 转对象

url 中 的 search

var url = new URL('https://developer.mozilla.org/en-US/docs/Web/API/URL/search?q=123');
var queryString = url.search; // Returns:"?q=123"

定义一个QueryParams类型,可以获取 search类型,🧐怎么办?

const str = "?age=12&name=zs";
//   || 怎么转化
const obj:QueryParams = {
	age:"12",
	name:'zs',
};

📓 思路:
如果按照 js 的想法

  1. 把这个str 的字符串 以?的分割符 切开,只要 右边的 str
  2. 切割后的右边str 还要以&的分隔符 切开,形成age = 12name = zs两部分
  3. 遍历切割后的两部分,在遍历的过程中,用=分隔符进行切割,左边为 key,右侧为 value,使用对象进行不断的收集
  4. 返回这个收集后的对象即可

道理是这个道理,但是实现起来还是有点困难😥

  1. 切割字符
    - S 为 传入的 字符
    -flag 为 要切割的字符
    -R 为结果集

如果传入的 S 满足 ${字符串(infer L)}flag${字符串(infer rest)} 这种结构,就要拆分 rest
同时 用 R 把 L,rest 收集起来
直到 rest 不满足了上面的数据结构,抛出 R

有人就要问了,收集 rest 可以理解,收集 L 是为了什么呢?
是为了最后一步 用 = 分割,左边 key 我要,右边的 value 我也要

type SplitStr<
	S extends string,
	flag extends string = '?',
	R extends string[] = []> = S extends `${infer L}${flag}${infer rest}` ? 
	SplitStr<rest,flag,[...R,L,rest]> : R;

开始进行第一步和第二步

type SecondQuery = SplitStr<typeof str,"?">[1];

type ThirdQuery = SplitStr<SecondQuery,"&"> // ["age=12", "name=zs"]

第三步 和 第四步就顺理成章了

type QueryParams = {
	[K in ThirdQuery[number]]:{
		[T in SplitStr<K,"=">[0]] : SplitStr<K,"=">[1];
	};
}[ThirdQuery[number]]

有可能小伙伴不理解ThirdQuery[number]这种写法,这是索引访问类型,可以戳这里查看更多关于👉索引访问类型

// ThirdQuery = ["name=zs","age = 12"]
  type S = ThirdQuery[number] // "age=12" | "name=zs"

image.png

vue 项目

🐕:以下 均为<script setup>

为 props 标注类型

1.运行时声明

<script setup lang="ts">  
const props = defineProps({  
  name: { typeStringrequiredtrue },  
  age: {
      type:Number,
      default:26,
      validator: value => {
        return value >= 0 // 除了验证是否符合type的类型,此处再判断该值结果是否符合验证
       }
   }  
})  
  
props.name // string  
props.age // number
</script>

2. 基于类型的声明

<script setup lang="ts">  
const props = defineProps<{  
  name: string  
  age?: number  
}>()  
</script>

这种的不足之处在于失去了定义 props 默认值的能力,我们可以使用 withDefaults 编译器宏:

<script setup lang="ts"> 
interface Props {  
  name: string  
  age?: number  
}  
  
const props = withDefaults(defineProps<Props>(), {  
  name'tsk',  
  age26  
})
</script>

3.🚶❌ 数组定义(不建议使用)

const props = defineProps(['foo', 'bar'])

这个在源码中这样定义的

export declare function defineProps<PropNames extends string = string>(props: PropNames[]): Readonly<{
    [key in PropNames]?: any;
}>;

这个不建议使用的原因是因为,没有很好的利用 ts 带来的好处

为 emits 标注类型

1.运行时声明

<script setup lang="ts">  
// 运行时  
const emit = defineEmits(['change''update'])
</script>

2. 基于类型

// 基于类型  
const emit = defineEmits<{  
  (e'change'id: number): void  
  (e'update'value: string): void  
}>() 

3. 外部定义

a.ts

export const checkEmit = {
  change: (val: Boolean) => true,
};

xxx.vue

import { checkEmit } from "./a";
const emits = defineEmits(checkEmit)

这个在一些 UI 库里最为常见, 页面逻辑清晰
但是如果 checkEmit 这样返回就会有一个警告⚠

export const checkEmit = {
  change: (val: Boolean) => false,
};

image.png

原因在这里,其实这个返回值并不是很重要,true / false 关系不大

image.png

结语

其实在实际项目中,遇到过很多很多有趣的 ts 类型,ts 虽然让我们的代码变得更加健壮,但是也会失去 js 应有的灵活性,合理使用 ts,不要为了 追求那一点点的代码提示,错误检查❌ 而花费大量的时间,做项目嘛,还是要考虑性价比的,希望以后可以能够更好的掌握 ts,深入的讨论 ts 中那些有趣的语法