函数

函数的类型签名

函数的类型就是描述了函数入参类型与函数返回值类型,它们同样使用:的语法进行类型标注。我们直接看最简单的例子:

  1. function foo(name: string): number {
  2. return name.length;
  3. }

以通过函数表达式(Function Expression,即 const foo = function(){} 的形式声明一个函数。在表达式中进行类型声明的方式是这样的:

  1. const foo = function (name: string): number {
  2. return name.length
  3. }

对 foo 这个变量进行类型声明:

  1. const foo: (name: string) => number = function (name) {
  2. return name.length
  3. }

箭头函数的类型标注

  1. // 方式一
  2. const foo = (name: string): number => {
  3. return name.length
  4. }
  5. // 方式二
  6. const foo: (name: string) => number = (name) => {
  7. return name.length
  8. }

在方式二的声明方式中,你会发现函数类型声明混合箭头函数声明时,代码的可读性会非常差。因此,一般不推荐这么使用,要么直接在函数中进行参数和返回值的类型声明,要么使用类型别名将函数声明抽离出来

  1. type FuncFoo = (name: string) => number
  2. const foo: FuncFoo = (name) => {
  3. return name.length
  4. }

如果只是为了描述这个函数的类型结构,我们甚至可以使用 interface 来进行函数声明:

  1. interface FuncFooStruct {
  2. (name: string): number
  3. }

void 类型

在 TypeScript 中,一个没有返回值(即没有调用 return 语句)的函数,其返回类型应当被标记为 void 而不是 undefined,即使它实际的值是 undefined。

  1. // 没有调用 return 语句
  2. function foo(): void { }
  3. // 调用了 return 语句,但没有返回值
  4. function bar(): void {
  5. return;
  6. }

可选参数与 rest 参数

在函数类型中我们也使用 ? 描述一个可选参数:

  1. // 在函数逻辑中注入可选参数默认值
  2. function foo1(name: string, age?: number): number {
  3. const inputAge = age || 18; // 或使用 age ?? 18
  4. return name.length + inputAge
  5. }
  6. // 直接为可选参数声明默认值
  7. function foo2(name: string, age: number = 18): number {
  8. const inputAge = age;
  9. return name.length + inputAge
  10. }

需要注意的是,可选参数必须位于必选参数之后。毕竟在 JavaScript 中函数的入参是按照位置(形参),而不是按照参数名(名参)进行传递。当然,我们也可以直接将可选参数与默认值合并,但此时就不能够使用 ? 了,因为既然都有默认值,那肯定是可选参数啦。

  1. function foo(name: string, age: number = 18): number {
  2. const inputAge = age || 18;
  3. return name.length + inputAge
  4. }

对于 rest 参数的类型标注也比较简单,由于其实际上是一个数组,这里我们也应当使用数组类型进行标注:

  1. function foo(arg1: string, ...rest: any[]) { }

当然,你也可以使用元祖类型进行标注:

  1. function foo(arg1: string, ...rest: [number, boolean]) { }
  2. foo("linbudu", 18, true)

重载

在某些逻辑较复杂的情况下,函数可能有多组入参类型和返回值类型:

  1. function func(foo: number, bar?: boolean): string | number {
  2. if (bar) {
  3. return String(foo);
  4. } else {
  5. return foo * 599;
  6. }
  7. }

在这个实例中,函数的返回类型基于其入参 bar 的值,并且从其内部逻辑中我们知道,当 bar 为 true,返回值为 string 类型,否则为 number 类型。而这里的类型签名完全没有体现这一点,我们只知道它的返回值是这么个联合类型。
要想实现与入参关联的返回值类型,我们可以使用 TypeScript 提供的函数重载签名(Overload Signature,将以上的例子使用重载改写:

  1. function func(foo: number, bar: true): string;
  2. function func(foo: number, bar?: false): number;
  3. function func(foo: number, bar?: boolean): string | number {
  4. if (bar) {
  5. return String(foo);
  6. } else {
  7. return foo * 599;
  8. }
  9. }
  10. const res1 = func(599); // number
  11. const res2 = func(599, true); // string
  12. const res3 = func(599, false); // number

基于重载签名,我们就实现了将入参类型和返回值类型的可能情况进行关联,获得了更精确的类型标注能力。

异步函数、Generator 函数等类型签名

  1. async function asyncFunc(): Promise<void> {}
  2. function* genFunc(): Iterable<void> {}
  3. async function* asyncGenFunc(): AsyncIterable<void> {}

其中,Generator 函数与异步 Generator 函数现在已经基本不再使用,这里仅做了解即可。而对于异步函数(即标记为 async 的函数),其返回值必定为一个 Promise 类型,而 Promise 内部包含的类型则通过泛型的形式书写,即 Promise

私有化类的构造函数

  1. class Foo {
  2. private constructor() { }
  3. }

当我们对类的构造函数私有化以后,就不能再实例化。那这个有啥用呢?
有些场景下私有构造函数确实有奇妙的用法,比如把类作为 utils 方法时,此时 Utils 类内部全部都是静态成员,我们也并不希望真的有人去实例化这个类。此时就可以使用私有构造函数来阻止它被错误地实例化:

  1. class Utils {
  2. public static identifier = "linbudu";
  3. private constructor(){}
  4. public static makeUHappy() {
  5. }
  6. }

Class

类与类成员的类型签名

一个函数的主要结构即是参数、逻辑和返回值,对于逻辑的类型标注其实就是对普通代码的标注,所以我们只介绍了对参数以及返回值地类型标注。
而到了 Class 中其实也一样,它的主要结构只有构造函数属性方法访问符(Accessor,我们也只需要关注这三个部分即可。
属性的类型标注类似于变量,而构造函数、方法、存取器的类型编标注类似于函数:

  1. class Foo {
  2. prop: string;
  3. constructor(inputProp: string) {
  4. this.prop = inputProp;
  5. }
  6. print(addon: string): void {
  7. console.log(`${this.prop} and ${addon}`)
  8. }
  9. get propA(): string {
  10. return `${this.prop}+A`;
  11. }
  12. set propA(value: string) {
  13. this.prop = `${value}+A`
  14. }
  15. }

唯一需要注意的是,setter 方法不允许进行返回值的类型标注,你可以理解为 setter 的返回值并不会被消费,它是一个只关注过程的函数。
就像函数可以通过函数声明函数表达式创建一样,类也可以通过类声明类表达式的方式创建。很明显上面的写法即是类声明,而使用类表达式的语法则是这样的:

  1. const Foo = class {
  2. prop: string;
  3. constructor(inputProp: string) {
  4. this.prop = inputProp;
  5. }
  6. print(addon: string): void {
  7. console.log(`${this.prop} and ${addon}`)
  8. }
  9. // ...
  10. }

修饰符

使用

  1. const Foo = class {
  2. prop: string;
  3. constructor(inputProp: string) {
  4. this.prop = inputProp;
  5. }
  6. print(addon: string): void {
  7. console.log(`${this.prop} and ${addon}`)
  8. }
  9. // ...
  10. }


在 TypeScript 中我们能够为 Class 成员添加这些修饰符:public / private / protected / readonly。除 readonly 以外,其他三位都属于访问性修饰符,而 readonly 属于操作性修饰符(就和 interface 中的 readonly 意义一致)。

概念

  • public:此类成员在类、类的实例、子类中都能被访问。
  • readonly:只读,不能修改。
  • private:此类成员仅能在类的内部被访问。
  • protected:此类成员仅能在类与子类中被访问,你可以将类和类的实例当成两种概念,即一旦实例化完毕(出厂零件),那就和类(工厂)没关系了,即不允许再访问受保护的成员

当你不显式使用访问性修饰符,成员的访问性默认会被标记为 public

上面的例子当中,我们通过构造函数为类成员赋值的方式还是略显麻烦,需要声明类属性以及在构造函数中进行赋值。简单起见,我们可以在构造函数中对参数应用访问性修饰符

  1. class Foo {
  2. constructor(public arg1: string, private arg2: boolean) { }
  3. }
  4. new Foo("linbudu", true)

此时,参数会被直接作为类的成员(即实例的属性),免去后续的手动赋值。

静态成员

在 TypeScript 中,你可以使用 static 关键字来标识一个成员为静态成员:

  1. class Foo {
  2. static staticHandler() { }
  3. public instanceHandler() { }
  4. }

不同于实例成员,在类的内部静态成员无法通过 this 来访问,需要通过 Foo.staticHandler 这种形式进行访问。我们可以查看编译到 ES5 及以下 target 的 JavaScript 代码(ES6 以上就原生支持静态成员了),来进一步了解它们的区别:

  1. var Foo = /** @class */ (function () {
  2. function Foo() {
  3. }
  4. Foo.staticHandler = function () { };
  5. Foo.prototype.instanceHandler =
  6. function () { };
  7. return Foo;
  8. }());

从中我们可以看到,静态成员直接被挂载在函数体上,而实例成员挂载在原型上,这就是二者的最重要差异:静态成员不会被实例继承,它始终只属于当前定义的这个类(以及其子类)。而原型对象上的实例成员则会沿着原型链进行传递,也就是能够被继承。
而对于静态成员和实例成员的使用时机,其实并不需要非常刻意地划分。比如我会用类 + 静态成员来收敛变量与 utils 方法:

  1. class Utils {
  2. public static identifier = "linbudu";
  3. public static makeUHappy() {
  4. Utils.studyWithU();
  5. // ...
  6. }
  7. public static studyWithU() { }
  8. }
  9. Utils.makeUHappy();

继承、抽象类、实现

与 JavaScript 一样,TypeScript 中也使用 extends 关键字来实现继承:

  1. class Base { }
  2. class Derived extends Base { }

基类与派生类

对于这里的两个类,比较严谨的称呼是 基类(Base派生类(Derived)。关于基类与派生类,我们需要了解的主要是派生类对基类成员的访问与覆盖操作

基类中的哪些成员能够被派生类访问,完全是由其访问性修饰符决定的。我们在上面介绍的派生类中可以访问到使用 public 或 protected 修饰符的基类成员。

除了访问以外,基类中的方法也可以在派生类中被覆盖,但我们仍然可以通过 super 访问到基类中的方法:

  1. class Base {
  2. print() { }
  3. }
  4. class Derived extends Base {
  5. print() {
  6. super.print()
  7. // ...
  8. }
  9. }

在派生类中覆盖基类方法时,我们并不能确保派生类的这一方法能覆盖基类方法,万一基类中不存在这个方法呢?所以,TypeScript 4.3 新增了 override 关键字,来确保派生类尝试覆盖的方法一定在基类中存在定义:

  1. class Base {
  2. printWithLove() { }
  3. }
  4. class Derived extends Base {
  5. override print() {
  6. // ...
  7. }
  8. }

在这里 TS 将会给出错误,因为尝试覆盖的方法并未在基类中声明。通过这一关键字我们就能确保首先这个方法在基类中存在,同时标识这个方法在派生类中被覆盖了。

抽象类

抽象类是对类结构与方法的抽象,简单来说,一个抽象类描述了一个类中应当有哪些成员(属性、方法等)一个抽象方法描述了这一方法在实际实现中的结构
抽象类使用 abstract 关键字声明:

  1. abstract class AbsFoo {
  2. abstract absProp: string;
  3. abstract get absGetter(): string;
  4. abstract absMethod(name: string): string
  5. }

注意,抽象类中的成员也需要使用 abstract 关键字才能被视为抽象类成员。

实现(implements)一个抽象类:

  1. class Foo implements AbsFoo {
  2. absProp: string = "linbudu"
  3. get absGetter() {
  4. return "linbudu"
  5. }
  6. absMethod(name: string) {
  7. return name
  8. }
  9. }

此时,我们必须完全实现这个抽象类的每一个抽象成员。需要注意的是,在 TypeScript 中无法声明静态的抽象成员

使用interface声明类的结构

  1. interface FooStruct {
  2. absProp: string;
  3. get absGetter(): string;
  4. absMethod(input: string): string
  5. }
  6. class Foo implements FooStruct {
  7. absProp: string = "linbudu"
  8. get absGetter() {
  9. return "linbudu"
  10. }
  11. absMethod(name: string) {
  12. return name
  13. }
  14. }

这里接口的作用和抽象类一样,都是描述这个类的结构
我们还可以使用 Newable Interface 来描述一个类的结构

  1. class Foo { }
  2. interface FooStruct {
  3. new(): Foo
  4. }
  5. declare const NewableFoo: FooStruct;
  6. const foo = new NewableFoo();

SOLID 原则

SOLID 原则是面向对象编程中的基本原则,它包括以下这些五项基本原则。

S,单一功能原则一个类应该仅具有一种职责,这也意味着只存在一种原因使得需要修改类的代码。如对于一个数据实体的操作,其读操作和写操作也应当被视为两种不同的职责,并被分配到两个类中。更进一步,对实体的业务逻辑和对实体的入库逻辑也都应该被拆分开来。

O,开放封闭原则一个类应该是可扩展但不可修改的。即假设我们的业务中支持通过微信、支付宝登录,原本在一个 login 方法中进行 if else 判断,假设后面又新增了抖音登录、美团登录,难道要再加 else if 分支(或 switch case)吗?

  1. enum LoginType {
  2. WeChat,
  3. TaoBao,
  4. TikTok,
  5. // ...
  6. }
  7. class Login {
  8. public static handler(type: LoginType) {
  9. if (type === LoginType.WeChat) { }
  10. else if (type === LoginType.TikTok) { }
  11. else if (type === LoginType.TaoBao) { }
  12. else {
  13. throw new Error("Invalid Login Type!")
  14. }
  15. }
  16. }

当然不,基于开放封闭原则,我们应当将登录的基础逻辑抽离出来,不同的登录方式通过扩展这个基础类来实现自己的特殊逻辑。

  1. abstract class LoginHandler {
  2. abstract handler(): void
  3. }
  4. class WeChatLoginHandler implements LoginHandler {
  5. handler() { }
  6. }
  7. class TaoBaoLoginHandler implements LoginHandler {
  8. handler() { }
  9. }
  10. class TikTokLoginHandler implements LoginHandler {
  11. handler() { }
  12. }
  13. class Login {
  14. public static handlerMap: Record<LoginType, LoginHandler> = {
  15. [LoginType.TaoBao]: new TaoBaoLoginHandler(),
  16. [LoginType.TikTok]: new TikTokLoginHandler(),
  17. [LoginType.WeChat]: new WeChatLoginHandler(),
  18. }
  19. public static handler(type: LoginType) {
  20. Login.handlerMap[type].handler()
  21. }
  22. }

L,里式替换原则一个派生类可以在程序的任何一处对其基类进行替换。这也就意味着,子类完全继承了父类的一切,对父类进行了功能地扩展(而非收窄)。

I,接口分离原则类的实现方应当只需要实现自己需要的那部分接口。比如微信登录支持指纹识别,支付宝支持指纹识别和人脸识别,这个时候微信登录的实现类应该不需要实现人脸识别方法才对。这也就意味着我们提供的抽象类应当按照功能维度拆分成粒度更小的组成才对。

D,依赖倒置原则,这是实现开闭原则的基础,它的核心思想即是对功能的实现应该依赖于抽象层,即不同的逻辑通过实现不同的抽象类。还是登录的例子,我们的登录提供方法应该基于共同的登录抽象类实现(LoginHandler),最终调用方法也基于这个抽象类,而不是在一个高阶登录方法中去依赖多个低阶登录提供方。