第一部分 前置内容

关键字

keyof 索引查询

对应任何类型T,keyof T的结果为该类型上所有公有属性key的联合:

interface Eg1 {name: string,readonly age: number,
}
// T1的类型实则是name | age
type T1 = keyof Eg1class Eg2 {private name: string;public readonly age: number;protected home: string;
}
// T2实则被约束为 age
// 而name和home不是公有属性,所以不能被keyof获取到
type T2 = keyof Eg2

infer

  • 表示在extends条件语句中待推断得类型变量
type Union<T> = T extends Array<infer U> ? U: never
  • 如果泛型参数T满足约束条件Array 那么就返回这个类型变量U
  • 有点懵逼再来一个
type ParamType<T> = T extends (param: infer P) => any ? P: T;
// 解析如果T能赋值给(param: infer P) => any 类型,就返回P,否则就返回Tinterface IDog {name: string;age:number;
}type Func = (dog:IDog) => void;type Param = ParamType<Func>; // IDog
type TypeString = ParamType<string> // string

T[K] 索引访问

interface Eg1 {name: string,readonly age: number,
}
// string
type V1 = Eg1['name']
// string | number
type V2 = Eg1['name' | 'age']
// any
type V2 = Eg1['name' | 'age2222']
// string | number
type V3 = Eg1[keyof Eg1]

T[keyof T]的方式,可以获取到T所有key的类型组成的联合类型; T[keyof K]的方式,获取到的是T中的key且同时存在于K时的类型组成的联合类型; 注意:如果[]中的key有不存在T中的,则是any;因为ts也不知道该key最终是什么类型,所以是any;且也会报错;

& 交叉类型注意点

交叉类型取的多个类型的并集,但是如果相同key但是类型不同,则该keynever

interface Eg1 {name: string,age: number,
}interface Eg2 {color: string,age: string,
}/*** T的类型为 {name: string; age: never; color: string}* 注意,age因为Eg1和Eg2中的类型不一致,所以交叉后age的类型是never*/
type T = Eg1 & Eg2
// 可通过如下示例验证
const val: T = {name: '',color: '',age: (function a() {throw Error()})(),
}

extends关键词特性(重点)

  • 用于接口,表示继承
interface T1 {name: string,
}interface T2 {sex: number,
}/*** @example* T3 = {name: string, sex: number, age: number}*/
interface T3 extends T1, T2 {age: number,
}

注意,接口支持多重继承,语法为逗号隔开。如果是type实现继承,则可以使用交叉类型type A = B & C & D

  • 表示条件类型,可用于条件判断

表示条件判断,如果前面的条件满足,则返回问号后的第一个参数,否则第二个。类似于js的三元运算。

/*** @example* type A1 = 1*/
type A1 = 'x' extends 'x' ? 1 : 2;/*** @example* type A2 = 2*/
type A2 = 'x' | 'y' extends 'x' ? 1 : 2;/*** @example* type A3 = 1 | 2*/
type P<T> = T extends 'x' ? 1 : 2;
type A3 = P<'x' | 'y'>

提问:为什么A2A3的值不一样?

  • 如果用于简单的条件判断,则是直接判断前面的类型是否可分配给后面的类型
  • extends前面的类型是泛型,且泛型传入的是联合类型时,则会依次判断该联合类型的所有子类型是否可分配给extends后面的类型(是一个分发的过程)。

总结,就是extends前面的参数为联合类型时则会分解(依次遍历所有的子类型进行条件判断)联合类型进行判断。然后将最终的结果组成新的联合类型。

  • 阻止extends关键词对于联合类型的分发特性

如果不想被分解(分发),做法也很简单,可以通过简单的元组类型包裹以下:

type P<T> = [T] extends ['x'] ? 1 : 2;
/*** type A4 = 2;*/
type A4 = P<'x' | 'y'>

条件类型的分布式特性文档

类型兼容性

集合论中,如果一个集合的所有元素在集合B中都存在,则A是B的子集;

类型系统中,如果一个类型的属性更具体,则该类型是子类型。(因为属性更少则说明该类型约束的更宽泛,是父类型)

因此,我们可以得出基本的结论:子类型比父类型更加具体,父类型比子类型更宽泛。 下面我们也将基于类型的可复制性(可分配性)、协变、逆变、双向协变等进行进一步的讲解。

可赋值性

interface Animal {name: string;
}interface Dog extends Animal {break(): void;
}let a: Animal;
let b: Dog;// 可以赋值,子类型更佳具体,可以赋值给更佳宽泛的父类型
a = b;
// 反过来不行
b = a;
  • 可赋值性在联合类型中的特性
type A = 1 | 2 | 3;
type B = 2 | 3;
let a: A;
let b: B;// 不可赋值
b = a;
// 可以赋值
a = b;

是不是A的类型更多,A就是子类型呢?恰恰相反,A此处类型更多但是其表达的类型更宽泛,所以A是父类型,B是子类型。

因此b = a不成立(父类型不能赋值给子类型),而a = b成立(子类型可以赋值给父类型)。

协变

interface Animal {name: string;
}interface Dog extends Animal {break(): void;
}let Eg1: Animal;
let Eg2: Dog;
// 兼容,可以赋值
Eg1 = Eg2;let Eg3: Array<Animal>
let Eg4: Array<Dog>
// 兼容,可以赋值
Eg3 = Eg4

通过Eg3Eg4来看,在AnimalDog在变成数组后,Array<Dog>依旧可以赋值给Array<Animal>,因此对于type MakeArray = Array<any>来说就是协变的。

最后引用维基百科中的定义:

协变与逆变(Covariance and contravariance )是在计算机科学中,描述具有父/子型别关系的多个型别通过型别构造器、构造出的多个复杂型别之间是否有父/子型别关系的用语。

简单说就是,具有父子关系的多个类型,在通过某种构造关系构造成的新的类型,如果还具有父子关系则是协变的,而关系逆转了(子变父,父变子)就是逆变的。可能听起来有些抽象,下面我们将用更具体的例子进行演示说明:

逆变

interface Animal {name: string;
}interface Dog extends Animal {break(): void;
}type AnimalFn = (arg: Animal) => void
type DogFn = (arg: Dog) => voidlet Eg1: AnimalFn;
let Eg2: DogFn;
// 不再可以赋值了,
// AnimalFn = DogFn不可以赋值了, Animal = Dog是可以的
Eg1 = Eg2;
// 反过来可以
Eg2 = Eg1;

理论上,Animal = Dog是类型安全的,那么AnimalFn = DogFn也应该类型安全才对,为什么Ts认为不安全呢?看下面的例子:

let animal: AnimalFn = (arg: Animal) => {}
let dog: DogFn = (arg: Dog) => {arg.break();
}// 假设类型安全可以赋值
animal = dog;
// 那么animal在调用时约束的参数,缺少dog所需的参数,此时会导致错误
animal({name: 'cat'});

从这个例子看到,如果dog函数赋值给animal函数,那么animal函数在调用时,约束的是参数必须要为Animal类型(而不是Dog),但是animal实际为dog的调用,此时就会出现错误。

因此,AnimalDog在进行type Fn<T> = (arg: T) => void构造器构造后,父子关系逆转了,此时成为“逆变”。

双向协变

Ts在函数参数的比较中实际上默认采取的策略是双向协变:只有当源函数参数能够赋值给目标函数或者反过来时才能赋值成功。

这是不稳定的,因为调用者可能传入了一个具有更精确类型信息的函数,但是调用这个传入的函数的时候却使用了不是那么精确的类型信息(典型的就是上述的逆变)。 但是实际上,这极少会发生错误,并且能够实现很多JavaScript里的常见模式:

// lib.dom.d.ts中EventListener的接口定义
interface EventListener {(evt: Event): void;
}
// 简化后的Event
interface Event {readonly target: EventTarget | null;preventDefault(): void;
}
// 简化合并后的MouseEvent
interface MouseEvent extends Event {readonly x: number;readonly y: number;
}// 简化后的Window接口
interface Window {// 简化后的addEventListeneraddEventListener(type: string, listener: EventListener)
}// 日常使用
window.addEventListener('click', (e: Event) => {});
window.addEventListener('mouseover', (e: MouseEvent) => {});

可以看到Windowlistener函数要求参数是Event,但是日常使用时更多时候传入的是Event子类型。但是这里可以正常使用,正是其默认行为是双向协变的原因。可以通过tsconfig.js中修改strictFunctionType属性来严格控制协变和逆变。

敲重点!!!敲重点!!!敲重点!!!

infer关键词的功能暂时先不做太详细的说明了,主要是用于extends的条件类型中让Ts自己推到类型,具体的可以查阅官网。但是关于infer的一些容易让人忽略但是非常重要的特性,这里必须要提及一下:

  • infer推导的名称相同并且都处于逆变的位置,则推导的结果将会是交叉类型
type Bar<T> = T extends {a: (x: infer U) => void;b: (x: infer U) => void;
} ? U : never;// type T1 = string
type T1 = Bar<{ a: (x: string) => void; b: (x: string) => void }>;// type T2 = never
type T2 = Bar<{ a: (x: string) => void; b: (x: number) => void }>;
  • infer推导的名称相同并且都处于协变的位置,则推导的结果将会是联合类型
type Foo<T> = T extends {a: infer U;b: infer U;
} ? U : never;// type T1 = string
type T1 = Foo<{ a: string; b: string }>;// type T2 = string | number
type T2 = Foo<{ a: string; b: number }>;

inter与协变逆变的参考文档点击这里


第二部分 Ts内置类型工具原理解析

Partial实现原理解析

Partial<T>T的所有属性变成可选的。

/*** 核心实现就是通过映射类型遍历T上所有的属性,* 然后将每个属性设置为可选属性*/
type Partial<T> = {[P in keyof T]?: T[P];
}
  • [P in keyof T]通过映射类型,遍历T上的所有属性
  • ?:设置为属性为可选的
  • T[P]设置类型为原来的类型

扩展一下,将制定的key变成可选类型:

/*** 主要通过K extends keyof T约束K必须为keyof T的子类型* keyof T得到的是T的所有key组成的联合类型*/
type PartialOptional<T, K extends keyof T> = {[P in K]?: T[P];
}/*** @example*     type Eg1 = { key1?: string; key2?: number }*/
type Eg1 = PartialOptional<{key1: string,key2: number,key3: ''
}, 'key1' | 'key2'>;

Readonly原理解析

/*** 主要实现是通过映射遍历所有key,* 然后给每个key增加一个readonly修饰符*/
type Readonly<T> = {readonly [P in keyof T]: T[P]
}/*** @example* type Eg = {*   readonly key1: string;*   readonly key2: number;* }*/
type Eg = Readonly<{key1: string,key2: number,
}>

Pick

挑选一组属性并组成一个新的类型。

type Pick<T, K extends keyof T> = {[P in K]: T[P];
};

基本和上述同样的知识点,就不再赘述了。

Record

构造一个typekey为联合类型中的每个子类型,类型为T。文字不好理解,先看例子:

/*** @example* type Eg1 = {*   a: { key1: string; };*   b: { key1: string; };* }* @desc 就是遍历第一个参数'a' | 'b'的每个子类型,然后将值设置为第二参数*/
type Eg1 = Record<'a' | 'b', {key1: string}>

Record具体实现:

/*** 核心实现就是遍历K,将值设置为T*/
type Record<K extends keyof any, T> = {[P in K]: T
}/*** @example* type Eg2 = {a: B, b: B}*/
interface A {a: string,b: number,
}
interface B {key1: number,key2: string,
}
type Eg2 = Record<keyof A, B>
  • 值得注意的是keyof any得到的是string | number | symbol
  • 原因在于类型key的类型只能为string | number | symbol

扩展: 同态与非同态。划重点!!! 划重点!!! 划重点!!!

  • PartialReadonlyPick都属于同态的,即其实现需要输入类型T来拷贝属性,因此属性修饰符(例如readonly、?:)都会被拷贝。可从下面例子验证:
/*** @example* type Eg = {readonly a?: string}*/
type Eg = Pick<{readonly a?: string}, 'a'>

Eg的结果可以看到,Pick在拷贝属性时,连带拷贝了readonly?:的修饰符。

  • Record是非同态的,不需要拷贝属性,因此不会拷贝属性修饰符

可能到这里就有小伙伴疑惑了,为什么Pick拷贝了属性,而Record没有拷贝?我们来对比一下其实现:

type Pick<T, K extends keyof T> = {[P in K]: T[P];
};type Record<K extends keyof any, T> = {[P in K]: T
}

可以看到Pick的实现中,注意P in K(本质是P in keyof T),T为输入的类型,而keyof T则遍历了输入类型;而Record的实现中,并没有遍历所有输入的类型,K只是约束为keyof any的子类型即可。

最后再类比一下Pick、Partial、readonly这几个类型工具,无一例外,都是使用到了keyof T来辅助拷贝传入类型的属性。

Exclude原理解析

Exclude<T, U>提取存在于T,但不存在于U的类型组成的联合类型。

/*** 遍历T中的所有子类型,如果该子类型约束于U(存在于U、兼容于U),* 则返回never类型,否则返回该子类型*/
type Exclude<T, U> = T extends U ? never : T;/*** @example* type Eg = 'key1'*/
type Eg = Exclude<'key1' | 'key2', 'key2'>

敲重点!!!

  • never表示一个不存在的类型
  • never与其他类型的联合后,是没有never
/*** @example* type Eg2 = string | number*/
type Eg2 = string | number | never

因此上述Eg其实就等于key1 | never,也就是type Eg = key1

Extract

Extract<T, U>提取联合类型T和联合类型U的所有交集。

type Extract<T, U> = T extends U ? T : never;/*** @example*  type Eg = 'key1'*/
type Eg = Extract<'key1' | 'key2', 'key1'>

Omit原理解析

Omit<T, K>从类型T中剔除K中的所有属性。

/*** 利用Pick实现Omit*/
type Omit = Pick<T, Exclude<keyof T, K>>;
  • 换种思路想一下,其实现可以是利用Pick提取我们需要的keys组成的类型
  • 因此也就是 Omit = Pick<T, 我们需要的属性联合>
  • 而我们需要的属性联合就是,从T的属性联合中排出存在于联合类型K中的
  • 因此也就是Exclude<keyof T, K>;

如果不利用Pick实现呢?

/*** 利用映射类型Omit*/
type Omit2<T, K extends keyof any> = {[P in Exclude<keyof T, K>]: T[P]
}
  • 其实现类似于Pick的原理实现
  • 区别在于是遍历的我们需要的属性不一样
  • 我们需要的属性和上面的例子一样,就是Exclude<keyof T, K>
  • 因此,遍历就是[P in Exclude<keyof T, K>]

Parameters 和 ReturnType

Parameters 获取函数的参数类型,将每个参数类型放在一个元组中。

/*** @desc 具体实现*/
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;/*** @example* type Eg = [arg1: string, arg2: number];*/
type Eg = Parameters<(arg1: string, arg2: number) => void>;
  • Parameters首先约束参数T必须是个函数类型,所以(...args: any) => any>替换成Function也是可以的
  • 具体实现就是,判断T是否是函数类型,如果是则使用inter P让ts自己推导出函数的参数类型,并将推导的结果存到类型P上,否则就返回never

敲重点!!!敲重点!!!敲重点!!!

  • infer关键词作用是让Ts自己推导类型,并将推导结果存储在其参数绑定的类型上。Eg:infer P 就是将结果存在类型P上,供使用。
  • infer关键词只能在extends条件类型上使用,不能在其他地方使用。

再敲重点!!!再敲重点!!!再敲重点!!!

  • type Eg = [arg1: string, arg2: number]这是一个元组,但是和我们常见的元组type tuple = [string, number]。官网未提到该部分文档说明,其实可以把这个作为类似命名元组,或者具名元组的意思去理解。实质上没有什么特殊的作用,比如无法通过这个具名去取值不行的。但是从语义化的角度,个人觉得多了语义化的表达罢了。
  • 定义元祖的可选项,只能是最后的选项
/*** 普通方式*/
type Tuple1 = [string, number?];
const a: Tuple1 = ['aa', 11];
const a2: Tuple1 = ['aa'];/*** 具名方式*/
type Tuple2 = [name: string, age?: number];
const b: Tuple2 = ['aa', 11];
const b2: Tuple2 = ['aa'];

扩展:infer实现一个推导数组所有元素的类型:

/*** 约束参数T为数组类型,* 判断T是否为数组,如果是数组类型则推导数组元素的类型*/
type FalttenArray<T extends Array<any>> = T extends Array<infer P> ? P : never;/*** type Eg1 = number | string;*/
type Eg1 = FalttenArray<[number, string]>
/*** type Eg2 = 1 | 'asd';*/
type Eg2 = FalttenArray<[1, 'asd']>

ReturnType 获取函数的返回值类型。

/*** @desc ReturnType的实现其实和Parameters的基本一样* 无非是使用infer R的位置不一样。*/
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

ConstructorParameters

ConstructorParameters可以获取类的构造函数的参数类型,存在一个元组中。

/*** 核心实现还是利用infer进行推导构造函数的参数类型*/
type ConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;/*** @example* type Eg = string;*/
interface ErrorConstructor {new(message?: string): Error;(message?: string): Error;readonly prototype: Error;
}
type Eg = ConstructorParameters<ErrorConstructor>;/*** @example* type Eg2 = [name: string, sex?: number];*/
class People {constructor(public name: string, sex?: number) {}
}
type Eg2 = ConstructorParameters<typeof People>
  • 首先约束参数T为拥有构造函数的类。注意这里有个abstract修饰符,等下会说明。
  • 实现时,判断T是满足约束的类时,利用infer P自动推导构造函数的参数类型,并最终返回该类型。

敲重点!!!敲重点!!!敲重点!!!

那么疑问来了,为什么要对T要约束为abstract抽象类呢?看下面例子:

/*** 定义一个普通类*/
class MyClass {}
/*** 定义一个抽象类*/
abstract class MyAbstractClass {}// 可以赋值
const c1: typeof MyClass = MyClass
// 报错,无法将抽象构造函数类型分配给非抽象构造函数类型
const c2: typeof MyClass = MyAbstractClass// 可以赋值
const c3: typeof MyAbstractClass = MyClass
// 可以赋值
const c4: typeof MyAbstractClass = MyAbstractClass

由此看出,如果将类型定义为抽象类(抽象构造函数),则既可以赋值为抽象类,也可以赋值为普通类;而反之则不行。

再敲重点!!!再敲重点!!!再敲重点!!!

这里继续提问,直接使用类作为类型,和使用typeof 类作为类型,有什么区别呢?

/*** 定义一个类*/
class People {name: number;age: number;constructor() {}
}// p1可以正常赋值
const p1: People = new People();
// 等号后面的People报错,类型“typeof People”缺少类型“People”中的以下属性: name, age
const p2: People = People;// p3报错,类型 "People" 中缺少属性 "prototype",但类型 "typeof People" 中需要该属性
const p3: typeof People = new People();
// p4可以正常赋值
const p4: typeof People = People;

结论是这样的:

  • 当把类直接作为类型时,该类型约束的是该类型必须是类的实例;即该类型获取的是该类上的实例属性和实例方法(也叫原型方法);
  • 当把typeof 类作为类型时,约束的满足该类的类型;即该类型获取的是该类上的静态属性和方法。

最后,只需要对infer的使用换个位置,便可以获取构造函数返回值的类型:

type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;

Ts compiler内部实现的类型

  • Uppercase
/*** @desc 构造一个将字符串转大写的类型* @example* type Eg1 = 'ABCD';*/
type Eg1 = Uppercase<'abcd'>;
  • Lowercase
/*** @desc 构造一个将字符串转小大写的类型* @example* type Eg2 = 'abcd';*/
type Eg2 = Lowercase<'ABCD'>;
  • Capitalize
/*** @desc 构造一个将字符串首字符转大写的类型* @example* type Eg3 = 'abcd';*/
type Eg3 = Capitalize<'Abcd'>;
  • Uncapitalize
/*** @desc 构造一个将字符串首字符转小写的类型* @example* type Eg3 = 'ABCD';*/
type Eg3 = Uncapitalize<'aBCD'>;

这些类型工具,在lib.es5.d.ts文件中是看不到具体定义的:

type Uppercase<S extends string> = intrinsic;
type Lowercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;
type Uncapitalize<S extends string> = intrinsic;

参考文章:Ts高手篇:22个示例深入讲解Ts最晦涩难懂的高级类型工具

typescript (TS)进阶篇 --- 内置高阶泛型工具类型(Utility Type)相关推荐

  1. python中的内置高阶函数

    一. 内置高阶函数的类型 (一).内置高阶函数map """ map():接收两个参数,一个是函数 一个是序列 map将传入的函数依次作用到序列的每个元素,并且把结果作为 ...

  2. Python中匿名函数与内置高阶函数详解

    大家好,从今天起早起Python将持续更新由小甜同学从 初学者的角度 学习Python的笔记,其特点就是全文大多由 新手易理解 的 代码与注释及动态演示 .刚入门的读者千万不要错过! 很多人学习pyt ...

  3. python高阶函数filter_Python进阶系列连载(13)——Python内置高阶函数filter(上)...

    前言 进阶部分连载继续~ 如果还没看过我的入门连载部分,先看: 当然,小编的免费入门课程已经有咯,看过连载的朋友可以看看视频再快速梳理一遍~ 前文传送门: filter filter是什么意思呢? 我 ...

  4. 一文看懂Python(八)-----内置高阶函数用法总结

    函数是Python的核心内容之一,能够提高代码的重复利用率.为了更好的使用函数,有必要了解函数的一些高级用法. Python内置了三个高阶函数:所谓高阶函数就是以函数作为参数,或者返回一个函数的函数, ...

  5. python内置高阶函数求导_Python——函数式编程、高阶函数和内置函数,及

    Python--函数式编程.高阶函数及内置函数 函数式编程 一.不可变数据:不用变量保存状态不修改变量 二.第一类对象:函数即"变量" 1.函数名可以当做参数传递 2.返回值可以是 ...

  6. python内置高阶函数_map()函数~~内置的高阶函数

    ''' map()函数 map()是 Python 内置的高阶函数,它接收一个函数 f 和一个 list, 并通过把函数 f 依次作用在 list 的每个元素上,得到一个新的 list 并返回. 例如 ...

  7. 列举5个python内置函数_Python5个内建高阶函数的使用

    描述 本文结合各种实际的例子详细讲解了Python5个内建高阶函数的使用,能够帮助理解Python的数据结构和提高数据处理的效率,这5个函数分别是: map reduce filter sorted/ ...

  8. VBS基础篇 - 内置函数

    VBS基础篇 - 内置函数 Date/Time 函数 函数 描述 CDate 把有效的日期和时间表达式转换为日期(Date)类型. Date 返回当前的系统日期. DateAdd 返回已添加指定时间间 ...

  9. python内置函数与闭包_Python进阶(五)----内置函数Ⅱ 和 闭包

    Python进阶(五)----内置函数Ⅱ 和 闭包 一丶内置函数Ⅱ ####内置函数#### 特别重要,反复练习 ###print() 打印输入 #sep 设定分隔符 # end 默认是换行可以打印到 ...

最新文章

  1. 迁移学习前沿研究亟需新鲜血液,深度学习理论不能掉链子
  2. 【Java】MD5字符串的加密解密
  3. java mockserver搭建_搭建Mock Server
  4. 数据湖之iceberg系列(四)iceberg-spark编程
  5. win7系统还原失败怎么解决
  6. IT项目管理-敏捷和传统
  7. echart 导出保存图片
  8. 常用的linux远程管理方法,常用windows下远程管理Linux服务器的方法
  9. C语言插件开发模式与分析
  10. WPF中如何重新定义Main函数
  11. 打开终端报错:bash: /某路径/bashrc: No such file or directory
  12. ThinkPHP5框架下载安装
  13. rcnn fastrcnn fasterrcnn总结
  14. java复习快速导航
  15. 汽车电子电气TARA分析从入门到放弃
  16. xcode证书签名快速完美解决
  17. 免Fan,国内直接访问,Instagram!文末还有…………
  18. 《Spring实战》读书笔记-第5章 构建Spring Web应用程序
  19. 尚融宝——阿里云短信验证功能(sms)
  20. 关于VB提示ByRef参数类型不符的分析

热门文章

  1. Excel基础(03)查找与替换
  2. elementUI tab 切换 table表头消失
  3. 【Photoshop】把同一场景中但焦点不一样的多张照片合成一张焦点清晰的图片
  4. 魔兽世界官方小说android,魔兽世界官方小说(套装共6册)
  5. 6月20日打卡50个单词
  6. unity2D横版游戏教程-1 让人物动起来
  7. 月赚千刀的国外联盟Lead项目,实操拆解
  8. [摘]四十二天学会英语的模范短文和经典100句
  9. Docker下elasticsearch8部署、扩容、基本操作实战(含kibana)
  10. 单片机PWM输出控制直流电机(89c51系列、stc12c5a60s2系列)