TypeScript 类型系统详解(五)

236 阅读34分钟

在 TypeScript 中,类型系统是确保代码质量的关键特性之一。通过类型注解和类型推断,开发者可以构建出既灵活又安全的应用。本文对 TypeScript 类型系统的深入探讨,是系列文章的第五篇。

1. 普适的数据类 Model

在本系列的上一篇文章中,我们花费了大量的时间构造了 User 类,在其中使用 Composition 设计模式实现了复杂功能。接下来,我们将 User 抽象成基类,并通过继承子类的方式将其拓展成其它具体功能的数据类。

首要问题:为什么这次不使用 Composition 构造方法了?

我想这里有一个准则:在整合子功能的时候,我们倾向于使用组合设计模式,而在实现具体业务的时候,我们倾向于使用继承。这就好比一个人在上学的时候和工作的时候采用的方式是不同的。

接下来我们要构建一个 App, 并在这个 App 中应用我们构建的普适类。

1.1 应用 setup

mkdir myTS
cd myTS
npm init -y
mkdir src
npm install axios@1.7.4 concurrently@8.2.2 nodemon@3.1.4
npm install json-server@1.0.0-beta.2 typescript@5.5.4 -D
tsc --init
touch db.json
cd src
touch Attributes.ts Collection.ts Eventing.ts index.html index.ts Model.ts Sync.ts User.ts
cd ../

1.2 在包管理文件中增加脚本

  "scripts": {
    "start:server": "json-server db.json",
    "start:run": "parcel src/index.html",
    "start": "concurrently npm:start:*"
  },

1.3 修改 Ts 配置文件

将 tsconfig.json 中的内容改为如下所示:

{
  "compilerOptions": {
    "target": "es2016",                                  
    "module": "commonjs",                                
    "rootDir": "./src",                                  
    "outDir": "./build",                                   
    "esModuleInterop": true,                             
    "forceConsistentCasingInFileNames": true,            
    "strict": false,                                      
    "skipLibCheck": true                                 
  }
}

1.4 构建用于获取和保存数据到对象内的工具类:Attributes -- Attributes.ts

// 承包内含数据【获取-设置】的合作类
export class Attributes<T> {
  constructor(private data: T) { }
  // 这里必须写成箭头函数,否则就会有 this 的指向问题

  // 获取某个字段
  get = <K extends keyof T>(key: K): T[K] => {
    return this.data[key];
  }

  // 设置某个或者某几个字段的值
  set = (update: T): void => {
    Object.assign(this.data, update);
  }

  // 获取全部值
  getAll = (): T => {
    return this.data;
  }
}  

1.5 构建用于发布订阅的工具类:Eventing -- Eventing.ts

// 返回为空的函数类型
type Callback = () => void;

// 承包【发布-订阅】的合作类
export class Eventing {
  // 实践数组
  events: { [eventName: string]: Callback[] } = {};

  // 订阅回调函数
  on(eventName: string, callback: Callback): void {
    const handlers = this.events[eventName] || [];
    handlers.push(callback);
    this.events[eventName] = handlers;
  }

  // 触发相关的回调数组
  trigger(eventName: string): void {
    const handlers = this.events[eventName];
    // 遍历,然后逐个调用
    if (handlers && handlers.length > 0) {
      handlers.forEach(callback => callback());
    }
  }
}

1.6 构建用于网络通信的工具类:Sync -- Sync.ts

import axios, { AxiosPromise, AxiosResponse } from 'axios';
import { HasId } from './Model';

// 承包网络数据【传送-请求】的合作类
export class Sync<T extends HasId> {
  constructor(public rootUrl: string) { }

  // 获取特定 id 对应的数据
  fetch(id: number): AxiosPromise {
    return axios.get(`${this.rootUrl}/${id}`);
  }

  // 保存或者新增一个结构体 -- 通过 id 的存在与否判断是新增还是更新
  save(data: T): AxiosPromise {
    const { id } = data;
    if (id) {
      return axios.put(`${this.rootUrl}/${id}`, data);
    } else {
      return axios.post(this.rootUrl, data);
    }
  }
}  

1.7 工具类构建完成之后通过组合设计模式构建数据基类:Model -- Model.ts

import { AxiosPromise, AxiosResponse } from 'axios';

// 外包类接口 1
interface ModelAttributes<T> {
  set(value: T): void;
  getAll(): T;
  get<K extends keyof T>(key: K): T[K];
}

// 外包类接口 2
interface Sync<T> {
  fetch(id: number): AxiosPromise;
  save(data: T): AxiosPromise<T>;
}

// 外包类接口 3
interface Events {
  on(eventName: string, callback: () => void): void;
  trigger(eventName: string): void;
}

// 泛型类接口
export interface HasId {
  id?: number;
}

// 普适信息类
export class Model<T extends HasId> {
  constructor(
    private attributes: ModelAttributes<T>, // 用来存储或者获取信息
    private events: Events, // 用来完成发布-订阅
    private sync: Sync<T> // 用来进行网路通信
  ) { }

  on = this.events.on; // 方法的转存
  trigger = this.events.trigger; // 方法的转存
  get = this.attributes.get; // 方法的转存

  // 涉及多个外包类的联合作用,所以需要重写而不是转存
  set(update: T): void {
    this.attributes.set(update);
    this.events.trigger('change');
  }

  // 涉及多个外包类的联合作用以及错误的处理,所以需要重写而不是转存
  fetch(): Promise<void> {
    const id = this.get('id');
    if (!id) {
      return Promise.reject(new Error('User ID is required to fetch data.'));
    }
    return this.sync.fetch(id).then((response: AxiosResponse<T>) => {
      this.set(response.data);
    }).catch(error => {
      console.error('Error fetching user:', error);
      throw error;
    });
  }

  // 涉及多个外包类的联合作用,所以需要重写而不是转存
  save(): AxiosPromise<T> {
    return this.sync.save(this.attributes.getAll());
  }
}  

1.8 数据基类完成之后通过继承的方式构建业务数据类:User -- User.ts

// 三个外包类
import { Eventing } from './Eventing';
import { Attributes } from './Attributes';
import { Sync } from './Sync';
// 信息基类
import { Model } from './Model';
// 静态包装类
import { Collection } from './Collection';

// 数据类型接口
interface UserProps {
  id?: number;
  name?: string;
  age?: number;
}

// User 类相关的根路径
const rootUrl = 'http://localhost:3000/users';

// 信息子类 -- 用户类
export class User extends Model<UserProps> {
  // 静态方法 1: 使用三个外包类创建一个用户实例
  static buildUser(attrs: UserProps): User {
    return new User(
      new Attributes<UserProps>(attrs),
      new Eventing(),
      new Sync<UserProps>(rootUrl)
    );
  }
  // 静态方法 2: 使用三个基类结合服务器数据创建一组用户实例
  static buildUserCollection(): Collection<User, UserProps> {
    return new Collection<User, UserProps>(
      rootUrl,
      (json: UserProps) => User.buildUser(json)
    )
  }

  // 检测实例是否为管理员为实例方法
  isAdminUser(): boolean {
    return this.get('id') === 1;
  }
}  

1.9 构建 User 类第二个静态方法需要用到的 Collection 类:Collection -- Collection.ts

import axios, { AxiosResponse } from 'axios';
import { Eventing } from "./Eventing";

// 可以看成是静态方法要用到的实例化包装类
export class Collection<T, K> {
  models: T[] = [];
  // 我们使用的是同一个发布-订阅类
  events: Eventing = new Eventing();

  constructor(
    // 网络通信的基地址
    public rootUrl: string,
    // 从外部传入的解析数据库信息的方法
    public deserialize: (json: K) => T
  ) { }

  // 订阅某个话题
  get on() {
    return this.events.on;
  }

  // 发布某个话题
  get trigger() {
    return this.events.trigger;
  }

  // 将所有的数据都请求回来并通过外部指定的反序列化方法将 raw data 转成相应的 Js 数据结构
  fetch(): void {
    axios.get(this.rootUrl).then((response: AxiosResponse) => {
      response.data.forEach((value: K) => {
        this.models.push(this.deserialize(value));
      });
      this.trigger('change');
    });
  }
}  

1.10 构建应用入口,使用 User 类 -- index.ts

import { User } from './User';

// 第一个静态方法构造元素
const user = User.buildUser({ id: 1 });
console.log('user:', user)
// 订阅 change 这个话题
user.on('change', () => {
  console.log(user);
});
// 设置内部数据
user.set({ name: 'Ahan', age: 17 });
// 发送网络请求,将其保存到数据库中
user.save();

console.log('is admin?', user.isAdminUser());

// 第二个静态方法构造元素的集合 -- 能够将所有的数据都请求回来,并根据每一个数据构建单个元素
const collection = User.buildUserCollection();
// 订阅 change 这个话题
collection.on('change', () => {
  console.log(collection);
});
// 获取全部元素并解析
collection.fetch();
console.log('collection:', collection)

1.11 在页面中使用入口文件 -- index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My Data Model</title>
    <!-- 使用 parce 之后,我们可以直接在 index.html 中加载 ts 文件 -->
    <script src="./index.ts"></script>
  </head>
  <body></body>
</html>

1.12 初始化数据库 -- db.json

{
  "users": []
}

1.13 通过脚本启动应用程序

yarn start

本小节构造的这个数据类以及使用这个类进行的应用可以泛化成实际业务中所需的形态,经过简单的修改之后就能够很好的完成功能,同时这个基类具有非常强的逻辑性及条例,值得好好品味。

2. 普适的视图类 View

我们的视图类仿照 React 中的类组件来做,它满足如下的基本要求:

  1. 视图类的基本功能是产生对应的 HTML 代码。 -- 使用 template 方法返回 HTML. 使用 render 将 HTML 渲染到页面上。
  2. 正如 HTML 之间可以相互嵌套;我们的视图类之间也是可以相互嵌套的。 -- 使用 parent 属性指向其上级视图类。
  3. 这个类需要处理来自用户的输入,例如点击或者键盘事件等。
  4. 这个视图类之后可能会和数据类紧密联系起来。
  5. 我们需要有获取返回的 HTML 代码中元素的功能。

**构建策略:**先构建一个 UserForm 视图类,然后抽象得到一般类,在反过来利用一般类实现 UserForm 类。

构建 UserForm 类

其基本结构为:

export class UserForm {
  parent: Element;

  template(): string {
    return `
<div>
  <h1>User Form</h1>
  <!-- 这里可以添加一些说明或其他元素 -->
  <input type="text" placeholder="Enter your name" />
</div>
    `;
  }

  render(): void {
    const templateElement = document.createElement('template');
    templateElement.innerHTML = this.template();
    this.parent.append(templateElement.content);
  }
}

使用示例:

// 假设有一个id为'form-container'的DOM元素
const formContainer = document.getElementById('form-container');
const userForm = new UserForm(formContainer);
userForm.render();

渲染线:

  1. 调用 render 方法
  2. render 方法内部调用 template 方法获取 html
  3. 将 html 作为 innerHTML 插入到容器元素(DocumentFragment)中
  4. 插入完之后就可以获取并往 html 中的元素上绑定事件了
  5. 将容器元素放到页面(Document)中
export class UserForm {
  parent: Element;

  constructor(parent: Element) {
    this.parent = parent;
  }

  template(): string {
    return `
<div>
  <h1>User Form</h1>
  <!-- 这里可以添加一些说明或其他元素 -->
  <input type="text" placeholder="Enter your name" />
  <button>Submit</button> <!-- 添加一个按钮用于点击事件 -->
</div>
    `;
  }

  render(): void {
    const templateElement = document.createElement('template');
    templateElement.innerHTML = this.template();
    const fragment = templateElement.content;
    this.parent.append(fragment);
    this.bindEvents(fragment); // 绑定事件
  }

  eventsMap(): { [key: string]: () => void } {
    return {
      'click:button': this.onButtonClick.bind(this), // 绑定 this 到当前实例
      'mouseover h1': this.onHoverHeader.bind(this), // 绑定 this 到当前实例
      'dragstart div': this.onDragDiv.bind(this) // 修正事件类型并绑定 this
    };
  }

  onButtonClick(): void {
    console.log('Button clicked!');
  }

  onHoverHeader(): void {
    console.log('Header hovered!');
  }

  onDragDiv(): void {
    console.log('Div is being dragged!');
  }

  bindEvents(fragment: DocumentFragment): void {
    const eventsMap = this.eventsMap();
    for (let eventKey in eventsMap) {
      const [eventName, selector] = eventKey.split(':');
      fragment.querySelectorAll(selector).forEach(element => {
        element.addEventListener(eventName, eventsMap[eventKey]);
      });
    }
  }
}

注意这里对 this 的处理,使用类写组件就是会有 this 指向的问题。

结合数据类

视图只有和数据结合起来才有意义,才是数据驱动,因此我们希望完成如下的调用方式:

import { UserForm } from './views/UserForm';
import { User } from './models/User';

const user = User.buildUser({ name: 'Alice', age: 20 });

const userForm = new UserForm(document.getElementById('root'), User);
userForm.render();

而,当数据发生变化的时候,我们希望视图类能够重新渲染

export class UserForm {
  constructor(public parent: Element, public model: User) {
    this.bindModel();
  }

  bindModel(): void {
    this.model.on('change',() => {
      this.render();
    })
  }

  template(): string {
    return `
<div>
  <h1>User Form</h1>
  <!-- 这里可以添加一些说明或其他元素 -->
  <input type="text" placeholder="Enter your name" />
  <button>Submit</button> <!-- 添加一个按钮用于点击事件 -->
</div>
    `;
  }

  render(): void {
    this.parent.innerHTML = '';
    const templateElement = document.createElement('template');
    templateElement.innerHTML = this.template();
    const fragment = templateElement.content;
    this.parent.append(fragment);
    this.bindEvents(fragment); // 绑定事件
  }

  eventsMap(): { [key: string]: () => void } {
    return {
      'click:button': this.onButtonClick.bind(this), // 绑定 this 到当前实例
      'mouseover h1': this.onHoverHeader.bind(this), // 绑定 this 到当前实例
      'dragstart div': this.onDragDiv.bind(this) // 修正事件类型并绑定 this
    };
  }

  onButtonClick(): void {
    console.log('Button clicked!');
  }

  onHoverHeader(): void {
    console.log('Header hovered!');
  }

  onDragDiv(): void {
    console.log('Div is being dragged!');
  }

  bindEvents(fragment: DocumentFragment): void {
    const eventsMap = this.eventsMap();
    for (let eventKey in eventsMap) {
      const [eventName, selector] = eventKey.split(':');
      fragment.querySelectorAll(selector).forEach(element => {
        element.addEventListener(eventName, eventsMap[eventKey]);
      });
    }
  }
}

极其细微的差别

下面的写法是错误的:Property 'engine' is used before its initialization

class Engine {
  start() {
    console.log('Engine is starting...');
  }
}

class Car {
  engine: Engine;

  constructor() {
    this.engine = new Engine();
  }
  
  start = this.engine.start;

}

其编译的结果为:

"use strict";
var Engine = /** @class */ (function () {
    function Engine() {
    }
    Engine.prototype.start = function () {
        console.log('Engine is starting...');
    };
    return Engine;
}());
var Car = /** @class */ (function () {
    function Car() {
        this.start = this.engine.start;
        this.engine = new Engine();
    }
    return Car;
}());

可以看出来在 this.engine 赋值之前就已经使用了。相当于 start = this.engine.start; 会提升到构造函数上面。

正确的写法为:

class Engine {
  start() {
    console.log('Engine is starting...');
  }
}

class Car {
  constructor(public engine: Engine = new Engine) {
    this.engine = new Engine();
  }
  
  start = this.engine.start;

}

编译之后为,可以看到顺序是正确的:

"use strict";
var Engine = /** @class */ (function () {
    function Engine() {
    }
    Engine.prototype.start = function () {
        console.log('Engine is starting...');
    };
    return Engine;
}());
var Car = /** @class */ (function () {
    function Car(engine) {
        if (engine === void 0) { engine = new Engine; }
        this.engine = engine;
        this.start = this.engine.start;
        this.engine = new Engine();
    }
    return Car;
}());

视图类使用数据类上的方法

我们给 UserForm 类上添加两个方法保证其自己能够实现状态的刷新:

  onSetNameClick = (): void => {
    const input = this.parent.querySelector('input'); // 获取 input 元素
    const name = input ? input.value : ''; // 如果 input 存在,获取其值
    this.model.set({ name }); // 假设 model 对象有一个 set 方法
  };

  onSetAgeClick = (): void => { // 修正了方法名的拼写
    this.model.setRandomAge(); // 假设 model 对象有一个 setRandomAge 方法
  };

将 UserForm 抽象成公共类

假设抽象成的公共类名称为 View, 那我们需要考虑 View 和 UserForm 的关系是继承还是组合。实际上,我们在构建基类的时候一般多用组合设计模式,但是在完成具体的业务的时候常使用的是继承,所以它们之间的关系是继承,如果强行使用组合就会丧失灵活性,会写很多多余的代码。

具体说来,我们的基类 View 需要实现 render bindEvents bindModel 方法以及 parent model 属性;而子类,例如 UserForm 需要实现更加业务化的 template eventsMap onSetNameClick onSetAgeClick 方法。

但是基类中的一些方法需要使用在子类中才实现的 template eventsMap 方法,所以 template eventsMap 在基类中应该是抽象方法,因此基类也就是抽象类

然后我们在抽象 View 的时候需要将原来的 UserForm 中的相关类型用泛型表示,同时应该注意多个泛型之间的约束关系(使用 extends 可以约束多个泛型之间的关系)

import { User } from '../models/User';
import { Model } from '../models/Model';

// 定义一个接口,假设这是模型需要实现的方法
interface ModelForView {
  on(eventName: string, callback: () => void): void;
}

// 抽象类 View 使用了泛型 T,其中 T 必须是 ModelForView 的实例或其子类的实例
export abstract class View<T extends ModelForView> {
  constructor(public parent: Element, public model: T) {
    this.bindModel();
  }

  // 抽象方法,子类需要实现具体的事件映射
  abstract eventsMap(): { [key: string]: () => {} };

  // 抽象方法,子类需要返回具体的模板字符串
  abstract template(): string;

  // 绑定模型的事件,当模型变化时重新渲染视图
  bindModel(): void {
    this.model.on('change', () => {
      this.render();
    });
  }

  // 绑定事件到元素
  bindEvents(fragment: DocumentFragment): void {
    const eventsMap = this.eventsMap();
    for (let eventKey in eventsMap) {
      const [eventName, selector] = eventKey.split(':');
      fragment.querySelectorAll(selector).forEach(element => {
        element.addEventListener(eventName, eventsMap[eventKey]);
      });
    }
  }

  // 渲染视图的方法,子类可以扩展或覆盖此方法
  render(): void {
    const templateElement = document.createElement('template');
    templateElement.innerHTML = this.template();
    this.parent.innerHTML = ''; // 清空父元素的内容
    this.parent.appendChild(templateElement.content); // 将模板内容添加到父元素
    this.bindEvents(templateElement.content); // 绑定事件
  }
}

当我们写完之后,查看一下,有些抽象类看是否真的有必要,如果只是占位,则可以将其改成默认值:

abstract eventsMap(): { [key: string]: () => {} };

可以写成:

eventsMap(){return {}};

使用公共视图类反写 UserForm

import { User } from '../models/User';
import { View } from './View'; // 假设抽象基类 View 在同一目录下的 View.ts 文件中

export class UserForm extends View<User> { // 指定泛型参数为 User,它继承自 ModelForView

  constructor(parent: Element) {
    super(parent, new User()); // 假设 User 继承自 ModelForView 或实现了 ModelForView 接口
  }

  // 覆盖基类的抽象方法,提供具体的事件映射
  eventsMap(): { [key: string]: () => void } {
    return {
      'click:button': this.onButtonClick.bind(this), // 绑定点击按钮事件
      'mouseover h1': this.onHoverHeader.bind(this), // 绑定鼠标悬停标题事件
      'dragstart div': this.onDragDiv.bind(this) // 绑定 div 拖拽开始事件
    };
  }

  // 覆盖基类的抽象方法,返回具体的模板字符串
  template(): string {
    return `
<div>
  <h1>User Form</h1>
  <!-- 这里可以添加一些说明或其他元素 -->
  <input type="text" placeholder="Enter your name" />
  <button>Submit</button> <!-- 添加一个按钮用于点击事件 -->
</div>
    `;
  }

  // 定义具体的事件处理函数
  onButtonClick(): void {
    console.log('Button clicked!');
    // 可以添加提交表单的逻辑
  }

  onHoverHeader(): void {
    console.log('Header hovered!');
  }

  onDragDiv(): void {
    console.log('Div is being dragged!');
  }

  onSetNameClick = (): void => {
    const input = this.parent.querySelector('input');
    const name = input ? input.value : '';
    this.model.set({ name });
  };

  onSetAgeClick = (): void => {
    this.model.setRandomAge();
  };

  onSaveClick = (): void => {
    this.model.save();
  };

  // 可以选择性地覆盖基类的 render 方法,如果需要添加额外的逻辑
  render(): void {
    // 调用基类的 render 方法
    super.render();
    // 这里可以添加额外的渲染逻辑
  }
}

当然使用公共视图类我们还可以很顺利的写出其它视图:

import { View } from './View'; // 修正了引号
import { User, UserProps } from '../models/User'; // 导入 User 模型和 UserProps

// 假设 View 类接受两个泛型参数,UserEdit 类继承自 View
export class UserEdit extends View<User, UserProps> {
  template(): string {
    return `
<div>
  <div class="user-show"></div> <!-- 用户展示区域 -->
  <div class="user-form"></div> <!-- 用户表单区域 -->
</div>
    `;
  }
  
  // 其他必要的方法,例如 render, eventsMap 等...
}
import { View } from './View'; // 修正了引号
import { User } from '../models/User'; // 导入 User 模型

// 假设 UserShow 类是 View 的子类,用于展示用户详情
export class UserShow extends View<User> { // 移除了 UserProps,因为它没有被使用
  template(): string {
    return `
<div>
  <h1>User Detail</h1>
  <div>User Name: ${this.model.get('name')}</div> <!-- 使用模板字符串 -->
  <div>User Age: ${this.model.get('age')}</div>
</div>
    `;
  }
  
  // 其他必要的方法...
}

这是整个项目的完整地址,通过这个教程,你也可以写出自己的前端框架:marionettejs

3. Ts 与 Express

有一些非常著名的 Js 第三方库如 express 等,在编写的时候并没有考虑深入融合 Ts,因此对 Ts 的支持性并没有那么的好,当我们的应用使用到这些库的时候,如果需要集成 Ts,则考虑以下问题:

  1. Ts 本身就很 OOP.
  2. 在 Ts 诞生之前写的 Js 第三方类库,它们很 any.
  3. 将 Ts 集成到这些库中是相当困难的。

为了顺利完成集成 Ts 的任务,我们有下面三种解决方案:

  1. 正常使用 Js, 但是在需要类型约束的地方使用注释说明,例如使用 JsDoc 做注释。
  2. 对于著名的类库,一定会有前辈已经提供出来的 @types/ 这个使用需要单独安装即可。
  3. 针对自己的需求自己写接口对数据的类型进行约束。

前两种,实际上没有什么好说的,这里我们使用的是第三种解决方案。

1. 首先,我们通过 bash 命令构建此次的应用程序的框架:

mkdir server
cd server
npm init -y
tsc --init
npm install concurrently nodemon

2. 其次,我们修改 tsconfig.json 的内容:

{
  "compilerOptions": {
    "target": "es2016",                                  
    "module": "commonjs",                                
    "rootDir": "./src",                                  
    "outDir": "./build",                                   
    "esModuleInterop": true,                             
    "forceConsistentCasingInFileNames": true,            
    "strict": false,                                      
    "skipLibCheck": true                                 
  }
}

3. 然后,我们在包管理文件中增加一些脚本命令:

"scripts": {
  "start:build": "tsc -w",
  "start:run": "nodemon build/index.js",
  "start": "concurrently npm:start:*"
}

4. 再次,安装一些 express 相关的库和中间件及其类型库:

npm install express body-parser cookie-session
npm install @types/express @types/cookie-session @types/body-parser

5. 最后,创建入口文件并填入内容:

mkdir src
touch src/index.ts
// index.ts
const express = require('express'); // 确保引入了 express
const app = express();

// 定义 GET 请求处理函数
app.get('/', (req, res) => {
  // 发送响应内容,这里使用了 JSX 语法,需要确保环境支持或转换为 HTML
  res.send(
    `<div>
      <h1>Hi there!</h1>
    </div>`
  );
});

// 启动服务器监听 3000 端口
app.listen(3000, () => {
  console.log('Listening on port 3000');
});

6. 优化路由,一般我们都是把路由模块单独拎出去的:

mkdir src/routes
touch src/routes/loginRoutes.ts
// loginRoutes.ts
import { Router, Request, Response } from 'express';

const router = Router();

// 定义 GET 请求处理函数
router.get('/', (req, res) => {
  // 发送响应内容,这里使用了 JSX 语法,需要确保环境支持或转换为 HTML
  res.send(`
    <form method="POST" action="/login">
      <div>
        <label for="email">Email</label>
        <input type="email" name="email" id="email" required />
      </div>
      <div>
        <label for="password">Password</label>
        <input type="password" name="password" id="password" required />
      </div>
      <button type="submit">Submit</button> 
    </form>
  `);
});

// 定义登录路由的处理函数
router.post('/login', (req, res) => {
  const { email, password } = req.body;
  // 发送一个包含表单的 HTML 响应
  res.send(email + password);
});

export { router };
// index.ts
import express from 'express';
import { router }from'./routes/loginRoutes';

const app = express();
app.use(router);

// 启动服务器监听 3000 端口
app.listen(3000, () => {
  console.log('Listening on port 3000');
});

7. 解析结构体: 通过表单提交的数据格式为 application/x-www-form-urlencoded,我们需要引入中间件来解析数据:

// index.ts
import express from 'express';
import { router } from './routes/loginRoutes';
import bodyParser from 'body-parser';

const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(router);

// 启动服务器监听 3000 端口
app.listen(3000, () => {
    console.log('Listening on port 3000');
});
// 定义登录路由的处理函数
router.post('/login', (req: Request, res: Response) => {
  const { email, password }= req.body;
  // 发送一个包含表单的 HTML 响应
  res.send(email + password);
});

8. 了解 bodyParse 中间件:

body-parser 中间件在 Express 应用中的功能是解析请求体(request body),将其转换为 JSON 或 URL 编码的数据,使其可以被访问和操作。以下是根据您提供的信息,对 body-parser 中间件功能的概述:

  1. 解析请求体body-parser 能够解析多种格式的请求体,包括 JSON、URL 编码(通常用于表单数据)等。

  2. 转换数据格式:它将请求体中的数据转换成 JavaScript 对象,使其易于在后续的路由处理函数中使用。

  3. 支持不同内容类型body-parser 可以处理多种 Content-Type,例如 application/jsonapplication/x-www-form-urlencoded

  4. 增强请求对象:使用 body-parser 后,请求对象(Request Object)会被增强,添加一个 body 属性,其中包含了解析后的数据。

  5. 提取表单数据:对于 POST 或 PUT 请求中包含的表单数据,body-parser 可以提取这些数据,并将其放在 req.body 对象中。

  6. 保持原始请求信息:即使请求体被解析,原始的请求信息(如 IP 地址、主机名等)仍然保留在请求对象中。

  7. 错误处理:如果请求体无法解析,body-parser 会向客户端发送一个错误响应。

9. 了解中间件:

一个典型的中间件接受 Request Response 和 NextFunction 三种类型的入参,其功能是对传入的 Request Response 对象进行改造,然后调用 NextFunction 将接力棒传给下一个中间件。

10. 从使用 body parse 中间件过程中反映出的问题:

集成 Ts 反映出的问题(Integration Issues)

  • 单独的类型定义文件无法准确表达 JavaScript 世界中的所有情况(例如中间件)。

缺点(CONS)

  1. 提供给我们的类型定义文件并不总是准确的。
  2. 输入到服务器(或任何具有外部输入的程序)不能保证一定存在,或者类型正确。

优点(PROS)

  • 使用 TypeScript 解决这些类型问题可以迫使我们编写更好的代码。

正如下面这段代码所示:

const { email, password }= req.body;

原本 @types/express 库提供给 express 的类型说明中定义 Request 中的 body: any;. 也就是说,express 也不确定 req 上有没有 body 这个属性,any 的意思就是它可能是 undefined.

我们使用 body parse 中间件之后,其作用的结果就是将携带的数据解析出来然后挂载到 req.body 上面,但是这里有一个很严重的问题,那就是,这样子搞我们就完全失去了对 Request.body 的约束,我们无法通过 Ts 得到任何的提示,这和我们决定使用 Ts 的初衷背驰。

我们可以到 node_modules 中的 @types 目录中手动修改文件类型说明,将其强行改成 body:{ lkey:stringl:string | undefined }; 这样 const { email, password }= req.body; 解析出来的 email 或者 password 就有可能是 undefined,这样在我们进行一些危险操作的时候,Ts 就会提示我们。这样我们看到警告或者报错之后,就会修改,所以写出来的代码就是健壮的:

// 定义登录路由的处理函数
router.post('/login', (req: Request, res: Response) => {
  const { email, password }= req.body;
  
  if (email) {
    res.send(email.toUppercase());
  } else {
    res.send('You must provide an email');
  }
});

但是这种做法毕竟只是扬汤止沸,我们需要更加强制的方法:

interface RequestWithBody extends Request {
  body: { [ key:string ]: string | undefined };
}
...
// 定义登录路由的处理函数
router.post('/login', (req: RequestWithBody, res: Response) => {
  const { email, password } = req.body;

  if (email) {
    res.send(email.toUpperCase());
  } else {
    res.send('You must provide an email');
  }
});

其实上面的方法也不是完全体,因为我们不可能为每一个路由都写一个 RequestWithBody 这样太过繁琐,因此上面的方法思路是正确的,但不是完全体。这个事情先搁置下来吧!

4. Express + Js 实现登录功能

原理介绍

实现登录功能的核心技术点在于使用中间件 cookie-session 这个中间件帮助我们处理了大部分的固定流程,我们只需在 app 上使用这个中间件,然后在合适的时间执行:

  req.session.loggedIn = true;
  res.redirect('/');

这样做的结果为:cookie-session 会自动为这个浏览器生成一个 cookie 并且 cookie 中埋有信息 loggedIn = true 接下来服务器会把这个 cookie 埋在浏览器中。

等到具有 cookie 的浏览器再次访问服务器的时候,cookie-session 会在所有携带的 cookie 中找到自己刚才埋的那个并解析,并将解析得到的信息挂载在 req.session 上,也就是说,已经登陆的浏览器的 req.session.loggedIn 的值为 true.

当退出的时候,只需要执行:

  req.session = undefined;
  res.redirect('/');

这样做之后,cookie-session 会返回一个新的 cookie 替代之前的 cookie 并且新的 cookie 中不再含有任何信息。

注意,不论是登录还是登出,设置 session 之后,我们总是会重定向至首页,此时可以在面板上看到重定向的响应码。

当前代码

// src/index.ts
import express from 'express';
import { router } from './routes/loginRoutes';
import bodyParser from 'body-parser';
import cookieSession from 'cookie-session';

const app = express();

app.use(bodyParser.urlencoded({ extended: true }));
app.use(cookieSession({
    name: 'session',
    keys: ['iot-template'],
}));
app.use(router);

app.listen(3000, () => {
    console.log('Listening on port 3000');
});

这里有一个巨坑的点:cookieSession 在设置 option 的时候必须给 name,并且 keys 是一个数组。

// src/routes/loginRoutes.ts
import { Router, Request, Response, NextFunction } from 'express';

interface RequestWithBody extends Request {
  body: { [key: string]: string | undefined };
}

const router = Router();

function requireAuth(req: Request, res: Response, next: NextFunction): void {
  if (req.session && req.session.loggedIn) {
    next();
    return;
  }
  res.status(403);
  res.send('Not permitted');
}

// 定义 GET 请求处理函数
router.get('/', (req: RequestWithBody, res: Response) => {
  if (req.session && req.session.loggedIn) {
    res.send(`
      <div>
        <div>You are logged in</div>
        <a href="/logout">Logout</a>
      </div>
    `)
  } else {
    res.send(`
      <div>
        <div>You are not logged in</div>
        <a href="/login">Login</a>
      </div>
    `)
  }
});

// 定义 GET 请求处理函数
router.get('/login', (req: RequestWithBody, res: Response) => {
  // 发送响应内容,这里使用了 JSX 语法,需要确保环境支持或转换为 HTML
  res.send(`
    <form method="POST" action="/login">
      <div>
        <label for="email">Email</label>
        <input type="email" name="email" id="email" required />
      </div>
      <div>
        <label for="password">Password</label>
        <input type="password" name="password" id="password" required />
      </div>
      <button type="submit">Submit</button> 
    </form>
  `);
});

// 定义登录路由的处理函数
router.post('/login', (req: RequestWithBody, res: Response) => {
  const { email, password } = req.body;

  if (email === 'hi@hi.com' && password === 'supcon') {
    req.session.loggedIn = true;

    res.redirect('/');
  } else {
    res.send('Invalid email or password.');
  }
});

router.get('/logout', (req: Request, res: Response) => {
  req.session = undefined;
  res.redirect('/');
})

router.get('/protected', requireAuth, (req: Request, res: Response) => {
  res.send('Welcome to protected route, logged in user');
})

export { router };
  1. get('/') 请求主页。
  2. get('/login') 请求登录页面。
  3. post('/login') 发送登录表单。
  4. get('/logout') 登出,并请求登出页面。
  5. get('/protected') 尝试访问受访问的页面。

5. 接下来怎么做

上述的代码是 flat 的,我们可以使用一个 Class 将其封装一下,这相对来说是比较 Easy 的,但是如果在此基础之上还能结合 Ts, 就会变得很难。

// index.ts
import express from 'express';
import { router } from './routes/loginRoutes';
import bodyParser from 'body-parser';
import cookieSession from 'cookie-session';

class Server {
  app: express.Express = express();

  constructor() {
    this.app.use(bodyParser.urlencoded({ extended: true }));
    this.app.use(cookieSession({
      name: 'session',
      keys: ['iot-template'],
    }));
    this.app.use(router);
  }

  start(): void {
    this.app.listen(3000, () => {
      console.log('Listening on port 3000')
    })
  }
}

new Server().start();

接下来,我们将会探究将 Express 和 Ts 结合起来的优势:

  1. Ts 保证了更好的类型安全性。
  2. 极大的增强开发者体验。

有一个集大成的 Ts 库,名为:ts-express-decorators 我们接下来会仿照这个库来继续。完成步骤为:

  1. 必须要深刻了解 class 和 prototype 的关系。使用 typescriptlang.org/play 这个工具。
  2. 先在 Ts 中引入 decorators, 初窥其强大能量。
  3. 使用 decorators 让我们的路由设置更加丝滑。

5.1 语法糖

这句话必须背过:Classes in Javascript are 'syntactic sugar' over prototypal inheritance.

下面的 Ts 语法及编译之后的结果:

class Boat {
  color: string = 'red';

  pilot(): void {
    console.log('switch');
  }
}

编译之后得到的结论:

"use strict";
var Boat = /** @class */ (function () {
  function Boat() {
    this.color = 'red';
  }
  Boat.prototype.pilot = function () {
    console.log('switch');
  };
  return Boat;
}());

我觉得对于一个优秀的前端工程师上面的这种基本的代码应该牢记在心。

5.2 Ts 中的装饰器

  1. 本质上是函数,用来修改 class 中的属性或者方法。
  2. 和 Js 中的装饰器是不同的。
  3. 只能在 class 中或者直接对 class 本身使用。
  4. 可以对同一个对象使用多个装饰器,但是需要明白其顺序。
  5. 是实验性质的。

需要在 tsconfig.json 中开启使用 Ts: "experimentalDecorators":true, "emitDecoratorMetadata":true

class Boat {
  // 类属性 'color',初始值为 'red'
  color: string = 'red';

  // 类方法 'formattedColor',返回船只颜色的描述
  get formattedColor(): string {
    return `This boat's color is ${this.color}`;
  }

  // 使用 'testDecorator' 装饰器的方法 'pilot'
  @testDecorator
  pilot(): void {
    console.log('swish');
  }
}

// 装饰器函数 'testDecorator' 的定义
function testDecorator(target: any, key: string): void {
  console.log('Target:', target);
  console.log('Key:', key);
}

new Boat().pilot(); 
/**
 * [run] Target: {}
 * [run] Key: pilot
 * [run] swish
 */

如果我们将其放入在线 Ts 编译器中,得到编译的结果为:

"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var Boat = /** @class */ (function () {
    function Boat() {
        // 类属性 'color',初始值为 'red'
        this.color = 'red';
    }
    Object.defineProperty(Boat.prototype, "formattedColor", {
        // 类方法 'formattedColor',返回船只颜色的描述
        get: function () {
            return "This boat's color is ".concat(this.color);
        },
        enumerable: false,
        configurable: true
    });
    // 使用 'testDecorator' 装饰器的方法 'pilot'
    Boat.prototype.pilot = function () {
        console.log('swish');
    };
    __decorate([
        testDecorator,
        __metadata("design:type", Function),
        __metadata("design:paramtypes", []),
        __metadata("design:returntype", void 0)
    ], Boat.prototype, "pilot", null);
    return Boat;
}());
// 装饰器函数 'testDecorator' 的定义
function testDecorator(target, key) {
    console.log('Target:', target);
    console.log('Key:', key);
}
new Boat().pilot();
/**
 * [run] Target: {}
 * [run] Key: pilot
 * [run] swish
 */ 

不难看出来,装饰器函数 testDecorator 的作用就是对 Boatprototype 做了手脚/加强。

了解属性/方法装饰器函数:

  1. 第一个参数指的是该属性或者方法所在类的原型。!!不是 this !!
  2. 第二个参数指的是该属性或者方法的【名称】。
  3. 第三个参数是这个属性或者方法的修饰器。
  4. 装饰器的作用时机是代码运行的时候而不是代码编写的时候,所以尽管它是 Ts 的功能,但是有别于一般的类型检测功能

Descriptor 中的内容简洁:

  1. writable: 表示这个属性值可否发生变化。
  2. enumerate: 表示这个属性是否对 for...in 生效。
  3. value: 属性的值。
  4. configurable: 表示这个属性值能否被再次配置,以及能否被删除。

通过 Object.getOwnPropertyDescriptor(obj, property) 可以获取某个属性的修饰 (Descriptor)

5.3 写一个具有一定实际作用的装饰器

我们可以对类方法施加装饰器,如果这个方法执行的时候报错,那我们通过这个装饰器捕捉错误使之不要外溢。

class Boat {
  // 类属性 'color',初始值为 'red'
  color: string = 'red';

  // 类方法 'formattedColor',返回船只颜色的描述
  get formattedColor(): string {
    return `This boat's color is ${this.color}`;
  }

  // 使用 'logError' 装饰器的方法 'pilot'
  @logError
  pilot(): void {
    throw new Error('Bad!');
    console.log('swish');
  }
}

// 装饰器函数 'logError' 的定义
function logError(target: any, key: string, desc: PropertyDescriptor): PropertyDescriptor {
  const method = desc.value; // 保存原始方法引用
  desc.value = function () {
    try {
      // 尝试执行原始方法
      method.apply(this, arguments);
    } catch (e) {
      // 如果发生错误,记录错误信息
      console.error('Error in method');
    }
  };
  return desc; // 返回修改后的属性描述符
}

new Boat().pilot();  // Error in method

将上面的装饰器写成工厂函数:

function logError(errorMessage:string){
  return function logError(target: any, key: string, desc: PropertyDescriptor): PropertyDescriptor {
  const method = desc.value;
  desc.value = function () {
    try {
      method.apply(this, arguments);
    } catch (e) {
      console.error(errorMessage);
    }
  };
  return desc;
}
}

注意,这里不是 target[key] 取值,尽管 target[key] === method 为真。

5.4 强调第一个参数为原型

由于第一个参数为原型,因此对于属性而言 target[key] 的值永远都是 undefined,因为方法挂载在原型上,但是属性没有。

6. 使用装饰器赋能服务类

我们期望在赋能之后,node 执行我们的代码的时候是通过下面的方式进行的:

  1. node 执行代码
  2. 先执行 class 内部的装饰器先执行
  3. 内部装饰器执行的时候将预设的 metadata 注入
  4. 所有的方法的装饰器逐个执行
  5. 类的修饰器最后再执行
  6. 类的修饰器最后执行读取注入的 metadata 并生成和原来等价的代码

6.1 metadata 介绍

  1. 可以附加到方法、属性或类定义上的信息片段
  2. (提议阶段):Js 将要添加的新特性
  3. 高度定制化内容
  4. 在 Ts 中,类型信息可以作为 metadata (可选的)
  5. 通常结合名为 reflect-metadata 这个库一起使用。 npm install reflect-metadata

下面的例子展示了这个库的使用方法:

import 'reflect-metadata';

const plane = {
  color: 'red',
}

Reflect.defineMetadata('note', 'hi there', plane);

const info = Reflect.getMetadata('note', plane);

console.log('plane: ', plane); // [run] plane:  { color: 'red' }
console.log('info:', info); // [run] info: hi there

可以看出来,reflect-metadata 虽然为 obj 存储了一些信息,但是并不直接挂载其上,而是存储在其它位置,需要通过特殊的 API 进行调用。

defineMetadata 和 getMetadata 这两个 API 的参数顺序分别为:键 值 对象 以及 键 对象, 也就是说永远都是键在第一位,对象在最后一位。

6.2 reflect-metadata 深入使用

先看下面的代码:

import 'reflect-metadata';

const plane = {
    color: 'red',
}

Reflect.defineMetadata('note', 'hi there', plane, 'color');
Reflect.defineMetadata('note', 'age', plane, 'age');

const infoWrapper = Reflect.getMetadata('note', plane);
const info = Reflect.getMetadata('note', plane, 'color');
const age = Reflect.getMetadata('note', plane, 'age');


console.log('plane: ', plane); // plane:  { color: 'red' }
console.log('infoWrapper:', infoWrapper); // undefined
console.log('info:', info); // info: hi there
console.log('age:', age); // age: age

这个例子表明,我们使用 reflect 定义元信息的时候是可以嵌套的,但是有别于一般对象的嵌套模式。

import 'reflect-metadata';

const plane = {
    color: 'red',
}

Reflect.defineMetadata('note', 'note"s info', plane);
Reflect.defineMetadata('note', 'hi there', plane, 'color');
Reflect.defineMetadata('note', 'age', plane, 'age');

const infoWrapper = Reflect.getMetadata('note', plane);
const info = Reflect.getMetadata('note', plane, 'color');
const age = Reflect.getMetadata('note', plane, 'age');


console.log('plane: ', plane); // plane:  { color: 'red' }
console.log('infoWrapper:', infoWrapper); // infoWrapper: note"s info
console.log('info:', info); // info: hi there
console.log('age:', age); // age: age

Reflect.defineMetadata('note', 'note"s info', plane) 是给 plane->note 设置元数据;而 Reflect.defineMetadata('note', 'hi there', plane, 'color') 是给 plane->note->color 设置元数据,而 note 和 color 本身并不是上下级关系。

6.3 元信息和装饰器联合使用

从下面的代码不难看出来,装饰器提供了元数据保存的路径,也就是 prototype -> property:

import "reflect-metadata";

class Plane {
  color: string = 'red';

  @markFunction
  fly(): void {
    console.log('vrrrrrrr');
  }
}

function markFunction(target: Plane, key: string): void {
  Reflect.defineMetadata('secret', 123, target, key);
}

const secret = Reflect.getMetadata('secret', Plane.prototype, 'fly');
console.log(secret);

那么很自然的就可以想到,将这个装饰器写成工厂函数:

import "reflect-metadata";

class Plane {
  color: string = 'red';

  @markFunction(('HI THERE'))
  fly(): void {
    console.log('vrrrrrrr');
  }
}

function markFunction(secretInfo: string) {
  return function (target: Plane, key: string): void {
    Reflect.defineMetadata('secret', secretInfo, target, key);
  }
}

const secret = Reflect.getMetadata('secret', Plane.prototype, 'fly');
console.log(secret); // 'HI THERE'

如果说我们使用装饰器将注入元信息的过程封装起来,那么提取元数据的过程也应该使用对应的函数或者装饰器封装起来;一旦有进有出,那么我们就可以很容易的实现:通过方法或者属性装饰器设置元数据,然后通过类装饰器读取这些元数据,这里利用到了装饰器是动态执行的,以及先执行属性和方法的装饰器,后执行类本身的装饰器这样一个执行顺序。

**坑点:**需要将 tsconfig.json 中相关设置为 "target": "es5",.

import "reflect-metadata";

@printMetadata
class Plane {
  color: string = 'red';

  @markFunction(('HI THERE'))
  fly(): void {
    console.log('vrrrrrrr');
  }
}

function markFunction(secretInfo: string) {
  return function (target: Plane, key: string): void {
    Reflect.defineMetadata('secret', secretInfo, target, key);
  }
}

function printMetadata(target: typeof Plane): void {
  for (let key in target.prototype) {
    const secret = Reflect.getMetadata('secret', target.prototype, key);
    if (secret !== undefined) {
      console.log(secret);
    }
  }
}

printMetadata(Plane);

6.4 路由和装饰器

参考上一小节的内容,我们很容易想到,我们通过方法的修饰器设置好路由信息,然后通过类装饰器读取这些路由信息,将其转成我们直接定义路由的形式,这样整个外观看起来就会清晰许多。

知识点:原型的类型

如果 class A 的实例的类型可以直接使用 A 来约束,那么 A.prototype 的类型需要使用 typeof A 来约束。

知识点:如果函数的返回值为 any 那么接受返回值的变量的类型可以被显式指定为任何而不报错

看下面的代码,就一目了然了。

function Any (a: any): any {
  return a;
}

const a = Any(1); // Ts 会认为 a 的类型为 any
const a2: number = Any(1); // Ts 会认为 a2 的类型为 number
const a3: string = Any(1); // Ts 会认为 a3 的类型为 string

console.log(a);

Express 搭配 Ts 的思路

  1. 首先提供一个类 AppRouter 这个类会提供一个单例(express.Router())向外暴露;在入口文件中使用此单例注册路由:app.use(AppRouter.getInstance());.在 use 这个 Router 之间需要给单例上注册好路由。
  2. 注册路由的方式比较异类:**已知装饰器内的函数体是在运行时执行的,因此我们如果通过装饰器(装饰器内部保持对 Router 单例的引用)注册路由,那么就只需要从被装饰的类中获取数据,而不用实例化类。**如下所示:
import './controllers/LoginController';
import './controllers/RootController';

这里我们仅是将导出类的代码文件跑一遍,装饰器就已经运行了(原理可以在在线 Ts 编辑器中编辑成 Js 就秒懂了)。装饰器函数的运行过程就是将类中数据(准确来说是路由信息)注册到 Router 单例的过程。 3. 我们拟定创建 use 装饰器工厂以使用中间件;构建 bodyValidator 装饰器对 post 请求的 body 上的字段做验证;构建 get post put 等装饰器往 Router 上面挂载路由。 4. 我们使用属性或者方法装饰器往类原型上添加信息,然后使用类本身的装饰器读取这些信息,然后在类本身装饰器内获取 Router 单例,最终还是调用单例上的方法将从其它装饰器中获取的信息整合之后注册到 Router 单例上。这里其实是利用了不同类别装饰器的执行的先后顺序。

本质上,我们依赖的重要规则有两个:一个是装饰器在运行时执行;一个是类装饰器的运行时机在方法或者属性装饰器之后。

相应的项目结构

  • src 所有的改变都局限在 src 文件夹之内
    • index.ts 后端服务的入口文件,在此进行配置和开始监听
    • AppRouter.ts 路由生成器,所有路由通过此生成,本质上是一个类,提供一个单例
    • controller 生成路由相关的所有材料总和
      • LoginController.ts 生成登录相关路由
      • RootController.ts 生成第一级路由
      • decorators 生成路由所需装饰器集合
        • bodyValidator.ts 验证 post 请求携带有效载荷的装饰器
        • controller.ts 根据提供的装饰器将【更加语义化的内容】翻译成可用路由的类
        • index.ts 统一的资源导出入口
        • MetadataKey.ts 元信息字段枚举
        • Methods.ts 路由方法名枚举
        • routes.ts 挂载路由的装饰器工厂
        • Use.ts 中间件挂载装饰器

全部代码

// index.ts
import express from 'express';
import bodyParser from 'body-parser';
import cookieSession from 'cookie-session';
import { AppRouter } from './AppRouter';
import './controllers/LoginController';
import './controllers/RootController';

const app = express();

app.use(bodyParser.urlencoded({ extended: true }));
app.use(cookieSession({
  name: 'session',
  keys: ['iot-template'],
}));
app.use(AppRouter.getInstance());

app.listen(3000, () => {
  console.log('Listening on port 3000')
})

// AppRouter.ts
import express from 'express';

export class AppRouter {
  private static instance: express.Router;
  static getInstance(): express.Router {
    if (!AppRouter.instance) {
      AppRouter.instance = express.Router();
    }
    return AppRouter.instance;
  }
}
// LoginController.ts
import { Request, Response } from 'express';
import "reflect-metadata";
import { controller, get, post, bodyValidator, use } from './decorators';
import { logger } from './decorators/controller';

interface RequestWithBody extends Request {
  body: { [key: string]: string | undefined };
}

@controller('/')
class LoginController {
  // @get('/')
  // add(a:number, b:number): number {
  //   return a+b;
  // }

  @get('/login')
  @use(logger)
  getLogin(req: RequestWithBody, res: Response) {
    // 发送响应内容,这里使用了 JSX 语法,需要确保环境支持或转换为 HTML
    res.send(`
      <form method="POST" action="/login">
        <div>
          <label for="email">Email</label>
          <input type="email" name="email" id="email" required />
        </div>
        <div>
          <label for="password">Password</label>
          <input type="password" name="password" id="password" required />
        </div>
        <button type="submit">Submit</button> 
      </form>
    `);
  }

  @post('/login')
  @use(logger)
  @bodyValidator('email', 'password')
  postLogin(req: RequestWithBody, res: Response) {
    const { email, password } = req.body;

    if (email === 'hi@hi.com' && password === 'supcon') {
      req.session.loggedIn = true;

      res.redirect('/');
    } else {
      res.send('Invalid email or password.');
    }
  };
}
// RootController.ts
import { Request, Response, NextFunction, RequestHandler } from 'express';
import { get, controller, use } from './decorators';
import { logger } from './decorators/controller';

const requireAuth: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
  if (req.session && req.session.loggedIn) {
    next();
    return;
  }

  res.status(403);
  res.send('Not Permitted');
};

@controller('/')
class RootController {
  @get('/')
  @use(logger)
  getRoot(req: Request, res: Response) {
    if (req.session && req.session.loggedIn) {
      res.send(`
        <div>
          <div>You are logged in</div>
          <a href="/logout">Logout</a>
        </div>
      `)
    } else {
      res.send(`
        <div>
          <div>You are not logged in</div>
          <a href="/login">Login</a>
        </div>
      `)
    }
  }

  @get('/logout')
  @use(logger)
  getLogout(req: Request, res: Response) {
    req.session = undefined;
    res.redirect('/');
  }

  @get('/protected')
  @use(logger)
  @use(requireAuth)
  getProtected(req: Request, res: Response) {
    res.send('Welcome to protected route, logged in user');
  }
}
// bodyValidator.ts
import 'reflect-metadata';
import { Metadatakeys } from './MetadataKey';

export function bodyValidator(...keys: string[]): PropertyDecorator {
    return function (target: any, key: string) {
        Reflect.defineMetadata(Metadatakeys.validator, keys, target, key);
    }
}
// controller.ts
import 'reflect-metadata';
import { AppRouter } from '../../AppRouter';
import { Methods } from './Methods';
import { Metadatakeys } from './MetadataKey';
import { NextFunction, RequestHandler, Response, Request } from 'express';

export function controller(routePrefix: string) {
  return function (target: Function) {
    const router = AppRouter.getInstance();
    for (let key in target.prototype) {
      const routeHandler = target.prototype[key];
      const path = Reflect.getMetadata(Metadatakeys.path, target.prototype, key);
      const method: Methods = Reflect.getMetadata(Metadatakeys.method, target.prototype, key);
      const middlewares = Reflect.getMetadata(Metadatakeys.middleware, target.prototype, key) || [];
      const requiredBodyProps = Reflect.getMetadata(Metadatakeys.validator, target.prototype, key) || [];
      const validator = bodyValidators(requiredBodyProps);
      if (path) {
        router[method](`${routePrefix === '/' ? '' : routePrefix}${path}`, ...middlewares, validator, routeHandler);
      }
    }
  }
}

export function logger(req: Request, res: Response, next: NextFunction) {
  next();
}

function bodyValidators(keys: string[]): RequestHandler {
  return function (req: Request, res: Response, next: NextFunction) {
    if (!req.body) {
      res.status(422)
      res.send('Invalid Request!');
      return;
    }

    for (let key of keys) {
      if (!req.body[key]) {
        res.status(422).send('Invalid Request!');
        return;
      }
    }

    next();
  }
}
// index.ts
export { Metadatakeys } from './MetadataKey';
export { controller } from './controller';
export { Methods } from './Methods';
export { use } from './Use';
export { bodyValidator } from './bodyValidator';
export * from './routes';
// MetadataKey.ts
export enum Metadatakeys {
  method = 'method',
  path = 'path',
  middleware = 'middleware',
  validator = 'validator',
}
// Methods.ts
export enum Methods {
    get = 'get',
    post = 'post',
    patch = 'patch',
    delete = 'delete',
    put = 'put',
}
// routes.ts
import 'reflect-metadata';
import { Methods } from './Methods';
import { Metadatakeys } from './MetadataKey';
import { RequestHandler } from 'express';

interface RouterHandlerDesciptor extends PropertyDecorator {
  value?: RequestHandler;
}

function routeBinder(method: string): Function {
  return function (path: string) {
    return function (target: any, key: string, desc: RouterHandlerDesciptor) {
      // 使用 Reflect 定义元数据 'path' 和 'method',关联到类的方法上
      Reflect.defineMetadata(Metadatakeys.path, path, target, key);
      Reflect.defineMetadata(Metadatakeys.method, method, target, key);
    };
  };
}

// 创建并导出 HTTP GET 方法的装饰器
export const get = routeBinder(Methods.get);

// 创建并导出 HTTP POST 方法的装饰器
export const post = routeBinder(Methods.post);

// 创建并导出 HTTP PUT 方法的装饰器
export const put = routeBinder(Methods.put);

// 创建并导出 HTTP DELETE 方法的装饰器
// 注意:这里的注释应为 'delete' 而不是 'get'
export const del = routeBinder(Methods.delete);

// 创建并导出 HTTP PATCH 方法的装饰器
export const patch = routeBinder(Methods.patch);
// Use.ts
import 'reflect-metadata';
import { RequestHandler } from 'express';
import { Metadatakeys } from './MetadataKey';

export function use(middleware: RequestHandler): Function {
  return function (target: any, key: string, desc: PropertyDecorator) {
    const middlewares = Reflect.getMetadata(Metadatakeys.middleware, target, key) || [];
    Reflect.defineMetadata(Metadatakeys.middleware, [...middlewares, middleware], target, key);
  }
}

7. Ts 和 React

更多详情参考官方网站:React in Ts

优点和缺点

优点:

  • 非常容易避免极其常见的拼写错误,例如错误的操作类型(action types)。
  • 让开发者对数据流动的类型有了更好的理解。

缺点:

  • 重构几乎所有事情要简单得多。
  • 类型定义文件不是最好的(特别是在 Redux 相关方面)。
  • 存在大量的泛型使用。
  • 需要大量的导入,因为几乎所有东西(动作创建器、动作、reducer、store、组件)都需要意识到不同的类型。
  • Redux 本质上是功能性的,与 TypeScript 类的集成较为困难。

构建项目

npx create-react-app rrts --typescript

类组件的 porps 类型限制示例

interface AppProps {
  color: string;
}

class App extends React.Component<AppProps> {
  render(){
    return <div>{this.props.color}</div>;
  }
}

Props 的类型通过 React.Component 的泛型指定。

类组件的 state 类型限制示例

有两种方法限制类组件的 state 的类型,第一种方法是和 props 一样,也就是通过泛型约束;另一种则是在 Ts 的 class 定义中,可以直接将属性写出来,这样写会自动生成 state 的类型约束:

class App extends React.Component<AppProps>{
  state={counter:0};
}

等价于,

interface AppState {
  state: {
    counter: numer,
  }
}
class App extends React.Component<AppProps, AppState>{
  constructor(){
    this.state={counter:0};
  }
}

在 Ts 的 React 项目中安装 Redux 相关

npm install redux react-redux axios redux-thunk @types/react-redux

一个可以获取数据的免费 Api 接口

此接口服务器的官网为:{JSON} Placeholder

React + Redux 使用 Ts 示例

// src/actions/index.ts
import { Dispatch } from 'redux';
import axios from 'axios';

export interface Todo {
  id: number; // 假设 id 是 number 类型
  title: string;
  completed: boolean;
}

const url = 'https://jsonplaceholder.typicode.com/todos';

// 使用 async 函数封装异步数据获取逻辑
export const fetchTodos = () => {
  return async (dispatch: Dispatch) => {
    const response = await axios.get<Todo[]>(url); // 使用 TypeScript 的泛型获取正确类型的响应数据
    dispatch({
      type: 'FETCH_TODOS',
      payload: response.data,
    });
  };
};

export const deleteTodo = (id: number): DeleteTodoAction => {
  return {
    type: ActionTypes.deleteTodo,
    payload: id
  };
}
// src/reducers/todos.ts
import { Todo, Action } from '../actions';
import { ActionTypes } from '../actions/types';

// todosReducer 函数,用于处理 todos 的状态变更
export const todosReducer = (state: Todo[] = [], action: Action) => {
  // 这里的 action 的类型是 Action 是联合类型
  switch (action.type) {
    case ActionTypes.fetchTodos:
      // 处理 fetchTodos 动作,返回从 API 获取的数据
      return action.payload;
    case ActionTypes.deleteTodo:
      // 处理 deleteTodo 动作,删除
      // 这里的 Action 类型就变成 DeleteTodoAction
      return action.payload;
    default:
      // 默认情况,返回原有的 state
      return state;
  }
};
// src/reducers/index.ts
import { combineReducers } from 'redux';
import { todosReducer } from './todos';
import { Todo } from '../actions';

// 定义应用程序的状态接口
export interface StoreState {
  todos: Todo[]; // 修正了 Todo 类型的定义,应该是 Todo 数组
}

// 使用 combineReducers 创建应用的根 reducer
export const reducers = combineReducers<StoreState>({
  todos: todosReducer, // 将 todosReducer 映射到 StoreState 的 todos 属性
});
// App.tsx
import React from 'react';
import { connect } from 'react-redux';
import { StoreState } from './store/state'; // 假设 StoreState 定义在 store/state 文件夹中
import { fetchTodos } from './actions'; // 假设 fetchTodos 动作创建器定义在 actions 文件夹中
import _App from './App'; // 假设 _App 组件定义在同级目录下

// 假设这是一个 React 组件的 render 方法
render() {
  return <div>Hi there!</div>;
}

// mapStateToProps 函数,用于将 Redux store 中的状态映射到组件的 props
const mapStateToProps = (state: StoreState): { todos: Todo[] } => {
  return { todos: state.todos }; // 从 state 中解构 todos 并返回
};

// 使用 connect 函数将 mapStateToProps 和 action creators 连接到 _App 组件
export const App = connect(
  mapStateToProps,
  { fetchTodos } // 将 fetchTodos 作为 props 传递给 _App 组件
)(_App);

经过 connect 的组件的 props 想要得到正确的类型约束,需要写成如下形式:

interface AppProps {
  todo: Todo[];
  fetchTodos: typeof fetchTodos;
  deleteTodo: typeof deleteTodo;
}

对于这里的 typeof 可以做如下解释:

const fa = (a: string): string => {
  return a + a;
}

const fb: typeof fa = fa;
const fc: typeof fa = (b: string): string => {
  return b.repeat(3);
}
// src/actions/types.ts
// 导入必要的类型和常量
import { Todo } from '../actions'; // 假设 Todo 类型定义在 actions 文件夹中

import { FetchTodosAction, DeleteTodoAction } from './todos';

export enum ActionTypes {
  fetchTodos;
  deleteTodo;
}

// 导出 FetchTodosAction 接口
export interface FetchTodosAction {
  type: ActionTypes.fetchTodos;
  payload: Todo[]; // 修正了 Todo 类型数组的语法
}

// 导出 DeleteTodoAction 接口
export interface DeleteTodoAction {
  type: ActionTypes.deleteTodo;
  payload: number; // 假设 payload 是要删除的 todo 的 id
}

export type Action = FetchTodosAction | DeleteTodoAction;

总结

观察上面的代码,我们总结 Ts 在 React 中使用的时候可以用在下面几个方面:

  1. 使用 interface 约束数据类型,用来限制 axios 或者 store:
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

// axios
await axios.get<Todo[]>(url) 

// StoreState
export interface StoreState {
  todos: Todo[];
}

// 用成泛型
export const reducers = combineReducers<StoreState>({
  todos: todosReducer, 
});
  1. 使用 redux 库中提供的 Dispatch 类型约束 dispatch:
import { Dispatch } from 'redux';
async (dispatch: Dispatch) => {...}
  1. 使用 enum 类型限制 action 的 type:
// ActionTypes
export const deleteTodo = (id: number): DeleteTodoAction => {
  return {
    type: ActionTypes.deleteTodo,
    payload: id
  };
}
  1. 专门为 action generator 函数写类型约束
// DeleteTodoAction
export const deleteTodo = (id: number): DeleteTodoAction => {
  return {
    type: ActionTypes.deleteTodo,
    payload: id
  };
}
  1. 给每一个 action 都写一个类型约束再将它们 union 起来,必要时使用 type guide 收缩类型。