最近在作運營側中臺項目的重構,目前的選型是 koa2+typescript。在實際生產中,切實體會到了 typescript 類型帶來的好處。javascript
本文來自「心譚博客」的《當Koa趕上Typescript的時候》,更多文章放在了Githubhtml
歡迎交流和Star前端
爲了更形象說明 typescript 的優點,仍是先來看一個場景吧:java
做爲一門靈活度特別大的語言,壞處就是:複雜邏輯編寫過程當中,數據結構信息可能因爲邏輯複雜、人員變動等狀況而丟失,從而寫出來的代碼含有隱含錯誤。node
好比此次我在給本身的博客編寫node 腳本的時候就遇到了這種狀況:git
const result = [];
function findAllFiles(root) {
const files = fs.readdirSync(root);
files.forEach(name => {
const file = path.resolve(root, name);
if (isFolder(file)) {
findAllFiles(file);
} else {
result.push({
path: file,
check: false,
content: fs.readFileSync(file)
});
}
});
}
複製代碼
result 保存了遞歸遍歷的全部文件的 path、check、content 信息,其中 content 信息會被傳給prettier.js
的check(content: string, options: object)
方法。github
顯然,上述代碼是有錯誤的,可是極難發現。只有運行它的時候,才能經過堆棧報錯來進行定位。但若是藉助 ts,就能夠當即發現錯誤,保持代碼穩健。mongodb
這個問題放在文章最後再說,下面看看 ts 在 koa 項目中的運用吧。typescript
因爲沒有歷史包袱,整個項目的架構仍是很是清爽的。以下所示:npm
.
├── README.md
├── bin # 存放scripts的腳本文件
├── dist # 編譯打包後的js文件
├── docs # 詳細文檔
├── package.json # npm
├── sh # pm2等腳本
├── src # 項目源碼
├── tmp # 存放臨時文件的地方
└── tsconfig.json # typescript編譯配置
複製代碼
由於是用 ts 來編寫代碼,所以須要專門編寫 typescript 的配置文件:tsconfig.json
。根據我的習慣,以及以前組內的 ts 項目,配置以下:
{
"compilerOptions": {
"module": "commonjs", // 編譯生成的模塊系統代碼
"target": "es2017", // 指定ecmascript的目標版本
"noImplicitAny": true, // 禁止隱式any類型
"outDir": "./dist",
"sourceMap": false,
"allowJs": false, // 是否容許出現js
"newLine": "LF"
},
"include": ["src/**/*"]
}
複製代碼
對於一些有歷史遺留的項目,或者說用 js 逐步重構爲 ts 的項目來講,因爲存在大量的 js 遺留代碼,所以allowJs
這裏應該爲true
,noImplicitAny
應該爲false
。
在package.json
中,配置兩個腳本,一個是 dev 模式,另外一個是 prod 模式:
{
"scripts": {
"dev": "tsc --watch & export NODE_ENV=development && node bin/dev.js -t dist/ -e dist/app.js",
"build": "rm -rf dist/* && tsc"
}
}
複製代碼
在 dev 模式下,須要 tsc 監聽配置中include
中指定的 ts 文件的變化,而且實時編譯。bin/dev.js
是根據項目須要編寫的監聽腳本,它會監聽dist/
目錄中編譯後的 js 文件,一旦有知足重啓條件,就重啓服務器。
koajs 與常見插件的類型聲明都要在@types 下安裝:
npm i --save-dev @types/koa @types/koa-router @types/koa2-cors @types/koa-bodyparser
複製代碼
爲了方便以後的開發和上線,src/config/
目錄以下:
.
├── dev.ts
├── index.ts
└── prod.ts
複製代碼
配置分爲 prod 和 dev 兩份。dev 模式下,向控制檯打印信息;在 prod 下,須要向指定位置寫入日誌信息。相似的,dev 下不須要進行身份驗證,prod 下須要內網身份驗證。所以,利用 ts 的extends
特性來複用數據聲明:
// mode: dev
export interface ConfigScheme {
// 監聽端口
port: number;
// mongodb配置
mongodb: {
host: string;
port: number;
db: string;
};
}
// mode: prod
export interface ProdConfigScheme extends ConfigScheme {
// 日誌存儲位置
logRoot: string;
}
複製代碼
在 index.ts 中,經過process.env.NODE_ENV
變量值來判斷模式,進而導出對應的配置。
import { devConf } from "./dev";
import { prodConf } from "./prod";
const config = process.env.NODE_ENV === "development" ? devConf : prodConf;
export default config;
複製代碼
如此,外界直接引入便可。但在開發過程當中,例如身份認證中間件。雖然 dev 模式下不會開啓,但編寫它的時候,引入的config
類型是ConfigScheme
,在訪問ProdConfigScheme
上的字段時候 ts 編譯器會報錯。
這時候,ts 的斷言就派上用場了:
import config, { ProdConfigScheme } from "./../config/";
const { logRoot } = config as ProdConfigScheme;
複製代碼
對於總體項目,和 koa 關聯較大的業務邏輯主要體如今中間件。這裏以運營系統必有的「操做留存中間件」的編寫爲例,展現如何在 ts 中編寫中間件的業務邏輯和數據邏輯。
引入 koa 以及編寫好的輪子:
import * as Koa from "koa";
import { print } from "./../helpers/log";
import config from "./../config/";
import { getDB } from "./../database/mongodb";
const { mongodb: mongoConf } = config; // mongo配置
const collectionName = "logs"; // 集合名稱
複製代碼
操做留存中須要留存的數據字段有:
staffName: 操做人
visitTime: 操做時間
url: 接口地址
params: 前端傳來的全部參數
複製代碼
ts 中藉助 interface 直接約束字段類型便可。一目瞭然,對於以後的維護者來講,基本不須要藉助文檔,便可理解咱們要和 db 交互的數據結構。
interface LogScheme {
staffName: string;
visitTime: string;
url: string;
params?: any;
}
複製代碼
最後,編寫中間件函數邏輯,參數須要指明類型。固然,直接指明參數是 any 類型也能夠,但這樣和 js 就沒差異,並且也體會不到 ts 帶來文檔化編程的好處。
由於以前已經安裝了@types/koa
,所以這裏不須要咱們手動編寫 .d.ts
文件。而且,koa 的內置數據類型已經被掛在了前面 import 進來的Koa
上了(是的,ts 幫咱們作了不少事情)。上下文的類型就是 Koa.BaseContext
,回調函數類型是() => Promise<any>
async function logger(ctx: Koa.BaseContext, next: () => Promise<any>) {
const db = await getDB(mongoConf.db); // 從db連接池中獲取連接實例
if (!db) {
ctx.body = "mongodb errror at controllers/logger";
ctx.status = 500;
return;
}
const doc: LogScheme = {
staffName: ctx.headers["staffname"] || "unknown",
visitTime: Date.now().toString(10),
url: ctx.url,
params: ctx.request.body
};
// 不須要await等待這段邏輯執行完畢
db.collection(collectionName)
.insertOne(doc)
.catch(error =>
print(`fail to log info to mongo: ${error.message}`, "error")
);
return next();
}
export default logger;
複製代碼
這裏以一個日誌輸出的單元函數爲例,說一下「索引簽名」的應用。
首先,經過聯合類型約束了日誌級別:
type LogLevel = "log" | "info" | "warning" | "error" | "success";
複製代碼
此時,打算準備一個映射:日誌等級 => 文件名稱 的數據結構,例如 info 級別的日誌對應輸出的文件就是 info.log
。顯然,這個 object 的全部 key,必須符合 LogLevel。寫法以下:
const localLogFile: {
[level in LogLevel]: string | void;
} = {
log: "info.log",
info: "info.log",
warning: "warning.log",
error: "error.log",
success: "success.log"
};
複製代碼
若是對於 log 級別的日誌,不須要輸出到文件僅僅須要打印到控制檯。那麼localLogFile
應該沒有log
字段,若是直接去掉log
字段,ts 編譯器報錯以下:
Property 'log' is missing in type '{ info: string; warning: string; error: string; success: string; }' but required in type '{ log: string | void; info: string | void; warning: string | void; error: string | void; success: string | void; }'.
複製代碼
根據錯誤,這裏將索引簽名字段設置爲「可選」便可:
const localLogFile: {
[level in LogLevel]?: string | void;
} = {
info: "info.log",
warning: "warning.log",
error: "error.log",
success: "success.log"
};
複製代碼
使用export
導出複雜對象時候,請加上類型聲明,不要依賴與 ts 的類型推斷。
index.ts
:
import level0 from "./level0";
export interface ApiScheme {
method: ApiMethod;
host: string;
}
export interface ApiSet {
[propName: string]: ApiScheme;
}
export const apis: ApiSet = {
...level0
};
複製代碼
level0.ts
:
import { ApiSet } from "./index";
// 聲明導出對象的數據類型
export const level0: ApiSet = {
"qcloud.tcb.getPackageInfo": {
method: "post",
host: tcb.dataUrl
},
"qcloud.tcb.getAlarmRecord": {
method: "post",
host: tcb.dataUrl
}
};
複製代碼
回到開頭的場景,若是用 typescript,咱們會先聲明result
中每一個對象的格式:
interface FileInfo {
path: string;
check: boolean;
content: string;
}
const result: FileInfo[] = [];
複製代碼
此時,你會發現 typescript 編譯器已經給出了報錯,在 content: fs.readFileSync(file)
這一行中,報錯信息以下:
不能將類型「Buffer」分配給類型「string」。
複製代碼
如此,在編寫代碼的時候,就能當即發現錯誤。而不是寫了幾百行,而後跑起來後,根據堆棧報錯一行行去定位問題。
仔細想一下,若是是 30 我的合做的大型 node/前端項目,出錯的風險會有多高?定位錯誤成本會有多高?因此,只想說 ts 真香!