當Koa趕上Typescript的時候

最近在作運營側中臺項目的重構,目前的選型是 koa2+typescript。在實際生產中,切實體會到了 typescript 類型帶來的好處。javascript

本文來自「心譚博客」《當Koa趕上Typescript的時候》,更多文章放在了Githubhtml

歡迎交流和Star前端

爲了更形象說明 typescript 的優點,仍是先來看一個場景吧:java

BUG 現場

做爲一門靈活度特別大的語言,壞處就是:複雜邏輯編寫過程當中,數據結構信息可能因爲邏輯複雜、人員變動等狀況而丟失,從而寫出來的代碼含有隱含錯誤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.jscheck(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編譯配置
複製代碼

typescript 編譯與 npm 配置

由於是用 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這裏應該爲truenoImplicitAny應該爲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
複製代碼

區分 dev/prod 環境

爲了方便以後的開發和上線,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

使用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 真香!

參考書籍

相關文章
相關標籤/搜索