【詳細(xì)教程】教你如何使用Node + Express + Typescript開(kāi)發(fā)一個(gè)應(yīng)用

Express是nodejs開(kāi)發(fā)中普遍使用的一個(gè)框架,下面要談的是如何結(jié)合Typescript去使用。

目標(biāo)

我們的目標(biāo)是能夠使用Typescript快速開(kāi)發(fā)我們的應(yīng)用程序,而最終我們的應(yīng)用程序卻是編譯為原始的JavaScript代碼,以由nodejs運(yùn)行時(shí)來(lái)執(zhí)行。

初始化設(shè)置

首要的是我們要?jiǎng)?chuàng)建一個(gè)目錄名為express-typescript-app來(lái)存放我們的項(xiàng)目代碼:

mkdir express-typescript-app
cd express-typescript-app

為了實(shí)現(xiàn)我們的目標(biāo),首先我們需要區(qū)分哪些是線上程序依賴項(xiàng),哪些是開(kāi)發(fā)依賴項(xiàng),這樣可以確保最終編譯的代碼都是有用的。

在這個(gè)教程中,將使用yarn命令作為程序包管理器,當(dāng)然npm也是一樣可以的。

生產(chǎn)環(huán)境依賴

express作為程序的主體框架,在生產(chǎn)環(huán)境中是必不可少的,需要安裝

yarn add express

這樣當(dāng)前目錄下就生成了一個(gè)package.json 文件,里面暫時(shí)只有一個(gè)依賴

開(kāi)發(fā)環(huán)境依賴項(xiàng)

在開(kāi)發(fā)環(huán)境中我們將要使用Typescript編寫(xiě)代碼。所以我們需要安裝typescript。另外也需要安裝node和express的類型聲明。安裝的時(shí)候帶上- D參數(shù)來(lái)確保它是開(kāi)發(fā)依賴。

yarn add -D typescript @types/express @types/node

安裝好之后,還有一點(diǎn)值得注意,我們并不想每次代碼更改之后還需要手動(dòng)去執(zhí)行編譯才生效。這樣體驗(yàn)太不好了!所以我們需要額外添加幾個(gè)依賴:

  • ts-node: 這個(gè)安裝包是為了不用編譯直接運(yùn)行typescript代碼,這個(gè)對(duì)本地開(kāi)發(fā)太有必要了
  • nodemon:這個(gè)安裝包在程序代碼變更之后自動(dòng)監(jiān)聽(tīng)然后重啟開(kāi)發(fā)服務(wù)。搭配ts-node模塊就可以做到編寫(xiě)代碼及時(shí)生效。

因此這兩個(gè)依賴都是在開(kāi)發(fā)的時(shí)候需要的,而不需編譯進(jìn)生產(chǎn)環(huán)境的。

yarn add -D ts-node nodemon

配置我們的程序運(yùn)行起來(lái)

配置Typescript文件

為我們將要用的typescript設(shè)置配置文件,創(chuàng)建tsconfig.json文件

touch tsconfig.json

現(xiàn)在讓我們給配置文件添加編譯相關(guān)的配置參數(shù):

  • module: "commonjs" — 如果使用過(guò)node的都知道,這個(gè)作為編譯代碼時(shí)將被編譯到最終代碼是必不可少的。
  • esModuleInterop: true — 這個(gè)選項(xiàng)允許我們默認(rèn)導(dǎo)出的時(shí)候使用*代替導(dǎo)出的內(nèi)容。
  • target: "es6" — 不同于前端代碼,我們需要控制運(yùn)行環(huán)境,得確保使用的node版本能正確識(shí)別ES6語(yǔ)法。
  • rootDir: "./" — 設(shè)置代碼的根目錄為當(dāng)前目錄。
  • outDir: "./build" — 最終將Typescript代碼編譯成執(zhí)行的Javascript代碼目錄。
  • strict: true — 允許嚴(yán)格類型檢查。

最終tsconfig.json文件內(nèi)容如下:

{
  "compilerOptions": {
    "module": "commonjs",
    "esModuleInterop": true,
    "target": "es6",
    "rootDir": "./",
    "outDir": "./build",
    "strict": true
  }
}

配置package.json腳本

目前還沒(méi)有 package.json文件的scripts項(xiàng),我們需要添加幾個(gè)腳本:第一個(gè)是start啟動(dòng)開(kāi)發(fā)模式,另一個(gè)是 build打包線上環(huán)境代碼的命令。

啟動(dòng)開(kāi)發(fā)模式我們需要執(zhí)行nodemon index.ts,而打包生產(chǎn)代碼,我們已經(jīng)在tsconfig.json中給出了所有需要的信息,所以我們只需要執(zhí)行tsc命令。

此刻下面是你package.json文件中所有的內(nèi)容,也可能由于我們創(chuàng)建項(xiàng)目的時(shí)間不一樣,導(dǎo)致依賴的版本號(hào)不一樣。

{
  "dependencies": {
    "express": "^4.17.1"
  },
  "devDependencies": {
    "@types/express": "^4.17.11",
    "@types/node": "^14.14.22",
    "nodemon": "^2.0.7",
    "ts-node": "^9.1.1",
    "typescript": "^4.1.3"
  }
}

Git配置

如果使用git來(lái)管理代碼,還需要添加.gitignore文件來(lái)忽視node_modules目錄和build目錄

touch .gitignore

添加忽視的內(nèi)容

node_modules
build

至此,所有的安裝過(guò)程已經(jīng)結(jié)束,比單純的無(wú)Typescript版本可能稍微復(fù)雜點(diǎn)。

創(chuàng)建我們的Express應(yīng)用

讓我們來(lái)正式開(kāi)始創(chuàng)建express應(yīng)用。首先創(chuàng)建主文件index.ts

touch index.ts

然后添加案例代碼,在網(wǎng)頁(yè)中輸出“hello world”

import express from 'express';

const app = express();
const PORT = 3000;

app.get('/', (req, res) => {
  res.send('Hello world');
});

app.listen(PORT, () => {
  console.log(`Express with Typescript! http://localhost:${PORT}`);
});

在終端命令行執(zhí)行啟動(dòng)命令 yarn run start

yarn run start

接下來(lái)會(huì)輸出以下內(nèi)容:

[nodemon] 2.0.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: ts,json
[nodemon] starting `ts-node index.ts`
Express with Typescript! http://localhost:3000

我們可以看到nodemon模塊已經(jīng)監(jiān)聽(tīng)到所有文件的變更后使用ts-node index.ts命令啟動(dòng)了我們的應(yīng)用。我們現(xiàn)在可以在瀏覽器打開(kāi)網(wǎng)址http://localhost:3000,將會(huì)看到網(wǎng)頁(yè)中輸出我們想要的“hello world”。

“Hello World”以外的功能

我們的 “Hello World”應(yīng)用算是創(chuàng)建好了,但是我們不僅于此,還要添加一些稍微復(fù)雜點(diǎn)的功能,來(lái)豐富一下應(yīng)用。大致功能包括:

  • 保存一系列的用戶名和與之匹配的密碼在內(nèi)存中
  • 允許提交一個(gè)POST請(qǐng)求去創(chuàng)建一個(gè)新的用戶
  • 允許提交一個(gè)POST請(qǐng)求讓用戶登錄,并且接受因?yàn)殄e(cuò)誤認(rèn)證返回的信息

讓我們一個(gè)個(gè)去實(shí)現(xiàn)以上功能!

保存用戶

首先,我們創(chuàng)建一個(gè)types.ts文件來(lái)定義我們用到的User類型。后面所有類型定義都寫(xiě)在這個(gè)文件中。

touch types.ts

然后導(dǎo)出定義的User類型

export type User = { username: string; password: string };

好了。我們將使用內(nèi)存來(lái)保存所有的用戶,而不是數(shù)據(jù)庫(kù)或者其它方式。根目錄下創(chuàng)建一個(gè)data目錄,然后在里面新建users.ts文件

mkdir data
touch data/users.ts

現(xiàn)在在users.ts文件里創(chuàng)建一個(gè)User類型的空數(shù)組

import { User } from "../types";

const users: User[] = [];

提交新用戶

接下來(lái)我們希望向應(yīng)用提交一個(gè)新用戶。我們?cè)谶@里將要用到處理請(qǐng)求參數(shù)的中間件body-parse

yarn add body-parser

然后在主文件里導(dǎo)入并使用它

import express from 'express';
import bodyParser from 'body-parser';

const app = express();
const PORT = 3000;

app.use(bodyParser.urlencoded({ extended: false }));

app.get('/', (req, res) => {
  res.send('Hello world');
});

app.listen(PORT, () => {
  console.log(`Express with Typescript! http://localhost:${PORT}`);
});

最后,我們可以在users文件里創(chuàng)建POST請(qǐng)求處理程序。 該處理程序?qū)?zhí)行以下操作:

  • 校驗(yàn)請(qǐng)求體中是否包含了用戶名和密碼,并且進(jìn)行有效性驗(yàn)證
  • 一旦提交的用戶名密碼不正確返回狀態(tài)碼為400的錯(cuò)誤信息
  • 添加一個(gè)新用戶到users數(shù)組中
  • 返回一個(gè)201狀態(tài)的錯(cuò)誤信息

讓我們開(kāi)始,首先,在data/users.ts文件中創(chuàng)建一個(gè)addUser的方法

import { User } from '../types';

const users: User[] = [];

const addUser = (newUser: User) => {
  users.push(newUser);
};

然后回到index.ts文件中添加一條"/users"的路由

import express from 'express';
import bodyParser from 'body-parser';
import { addUser } from './data/users';

const app = express();
const PORT = 3000;

app.use(bodyParser.urlencoded({ extended: false }));

app.get('/', (req, res) => {
  res.send('Hello world');
});

app.post('/users', (req, res) => {
  const { username, password } = req.body;
  if (!username?.trim() || !password?.trim()) {
    return res.status(400).send('Bad username or password');
  }
  addUser({ username, password });
  res.status(201).send('User created');
});

app.listen(PORT, () => {
  console.log(`Express with Typescript! http://localhost:${PORT}`);
});

這里的邏輯不復(fù)雜,我們簡(jiǎn)單解釋一下,首先請(qǐng)求體中要包含usernamepassword兩個(gè)變量,而且使用trim()函數(shù)去除收尾的空字符,保證它的長(zhǎng)度大于0。如果不滿足,返回400狀態(tài)和自定義錯(cuò)誤信息。如果驗(yàn)證通過(guò),則將用戶信息添加到users數(shù)組并且返回201狀態(tài)回來(lái)。

注意:你有沒(méi)有發(fā)現(xiàn)users數(shù)組是沒(méi)有辦法知道有沒(méi)有同一個(gè)用戶被添加兩次的,我們暫且不考慮這種情況。

讓我們重新打開(kāi)一個(gè)終端(不要關(guān)掉運(yùn)行程序的終端),在終端里通過(guò)curl命令來(lái)發(fā)出一個(gè)POST請(qǐng)求注冊(cè)接口

curl -d "username=foo&password=bar" -X POST http://localhost:3000/users

你將會(huì)在終端的命令行中發(fā)現(xiàn)輸出了下面的信息

User created

然后再請(qǐng)求一次接口,這次password僅僅為空字符串,測(cè)試一下請(qǐng)求失敗的情況

curl -d "username=foo&password= " -X POST http://localhost:3000/users

沒(méi)有讓我們失望,成功返回了一下錯(cuò)誤信息

Bad username or password

登錄功能

登錄有點(diǎn)類似,我們從請(qǐng)求體中拿到usernamepassword的值然后通過(guò)Array.find方法去users數(shù)組中查找相同的用戶名和密碼組合,返回200狀態(tài)碼說(shuō)明用戶登錄成功,而401狀態(tài)碼表示用戶不被授權(quán),登錄失敗。

首先我們?cè)?code>data/users.ts文件中添加getUser方法:

import { User } from '../types';

const users: User[] = [];

export const addUser = (newUser: User) => {
  users.push(newUser);
};

export const getUser = (user: User) => {
  return users.find(
    (u) => u.username === user.username && u.password === user.password
  );
};

這里getUser方法將會(huì)從users數(shù)組里返回與之匹配用戶或者undefined。

接下來(lái)我們將在index.ts里調(diào)用getUser方法

import express from 'express';
import bodyParser from 'body-parser';
import { addUser, getUser } from "./data/users';

const app = express();
const PORT = 3000;

app.use(bodyParser.urlencoded({ extended: false }));

app.get('/', (req, res) => {
  res.send('Hello word');
});

app.post('/users', (req, res) => {
  const { username, password } = req.body;
  if (!username?.trim() || !password?.trim()) {
    return res.status(400).send('Bad username or password');
  }
  addUser({ username, password });
  res.status(201).send('User created');
});

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const found = getUser({username, password})
  if (!found) {
    return res.status(401).send('Login failed');
  }
  res.status(200).send('Success');
});

app.listen(PORT, () => {
  console.log(`Express with Typescript! http://localhost:${PORT}`);
});

現(xiàn)在我們還是用curl命令去請(qǐng)求注冊(cè)接口和登錄接口,登錄接口請(qǐng)求兩次,一次成功一次失敗

curl -d "username=joe&password=hard2guess" -X POST http://localhost:3000/users
# User created

curl -d "username=joe&password=hard2guess" -X POST http://localhost:3000/login
# Success

curl -d "username=joe&password=wrong" -X POST http://localhost:3000/login
# Login failed

沒(méi)問(wèn)題,結(jié)果都按我們預(yù)想的順利返回了

探索Express類型

您可能已經(jīng)發(fā)現(xiàn),講到現(xiàn)在,好像都是一些基礎(chǔ)的東西,Express里面比較深的概念沒(méi)有涉及到,比如自定義路由,中間件和句柄等功能,我們現(xiàn)在就來(lái)重構(gòu)它。

自定義路由類型

或許我們希望的是創(chuàng)建這樣一個(gè)標(biāo)準(zhǔn)的路由結(jié)構(gòu)像下面這樣

const route = {
  method: 'post',
  path: '/users',
  middleware: [middleware1, middleware2],
  handler: userSignup,
};

我們需要在types.ts文件中定義一個(gè)Route類型。同時(shí)也需要從Express庫(kù)中導(dǎo)出相關(guān)的類型:Request,ResponseNextFunctionRequest表示客戶端的請(qǐng)求數(shù)據(jù)類型,Response是從服務(wù)器返回值類型,NextFunction則是next()方法的簽名,如果使用過(guò)express的中間件應(yīng)該很熟悉。

types.ts文件中,重新定義Route類型

export type User = { username: string; password: string };

type Method =
  | 'get'
  | 'head'
  | 'post'
  | 'put'
  | 'delete'
  | 'connect'
  | 'options'
  | 'trace'
  | 'patch';

export type Route = {
  method: Method;
  path: string;
  middleware: any[];
  handler: any;
};

如果你熟悉express中間件的話,你應(yīng)該知道一個(gè)典型的中間件長(zhǎng)這樣:

function middleware(request, response, next) {
  // Do some logic with the request
  if (request.body.something === 'foo') {
    // Failed criteria, send forbidden resposne
    return response.status(403).send('Forbidden');
  }
  // Succeeded, go to the next middleware
  next();
}

由此可知,一個(gè)中間件需要傳入三個(gè)參數(shù),分別是Request,ResponseNextFunction類型。因此如果需要我們創(chuàng)建一個(gè)Middleware類型:

import { Request, Response, NextFunction } from 'express';

type Middleware = (req: Request, res: Response, next: NextFunction) => any;

然后express已經(jīng)有了一個(gè)叫RequestHandler類型,所以在這里我們只需要從express導(dǎo)出就好了,如果取個(gè)別名可以采用類型斷言。

import { RequestHandler as Middleware } from 'express';

export type User = { username: string; password: string };

type Method =
  | 'get'
  | 'head'
  | 'post'
  | 'put'
  | 'delete'
  | 'connect'
  | 'options'
  | 'trace'
  | 'patch';

export type Route = {
  method: Method;
  path: string;
  middleware: Middleware[];
  handler: any;
};

最后我們只需要為handler指定類型。這里的handler應(yīng)該是程序執(zhí)行的最后一步,因此我們?cè)谠O(shè)計(jì)的時(shí)候就不需要傳入next參數(shù)了,類型也就是RequestHandler去掉第三個(gè)參數(shù)。

import { Request, Response, RequestHandler as Middleware } from 'express';

export type User = { username: string; password: string };

type Method =
  | 'get'
  | 'head'
  | 'post'
  | 'put'
  | 'delete'
  | 'connect'
  | 'options'
  | 'trace'
  | 'patch';

export type Handler = (req: Request, res: Response) => any;

export type Route = {
  method: Method;
  path: string;
  middleware: Middleware[];
  handler: Handler;
};

添加一些項(xiàng)目結(jié)構(gòu)

我們需要通過(guò)增加一些結(jié)構(gòu)來(lái)把中間件和處理程序從index.ts文件中移除

創(chuàng)建處理器

我們把一些處理方法移到handlers目錄中

mkdir handlers
touch handlers/user.ts

那么在handlers/user.ts文件中,我們添加如下代碼。和用戶注冊(cè)相關(guān)的處理代碼已經(jīng)被我們從index.ts文件中重構(gòu)到這里。重要的是我們可以確定signup方法滿足我們定義的Handlers類型

import { addUser } from '../data/users';
import { Handler } from '../types';

export const signup: Handler = (req, res) => {
  const { username, password } = req.body;
  if (!username?.trim() || !password?.trim()) {
    return res.status(400).send('Bad username or password');
  }
  addUser({ username, password });
  res.status(201).send('User created');
};

同樣,我們把創(chuàng)建auth處理器添加login方法

touch handlers/auth.ts

添加以下代碼

import { getUser } from '../data/users';
import { Handler } from '../types';

export const login: Handler = (req, res) => {
  const { username, password } = req.body;
  const found = getUser({ username, password });
  if (!found) {
    return res.status(401).send('Login failed');
  }
  res.status(200).send('Success');
};

最后也給我們的首頁(yè)增加一個(gè)處理器

touch handlers/home.ts

功能很簡(jiǎn)單,只要輸出文本

import { Handler } from '../types';

export const home: Handler = (req, res) => {
  res.send('Hello world');
};
中間件

現(xiàn)在還沒(méi)有任何的自定義中間件,首先創(chuàng)建一個(gè)middleware目錄

mkdir middleware

我們將添加一個(gè)打印客戶端請(qǐng)求路徑的中間件,取名requestLogger.ts

touch middleware/requestLogger.ts

從express庫(kù)中導(dǎo)出需要定義的中間件類型的RequestHandler類型

import { RequestHandler as Middleware } from 'express';

export const requestLogger: Middleware = (req, res, next) => {
  console.log(req.path);
  next();
};
創(chuàng)建路由

既然我們已經(jīng)定義了一個(gè)新的Route類型和自己的一些處理器,就可以把路由定義獨(dú)立出來(lái)一個(gè)文件,在根目錄創(chuàng)建routes.ts

touch routes.ts

以下是該文件的所有代碼,為了演示就只給/login添加了requestLogger中間件

import { login } from './handlers/auth';
import { home } from './handlers/home';
import { signup } from './handlers/user';
import { requestLogger } from './middleware/requestLogger';
import { Route } from './types';

export const routes: Route[] = [
  {
    method: 'get',
    path: '/',
    middleware: [],
    handler: home,
  },
  {
    method: 'post',
    path: '/users',
    middleware: [],
    handler: signup,
  },
  {
    method: 'post',
    path: '/login',
    middleware: [requestLogger],
    handler: login,
  },
];
重構(gòu)index.ts文件

最后也是最重要的一步就是簡(jiǎn)化index.ts文件。我們通過(guò)一個(gè)forEach循環(huán)routes文件中聲明的路由信息來(lái)代替所有的route相關(guān)的代碼。這樣做最大的好處是為所有的路由定義了類型。

import express from 'express';
import bodyParser from 'body-parser';
import { routes } from './routes';

const app = express();
const PORT = 3000;

app.use(bodyParser.urlencoded({ extended: false }));

routes.forEach((route) => {
  const { method, path, middleware, handler } = route;
  app[method](path, ...middleware, handler);
});

app.listen(PORT, () => {
  console.log(`Express with Typescript! http://localhost:${PORT}`);
});

這樣看起來(lái)代碼結(jié)構(gòu)清晰多了,架構(gòu)的好處就是如此。另外有了Typescript強(qiáng)類型的支持,保證了程序的穩(wěn)定性。

完整代碼

Github:
https://github.com/fantingsheng/express-typescript-app

獲取更多作者文章,關(guān)注公眾號(hào)太空編程

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容