ThinkJS 3.0 如何實現對 TypeScript 的支持

ThinkJS 3.0 是一款面向未來開發的 Node.js 框架,內核基於 Koa 2.0。 3.0 相比 2.0 版本進行了模塊化改造,使得內核本身只包含了最少量必須的代碼,甚至還不足以構成一個完整的 Web MVC 框架,除了內核裡面實現的 Controller, View 和 Model 被實現為擴展(Extend)模塊 think-view 和 think-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@2nthinkjs new project-name typescriptn

實現支持 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/koan

然後在 ThinkJS 項目裡面添加 index.d.ts, 並在 package.json 裡面添加 "type": "index.d.ts",,這樣 IDE (比如 VSCode)就能知道這個項目的類型定義文件的位置,我們需要一個原型來驗證想法的可行性:

// in thinkjs/index.d.tsnn import * as Koa from koa;nn interface Think {n app: Koa;n }n // expect think to be global variablen declare var think: Think;nn // in Controllernn import thinkjs;n // bellow will cause type errorn think.appn

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

// in thinkjs/index.d.tsnn import * as Koa from koa;nn declare namespace ThinkJS {n interface Think {n app: Koa;n }n export var think: Think;n }n export = ThinkJSnn // in Controllern import { think } from thinkjs;nn // working!n think.appn

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

基本雛形

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

import * as Koa from koa;nimport * as Helper from think-helper;nimport * as ThinkCluster from think-cluster;nndeclare namespace ThinkJS {nn export interface Application extends Koa {n think: Think;n request: Request;n response: Response;n }nn export interface Request extends Koa.Request {n }nn export interface Response extends Koa.Response {n }nn export interface Context extends Koa.Context {n request: Request;n response: Response;n }nn export interface Controller {n new(ctx: Context): Controller;n ctx: Context;n body: any;n }nn export interface Service {n new(): Service;n }nn export interface Logic {n new(): Logic;n }nn export interface Think extends Helper.Think {n app: Application;n Controller: Controller;n Logic: Logic;n Service: Service; n }nn export var think: Think;n}nnnexport = ThinkJS;n

這裡面定義到的類都是 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";nimport "think-view";n// import "think-model";nexport default class extends think.Controller {n indexAction() {n this.model(); // reports an errorn this.display(); // OKn }n}nn// in think-viewndeclare module thinkjs {n interface Controller {n dispay(): voidn }n}nn// in think-modelndeclare module thinkjs {n interface Controller {n model(): voidn }n}n

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

// think.jsnimport { think } from "thinkjs";nimport "think-view";nimport "think-model";n// import the rest extend modulen// import project exnted filesnexport default think;nn// some_controller.jsnimport think from ./think.js;nexport default class extends think.Controller {n indexAction() {n this.model();n this.display();n }n}n

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

黑科技:path

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

import {think} from thinkjs;nexport default class extends think.Controller {n indexAction() {n this.model();n this.display();n }n}nnimport * as ThinkJS from ../node_modules/thinkjs;nimport think-view;nimport think-model;nn// other extend modulesn// ...nexport const think = ThinkJS.think;n

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

// in thinkjs/index.d.tsnn import { Think } from thinkjs;nn // this is a external modulen declare module thinkjs {n // put all declaration in heren }nn // curently TypeScript think this is in ../node_modules/thinkjs modulen declare namespace ThinkJS {n export var think: Think;n }nn export = ThinkJS;n

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

引入項目擴展

項目裡面的擴展同樣使用增量模塊定義,代碼如下

declare module thinkjs {n export interface Controller {n yourControllerExtend(): voidn }n}nnconst controller = {n yourControllerExtend() {n // do somethingn }n};nnexport default controller;n

ThinkJS 支持擴展的對象總共有8個,為了方便,在 think-cli 2.0 版本中,TypeScript 的官方模板默認生成所有對象的定義,並在 src/index.ts 裡面引入。

import * as ThinkJS from ../node_modules/thinkjs;nnimport ./extend/controller;nimport ./extend/logic;nimport ./extend/context;nimport ./extend/think;nimport ./extend/service;nimport ./extend/application;nimport ./extend/request;nimport ./extend/response; nn// import the rest extends modules on neednnexport const think = ThinkJS.think;n

完善介面

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

/*n* get confign* @memberOf Controllern*/nconfig(name: string): Promise<string>;n/**n * set confign * @memberOf Controllern */nconfig(name: string, value: string): Promise<string>;n

TSLint

我們基於 ThinkJS 項目的特點配置了一套 tslint 的規則並保證開箱代碼符合規範。

編譯部署

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

const thinkjs_1 = require("thinkjs");nclass default_1 extends thinkjs_1.think.Controller {n

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

最後

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


推薦閱讀:

TypeScript 2.1中的類型運算
TypeScript入門
是時候再給TypeScript一次機會了【譯】
你所不知道的 Typescript 與 Redux 類型優化

TAG:ThinkJS | TypeScript | Nodejs |