TypeScript在node專案中的實踐

賈順名發表於2018-07-21

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

為什麼選擇TS

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

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

image

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

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

TypeScript在node專案中的實踐

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

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

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

TypeScript在node專案中的實踐

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

TypeScript在node專案中的實踐

這是最基礎的、能夠讓程式更加穩定的兩個特性,當然,還有更多的功能在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 用於處理介面請求,原appsroutes資料夾。
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中的一些問題

TypeScript在node專案中的實踐

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

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

import module from '../../../../f**k-module'
複製程式碼

小結

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

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

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

相關文章