六、TypeScript进阶

郁子大约 44 分钟约 13170 字笔记渡一教育强化课袁进

(一)深入理解类和接口

1.面向对象概述

1)意义

  • TS 为前端面向对象开发带来了契机
    • JS 语言没有类型检查,如果使用面向对象的方式开发,会产生大量的接口,而大量的接口会导致调用复杂度剧增
    • 这种复杂度必须通过严格的类型检查来避免错误,尽管可以使用注释或文档或记忆力,但是它们没有强约束力
    • TS 带来了完整的类型系统,因此开发复杂程序时,无论接口数量有多少,都可以获得完整的类型检查,并且这种检查是据有强约束力的
  • 面向对象中有许多非常成熟的模式,能处理复杂问题
    • 在过去的很多年中,在大型应用或复杂领域,面向对象已经积累了非常多的经验

2)概念

  • Oriented(基于) Object(事物),简称 OO,面向对象
  • 是一种编程思想,提出一切以类对切入点思考问题
  • 类是可以产生对象的模板
编程思想说明
面向过程功能流程 为思考切入点
不太适合大型应用
主要是模块化
函数式编程数学运算 为思考切入点
面向对象划分类 为思考切入点
类是最小的功能单元
主要是组件化

学开发最重要最难的是思维

3)如何学习

  • TS 中的 OOP
    • 面向对象编程,Oriented Object Programming
  • 小游戏练习
  • 理解 -> 想法 -> 实践 -> 理解 -> ....

2.类的继承

1)继承的作用

  • 继承可以描述类与类之间的关系

坦克、玩家坦克、敌方坦克


玩家坦克是坦克,敌方坦克是坦克

  • 如果 A 和 B 都是类,并且可以描述为 A 是 B,则 A 和 B 形成继承关系
    • B 是父类,A 是子类
    • B 派生 A,A 继承自 B
    • B 是 A 的基类,A 是 B 的派生类
  • 如果 A 继承自 B,则 A 中自动拥有 B 中的所有成员
export class Tank {
  // 出生坐标
  x: number = 0;
  y: number = 0;
}

export class PlayerTank extends Tank {}

export class EnemyTank extends Tank {}

const p = new PlayerTank();

2)成员的重写

  • 重写 Override
    • 子类中覆盖父类的成员
  • 子类成员不能改变父类成员的类型
  • 无论是属性还是方法,子类都可以对父类的相应成员进行重写
    • 但是重写时,需要保证 类型的匹配
  • 注意 this 关键字
    • 在继承关系中,this 的指向是动态的
    • 调用方法时,根据 具体的调用者 确定 this 指向
  • 注意 super 关键字
    • 在子类的方法中,可以使用 super 关键字读取父类成员
    • 如果子类没有同名成员,可以使用 this 调用
    • 如果子类有同名成员,this 调用的是子类自身的,super 才能调用父类的
export class Tank {
  // 出生坐标
  x: number = 0;
  y: number = 0;

  shoot() {
    console.log("发射子弹");
  }

  name: string = "坦克";
  sayHello() {
    console.log(`我是一个${this.name}`);
  }
}

export class PlayerTank extends Tank {
  x: number = 20;
  y: number = 20;

  shoot() {
    console.log("玩家坦克发射子弹");
  }

  name: string = "玩家坦克";

  mySayHello() {
    super.sayHello();
    this.sayHello();
  }
}

export class EnemyTank extends Tank {
  shoot() {
    console.log("敌方坦克发射子弹");
  }

  name: string = "敌方坦克";
}

const t = new Tank();
console.log(t.x, t.y); // 0 0

const p = new PlayerTank();
console.log(p.x, p.y); // 20 20
p.shoot(); // 玩家坦克发射子弹
p.sayHello(); // 我是一个玩家坦克
p.mySayHello(); // 我是一个玩家坦克

3)类型匹配

  • 主要原则是 鸭子辨型法
  • 子类的对象,始终可以赋值给父类
    • 面向对象中,这种现象叫做里氏替换原则
  • 如果需要判断一个数据的具体子类类型,可以使用 instanceof
export class Tank {
  name: string = "坦克";
  sayHello() {
    console.log(`我是一个${this.name}`);
  }
}

export class PlayerTank extends Tank {
  name: string = "玩家坦克";
  life: number = 5;
}

export class EnemyTank extends Tank {
  name: string = "敌方坦克";
}

const p: Tank = new PlayerTank();
p.sayHello(); // 我是一个玩家坦克
// console.log(p.life); // 报错,只能使用Tank和PlayerTank共有的属性

// 类型保护
if (p instanceof PlayerTank) {
  console.log(p.life); // 5,可以确定当前行的p一定是PlayerTank
}

4)protected 修饰符

修饰符包含
只读修饰符readonly
访问权限修饰符private、public、protected
  • 受保护的成员,只能在自身和子类中访问
  • 不会出现在编译结果中
export class Tank {
  protected name: string = "坦克";
  sayHello() {
    console.log(`我是一个${this.name}`);
  }
}

export class PlayerTank extends Tank {
  test() {
    console.log(this.name);
  }
}

export class EnemyTank extends Tank {
  protected name: string = "敌方坦克";
}

5)单根性和传递性

  • 单根性
    • 每个类最多只能拥有一个父类
  • 传递性
    • 如果 A 是 B 的父类,并且 B 是 C 的父类
    • 则可以认为 A 也是 C 的父类
export class Tank {
  protected name: string = "坦克";
}

export class PlayerTank extends Tank {}

export class EnemyTank extends Tank {
  health: number = 1;
}

export class BossTank extends EnemyTank {
  name: string = "敌方坦克";
  health: number = 3;
}

const b = new BossTank();
console.log(b.name, b.health); // 敌方坦克 3

3.抽象类

1)为什么需要抽象类

  • 某个类只表示一个抽象概念
    • 主要用于提取子类共有的成员,而不能直接创建它的对象
  • 该类可以作为抽象类
  • 给类前面加上 abstract
    • 表示该类是一个抽象类,只用于继承
    • 不可以创建一个抽象类的对象
// class Chess {}
abstract class Chess {}

class Horse extends Chess {}

class Pao extends Chess {}

class Soldier extends Chess {}

const h = new Horse();
const p = new Pao();
const s = new Soldier();

// const c = new Chess(); // 不应该创建这个对象 => 转换为抽象概念

2)抽象成员

  • 父类中,可能知道有些成员是必须存在的,但是不知道该成员的值或实现是什么
  • 因此,需要有一种强约束,让继承该类的子类必须要实现该成员
  • 抽象类中可以有抽象成员,这些抽象成员必须在子类中实现
  • 有三种实现方式
    • 普通声明属性
    • 构造函数中声明
    • 读取器中声明
abstract class Chess {
  x: number = 0;
  y: number = 0;

  // // 无法确定具体名字
  // readonly name: string = "";
  abstract readonly name: string;

  abstract move(targetX: number, targetY: number): boolean;
}

class Horse extends Chess {
  readonly name: string = "Horse";
  move(targetX: number, targetY: number): boolean {
    this.x = targetX;
    this.y = targetY;
    console.log("马走日");
    return true;
  }
}

class Pao extends Chess {
  readonly name: string;
  constructor() {
    super();
    this.name = "Pao";
  }
  move(targetX: number, targetY: number): boolean {
    this.x = targetX;
    this.y = targetY;
    console.log("炮可以跳");
    return true;
  }
}

class Soldier extends Chess {
  get name() {
    return "Soldier";
  }
  move(targetX: number, targetY: number): boolean {
    this.x = targetX;
    this.y = targetY;
    console.log("兵只能走一格");
    return true;
  }
}

3)设计模式 —— 模板模式

  • 设计模式
    • 面对一些常见的功能场景,有一些固定的、经过多年实践的成熟方法
    • 这些方法称之为设计模式
  • 模板模式
    • 有些方法,所有的子类实现的流程完全一致,只是流程中的某个步骤的具体实现不一致
    • 可以将该方法提取到父类,在父类中完成整个流程的实现
    • 遇到实现不一致的方法时,将该方法做成抽象方法
  • 避免了子类重复调用父类的方法
  • 对流程的调用顺序和方式作了强约束,方便团队协作
abstract class Chess {
  x: number = 0;
  y: number = 0;

  // 定义子类统一的流程作为模板
  move(targetX: number, targetY: number): boolean {
    console.log("1.边界判断");
    console.log("2.目前为止是否有己方棋子");
    // 3.棋子移动规则判断【子类实现不一致】
    if (this.rule(targetX, targetY)) {
      this.x = targetX;
      this.y = targetY;
      console.log("移动成功");
      return true;
    }
    return false;
  }

  protected abstract rule(targetX: number, targetY: number): boolean;
}

class Horse extends Chess {
  protected rule(targetX: number, targetY: number): boolean {
    return true;
  }
}

class Pao extends Chess {
  protected rule(targetX: number, targetY: number): boolean {
    return false;
  }
}

class Soldier extends Chess {
  protected rule(targetX: number, targetY: number): boolean {
    return true;
  }
}

4.静态成员

1)什么是静态成员

  • 附着在类上的成员(属于某个构造函数的成员)
    • 使用 static 修饰的成员
  • 实例成员
    • 对象成员,属于某个类的对象
    • 需要通过实例调用
    • 逻辑上每个实例对象不同的属性或方法
  • 静态成员
    • 非实例成员,属于某个类
    • 直接通过类名调用
    • 逻辑上每个类的实例共有的属性或方法
class User {
  constructor(
    public loginId: string,
    public loginPwd: string,
    public name: string,
    public age: number,
  ) {}

  /**
   * 该方法是实例方法,必须通过实例调用
   * 但是该方法就是为了生成实例对象,冲突了
   */
  // login(loginId: string, loginPwd: string): User | undefined {
  //   return undefined;
  // }

  /**
   * 相当于JS写法
   *    User.login = function (loginId, loginPwd) {}
   * 该方法是静态方法,必须通过类名调用
   */
  static login(loginId: string, loginPwd: string): User | undefined {
    return undefined;
  }
}

User.login("admin", "123");

2)静态方法中的 this

  • 实例方法中的 this 指向的是 当前对象
  • 静态方法中的 this 指向的是 当前类
class User {
  // 不同实例对象应该获得同一个用户列表,所以应该是静态成员
  static users: User[] = [];

  constructor(
    public loginId: string,
    public loginPwd: string,
    public name: string,
    public age: number,
  ) {
    /**
     * 将新创建的用户加入数组中
     */
    // this.users.push(this); // 报错,第一个this本意是指向类,但在构造函数(实例方法)中指向当前对象
    User.users.push(this);
  }

  sayHello() {
    console.log(`大家好,我叫${this.name},今年${this.age}岁,账号是${this.loginId}`);
  }

  static login(loginId: string, loginPwd: string): User | undefined {
    // return User.users.find(
    //   (u: User) => u.loginId === loginId && u.loginPwd === loginPwd
    // );
    // 等同于
    return this.users.find((u: User) => u.loginId === loginId && u.loginPwd === loginPwd);
  }
}

3)设计模式 —— 单例模式

  • 某些类在系统中最多只能有一个对象
  • 为了避免开发者造成随意创建多个类对象的错误,可以使用单例模式进行强约束
class Board {
  width: number = 500;
  height: number = 700;

  init() {
    console.log("初始化棋盘");
  }

  // 1.构造函数私有化,禁止使用new创建对象
  private constructor() {}

  /**
   * 写法一
   * 推荐
   */
  // 2.定义私有的静态成员,表示该类在系统中唯一的对象
  private static _board?: Board;
  // 3.定义公有的静态方法,只能调用该方法创建对象,内部返回系统中唯一的对象
  static createBoard(): Board {
    if (this._board) {
      return this._board;
    }
    this._board = new Board();
    return this._board;
  }

  /**
   * 写法二
   * 无法在需要时再创建对象
   * 无法实现在创建对象时完成其他操作
   */
  // static readonly singleBoard = new Board();
}

5.再谈接口

  • 接口用于约束类、对象、函数,是一个类型契约

有一个马戏团,马戏团中有很多动物,包括:狮子、老虎、猴子、狗


这些动物都具有共同的特征:名字、年龄、种类名称,还包含一个共同的方法:打招呼


它们各自有各自的技能,技能是可以通过训练改变的


狮子和老虎能进行火圈表演,猴子能进行平衡表演,狗能进行智慧表演


马戏团中有以下常见的技能:

  • 火圈表演:单火圈、双火圈
  • 平衡表演:独木桥、走钢丝
  • 智慧表演:算术题、跳舞

1)不使用接口实现

  • 对能力(成员函数)没有强约束力
  • 容易将类型和能力耦合在一起
  • 根本原因:系统中缺少对能力的定义

2)接口基本使用

  • 面向对象领域中的接口
  • 表达了某个类是否拥有某种能力
  • 某个类具有某种能力,其实就是实现了某种接口
// interface.ts
export interface IFireShow {
  singleFire(): void;
  doubleFire(): void;
}

// index.ts
import { IFireShow } from "./interfaces";

export class Tiger extends Animal implements IFireShow {
  type: string = "老虎";

  singleFire() {
    console.log(`${this.name}穿越了单火圈`);
  }

  doubleFire() {
    console.log(`${this.name}穿越了双火圈`);
  }
}
  • Java 中可以使用 a instanceof b 判断 a 是否继承了接口 b
    • 但由于 TS 编译后没有接口,且判断是在运行时执行
    • 所以 TS 只能通过类型保护函数判断是否继承了接口
  • 类型保护函数
    • 通过调用该函数,会触发 TS 的类型保护,该函数必须返回 boolean
    • 返回值 ani is IFireShow 表示 ani 对象是否具有 IFireShow 类型
// interface.ts
export function hasFireShow(ani: object): ani is IFireShow {
  if ((ani as IFireShow).singleFire && (ani as IFireShow).doubleFire) {
    return true;
  }
  return false;
}

// index.ts
animals.forEach((a) => {
  if (hasFireShow(a)) {
    a.singleFire();
    a.doubleFire();
  }
});

3)接口和类型别名的区别

  • 接口可以被类实现,而类型别名不可以
  • 接口可以继承类,表示该类的所有成员都在接口中【TS 特有】
    • 用于合并多个类成员
class A {
  a1: string = "";
  a2: string = "";
  a3: string = "";
}
class B {
  b1: number = 0;
  b2: number = 0;
  b3: number = 0;
}

interface C extends A, B {}

const c: C = {
  a1: "",
  a2: "",
  a3: "",
  b1: 0,
  b2: 3,
  b3: 4,
};

6.索引器

  • 使用成员表达式 对象[值] ,其实就是索引器
  • 在 TS 中,默认情况下,不对索引器(成员表达式)做严格的类型检查
    • const name = user["pid"]; 访问不存在的属性不报错,返回 undefined
    • 因为成员表达式访问的属性可能是执行时才能确定属性名

1)隐式 any

  • 可以使用配置 noImplicitAny 开启对隐式 any 的检查
  • TS 根据实际情况推导出的 any 类型

2)TS 中使用索引器

  • 在索引器中,键的类型可以是字符串,也可以是数字
  • 在类中,索引器书写的位置应该是 所有成员之前
class User {
  // 针对所有成员
  // [prop: string]: any;
  // 或者
  [prop: string]: string | number | { (): void };

  constructor(
    public name: string,
    public age: number,
  ) {}

  sayHello() {}
}

const u = new User("aaa", 111);
console.log(u["pid"]);
u["sayHello"]();
  • 在 JS 中,所有的成员名本质上,都是字符串
  • 如果使用数字作为成员名,会自动转换为字符串
class MyArray {
  [index: number]: string;

  0 = "1";
  1 = "ass";
  2 = "sfdg";
}
/*
  编译结果:
  class MyArray {
    constructor() {
      this[0] = "1"
    }
  }
*/

const my = new MyArray();
console.log(my[5]);
my[4] = "222";

面试题

const arr = [];
arr[0] = 1;
arr["0"] = 3;
console.log(arr[0]); // 3

3)TS 中索引器的作用

  • 在严格的检查下,可以实现为类动态增加成员
  • 可以实现动态的操作类成员
  • 在 TS 中,如果某个类中使用了两种类型的索引器
    • 要求两种索引器的值类型必须匹配
    • 如果类型不一致,两个类型必须是父子关系
class B {}

class A {
  [prop: number]: B;
  [prop: string]: object;
  // B 是 object 的子类型
}

7.this 指向约束

https://yehudakatz.com/2011/08/10/understanding-javascript-function-invocation-and-this/open in new window

1)在 JS 中 this 指向的几种情况

  • 大部分时候,this 的指向取决于函数的 调用方式
调用方式this 指向
直接调用函数(全局调用)全局对象 Window/global
或启用严格模式:undefined
使用 对象.方法 调用对象本身
dom 事件的处理函数事件处理对象
特殊:箭头函数在函数 声明时 确定指向
指向函数位置的 this
特殊:使用 bind、apply、call手动绑定 this 对象

2)TS 中的 this

  • 不同方式调用函数时,this 的类型不同
const u = {
  name: "ssf",
  age: 33,
  sayHello() {
    // this 为 any
    console.log(this.name, this.age);
  },
};

class User {
  constructor(
    public name: string,
    public age: number,
  ) {}

  sayHello() {
    // this 为 类的实例对象
    console.log(this, this.name, this.age);
  }
}
  • 配置 noImplicitThis
    • 表示不允许 this 隐式的指向 any
  • 在 TS 中,允许在书写函数时,手动声明该函数中 this 的指向
    • 将 this 作为函数的第一个参数
    • 该参数只用于约束 this
    • 并不是真正的参数,也不会出现在编译结果中
interface IUser {
  name: string;
  age: number;
  sayHello(this: IUser): void;
}

// 字面量约束this
const u: IUser = {
  name: "ssf",
  age: 33,
  sayHello() {
    console.log(this.name, this.age);
  },
};
const say = u.sayHello;
// say(); // 报错,这样调用this指向不明

// 类约束this
class User {
  constructor(
    public name: string,
    public age: number,
  ) {}

  sayHello(this: IUser) {
    // this 为 类的实例对象
    console.log(this, this.name, this.age);
  }
}

(二)项目实战:使用 Webpack + TS 开发俄罗斯方块

1.概述

1)技术栈

  • Webpack
  • jQuery
  • TypeScript
  • 面向对象开发

2)项目目的

  • 学习 TS 如何结合 Webpack 做开发
  • 巩固 TS 的知识
  • 锻炼逻辑思维能力
  • 体验面向对象编程的思想

2.工程搭建

1)环境

  • 浏览器
  • 模块化
{
  "devDependencies": {
    "clean-webpack-plugin": "^2.0.1",
    "html-webpack-plugin": "^3.2.0",
    "ts-loader": "^5.4.5",
    "typescript": "^3.4.5",
    "webpack": "^4.30.0",
    "webpack-cli": "^3.3.1",
    "webpack-dev-server": "^3.3.1"
  },
  "scripts": {
    "build": "webpack --mode=production", // 生产模式打包
    "dev": "webpack-dev-server" // 启动服务器
  },
  "name": "teris-game",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}

2)Webpack

  • 构建工具,根据入口文件寻找依赖,打包
  • 安装 webpack
  • 安装 html-webpack-plugin
  • 安装 clean-webpack-plugin
  • 安装 webpack-dev-server
  • 安装 TS 的相应 loader
    • ts-loader【官方】
    • awesome-typescript-loader【民间】
  • 本地项目依赖 typescript
// webpack.config.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CleanWebpackPlugin = require("clean-webpack-plugin");

module.exports = {
  entry: "./src/index.ts", // 启动文件/入口文件
  output: {
    // 出口文件
    path: path.resolve("./dist"), // 根目录
    filename: "script/bundle.js", // 文件位置
  },
  plugins: [
    // 生成页面的模板
    new HtmlWebpackPlugin({
      template: "./public/index.html",
    }),
    // 清空上次打包输出的文件
    new CleanWebpackPlugin(),
  ],
  module: {
    // 加载规则
    rules: [
      {
        test: /.ts$/,
        use: {
          loader: "ts-loader",
          options: {
            transpileOnly: true,
          },
        },
      },
    ],
  },
  resolve: {
    // 解析模块时读取的扩展名
    extensions: [".ts", ".js"],
  },
};

3)系统模式

  • 单一职能原则
    • 每个类只做跟它相关的一件事
  • 开闭原则
    • 系统中的类,应该对扩展开放,对修改关闭
  • 基于以上两个原则,采取数据-界面分离模式
    • 如:React 中的展示组件和容器组件
    • react 库负责处理数据,react-dom/react-native 库负责页面展示

3.开发小方块类 Square

传统面向对象语言书写类属性时

  • 所有的属性全部私有化
    • Java:私有变量正常命名
    • C#:私有变量以 _ 开头
  • 使用公开的方法提供对属性的访问
    • Java:使用公共的方法 getXsetX
    • C#:使用访问器 get xset x

1)逻辑坐标

  • 以任意面板左上角为原点,小方块左上角的坐标
  • 与真实的像素值无关

2)完成显示

  • 能处理自己的数据,知道什么时候需要显示,但不知道怎么显示
  • 只需要在类中定义一个属性,绑定显示者
import { IViewer, Point } from "./types";

export class Square {
  private _point: Point = {
    x: 0,
    y: 0,
  };
  public get point() {
    return this._point;
  }
  public set point(val) {
    this._point = val;
    // 完成显示
    this._viewer && this._viewer.show();
  }

  private _color: string = "red";
  public get color() {
    return this._color;
  }
  public set color(val) {
    this._color = val;
  }

  // 属性:显示者
  private _viewer?: IViewer;
  public get viewer() {
    return this._viewer;
  }
  public set viewer(val) {
    this._viewer = val;
    this._viewer && this._viewer.show();
  }
}

4.开发小方块的显示类 SquarePageViewer

  • 用于将一个小方块显示到页面上

5.开发方块的组合类 SquareGroup

1)属性 1:小方块的数组

  • 该数组的组成不能发生变化,是只读数组
private _squares: ReadonlyArray<Square>;
// 或者
private _squares: readonly Square[];

2)属性 2:形状

  • 一个方块的组合,取决于组合的形状
    • 一组相对坐标的组合,该组合中有一个特殊坐标,表示形状的中心
  • 如果知道形状、中心点坐标、颜色,就可以设置小方块的数组
  • 中心点坐标设为 (0, 0),每一个方块的坐标设为相对中心点坐标的坐标
    • 这样整个方块组合移动、旋转时,都可以通过控制中心点坐标来实现
    • 中心点其实是当前方块组合任意一个方块
import { Square } from "./Square";
import { Point, Shape } from "./types";

export class SquareGroup {
  // 只读数组
  // private _squares: ReadonlyArray<Square>;
  private _squares: readonly Square[];

  constructor(
    private _shape: Shape, // 各个方块相对中心点的坐标
    private _centerPoint: Point, // 中心点的坐标
    private _color: string,
  ) {
    // 设置小方块的数组
    const arr: Square[] = [];
    this._shape.forEach((p) => {
      const sq = new Square();
      sq.color = this._color;
      sq.point = {
        x: this._centerPoint.x + p.x,
        y: this._centerPoint.y + p.y,
      };
      arr.push(sq);
    });
    this._squares = arr;
  }

  public get squares() {
    return this._squares;
  }

  /**
   * 设置访问器
   * 修改中心点坐标后同步修改方块组合的坐标
   */
  public get centerPoint(): Point {
    return this._centerPoint;
  }
  public set centerPoint(v: Point) {
    this._centerPoint = v;
    this._shape.forEach((p, i) => {
      this._squares[i].point = {
        x: this._centerPoint.x + p.x,
        y: this._centerPoint.y + p.y,
      };
    });
  }
}

6.俄罗斯方块的生产者

/**
 * 随机产生一个俄罗斯方块
 * @param centerPoint 中心点坐标
 * @description 颜色随机,形状(其他方块坐标)随机
 */
export const createTeris = (centerPoint: Point) => {
  let index = getRandom(0, shapes.length);
  const shape = shapes[index];

  // index = getRandom(0, colors.length);
  // const color = colors[index];
  const color = `rgb(${getRandom(0, 256)}, ${getRandom(0, 256)}, ${getRandom(0, 256)})`;

  return new SquareGroup(shape, centerPoint, color);
};

7.俄罗斯方块的规则类

  • 提供一系列函数,根据游戏规则判断各种情况
import GameConfig from "./GameConfig";
import { SquareGroup } from "./SquareGroup";
import { MoveDirection, Point, Shape } from "./types";

/**
 * 类型保护函数
 * @param obj 判断对象
 * @returns 是否是 Point 类型
 */
const isPoint = (obj: any): obj is Point => {
  if (typeof obj.x === "undefined") {
    return false;
  }
  return true;
};

export class TerisRule {
  /**
   * 判断某个形状的方块,是否能移动到目标位置
   * @param shape 方块形状
   * @param targetPoint 目标位置
   * @returns 是否能移动
   */
  static canIMove(shape: Shape, targetPoint: Point): boolean {
    // 1.假设中心点已经移动到了目标位置,算出每个方块坐标
    const targetSquarePoints: Point[] = shape.map((s) => ({
      x: s.x + targetPoint.x,
      y: s.y + targetPoint.y,
    }));
    // 2.边界判断:至少有一个方块坐标不在面板内
    if (targetSquarePoints.some((p) => p.x < 0 || p.x > GameConfig.panelSize.width - 1 || p.y < 0 || p.y > GameConfig.panelSize.height - 1))
      return false;
    return true;
  }

  // /**
  //  * 将方块移动到目标位置
  //  * @param teris 待移动的方块
  //  * @param targetPoint 目标位置
  //  * @returns 是否移动成功
  //  */
  // static move(teris: SquareGroup, targetPoint: Point): boolean {}

  // /**
  //  * 将方块每次向目标方向移动1格
  //  * @param teris 待移动的方块
  //  * @param direction 目标方向
  //  * @returns 是否移动成功
  //  */
  // static move(teris: SquareGroup, direction: MoveDirection): boolean {}

  /**
   * 函数重载
   */
  static move(teris: SquareGroup, targetPoint: Point): boolean;
  static move(teris: SquareGroup, direction: MoveDirection): boolean;
  static move(teris: SquareGroup, targetPointOrDirection: Point | MoveDirection): boolean {
    if (isPoint(targetPointOrDirection)) {
      if (this.canIMove(teris.shape, targetPointOrDirection)) {
        teris.centerPoint = targetPointOrDirection;
        return true;
      }
      return false;
    } else {
      const direction = targetPointOrDirection;
      let targetPoint: Point;
      if (direction === MoveDirection.down) {
        targetPoint = {
          x: teris.centerPoint.x,
          y: teris.centerPoint.y + 1,
        };
      } else if (direction === MoveDirection.left) {
        targetPoint = {
          x: teris.centerPoint.x - 1,
          y: teris.centerPoint.y,
        };
      } else {
        targetPoint = {
          x: teris.centerPoint.x + 1,
          y: teris.centerPoint.y,
        };
      }
      return this.move(teris, targetPoint);
    }
  }

  /**
   * 将当前的方块,移动到目标方向的终点
   * @param teris 待移动的方块
   * @param direction 目标方向
   */
  static moveDirectly(teris: SquareGroup, direction: MoveDirection) {
    while (this.move(teris, direction)) {}
  }
}

8.开发旋转功能

1)旋转的本质

  • 根据当前形状,生成新的形状
  • 顺时针:(x, y) => (-y, x)
  • 逆时针:(x, y) => (y, -x)
// SquareGroup.ts
// 旋转方向是否为顺时针
protected isClock: boolean = true;

/**
 * 根据当前形状,生成新的形状
 */
afterRotateShape(): Shape {
  if (this.isClock) {
    return this._shape.map((p) => ({
      x: -p.y,
      y: p.x,
    }));
  }
  return this._shape.map((p) => ({
    x: p.y,
    y: -p.x,
  }));
}

2)旋转的状态

  • 有些方块是不旋转的
    • 如:“田”型
  • 有些方块旋转时只有两种状态
    • 如:“Z”型顺逆时针分步旋转
  • rotate 方法有一种通用的实现方式
    • 但是不同的情况下,会有不同的具体实现
    • 模板模式

  • 将 SquareGroup 作为父类
    • 其他的方块都是它的子类,子类可以重写父类的方法
export class SShape extends SquareGroup {
  constructor(_centerPoint: Point, _color: string) {
    super(
      [
        // ...
      ],
      _centerPoint,
      _color,
    );
  }
  rotate() {
    super.rotate();
    this.isClock = !this.isClock;
  }
}
export class SquareShape extends SquareGroup {
  constructor(_centerPoint: Point, _color: string) {
    super(
      [
        // ...
      ],
      _centerPoint,
      _color,
    );
  }
  // 返回当前形状 => 不旋转
  afterRotateShape(): Shape {
    return this.shape;
  }
}

export const createTeris = (centerPoint: Point): SquareGroup => {
  let index = getRandom(0, shapes.length);
  const shape = shapes[index];
  const color = `rgb(${getRandom(0, 256)}, ${getRandom(0, 256)}, ${getRandom(0, 256)})`;
  return new shape(centerPoint, color);
};
  • 旋转不能超出边界
/**
 * 旋转
 * @param teris 待旋转的方块
 * @returns 是否可以旋转(旋转后是否超出边界)
 */
static rotate(teris: SquareGroup): boolean {
  const newShape = teris.afterRotateShape();
  if (this.canIMove(newShape, teris.centerPoint)) {
    teris.rotate();
    return true;
  }
  return false;
}

9.开发游戏类

  • 清楚什么时候进行显示的切换,但不知道如何显示
import GameConfig from "./GameConfig";
import { SquareGroup } from "./SquareGroup";
import { createTeris } from "./Teris";
import { TerisRule } from "./TerisRule";
import { GameStatus, GameViewer, MoveDirection } from "./types";

export class Game {
  // 游戏状态
  private _gameStatus: GameStatus = GameStatus.init;
  // 当前操作的方块组
  private _currentTeris?: SquareGroup;
  // 下一个方块组
  private _nextTeris: SquareGroup = createTeris({
    x: 0,
    y: 0,
  });
  // 下落计时器
  private _timer?: number;
  // 下落间隔时间
  private _duration: number = 1000;

  constructor(private _viewer: GameViewer) {
    this.resetCenterPoint(GameConfig.nextSize.width, this._nextTeris);
    this._viewer.showNext(this._nextTeris);
  }

  /**
   * 游戏开始
   */
  start() {
    if (this._gameStatus === GameStatus.playing) return;
    this._gameStatus = GameStatus.playing;
    // 给当前玩家操作的方块组赋值
    if (!this._currentTeris) {
      this.switchTeris();
    }
    this.autoDrop();
  }

  /**
   * 游戏暂停
   */
  pause() {
    if (this._gameStatus === GameStatus.playing) {
      this._gameStatus = GameStatus.pause;
      clearInterval(this._timer);
      this._timer = undefined;
    }
  }

  /**
   * 操作向左
   */
  controlLeft() {
    if (this._currentTeris && this._gameStatus === GameStatus.playing) {
      TerisRule.move(this._currentTeris, MoveDirection.left);
    }
  }

  /**
   * 操作向右
   */
  controlRight() {
    if (this._currentTeris && this._gameStatus === GameStatus.playing) {
      TerisRule.move(this._currentTeris, MoveDirection.right);
    }
  }

  /**
   * 操作向下
   */
  controlDown() {
    if (this._currentTeris && this._gameStatus === GameStatus.playing) {
      TerisRule.move(this._currentTeris, MoveDirection.down);
    }
  }

  /**
   * 操作旋转
   */
  controlRotate() {
    if (this._currentTeris && this._gameStatus === GameStatus.playing) {
      TerisRule.rotate(this._currentTeris);
    }
  }

  /**
   * 更新当前方块组
   */
  private switchTeris() {
    this._currentTeris = this._nextTeris;
    // 新方块组在面板中居中下落
    this.resetCenterPoint(GameConfig.panelSize.width, this._currentTeris);
    this._nextTeris = createTeris({
      x: 0,
      y: 0,
    });
    // 下一个方块组在右侧区域中居中
    this.resetCenterPoint(GameConfig.nextSize.width, this._nextTeris);
    this._viewer.switchNext(this._currentTeris);
    this._viewer.showNext(this._nextTeris);
  }

  /**
   * 方块组自由下落
   */
  private autoDrop() {
    if (this._timer || this._gameStatus !== GameStatus.playing) return;
    this._timer = setInterval(() => {
      this._currentTeris && TerisRule.move(this._currentTeris, MoveDirection.down);
    }, this._duration);
  }

  /**
   * 重新计算中心点坐标,使该方块出现在特定区域的中上方
   * @param width 逻辑宽度(一个格子宽度)
   * @param teris 要计算中心点坐标的方块组
   */
  private resetCenterPoint(width: number, teris: SquareGroup) {
    // 1.先更新中心点坐标
    const x = Math.ceil(width / 2) - 1,
      y = 0;
    teris.centerPoint = { x, y };
    // 2.判断纵轴方向是否超出区域上方
    while (teris.squares.some((sq) => sq.point.y < 0)) {
      // // 如果区域高度只有一格(游戏设置问题),程序会死循环
      // TerisRule.move(teris, MoveDirection.down);
      // 强制下移一格
      teris.centerPoint = {
        x: teris.centerPoint.x,
        y: teris.centerPoint.y + 1,
      };
    }
  }
}

10.触底处理

1)触底

  • 当前方块到达最底部

2)触底时机(调用函数时机)

  • 自动下落
  • 玩家控制下落

3)触底后(函数如何编写)

  • 切换当前方块
  • 保存已落下的方块
  • 消除方块的处理
    • 从界面上移除
    • 从 exists 数组中移除
    • 改变删除行上方的方块 y 坐标
  • 游戏是否结束
    • 应该在切换下一个方块组前判断

11.积分

private _score: number = 0;

public get score(): number {
  return this._score;
}

public set score(val: number) {
  this._score = val;
  this._viewer.showScore(val);
  // 获得最后一个分数小于当前分数的级别
  // “!”表示去掉undefined的情况
  const level = GameConfig.levels
    .filter((level) => level.score <= val)
    .pop()!;
  if (level.duration === this._duration) return;
  this._duration = level.duration;
  if (this._timer) {
    clearInterval(this._timer);
    this._timer = undefined;
    this.autoDrop();
  }
}

12.完成游戏界面

13.总结

  • 面向对象带来了新的开发方式
    • 特别善于解决复杂问题
  • TypeScript 的某些语法是专门为面向对象准备
  • 学习一些设计模式
  • 游戏特别容易使用面向对象的思维

开发追求

高内聚、低耦合

(三)装饰器

1.概述

  • 面向对象的概念,decorator
    • Java 中叫注解
    • C# 中叫特征
  • 在 Angular 中大量使用,React 中也会用到
  • 目前 JS 支持装饰器

1)解决的问题

  • 装饰器能够带来额外的信息量,可以分离关注点
  • 关注点问题
    • 在定义某个东西时,应该最清楚该东西的相关情况
  • 信息书写位置的问题
    • 如果要对某个类进行规则验证
    • 只有在书写类的时候最清楚应该有什么规则
  • 重复代码的问题
  • 问题产生的根源
    • 某些信息在定义时,能够附加的信息量有限

2)装饰器的作用

  • 为某些属性、类、参数、方法提供元数据信息
  • 元数据 metadata
    • 描述数据的数据
    • 类似 HTML 文档中的 meta
  • 元数据参与运行

3)装饰器的本质

  • 在 JS 中,装饰器是 一个函数
  • 装饰器要参与运行
  • 装饰器可以修饰
    • 成员(属性+方法)
    • 参数
class User {
  @require
  @range(3, 5)
  @description("账号")
  loginId: string; // 描述是:账号,验证规则:1.必填,2.必须是3-5个字符

  loginPwd: string; // 必须是6-12个字符
  age: number; // 必须是0-100之间的数字
  gender: "男" | "女";
}

2.类装饰器

  • 类装饰器的本质是一个函数
  • 在 JS 中对格式没有要求
  • 在 TS 中要求该函数接收一个参数
    • 表示类本身
    • 即:构造函数本身

1)使用装饰器

  • 格式:@得到一个函数

2)TS 中将变量约束为类

  • 约束为 Function 类型
    • 范围太广,包含了普通函数
  • 约束为 new (参数) => object 构造函数的格式
    • 只有类有构造函数

3)TS 中使用装饰器

  • 需要开启 experimentalDecorators 配置
function test(target: new () => object) {
  console.log(target); // [class A]
}

@test
class A {}

4)装饰器函数的运行时间

  • 在类定义后直接运行
  • 编译结果是生成装饰器函数,包裹着类
var __decorate =
  (this && this.__decorate) ||
  function (decorators, target, key, desc) {
    // ......
  };
function test(target) {
  console.log(target);
}
let A = class A {};
A = __decorate([test], A);

5)类装饰器的返回值

  • void
    • 仅运行函数
  • 返回一个新的类
    • 会将新的类替换掉装饰目标
    • 不建议使用,会丢失类型检查
function test(target: new () => object) {
  console.log(target);
  return class B extends target {};
}

@test
class A {}

const a = new A();
console.log(a); // B {}

6)修饰不定参数的类

  • 使用剩余参数
  • 约束为 any 数组
function test(target: new (...args: any[]) => object) {
  console.log(target);
}

@test
class A {}

@test
class C {
  constructor(public name: string) {}
}

@test
class D {
  constructor(
    public name: string,
    public age: number,
  ) {}
}

7)手动调用装饰器

  • 类装饰器要求必须有参数,表示描述信息
  • 调用的函数必须返回一个函数(装饰器)
function test(str: string) {
  return function (target: new (...args: any[]) => object) {
    console.log(target);
  };
}

@test("这是一个类")
class A {}

8)多个装饰器

  • 会按照 后加入先调用 的顺序进行调用
type Constructor = new (...args: any[]) => object;
function d1(target: Constructor) {
  console.log("d1");
}
function d2(target: Constructor) {
  console.log("d2");
}

@d1
@d2
class A {}
// 从下到上,先输出d2再输出d1
  • 如果是手动调用函数的格式
  • 则是从上到下调用函数,再从下到上调用装饰器

面试题

type Constructor = new (...args: any[]) => object;
// 普通函数【装饰器工厂】
function d1() {
  console.log("d1");
  // 返回值才是装饰器
  return function (target: Constructor) {
    console.log("d1 decorator");
  };
}
function d2() {
  console.log("d2");
  return function (target: Constructor) {
    console.log("d2 decorator");
  };
}

@d1()
@d2()
class A {}
/**
d1
d2
d2 decorator
d1 decorator
*/

3.成员装饰器

  • 可以有多个装饰器修饰

1)属性

  • 属性装饰器也是一个函数
  • 该函数需要两个参数
  • 参数 1
    • 如果是静态属性,则为类本身【同类装饰器】
    • 如果是实例属性,则为类的原型【常用】
  • 参数 2
    • 固定为一个字符串,表示属性名
// 装饰实例属性
type Constructor = new (...args: any[]) => object;
function d(target: any, key: string) {
  console.log(target, key);
  /**
   * target === A.prototype
   * {} 'prop1'
   * {} 'prop2'
   */
}

class A {
  @d
  prop1: string = "";

  @d
  prop2: string = "";
}
  • 装饰静态属性
type Constructor = new (...args: any[]) => object;
function d(target: any, key: string) {
  console.log(target, key);
  /**
   * {} 'prop1'
   * [class A] { prop2: '' } 'prop2'
   */
}

class A {
  @d
  prop1: string = "";

  @d
  static prop2: string = "";
}

2)方法

  • 方法装饰器也是一个函数
  • 该函数需要三个参数
  • 参数 1
    • 如果是静态方法,则为类本身
    • 如果是实例方法,则为类的原型
  • 参数 2
    • 固定为一个字符串,表示方法名
  • 参数 3
    • 属性描述对象
    • Object.defineProperty() 的第三个参数
type Constructor = new (...args: any[]) => object;
function d() {
  return function (target: any, key: string, descriptor: PropertyDescriptor) {
    console.log(target, key, descriptor);
  };
  /**
  {} 'method1' {
  value: [Function: method1],
    writable: true,
    enumerable: false,
    configurable: true
  }
  */
}

class A {
  @d()
  method1() {}
}
  • 可以用于通用控制属性描述
function enumerable(target: any, key: string, descriptor: PropertyDescriptor) {
  // console.log(target, key, descriptor);
  descriptor.enumerable = true;
}

function useless(target: any, key: string, descriptor: PropertyDescriptor) {
  descriptor.value = function () {
    console.warn(key + "方法已过期");
  };
}

class A {
  @enumerable
  @useless
  method1() {
    console.log("method1");
  }

  @enumerable
  method2() {}
}

const a = new A();
a.method1();

4.练习:类和属性的描述装饰器

1)定义类装饰器和属性装饰器

type Constructor = new (...args: any[]) => object;

/**
 * 将类的装饰信息保存到该类的原型中
 * @param description 类的描述信息
 * @returns 类装饰器
 */
export const classDescriptor = (description: string) => {
  return (targetClass: Constructor) => {
    targetClass.prototype.$classDescription = description;
  };
};

/**
 * 将所有属性的信息保存到该类的原型中
 * @param description 属性的描述信息
 * @returns 属性装饰器
 * @description 该装饰器可能调用多次
 */
export const propDescriptor = (description: string) => {
  return (targetPrototype: any, propName: string) => {
    if (!targetPrototype.$propDescriptions) {
      targetPrototype.$propDescriptions = [];
    }
    targetPrototype.$propDescriptions.push({
      propName,
      description,
    });
  };
};

/**
 * 输出实例对象的相关描述信息
 * @param obj 实例对象
 */
export const printObject = (obj: any): void => {
  // 1.输出类的描述信息,没有则输出原型上的类名
  if (obj.$classDescription) {
    console.log(obj.$classDescription);
  } else {
    // console.log(obj.__proto__.constructor.name);
    console.log(Object.getPrototypeOf(obj).constructor.name);
  }
  // 2.输出所有属性名描述信息和属性值,没有则输出属性名
  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      const prop = obj.$propDescriptions.find((p: any) => p.propName === key);
      if (prop) {
        console.log(`\t${prop.description}: ${obj[key]}`);
      } else {
        console.log(`\t${key}: ${obj[key]}`);
      }
    }
  }
};

2)使用装饰器

import { classDescriptor, printObject, propDescriptor } from "./Descriptor";

@classDescriptor("用户类")
class User {
  @propDescriptor("用户名")
  loginId: string;

  loginPwd: string;

  constructor(loginId: string, loginPwd: string) {
    this.loginId = loginId;
    this.loginPwd = loginPwd;
  }
}

const user = new User("admin", "123");
printObject(user);
/**
用户类
    用户名: admin
    loginPwd: 123
*/

5.reflect-metadata 库

  • 手动将元数据信息绑定到原型上
    • 会造成原型污染

reflect-metadataopen in new window

  • 保存元数据
  • 这是一个基础库,多数第三方库会依赖该库开发

1)基本使用

import "reflect-metadata"; // 提供了一些全局对象

@Reflect.metadata("a", "一个类") // 传入 key-value,后续根据key值区分多个装饰器
class A {
  @Reflect.metadata("prop", "一个属性")
  prop1: string = "prop1";
}

const obj = new A();

// 获取附着在类A上的键名为a的元数据
console.log(Reflect.getMetadata("a", A)); // 一个类
console.log(Reflect.getMetadata("a", Object.getPrototypeOf(obj).constructor)); // 一个类

// 获取附着在obj对象上的某个成员的元数据
console.log(Reflect.getMetadata("prop", obj, "prop1")); // 一个属性

2)改造自定义类和属性装饰器

import "reflect-metadata";

type Constructor = new (...args: any[]) => object;

// KEY值一定不能冲突,最好使用Symbol类型
// const KEY = "descriptor";
const KEY = Symbol.for("descriptor");

export const descriptor = (description: string) => {
  return Reflect.metadata(KEY, description);
};

/**
 * 输出实例对象的相关描述信息
 * @param obj 实例对象
 */
export const printObject = (obj: any): void => {
  const classConstructor = Object.getPrototypeOf(obj).constructor;

  // 1.输出类的描述信息,没有则输出原型上的类名
  if (Reflect.hasMetadata(KEY, classConstructor)) {
    console.log(Reflect.getMetadata(KEY, classConstructor));
  } else {
    console.log(classConstructor.name);
  }
  // 2.输出所有属性名描述信息和属性值,没有则输出属性名
  for (const key in obj) {
    if (Reflect.hasMetadata(KEY, obj, key)) {
      console.log(`\t${Reflect.getMetadata(KEY, obj, key)}: ${obj[key]}`);
    } else {
      console.log(`\t${key}: ${obj[key]}`);
    }
  }
};
import { descriptor, printObject } from "./Descriptor";

@descriptor("用户类")
class User {
  @descriptor("用户名")
  loginId: string;

  loginPwd: string;

  constructor(loginId: string, loginPwd: string) {
    this.loginId = loginId;
    this.loginPwd = loginPwd;
  }
}

const user = new User("admin", "123");
printObject(user);
/**
用户类
    用户名: admin
    loginPwd: 123
*/

6.class-validator 和 class-transformer 库

1)class-validator

class-validatoropen in new window

  • 在类上使用装饰器完成数据验证
  • 使用前需要先安装 reflect-metadata 库
import "reflect-metadata";
import { IsNotEmpty, validate, MinLength, MaxLength, Min, Max } from "class-validator";

class RegUser {
  @IsNotEmpty({ message: "账号不可以为空" })
  @MinLength(5, { message: "账号必须至少有5个字符" })
  @MaxLength(12, { message: "账号最多12个字符" })
  loginId: string = "admin";

  loginPwd: string = "123";

  @Min(0, { message: "年龄的最小值是0" })
  @Max(100, { message: "年龄的最大值是100" })
  age: number = 25;

  gender: "男" | "女" = "女";
}

const post = new RegUser();
post.loginId = "22";
post.age = -1;

validate(post).then((errors) => {
  console.log(errors);
});
/*
[
  ValidationError {
    target: RegUser { loginId: '22', loginPwd: '123', age: -1, gender: '女' },
    value: '22',
    property: 'loginId',
    children: [],
    constraints: { minLength: '账号必须至少有5个字符' }
  },
  ValidationError {
    target: RegUser { loginId: '22', loginPwd:
    '123', age: -1, gender: '女' },
    value: -1,
    property: 'age',
    children: [],
    constraints: { min: '年龄的最小值是0' }   
  }
]
*/

2)class-transformer

class-transformeropen in new window

  • 将平面对象(JSON)转换为类对象
  • 使用前需要先安装 reflect-metadata 库

MYJsonopen in new window

  • 模拟服务器数据
import "reflect-metadata";
import { plainToClass, Type } from "class-transformer";
import axios from "axios";

class User {
  id: number = 1;
  firstName: string = "a";
  lastName: string = "b";

  @Type(() => Number)
  age: number = 24;

  getName() {
    return this.firstName + " " + this.lastName;
  }

  isAdult() {
    return this.age > 36 && this.age < 60;
  }
}

axios
  .get("https://api.myjson.com/bins/1b59tw")
  .then((resp) => resp.data)
  .then((users: Object[]) => {
    const us = plainToClass(User, users);
    for (const u of us) {
      console.log(typeof u.age, u.age);
    }
  });

7.补充

1)参数装饰器

  • 用于有 依赖注入/依赖倒置 的大型项目
  • 要求函数有三个参数
  • 参数 1
    • 如果方法是静态的,则为类本身
    • 如果方法是实例方法,则为类的原型
  • 参数 2
    • 方法名称
  • 参数 3
    • 在参数列表中的索引
    • 从 0 开始
class MyMath {
  sum(a: number, @test b: number): number {
    return a + b;
  }
}
function test(target: any, method: string, index: number) {
  console.log(target, method, index);
  // {} sum 1
}

2)TS 自动注入的元数据

  • 如果安装了 reflect-metadata
  • 并且导入了该库,在某个成员上添加了元数据
  • 并且启用了 emitDecoratorMetadata
import "reflect-metadata";
class User {
  @Reflect.metadata("a", "b")
  loginId: string = "admin";

  @Reflect.metadata("a", "b")
  age: number = 24;
}
  • 则 TS 在编译结果中,会将约束的类型作为元数据加入到相应位置
  • TS 的类型检查(约束)将有机会在运行时进行
class User {
  constructor() {
    this.loginId = "admin";
    this.age = 24;
  }
}
__decorate([Reflect.metadata("a", "b")], User.prototype, "loginId", void 0);
__decorate([Reflect.metadata("a", "b")], User.prototype, "age", void 0);

3)AOP

  • Aspect Oriented Programming
    • 一种编程方式(设计模式)
    • 属于面向对象开发
  • 将一些在业务中共同出现的功能块,横向切分
    • 以达到分离关注点的目的
class RegUser {
  @规则
  loginId: string = "admin";

  @规则
  loginPwd: string = "123";

  @规则
  age: number = 24;

  @规则
  pid: string = "11111";

  @规则
  email: string = "admin@example.com";

  /**
   * 将用户保存到数据库
   */
  save() {
    if (validator()) {
      // 通过后保存数据库
    }
  }
}

// 将验证处理抽离出来统一处理
function validator(): boolean {
  return true;
}

(四)类型演算

  • 根据已知的信息,计算出新的类型

1.关键字 typeof

  • TS 中的 typeof 书写在 类型约束 的位置上
  • 表示获取某个数据的类型
const a = "a"; // 字面量类型
console.log(typeof a); // string 【JS本身有的关键字】

let b: typeof a = "a"; // 让b和a的类型保持一致
  • 当 typeof 作用于类时,得到的类型是该类的 构造函数
class User {
  loginId: string = "admin";
  loginPwd: string = "123";
}

// 需求:传入一个类,调用该类的构造函数,创建用户对象
// function createUser(cls: ???): User {

// function createUser(cls: new () => User): User {
//   return new cls();
// }
// 或者
function createUser(cls: typeof User): User {
  return new cls();
}

const u = createUser(User);

2.关键字 keyof

  • 作用于类、接口、类型别名
  • 用于获取其他类型中的所有成员名组成的联合类型
interface User {
  loginId: string;
  loginPwd: string;
  age: number;
}

// // 无法百分百确定 obj[prop] 属于接口定义的属性
// function printUserProperty(obj:User, prop: string) {
//   console.log(obj[prop]);
// }

// 接口属性变化时无法自动更新 prop 的限制类型
// function printUserProperty(obj:User, prop: "loginId" | "loginPwd" | "age") {
//   console.log(obj[prop]);
// }

function printUserProperty(obj: User, prop: keyof User) {
  console.log(obj[prop]);
}

3.关键字 in

  • 该关键字往往和 keyof 联用
  • 限制某个索引类型的取值范围

1)统一属性值得到新类型

interface User {
  loginId: string;
  loginPwd: string;
  age: number;
}

// 使用索引器定义类型别名,只能添加定义好的属性
type UserString = {
  // loginId: string;
  // loginPwd: string;
  // age: number;

  // =>
  // [p in "loginId" | "loginPwd" | "age"]: string;

  // =>
  // 将User的所有属性值类型变成字符串,得到一个新类型
  [p in keyof User]: string;
};

// const u: UserString = {};
// u.abc = "123"; // 报错

2)拷贝所有属性值得到新类型并扩展新功能

// 保持User中所有属性值的类型不变,得到一个新类型
type UserObject = {
  [p in keyof User]: User[p];
};

// 保持User中所有属性值的类型不变,得到一个只读的新类型
type UserReadonly = {
  readonly [p in keyof User]: User[p];
};

// 保持User中所有属性值的类型不变,得到一个可选的新类型
type UserPartial = {
  [p in keyof User]?: User[p];
};

3)得到多个类型的新类型

interface User {
  loginId: string;
  loginPwd: string;
  age: number;
}
interface Article {
  title: string;
  publishDate: Date;
}

type NewString<T> = {
  [p in keyof T]: string;
};

type NewReadonly<T> = {
  readonly [p in keyof T]: T[p];
};

type NewPartial<T> = {
  [p in keyof T]?: T[p];
};

const u: NewString<Article> = {
  title: "Sfsdf",
  publishDate: "sdf",
};

4.TS 中预设的类型演算

预设说明
Partial<T>将类型 T 中的成员变为可选 ?:
Required<T>将类型 T 中的成员变为必填 -?:
Readonly<T>将类型 T 中的成员变为只读 readonly
Exclude<T, U>从 T 中剔除可以赋值给 U 的类型
Extract<T, U>提取 T 中可以赋值给 U 的类型
NonNullable<T>从 T 中剔除 null 和 undefined
ReturnType<T>获取函数返回值类型,T 是函数的类型
InstanceType<T>获取构造函数类型的实例类型
interface User {
  name: string;
  age: number;
}
let u1: Partial<User> = {
  age: 24,
};

// let u2: Exclude<"a" | "b" | "c" | "d", "b" | "c">; // "a" | "d"
type T = "男" | "女" | null | undefined;
type NEWT = Exclude<T, null | undefined>; // "男" | "女"

let u3: Extract<"a" | "b" | "c" | "d", "b" | "c" | "e" | "f">; // "b" | "c"

type Func = () => number;
type returnType = ReturnType<Func>; // number

function sum(a: number, b: number) {
  return a + b;
}
let a: ReturnType<typeof sum>; // number

class User {}
let u4: InstanceType<typeof User>; // User

// 约束构造函数
type twoParamsConstructor = new (arg1: any, arg2: any) => User;
type Inst = InstanceType<twoParamsConstructor>; // User

(五)声明文件

  • .d.ts 结尾的文件
  • 为 JS 代码提供类型声明

1.声明文件的位置

1)放置到 tsconfig.json 配置中包含的目录中

{
  // 只要声明文件在 src 目录或子目录下都可以识别到
  "include": ["./src"]
}

2)放置到 node_modules/@types 文件夹中

  • 通常是安装第三方包后存放

3)手动配置

  • 默认情况下会先从 include 配置目录中找
  • 如果没有再从 node_modules 目录中找
  • 开启该配置后,前面的配置都失效
{
  "compilerOptions": {
    "typeRoots": ["./types"]
  }
}

4)与 JS 代码所在目录相同,并且文件名也相同的文件

  • 最好的方式
  • 实际上就是用 TS 代码书写的工程发布之后的格式

2.编写

1)自动生成

  • 工程是使用 TS 开发的,发布(编译)之后是 JS 文件,发布的是 JS 文件
  • 如果发布的文件,需要其他开发者使用,可以使用声明文件,来描述发布结果中的类型
  • 配置 tsconfig.json
{
  "declaration": true
}

2)手动编写

  • 已有库是使用 JS 书写而成,并且更改该库的代码为 TS 成本较高
    • 如:VueJS
    • 可以先用声明文件对 JS 代码进一步描述
    • 后面再慢慢修改为 TS 代码
  • 一些第三方库使用 JS 书写而成,并且这些第三方库没有提供声明文件
a)全局声明
  • 声明一些全局的对象、属性、变量
  • namespace
    • 表示命名空间,可以将其认为是一个对象
    • 命名空间中的内容必须通过 命名空间.成员名 访问
    • 没有 ES6 模块化时要依托该关键字包裹
    • 现仅用于声明文件
/**
 * 只是告诉TS编译时如何报错
 * 与代码运行时无关
 * 该文件不参与运行
 */

// 写法一
declare var console: object;

// 写法二
declare var console: {
  log(message: any): void;
};

// 写法三
interface Console {
  log(message: any): void;
}
declare var console: Console;

// 写法四
declare namespace console {
  function log(message?: any): void;
  function error(message?: any): void;
}

type TimeHandler = () => void;
declare function setTimeout(handler: TimeHandler, miliseconds: number): void;
declare function setInterval(handler: TimeHandler, miliseconds: number): void;
b)模块声明
  • 只有导入相应模块后,该声明文件才生效
// types/lodash/index.d.ts
declare module "lodash" {
  export function chunk<T>(array: T[], size: number): T[][];
}
c)三斜线指令
  • 在一个声明文件中,包含另一个声明文件
/// <reference path="../types/lodash/index.d.ts" />

3.发布

1)当前工程使用 TS 开发

  • 编译完成后,将编译结果所在文件夹直接发布到 npm 上即可

2)为其他第三方库开发的声明文件

a)发布到 @types/**
  • 进入 GitHub 的开源项目
  • Fork 到自己的开源库中
  • 从自己的开源库中克隆到本地
  • 本地新建分支(如:MyLodash4.3)
    • 在新分支中进行声明文件的开发
    • 在 types 目录中新建文件夹,在新的文件夹中开发声明文件
  • Push 分支到你的开源库
  • 到官方的开源库中,提交 pull request
b)等待官方管理员审核(1 天)
  • 审核通过之后,会将你的分支代码合并到主分支,然后发布到 npm
npm install @types/你发布的库名

(六)项目实战:电影管理系统

1.概述

1)服务器端

  • 提供 API 接口
  • TS + Express + MongoDB + class-validator + class-transformer

2)客户端

  • Ajax 请求接口获得数据,使用数据渲染页面
  • TS + React 全家桶(react-router、redux、antd)

3)开发顺序

  • 先开发服务器端
    • 使用 postman 测试
  • 再开发客户端

牢记

TS 是一个可选的(渐近式)、静态的类型系统

2.服务器开发环境搭建

3.使用 TSLint 进行代码风格检查

TSLintopen in new window

  • 和 ESLint 相似
  • 用于检查代码风格
npx tslint --init

4.开发 Movie 实体类

import { ArrayMinSize, IsInt, IsNotEmpty, Max, Min } from "class-validator";

export class Movie {
  @IsNotEmpty({ message: "电影名称不可以为空" })
  public name: string;

  @IsNotEmpty({ message: "电影类型不可以为空" })
  @ArrayMinSize(1, { message: "电影类型至少得有一个" })
  public types: string[];

  @IsNotEmpty({ message: "上映地区不可以为空" })
  @ArrayMinSize(1, { message: "上映地区至少得有一个" })
  public areas: string[];

  @IsNotEmpty({ message: "时长不可以为空" })
  @IsInt({ message: "时长必须是整数" })
  @Min(1, { message: "时长最小一分钟" })
  @Max(999999, { message: "时长过长" })
  public timeLong: number;

  @IsNotEmpty({ message: "是否热映不可以为空" })
  public isHot: boolean = false;

  @IsNotEmpty({ message: "是否即将上映不可以为空" })
  public isComing: boolean = false;

  @IsNotEmpty({ message: "是否是经典影片不可以为空" })
  public isClassic: boolean = false;

  public description?: string;

  public poster?: string;
}

5.处理 plain Object 的转换

1)plain Object

  • 平面对象 const m: any = {}
  • 客户端传递过来的对象可能是平面对象
  • 数据验证直接通过

2)转换 plain Object

  • 使用 class-transformer
import "reflect-metadata";
import { plainToClass } from "class-transformer";
import { Movie } from "./entities/Movie";

// const m = new Movie();
const m: any = {};
m.name = 2343;
m.types = "sdf";
m.areas = ["中国大陆"];
m.isClassic = true;
m.timeLong = 2;

const movie = plainToClass(Movie, m as object);
  • 绑定运行时数据的类型
import { ArrayMinSize, IsInt, IsNotEmpty, Max, Min } from "class-validator";
import { Type } from "class-transformer";

export class Movie {
  @IsNotEmpty({ message: "电影名称不可以为空" })
  @Type(() => String)
  public name: string;

  @IsNotEmpty({ message: "电影类型不可以为空" })
  @ArrayMinSize(1, { message: "电影类型至少得有一个" })
  @Type(() => Array)
  public types: string[];

  @IsNotEmpty({ message: "时长不可以为空" })
  @IsInt({ message: "时长必须是整数" })
  @Min(1, { message: "时长最小一分钟" })
  @Max(999999, { message: "时长过长" })
  @Type(() => Number)
  public timeLong: number;
}

6.定义数据库模型

1)技术选型

  • 数据库使用 MongoDB
  • 数据库驱动
    • mongodb
      • 官方驱动
    • mongoose
      • 基于官方驱动优化而成
    • 两个驱动对 TS 的支持都不太友好
  • 其他数据库驱动 typeorm
    • 完全用 TS 编写
    • 基于类
    • 对 MongoDB 的支持不太友好
  • 没有完美解决方案,暂时选用 mongoose
{
  "devDependencies": {
    "@types/mongoose": "^5.3.27"
  },
  "dependencies": {
    "mongoose": "^5.5.6"
  }
}

2)数据库模型

import Mongoose from "mongoose";
import { Movie } from "../entities/Movie";

// 开发期间的类型检查(TS)
// Mongoose.Document封装了id、save等常用属性和方法
export interface IMovie extends Mongoose.Document, Movie {}

const movieSchema = new Mongoose.Schema<IMovie>(
  {
    // 运行期间的类型(JS)
    name: String,
    types: [String],
    areas: [String],
    timeLong: Number,
    isHot: Boolean,
    isClassic: Boolean,
    isComing: Boolean,
    description: String,
    poster: String,
  },
  {
    versionKey: false,
  },
);

export default Mongoose.model<IMovie>("Movie", movieSchema);

3)数据库配置文件

import Mongoose from "mongoose";
import MovieSchema from "./MovieSchema";

// 写localhost会连接失败
Mongoose.connect("mongodb://127.0.0.1:27017/movieDB", {
  useNewUrlParser: true,
}).then(() => console.log("连接数据库成功"));

// 必须导出一个声明 var/function,所以要加 {}
export { MovieSchema as MovieModel };

7.增删改查功能

import { MovieModel } from "../db";
import { IMovie } from "../db/MovieSchema";
import { Movie } from "../entities/Movie";

export class MovieService {
  public static async add(movie: Movie): Promise<IMovie | string[]> {
    // 1.转换类型
    // movie = Movie.transformToClass(Movie, movie);
    movie = Movie.transformToClass(movie);
    // 2.数据验证
    const errors = await movie.validateMovie();
    if (errors.length > 0) {
      return errors;
    }
    // 3.添加到数据库
    return await MovieModel.create(movie);
  }

  public static async edit(id: string, movie: Movie): Promise<string[]> {
    /**
     * 如果覆盖掉原来的平面对象,修改某些字段时
     * 未提供的字段有可能使用实体类的默认值覆盖现有值
     * 应该保持原有平面对象不被破坏
     */
    // movie = Movie.transformToClass(movie);

    const movieObj = Movie.transformToClass(movie);
    const errors = await movieObj.validateMovie(true);
    if (errors.length > 0) {
      return errors;
    }
    await MovieModel.updateOne(
      {
        _id: id,
      },
      movie,
    );
    return errors;
  }

  public static async remove(id: string): Promise<void> {
    await MovieModel.deleteOne({
      _id: id,
    });
  }

  public static async findById(id: string): Promise<IMovie | null> {
    return await MovieModel.findById(id);
  }
}

8.按条件查询电影

/**
 * 条件查询
 * @param condition 查询条件 page、limit、keyword
 */
public static async find(
  condition: SearchCondition
): Promise<ISearchResult<Movie>> {
  const searchObj = SearchCondition.transformToClass(condition);
  const errors = await searchObj.validateMovie(true);
  if (errors.length > 0) {
    return {
      total: 0,
      data: [],
      errors,
    };
  }
  const movies = await MovieModel.find({
    name: {
      $regex: new RegExp(searchObj.keyword),
    },
  })
    .skip((searchObj.page - 1) * searchObj.limit)
    .limit(searchObj.limit);
  const total = await MovieModel.find({
    name: {
      $regex: new RegExp(searchObj.keyword),
    },
  }).countDocuments();
  return {
    total,
    data: movies,
    errors: [],
  };
}

9.完成 API 接口

1)参数传递形式

  • params
    • localhost:3000/api/movie/xxx
  • query
    • localhost:3000/api/movie/?xxx=yyy

10.完成图片上传接口

1)一般流程

  • 通常情况下,服务器会提供一个统一的 api 接口,用于处理上传的文件
    • 如:/api/upload
  • 客户端会使用 post 请求,请求服务器
    • content-type: multipart/form-data
  • 服务器得到上传的文件
    • 使用 express 的中间件 multer

2)几个问题

-设置上传的文件后缀名

  • 根据客户端的文件后缀名决定
  • 限制文件的上传尺寸
  • 限制文件的后缀名
  • 响应给客户端
    • 错误:响应错误消息
    • 正确:响应文件的路径
import Express from "express";
import multer from "multer";
import path from "path";
import { ResponseHelper } from "./ResponseHelper";

const router = Express.Router();

const storage = multer.diskStorage({
  destination: path.resolve(__dirname, "../../public/upload"),
  filename(req, file, cb) {
    // 文件名
    const time = new Date().getTime();
    // 后缀名
    const extname = path.extname(file.originalname);
    // 文件全称
    cb(null, `${time}${extname}`);
  },
});

const allowedExtensions = [".jpg", ".png", ".gif", ".bmp", ".jiff"];

const upload = multer({
  storage,
  limits: {
    fileSize: 1024 * 1024,
  },
  fileFilter(req, file, cb) {
    const extname = path.extname(file.originalname);
    if (allowedExtensions.includes(extname)) {
      cb(null, true);
    } else {
      cb(new Error("文件类型不正确"));
    }
  },
}).single("imgFile");

router.post("/", (req, res) => {
  upload(req, res, (err) => {
    if (err) {
      ResponseHelper.sendError(err.message, res);
    }
    if (req.file) {
      const url = `/upload/${req.file.filename}`;
      ResponseHelper.sendData(url, res);
    }
  });
});

export default router;

11.搭建客户端工程并完成 AJAX 请求

  • React 脚手架
    • create-react-app
    • NextJS(SSR)
    • UmiJS(阿里)
  • 有时服务器和客户端会共用一些类型
    • 如果要处理此处的重复代码问题,最佳做法是自行使用 Webpack 搭建工程
  • API 请求功能
    • 客户端端口 3001,请求 /api/movie
    • 最终请求的地址:http://localhost:3001/api/movie
    • 需要使用 axios 的代理
import axios from "axios";
import { IResponseData, IResponseError, ISearchCondition, IResponsePageData } from "./CommonTypes";

export interface IMovie {
  _id?: string;
  name: string;
  types: string[];
  areas: string[];
  timeLong: number;
  isHot: boolean;
  isComing: boolean;
  isClassic: boolean;
  description?: string;
  poster?: string;
}

export class MovieService {
  public static async add(movie: IMovie): Promise<IResponseData<IMovie> | IResponseError> {
    const { data } = await axios.post("/api/movie", movie);
    return data;
  }

  public static async edit(id: string, movie: IMovie): Promise<IResponseData<true> | IResponseError> {
    const { data } = await axios.put("/api/movie/" + id, movie);
    return data;
  }

  public static async delete(id: string): Promise<IResponseData<true> | IResponseError> {
    const { data } = await axios.delete("/api/movie/" + id);
    return data;
  }

  public static async getMovieById(id: string): Promise<IResponseData<IMovie | null>> {
    const { data } = await axios.get("/api/movie/" + id);
    return data;
  }

  public static async getMovies(condition: ISearchCondition): Promise<IResponsePageData<IMovie>> {
    const { data } = await axios.get("/api/movie", {
      params: condition,
    });
    return data;
  }
}

12.创建 reducer 和 action

1)Redux

  • 适合大型项目
  • 不是所有的状态数据都要放到 redux 中
  • action
    • 平面对象 plain object
    • 描述了数据变化的方式
  • reducer
    • 数据变化的具体内容
    • 需要 action 触发
  • store
    • 存储数据的仓库

2)Actions

import { ISearchCondition } from "../../services/CommonTypes";
import { IMovie } from "../../services/MovieService";
import { IAction } from "./ActionType";

export type SaveMoviesAction = IAction<
  "movie_save",
  {
    movies: IMovie[];
    total: number;
  }
>;
export type SetLoadingAction = IAction<"movie_set_loading", boolean>;
export type SetConditionAction = IAction<"movie_set_condition", ISearchCondition>;
export type DeleteAction = IAction<"movie_delete", string>;
export type MovieActions = SaveMoviesAction | SetLoadingAction | SetConditionAction | DeleteAction;

const saveMoviesAction = (movies: IMovie[], total: number): SaveMoviesAction => {
  return {
    type: "movie_save",
    // 负载/载荷
    payload: {
      movies,
      total,
    },
  };
};
const setLoadingAction = (isLoading: boolean): SetLoadingAction => {
  return {
    type: "movie_set_loading",
    payload: isLoading,
  };
};
const setCondition = (condition: ISearchCondition): SetConditionAction => {
  return {
    type: "movie_set_condition",
    payload: condition,
  };
};
const deleteAction = (id: string): DeleteAction => {
  return {
    type: "movie_delete",
    payload: id,
  };
};
export default {
  saveMoviesAction,
  setLoadingAction,
  setCondition,
  deleteAction,
};

3)Reducers

import { Reducer } from "react";
import { ISearchCondition } from "../../services/CommonTypes";
import { IMovie } from "../../services/MovieService";
import { MovieActions, SaveMoviesAction, SetConditionAction, SetLoadingAction, DeleteAction } from "../actions/MovieAction";

export type IMovieCondition = Required<ISearchCondition>;

export interface IMovieState {
  /**
   * 电影数组
   */
  data: IMovie[];
  /**
   * 查询条件
   */
  condition: IMovieCondition;
  /**
   * 总记录数
   */
  total: number;
  /**
   * 是否正在加载数据
   */
  isLoading: boolean;
}

const defaultState: IMovieState = {
  data: [],
  condition: {
    page: 1,
    limit: 10,
    key: "",
  },
  total: 0,
  isLoading: false,
};

type MovieReducers<A> = Reducer<IMovieState, A>;
const saveMovie: MovieReducers<SaveMoviesAction> = (state, action) => ({
  ...state,
  data: action.payload.movies,
  total: action.payload.total,
});
const setCondition: MovieReducers<SetConditionAction> = (state, action) => ({
  ...state,
  condition: {
    ...state.condition,
    ...action.payload,
  },
});
const setLoading: MovieReducers<SetLoadingAction> = (state, action) => ({
  ...state,
  isLoading: action.payload,
});
const deleteMovie: MovieReducers<DeleteAction> = (state, action) => ({
  ...state,
  data: state.data.filter((m) => m._id !== action.payload),
  total: state.total - 1,
});

export default (state: IMovieState = defaultState, action: MovieActions) => {
  // 可辨识联合类型
  switch (action.type) {
    case "movie_save":
      return saveMovie(state, action);
    case "movie_set_condition":
      return setCondition(state, action);
    case "movie_set_loading":
      return setLoading(state, action);
    case "movie_delete":
      return deleteMovie(state, action);
    default:
      return state;
  }
};

13.创建仓库

import { applyMiddleware, createStore } from "redux";
import { rootReducer } from "./reducers/RootReducer";
import logger from "redux-logger";

export const store = createStore(rootReducer, applyMiddleware(logger));

14.用 thunk 处理副作用

  • redux-thunk
  • redux-saga
  • dva
    • 基于 redux-saga
    • 同时还是脚手架
    • 阿里推出,缺少维护
  • umijs
    • 基于 redux-saga 和 dva
    • 阿里推出
/**
 * 副作用操作
 * 根据条件从服务器获取电影数据
 */
const fetchMovies = (condition: ISearchCondition): ThunkAction<Promise<void>, IRootState, any, MovieActions> => {
  return async (dispatch, getState) => {
    // 1.设置加载状态
    dispatch(setLoadingAction(true));
    // 2.设置条件
    dispatch(setCondition(condition));
    // 3.获取服务器数据
    const curCondition = getState().movie.condition;
    const resp = await MovieService.getMovies(curCondition);
    // 4.更改仓库数据
    dispatch(saveMoviesAction(resp.data, resp.total));
    // 关闭加载状态
    dispatch(setLoadingAction(false));
  };
};
import { applyMiddleware, createStore } from "redux";
import { IRootState, rootReducer } from "./reducers/RootReducer";
import logger from "redux-logger";
import thunk, { ThunkMiddleware } from "redux-thunk";

/**
 * ThunkMiddleware<IRootState> 修改dispatch的类型判断
 * 不是用redux定义的action类型,使用thunk定义的函数类型
 */
export const store = createStore(rootReducer, applyMiddleware(thunk as ThunkMiddleware<IRootState>, logger));

15.添加路由功能

import React from "react";
import { Layout } from "./pages/Layout";
import { BrowserRouter, Route } from "react-router-dom";

const App: React.FC = () => {
  return (
    <BrowserRouter>
      <Route path="/" component={Layout}></Route>
    </BrowserRouter>
  );
};

export default App;

16.制作布局

17.制作电影表格组件 1

  • 仓库里面有数据,但没有界面【容器组件】
  • MovieTable 组件有界面,但是没有数据【展示组件】
  • 使用 react-redux 将两者连接起来

18.制作电影表格组件 2

19.制作电影表格组件 3

20.制作图片上传组件

  • 展示组件可以有状态 state
  • 但是状态只与纯展示有关,不涉及数据

21.制作电影表单组件

22.制作修改电影页面

23.项目打包

1)组件属性的默认值

import React from "react";

interface MyProps {
  a: string; // 必选
  b: string; // 必选
}

class Test extends React.Component<MyProps> {
  static defaultProps: Pick<MyProps, "a"> = {
    a: "123",
  };
}

class User extends React.Component {
  render() {
    return <Test b="34234" />;
  }
}

export default {};

2)设置静态资源

  • 解决刷新 404 问题
  • 请求所有地址都重定向到根目录 index.html

connect-history-api-fallbackopen in new window

app.use(history());
app.use("/", Express.static("public/build"));
app.use("/upload", Express.static("public/upload"));

(七)TS 和其他前端技术的融合

  • 看官方文档
上次编辑于: