TypeScript在node項目中的實踐

  • 2019 年 12 月 9 日
  • 筆記

TypeScript在node項目中的實踐

TypeScript可以理解為是JavaScript的一個超集,也就是說涵蓋了所有JavaScript的功能,並在之上有著自己獨特的語法。 最近的一個新項目開始了TS的踩坑之旅,現分享一些可以借鑒的套路給大家。

為什麼選擇TS

作為巨硬公司出品的一個靜態強類型編譯型語言,該語言已經出現了幾年的時間了,相信在社區的維護下,已經是一門很穩定的語言。 我們知道,JavaScript是一門動態弱類型解釋型腳本語言,動態帶來了很多的便利,我們可以在程式碼運行中隨意的修改變數類型以達到預期目的。 但同時,這是一把雙刃劍,當一個龐大的項目出現在你的面前,面對無比複雜的邏輯,你很難通過程式碼看出某個變數是什麼類型,這個變數要做什麼,很可能一不小心就會踩到坑。

而靜態強類型編譯能夠帶來很多的好處,其中最重要的一點就是可以幫助開發人員杜絕一些馬虎大意的問題:

圖為rollbar統計的數千個項目中數量最多的前十個異常

不難看出,因為類型不匹配、變數為空導致的異常比你敢承認的次數要多。 譬如

而這一點在TS中得到了很好的改善,任何一個變數的引用,都需要指定自己的類型,而你下邊在程式碼中可以用什麼,支援什麼方法,都需要在上邊進行定義:

這個提示會在開發、編譯期來提示給開發者,避免了上線以後發現有問題,再去修改。

另外一個由靜態編譯類型帶來的好處,就是函數簽名。 還是就像上邊所說的,因為是一個動態的腳本語言,所以很難有編輯器能夠在開發期間正確地告訴你所要調用的一個函數需要傳遞什麼參數,函數會返回什麼類型的返回值。

而在TS中,對於一個函數,首先你需要定義所有參數的類型,以及返回值的類型。 這樣在函數被調用時,我們就可以很清晰的看到這個函數的效果:

這是最基礎的、能夠讓程式更加穩定的兩個特性,當然,還有更多的功能在TS中的:TypeScript | Handbook

TypeScript在node中的應用

在TS的官網中,有著大量的示例,其中就找到了Express版本的例子,針對這個稍作修飾,應用在了一個 koa 項目中。

環境依賴

在使用TS之前,需要先準備這些東西:

  1. VS code,同為巨硬公司出品,本身就是TS開發的,遂該編輯器是目前對TS支援度最高的一個
  2. Node.js 推薦8.11版本以上
  3. npm i -g typescript,全局安裝TS,編譯所使用的tsc命令在這裡
  4. npm i -g nodemon,全局安裝nodemon,在tsc編譯後自動刷新伺服器程式

以項目中使用的一些核心依賴:

  1. reflect-metadata: 大量裝飾器的包都會依賴的一個基礎包,用於注入數據
  2. routing-controllers: 使用裝飾器的方式來進行koa-router的開發
  3. sequelize: 抽象化的資料庫操作
  4. sequelize-typescript: 上述插件的裝飾器版本,定義實體時使用

項目結構

首先,放出目前項目的結構:

.  ├── README.md  ├── copy-static-assets.ts  ├── nodemon.json  ├── package-lock.json  ├── package.json  ├── dist  ├── src  │   ├── config  │   ├── controllers  │   ├── entity  │   ├── models  │   ├── middleware  │   ├── public  │   ├── app.ts  │   ├── server.ts  │   ├── types  │   └── utils  ├── tsconfig.json  └── tslint.json

src為主要開發目錄,所有的TS程式碼都在這裡邊,在經過編譯過後,會生成一個與src同級的dist文件夾,這個文件夾是node引擎實際運行的程式碼。 在src下,主要程式碼分為了如下結構(依據自己項目的實際情況進行增刪):

#

folder

desc

1

controllers

用於處理介面請求,原apps、routes文件夾。

2

middleware

存放了各種中間件、全局 or 自定義的中間件

3

config

各種配置項的位置,包括埠、log路徑、各種巴拉巴拉的常量定義。

4

entity

這裡存放的是所有的實體定義(使用了sequelize進行資料庫操作)。

5

models

使用來自entity中的實體進行sequelize來完成初始化的操作,並將sequelize對象拋出。

6

utils

存放的各種日常開發中提煉出來的公共函數

7

types

存放了各種客制化的複合類型的定義,各種結構、屬性、方法返回值的定義(目前包括常用的Promise版redis與qconf)

controllers

controllers只負責處理邏輯,通過操作model對象,而不是資料庫來進行數據的增刪改查

鑒於公司絕大部分的Node項目版本都已經升級到了Node 8.11,理所應當的,我們會嘗試新的語法。 也就是說我們會拋棄Generator,擁抱async/await

使用KoaExpress寫過介面的童鞋應該都知道,當一個項目變得龐大,實際上會產生很多重複的非邏輯程式碼:

router.get('/', ctx => {})  router.get('/page1', ctx => {})  router.get('/page2', ctx => {})  router.get('/page3', ctx => {})  router.get('/pageN', ctx => {})

而在每個路由監聽中,又做著大量重複的工作:

router.get('/', ctx => {    let uid = Number(ctx.cookies.get('uid'))    let device = ctx.headers['device'] || 'ios'    let { tel, name } = ctx.query  })

幾乎每一個路由的頭部都是在做著獲取參數的工作,而參數很可能來自headerbody甚至是cookiequery

所以,我們對原來koa的使用方法進行了一個較大的改動,並使用routing-controllers大量的應用裝飾器來幫助我們處理大部分的非邏輯程式碼。

原有router的定義:

module.exports = function (router) {    router.get('/', function* (next) {      let uid = Number(this.cookies.get('uid'))      let device = this.headers['device']        this.body = {        code: 200      }    })  }

使用了TypeScript與裝飾器的定義:

@Controller  export default class {    @Get('/')    async index (      @CookieParam('uid') uid: number,      @HeaderParam('device') device: string    ) {      return {        code: 200      }    }  }

為了使介面更易於檢索、更清晰,所以我們拋棄了原有的bd-router的功能(依據文件路徑作為介面路徑、TS中的文件路徑僅用於文件分層)。 直接在controllers下的文件中聲明對應的介面進行監聽。

middleware

如果是全局的中間件,則直接在class上添加@Middleware裝飾器,並設置type: 'after|before'即可。 如果是特定的一些中間件,則創建一個普通的class即可,然後在需要使用的controller對象上指定@UseBefore/@UseAfter(可以寫在class上,也可以寫在method上)。

所有的中間件都需要繼承對應的MiddlewareInterface介面,並需要實現use方法

// middleware/xxx.ts  import {ExpressMiddlewareInterface} from "../../src/driver/express/ExpressMiddlewareInterface"    export class CompressionMiddleware implements KoaMiddlewareInterface {    use(request: any, response: any, next?: Function): any {      console.log("hello compression ...")      next()    }  }    // controllers/xxx.ts  @UseBefore(CompressionMiddleware)  export default class { }

entity

文件只負責定義數據模型,不做任何邏輯操作

同樣的使用了sequelize+裝飾器的方式,entity只是用來建立與資料庫之間通訊的數據模型。

import { Model, Table, Column } from 'sequelize-typescript'    @Table({    tableName: 'user_info_test'  })  export default class UserInfo extends Model<UserInfo> {    @Column({      comment: '自增ID',      autoIncrement: true,      primaryKey: true    })    uid: number      @Column({      comment: '姓名'    })    name: string      @Column({      comment: '年齡',      defaultValue: 0    })    age: number      @Column({      comment: '性別'    })    gender: number  }

因為sequelize建立連接也是需要對應的資料庫地址、賬戶、密碼、database等資訊、所以推薦將同一個資料庫的所有實體放在一個目錄下,方便sequelize載入對應的模型 同步的推薦在config下創建對應的配置資訊,並添加一列用於存放實體的key。 這樣在建立資料庫鏈接,載入數據模型時就可以動態的導入該路徑下的所有實體:

// config.ts  export const config = {    // ...    mysql1: {      // ... config  +   entity: 'entity1' // 添加一列用來標識是什麼實體的key    },    mysql2: {      // ... config  +   entity: 'entity2' // 添加一列用來標識是什麼實體的key    }    // ...  }    // utils/mysql.ts  new Sequelize({    // ...    modelPath: [path.reolve(__dirname, `../entity/${config.mysql1.entity}`)]    // ...  })

model

model的定位在於根據對應的實體創建抽象化的資料庫對象,因為使用了sequelize,所以該目錄下的文件會變得非常簡潔。 基本就是初始化sequelize對象,並在載入模型後將其拋出。

export default new Sequelize({    host: '127.0.0.1',    database: 'database',    username: 'user',    password: 'password',    dialect: 'mysql', // 或者一些其他的資料庫    modelPaths: [path.resolve(__dirname, `../entity/${configs.mysql1.entity}`)], // 載入我們的實體    pool: { // 連接池的一些相關配置      max: 5,      min: 0,      acquire: 30000,      idle: 10000    },    operatorsAliases: false,    logging: true // true會在控制台列印每次sequelize操作時對應的SQL命令  })

utils

所有的公共函數,都放在這裡。 同時推薦編寫對應的索引文件(index.ts),大致的格式如下:

// utils/get-uid.ts  export default function (): number {    return 123  }    // utils/number-comma.ts  export default function(): string {    return '1,234'  }    // utils/index.ts  export {default as getUid} from './get-uid'  export {default as numberComma} from './number-comma'

每添加一個新的util,就去index中添加對應的索引,這樣帶來的好處就是可以通過一行來引入所有想引入的utils

import {getUid, numberComma} from './utils'

configs

configs下邊存儲的就是各種配置資訊了,包括一些第三方介面URL、資料庫配置、日誌路徑。 各種balabala的靜態數據。 如果配置文件多的話,建議拆分為多個文件,然後按照utils的方式編寫索引文件。

types

這裡存放的是所有的自定義的類型定義,一些開源社區沒有提供的,但是我們用到的第三方插件,需要在這裡進行定義,一般來說常用的都會有,但是一些小眾的包可能確實沒有TS的支援,例如我們有使用的一個node-qconf

// types/node-qconf.d.ts  export function getConf(path: string): string | null  export function getBatchKeys(path: string): string[] | null  export function getBatchConf(path: string): string | null  export function getAllHost(path: string): string[] | null  export function getHost(path: string): string | null

類型定義的文件規定後綴為 .d.ts types下邊的所有文件可以直接引用,而不用關心相對路徑的問題(其他普通的model則需要寫相對路徑,這是一個很尷尬的問題)。

目前使用TS中的一些問題

當前GitHub倉庫中,有2600+的開啟狀態的issues,篩選bug標籤後,依然有900+的存在。 所以很難保證在使用的過程中不會踩坑,但是一個項目擁有這麼多活躍的issues,也能從側面說明這個項目的受歡迎程度。

目前遇到的唯一一個比較尷尬的問題就是: 引用文件路徑一定要寫全。。

import module from '../../../../f**k-module'

小結

初次嘗試TypeScript,深深的喜歡上了這個語言,雖說也會有一些小小的問題,但還是能克服的:)。 使用一門靜態強類型編譯語言,能夠將很多bug都消滅在開發期間。

基於上述描述的一個簡單示例:程式碼倉庫

希望大家玩得開心,如有任何TS相關的問題,歡迎來騷擾。NPM loves U.