ThinkJS 3.0 如何實現對 TypeScript 的支援

lizheming發表於2017-11-15

ThinkJS 3.0 是一款面向未來開發的 Node.js 框架,核心基於 Koa 2.0。 3.0 相比 2.0 版本進行了模組化改造,使得核心本身只包含了最少量必須的程式碼,甚至還不足以構成一個完整的 Web MVC 框架,除了核心裡面實現的 Controller, View 和 Model 被實現為擴充套件(Extend)模組 think-viewthink-model,這樣實現的好處也是顯而易見的,如果我的 Web 服務只是簡單的 RESTful API,就不需要引入 View 層,讓程式碼保持輕快。

think-cli 2.0 新版釋出

在本文釋出的同時 ThinkJS 團隊釋出了新版的腳手架 think-cli 2.0,新版腳手架最大的特點是腳手架和模板分離,可以在不修改腳手架的基礎上新增各種專案啟動模板,如果老司機想跳過下面實現細節,快速開始嘗試 TypeScript 下的 ThinkJS 3.0, 可以用 think-cli 2.0 和 TypeScript 的官方模板:

npm install -g thinkjs-cli@2
thinkjs new project-name typescript
複製程式碼

實現支援 TypeScript

TypeScript 是 JavaScript 的超集,其最大的的特性是引入了靜態型別檢查,按照一般的經驗,在中大型的專案上引入 TypeScript 收穫顯著,並有相當的使用群體,這也就堅定了 ThinkJS 3.0 支援 TypeScript 的決心。我們希望 TS 版本的程式碼對使用者的侵入儘可能的小,配置足夠簡單,並且介面定義準確,清晰。基於這樣的目的,本文在接下來的章節會探討在實現過程中的一些思考和方案。

繼承 Koa 的定義

因為 ThinkJS 3.0 基於 Koa,我們需要把型別定義構建在其定義之上,大概的思路就是用繼承的方式定義 ThinkJS 自己的介面並新增自己的擴充套件實現,最後再組織起來。話是這麼說,還是趕緊寫點程式碼驗證一下。發現 Koa 的 TS 定義沒有自己實現而是在 DefinitelyTyped 裡面,這種情況多數是庫的作者沒有實現 TypeScript 介面定義,由社群的夥伴實現出來了並上傳,方便大家使用,而 ThinkJS 本身計劃支援 TypeScript,所有後面的實現都是定義在專案的 index.d.ts 檔案裡面。好回到程式碼,首先安裝 Koa 和型別定義。

npm install koa @types/koa

然後在 ThinkJS 專案裡面新增 index.d.ts, 並在 package.json 裡面新增 "type": "index.d.ts",,這樣 IDE (比如 VSCode)就能知道這個專案的型別定義檔案的位置,我們需要一個原型來驗證想法的可行性:

  // in thinkjs/index.d.ts

  import * as Koa from 'koa';

  interface Think {
    app: Koa;
  }
  // expect think to be global variable
  declare var think: Think;
複製程式碼
  // in Controller

  import ”thinkjs“;
  // bellow will cause type error
  think.app
複製程式碼

出師不利,這樣的定義是不能正常工作的,IDE 的輸入感知也不會生效,原因是 TypeScript 為了避免全域性汙染,嚴格區分模組 scope 和全域性定義的 scope, 一旦使用了 import 或者 export 就會認為是模組,think 變數就只存在於模組 scope 裡面了。仔細一想這種設定也合理,於是修改程式碼,改成模組。改成模組後與JS版本的區別是 TypeScript 裡面需要顯式獲取 think 物件:

  // in thinkjs/index.d.ts

  import * as Koa from 'koa';

  declare namespace ThinkJS {
    interface Think {
      app: Koa;
    }
    export var think: Think;
  }
  export = ThinkJS
複製程式碼
  // in Controller
  import { think } from ”thinkjs“;

  // working!
  think.app
複製程式碼

經過驗證果然行得通,準備新增更多實現。

基本雛形

接下來先實現一版基本的架子,這個架子基本上反應了 ThinkJS 裡面最重要的類和他們之間的關係。

import * as Koa from 'koa';
import * as Helper from 'think-helper';
import * as ThinkCluster from 'think-cluster';

declare namespace 'ThinkJS' {

  export interface Application extends Koa {
    think: Think;
    request: Request;
    response: Response;
  }

  export interface Request extends Koa.Request {
  }

  export interface Response extends Koa.Response {
  }

  export interface Context extends Koa.Context {
    request: Request;
    response: Response;
  }

  export interface Controller {
    new(ctx: Context): Controller;
    ctx: Context;
    body: any;
  }

  export interface Service {
    new(): Service;
  }

  export interface Logic {
    new(): Logic;
  }

  export interface Think extends Helper.Think {
    app: Application;
    Controller: Controller;
    Logic: Logic;
    Service: Service; 
  }

  export var think: Think;
}


export = ThinkJS;
複製程式碼

這裡面定義到的類都是 ThinkJS 裡面支援擴充套件的型別,為了簡潔起見省略了許多方法和欄位的定義,需要指出的是 ControllerServiceLogic這三個介面需要被繼承 extends,要求實現構造器並返回本身型別的一個例項。架子基本確定,開始定義介面。

定義介面

定義介面是整個實現最難的部分,在過程中走了不少彎路。主要原因是 ThinkJS 3.0 高度模組化,程式裡面用到的 Extend 方法都由具體模組生成,我們的實現方案也經歷了幾個階段,簡單列舉一下這個過程。

全量定義

這是第一階段 ThinkJS 3.0 支援 TypeScript 的方案, 當時對全域性 scope 和模組 scope 的問題還不是很清晰,以至於一些想法得不到驗證,也漸漸偏離了最佳的方案。當時考慮到擴充套件模組不是很多,直接全量定義所有擴充套件介面,這樣使用者不管有沒有引入某個 Extend 模組,都能獲得模組的介面提示。這樣做的弊端有很多,比如無法支援專案內 Extend 等,但這個方案的好處是需要使用者關注的東西最少,程式碼開箱即用。

增量模組

我們清楚按需引入才是最理想的方案,後來我們發現 TypeScript 有一個特性叫 Module Augmentation ,其實這個特性最大用處就是可以在不同模組擴充某一個模組的介面定義,讓增量模組定義生效很重要的一點前提是,需要使用者在檔案中顯式載入對應的模組,也就是讓 TypeScript 知道誰對模組實現了增量定義。比如,要想獲得 think-view 定義的增量介面,需要在 Controller 實現中引入:

import { think } from "thinkjs";
import "think-view";
// import "think-model";
export default class extends think.Controller {
  indexAction() {
    this.model();  // reports an error
    this.display(); // OK
  }
}
複製程式碼
// in think-view
declare module 'thinkjs' {
  interface Controller {
    dispay(): void
  }
}
複製程式碼
// in think-model
declare module 'thinkjs' {
  interface Controller {
    model(): void
  }
}
複製程式碼

這樣寫很麻煩,但如果不去 import TypeScript 是無法完成提示和追溯的,一個簡化版本是我們可以在一個檔案裡面定義所有的用到的 Extend 模組,並輸出 think 物件,比如

// think.js
import { think } from "thinkjs";
import "think-view";
import "think-model";
// import the rest extend module
// import project exnted files
export default think;
複製程式碼
// some_controller.js
import think from './think.js';
export default class extends think.Controller {
  indexAction() {
    this.model();
    this.display();
  }
}
複製程式碼

這樣問題已經基本解決了,只是用了相對路徑,如果在多級目錄下路徑就比較凌亂,有沒有更好的方案呢?

黑科技:path

我們知道 Webpack 裡面有一個非常好用的功能是 alias,就是用來解決相對路徑引用問題的,發現 TypeScript 也有類似概念叫 compilerOptions.path,相當於對某個路徑定義了一個縮寫,這樣只要對剛才的定義檔案新增到 compilerOptions.path 裡面,並且縮寫名稱叫 thinkjs(定義成 thinkjs 這樣編譯後就能正常執行, 下面會提到),那 Controller 的實現就毫無違和感了:

import {think} from 'thinkjs';
export default class extends think.Controller {
  indexAction() {
    this.model();
    this.display();
  }
}
複製程式碼
import * as ThinkJS from '../node_modules/thinkjs';
import 'think-view';
import 'think-model';

// other extend modules
// ...
export const think = ThinkJS.think;
複製程式碼

注意到這裡 ThinkJS 是通過相對路徑引用的,因為 'thinkjs' 模組已經被重定向,這裡還需要一個小小的黑科技來騙過 TypeScript 讓其知道模組 '../node_modules/thinkjs'‘thinkjs'

  // in thinkjs/index.d.ts

  import { Think } from 'thinkjs';

  // this is a external module
  declare module ‘thinkjs’ {
    // put all declaration in here
  }

  // curently TypeScript think this is in '../node_modules/thinkjs' module
  declare namespace ThinkJS {
    export var think: Think;
  }

  export = ThinkJS;
複製程式碼

對於實現,其實我們更關心介面的優雅,也許後面有更合理的實現,但是前提是寫法要保持簡潔。

引入專案擴充套件

專案裡面的擴充套件同樣使用增量模組定義,程式碼如下

declare module 'thinkjs' {
  export interface Controller {
    yourControllerExtend(): void
  }
}

const controller = {
  yourControllerExtend() {
    // do something
  }
};

export default controller;
複製程式碼

ThinkJS 支援擴充套件的物件總共有8個,為了方便,在 think-cli 2.0 版本中,TypeScript 的官方模板預設生成所有物件的定義,並在 src/index.ts 裡面引入。

import * as ThinkJS from '../node_modules/thinkjs';

import './extend/controller';
import './extend/logic';
import './extend/context';
import './extend/think';
import './extend/service';
import './extend/application';
import './extend/request';
import './extend/response'; 

// import the rest extends modules on need

export const think = ThinkJS.think;
複製程式碼

完善介面

最後就是一些介面的定義和新增文件,相當於從原始碼結合著文件,把所有 ThinkJS 3.0 的介面都定義出來, 最終目的是能提供一個清晰的開發介面提示,舉個例子

*
* get config
* @memberOf Controller
*/
config(name: string): Promise<string>;
/**
 * set config
 * @memberOf Controller
 */
config(name: string, value: string): Promise<string>;
複製程式碼

TSLint

我們基於 ThinkJS 專案的特點配置了一套 tslint 的規則並保證開箱程式碼符合規範。

編譯部署

在開發環境可以使用 think-typescript 編譯,還支援 tsc 直接編譯,之前 import { think } from 'thinkjs' 會被編譯為

const thinkjs_1 = require("thinkjs");
class default_1 extends thinkjs_1.think.Controller {
複製程式碼

這個路徑並沒有按照 compileOptions.path 的配置進行相對路徑的計算,但是不管哪種方式都能正常工作,而且當前方式的結果更為理想,只是要求縮寫名一定是 thinkjs 。

最後

在用 VSCode 開發 TypeSccript 的 ThinkJS 3.0 過程中,能獲得智慧感知和更多的錯誤提示,感覺程式碼得到了更多的保護和約束,有點之前在後端寫 Java 的體驗,如果還沒有嘗試過 TypeScript 的同學,趕緊來試試吧。


相關文章