ThinkJS 中如何实现对 TypeScript 的支持

284 阅读8分钟
原文链接: zhuanlan.zhihu.com

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 的同学,赶紧来试试吧。