六、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/
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:使用公共的方法
getX
、setX
- C#:使用访问器
get x
、set x
- Java:使用公共的方法
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 库
- 手动将元数据信息绑定到原型上
- 会造成原型污染
- 保存元数据
- 这是一个基础库,多数第三方库会依赖该库开发
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
- 在类上使用装饰器完成数据验证
- 使用前需要先安装 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
- 将平面对象(JSON)转换为类对象
- 使用前需要先安装 reflect-metadata 库
- 模拟服务器数据
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.声明文件的位置
tsconfig.json
配置中包含的目录中
1)放置到 {
// 只要声明文件在 src 目录或子目录下都可以识别到
"include": ["./src"]
}
node_modules/@types
文件夹中
2)放置到 - 通常是安装第三方包后存放
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)为其他第三方库开发的声明文件
@types/**
中
a)发布到 - 进入 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 进行代码风格检查
- 和 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 的支持都不太友好
- mongodb
- 其他数据库驱动 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 的代理
- 客户端端口 3001,请求
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
app.use(history());
app.use("/", Express.static("public/build"));
app.use("/upload", Express.static("public/upload"));
(七)TS 和其他前端技术的融合
- 看官方文档