入坑 TypeScript

TypeScript 不同于 JavaScript 最大的一点就是添加了类型系统, 在此基础上进行了静态类型检查, 同时也带来了补全和提示. 可以在编译期检查错误, 避免运行时错误. 从而大大降低了出错的可能. 例如

@Service()
export class SubmissionService {
  public async create (userId: string, id: number, code: string, lang: string): Promise<number> {
    const problem = await Problem.findById<Problem>(id)
    if (!problem) {
      throw new BadRequestError('Problem 不存在')
    }
    const submission = await Submission.create<Submission>({
      problemId: id,
      userId,
      lang,
      code
    })
    await queue.submitCheckCodeTask(submission.id)

    return submission.id
  }
}

在这个服务中, 有一个 create 方法, 接收的参数有 userId, id, code, lang. 这里我都定义了参数类型. 在外部调用这个方法时, 如果我没有传入正确的参数类型那么编辑器会提醒同时编译将不通过.

findById 这个方法具有如下签名:

(method) Model<T>.findById<Problem>(identifier?: string | number, options?: IFindOptions): Bluebird<Problem>

一般这些声明文件会在单独的 d.ts 文件里面, 它是一个类型定义文件. TypeScript 一般会把 ts 文件编译成三部分, 一部分是 JS 代码, 一部分是 SourceMap, 再有就是 d.ts 文件包含了各种类型约束.

可以看到 findById 第一个参数接受一个 identifier , 这个 identifier 必须是字符串或者数字, 同时 ? 表示了他是可选参数. 后面的 options 也一样, 它是一个 IFindOptions 类型的. IFindOptions 是我们自己定义的一个接口, 他有点类似 C++ 中的 struct 结构.

这个 IFindOptions 定义如下:

export interface IFindOptions extends LoggingOptions, SearchPathOptions {
    where?: WhereOptions | Array<col | and | or | string> | col | and | or | string;
    attributes?: FindOptionsAttributesArray | {
        include?: FindOptionsAttributesArray;
        exclude?: Array<string>;
    };
    paranoid?: boolean;
    include?: Array<typeof Model | IIncludeOptions>;
    order?: string | col | literal | Array<string | number | typeof Model | {
        model: typeof Model;
        as?: string;
    }> | Array<string | col | literal | Array<string | number | typeof Model | {
        model: typeof Model;
        as?: string;
    }>>;
    limit?: number;
    offset?: number;
    lock?: string | {
        level: string;
        of: typeof Model;
    };
    raw?: boolean;
    having?: WhereOptions;
    group?: string | string[] | Object;
}

IFindOptions 内部所拥有的属性和方法是固定的, 如果你传入了不存在的属性, 或者没有正确传递类型, 那么都会提醒出错.

再看下这段代码:

const submission = await Submission.create<Submission>({
  problemId: id,
  userId,
  lang,
  code
})

这段代码创建了一个 submission 记录. 注意到 create<Submission> 这里, 接触过 C++ 的应该很容易联想到泛型. 如果不加上这个其实也是没问题的, 但编译器将不能正确识别 submissionSubmission 类型. Submission.create 本质上是 Model.create 方法, 它返回的是一个 Model 类型的, 但你可以通过 TypeScript 的泛型支持来让其返回特定 Model 类型.

用一个简单的例子来说明下泛型:

function identity<T>(arg: T): T {
    return arg;
}

如上是一个典型的泛型例子, 参数的类型是不限制的, 但如果我就这样设置为 any 类型, 然后在返回值类型的 data 也给设置为 any 类型, 就无法体现出返回值的 arg 和参数 arg 是同一个这点. 因此我可以通过泛型来声明他们是同一类型. 这样就不会丢失类型细节.

如上的 Submission.create 方法也同理. 也因为其本质是 Model.create 方法, 而该方法第一个参数为 value: any, 因此不能正确检测我们传入的属性和值是否正确匹配 Submission. 但我们可以这样做:

const submission = await Submission.create<Submission>({
  problemId: id,
  userId,
  lang,
  code
} as Submission)

我们在最后面加上了 as Submission, 这是一个类型断言, 也就是要求了前面那个对象要匹配这个结构. 如果我们再 Submission Model 并不存在 code 这个属性, 那么编译不能通过. 如果 Submission Model 上面的 code 是整型而我们传入的是一个字符串, 那么编译不能通过. 除了 as 的用法, 也可以直接在对象前面加上 <Submission> 达到同样目的.

在此之前其实我有使用了 TypeORM, 这是一个用 TypeScript 写的 ORM. 他就能够提示我传入的属性是否正确, 而且也可以提示我有什么属性可以传, 这点比 Sequelize 强大很多. 不过TypeORM 个人用了一段时间觉得很难用, 最后还是结合了 typescript-sequelize 这个库转向了 Sequelize

另外再上述的 create 方法中我们是没有声明返回值类型的, 因为 TypeScript 可以做到自动的类型推到, 他可以根据我们的返回值来推导类型. 上述的 create 方法 VSCode 已经自动的判断为返回类型为 Promise<number> , 注意不是 number.

当然以上关于类型的特性是所有静态类型语言都具备的, TypeScript 在 JavaScript 上面加入的类型系统但受限于一些 JS 类库没有类型定义的支持, 例如上面这个我用的是 Sequelize 可以看到其支持还是比较弱的. 而且 Sequelize 的写法过于面向过程. 在 TypeScript 面向对象的写法中有点格格不入.

最后你可能注意到了一开始的例子中有两个语法糖. @Service()public . TypeScript 对面向对象编程的支持很好, 同 Java 和 C++ 这些语言一样, 类具有 public , private, protected 等修饰词, 但不同的是 TypeScript 对这些的处理只是编译层面上的, 也就是说最后编译输出的代码其实并没有所谓的私有. 也支持静态属性, 抽象类等. 并且在 TypeScript 中, 类也可以当做接口来用, 这点非常实用.

装饰器的写法是我使用 TypeScript 的一个重要原因之一, 它让代码的书写更加简介明了. 如上 @Service 其实是来自于 typedi, 我可以在外部通过依赖注入取得它的示例. 这里可能装饰器的用法并不明显, 换个例子看下你可能就有所体会了.

export class ProblemQuery {
  @IsNumberString() public limit: string
  @IsNumberString() public offset: string
  @IsIn(['asc', 'desc']) public order: string
  @IsString() public sortby: string
}

@JsonController('/v1/problems')
export class ProblemsController {
  @Get('/')
  public async index (@Ctx() ctx: Context): Promise<void> {
    await transformAndValidate(ProblemQuery, JSON.parse(JSON.stringify(ctx.query)))
    const problems = await ctx.services.problems.list(
      parseInt(ctx.query.offset, 10), parseInt(ctx.query.limit, 10), ctx.query.sortby, ctx.query.order
    )
    ctx.ok(problems)
  }

  @Get('/:id')
  public async show (@Ctx() ctx: Context, @Param('id') id: number): Promise<void> {
    const problem = await ctx.services.problems.show(id)
    ctx.ok(problem)
  }
}

满眼的装饰器. 这里的 JsonController, Get 想必不需要做过多解释. 为了方便简洁, 我并没有在控制器通过依赖注入取得 service 实例, 而是在主文件就把全部的 service 实例挂载到了 ctx.services 上面. 但需要注意的是, 编辑器会提示你 services 不存在于 app.context 上, 因此我们可以这么做:

declare module 'koa' {
  export interface BaseContext {
    services: {
      problems: ProblemService,
      submissions: SubmissionService,
      user: UserService
    }
    ok <T> (data?: T, message?: string): void,
    error <T> (data?: T, message?: string): void
  }
}

之后我们就可以这样对 app.context 进行扩展.

app.context.services = {
  problems: Container.get(ProblemService),
  submissions: Container.get(SubmissionService),
  users: Container.get(UserService)
})

控制器当中, 我使用了一个库叫 routing-controller, 这是一个用 TypeScript 写的同时支持 Express 和 Koa 的路由. 再看 ProblemQuery 这个类, 前面有提及, TypeScript 可以把一个类当接口用. 这个类中其实定义了一些属性, 然后用装饰器进行了修饰, 这些装饰器来自于另一个库叫 class-validator, 如果参数没有通过这些检验那么会返回错误给前端.

之前参数校验我是用 JOI 来做的, class-validator 的表达能力没有 JOI 强, 但其语法糖写起来很简洁.

以前也有写过一阵子 C#, 觉得 TypeSciprt 和 C# 还是很相像, 毕竟同个爹嘛. TypeScript 对函数重载也有良好的支持. 例如:

class Injector {
  
  ...
  
  public resolve (func: (...args: any[]) => any): (...args: any[]) => any
  public resolve (denpendencies: string[], func: (...args: any[]) => any): (...args: any[]) => any
  public resolve (funcOrDependencies: ((...args: any[]) => any) | string[], funcOrUndefined?: (...args: any[]) => any): (...args: any[]) => any {
    let denpendencies: Array<string|symbol>
    let func: () => any
    if (Array.isArray(funcOrDependencies)) {
      denpendencies = funcOrDependencies
      func = funcOrUndefined
    } else if (funcOrDependencies instanceof Function) {
      denpendencies = Array.from(this.container.keys())
      func = funcOrDependencies
    }
    
    ...
    
  }
}

上面写了三个 resolve 但其实只是两个, 对于 resolve 这个方法, 我希望可以有两种方式调用:

injector.resolve(['param1', 'param2'], func)
injector.resolve(func)

也就是说第一个参数可能是数组也可能是函数, 当第一个是数组的时候要求传入第二个参数为函数. 以上在 JavaScript 中是用的比较广的, 在 TypeScript 中可以通过函数重载来处理. 第三个 resolve 是对上面两个 resovle 的合并处理, 你需要自己在函数内部里面判断 funcOrDependencies 到底是数组还是方法, 然后进行处理.

其实说了这么多最后会发现好像反而复杂化了, 确实 TypeScript 确实写起来比 JavaScript 稍微复杂了一点, 但带来的类型检查等还是可以极大提高效率和降低出错概率.

这就让我想起在实习的公司里面, 我们每个 Service 都是一堆普通方法组成的, 在控制器中我们需要调用什么方法, 就把那个方法引入来. 后来我认为这样过于复杂, 就在运行时把全部 service 挂载到 ctx.services 上面. 这样写起来是方便了, 但是提醒都没了, 个人觉得如果这样反而降低了效率. 如果用 TypeScript 的话我们可以自己扩展修改 Context 接口来让编辑器提醒.

d.ts 是 TypeScript 的类型声明定义文件, 如果是直接 TypeScript 写的可以不需要这个. 现在稍微有点人气的 JavaScript 库都有了相应的 d.ts 文件. 而且 TypeScript 新版本也支持直接通过 npm 安装这类文件. 例如 npm install @types/koa.

这类文件决定了 VSCode 如何进行静态类型检查, 我觉得这个挺牛逼的, 可以自己编程决定 VSCode 如何进行提醒. 另外, d.ts 其实已经反哺 JavaScript 社区. 使用 VSCode 编写 JavaScript 的你可能已经注意到了 VSCode 会对一些比较出名的类库进行类型方法的参数提醒.

最后说下测试, 测试的话其实问题并不大, 但个人认为既然是写 TypeScript 就要尽量让代码保持面向对象的风格. 所以我最后使用了一个叫 mocha-typescript 的库, 这个库写测试非常酷.

@suite class Problems {
  public async before (): Promise<void> {
    app = await connection
    user = await User.create<User>(User.MOCK_DATA())
    problem = await Problem.create<Problem>(Problem.MOCK_DATA({ userId: user.id }))
  }

  public async after (): Promise<void> {
    await problem.destroy()
    await user.destroy()
  }

  @test public async index (): Promise<void> {
    const res = await request(app)
      .get('/v1/problems')
      .query({
        limit: 1,
        offset: 0,
        sortby: 'id',
        order: 'desc'
      })
      .expect(200)
    assert.isNumber(res.body.data.count)
    assert.isArray(res.body.data.rows)
    assert.lengthOf(res.body.data.rows, 1)
  }

  @test public async show (): Promise<void> {
    const res = await request(app)
      .get(`/v1/problems/${problem.id}`)
      .expect(200)
    assert.equal(res.body.data.id, problem.id)
  }
}

厉害吧. describeit 的写法真的不好看. 然后关于测试覆盖率的话, 用 nyc 还是可以进行统计的, 最后我的测试命令是 tsc && nyc mocha build/test --ui mocha-typescript --require source-map-support/register --timeout=60000 , 可以准确的对 TypeScript 文件进行代码覆盖率统计. Very good!

相比 Babel, TypeScript 对 ECMAScript 的支持更全面, 例如 TypeScript 可以使用的装饰器类型就比 Babel 多, 而且 TypeScript 还多了很多不错的语法糖例如 private , 虽然只是一个检查而已. 最近一段时间使用 TypeScript 的过程非常愉悦. TypeScript 的生态也已经非常不错了.

相比 Flow 的话, Flow 只是一个静态类型检查, 而 TypeScript 拥有比较完备的面向对象的支持. 有的人反对 JavaScript 面向对象化, 但事实是 React, Angular, TypeScript 都在往这方面靠. 使用类的写法替代 prototype 的写法个人认为未尝不可. 另外用一个类封装多个方法, 也比直接暴露多个方法显得更加清晰.

从知道 TypeScript 这么一个东西到真正去学习, 间隔了差不多一年时间. 一开始接触到 TypeScript 的时候, 对于那种静态类型检查带来的好处的感受并不是特别深, 所以并没有想过去深入学习, 那时候反而对 CoffeeScript 有兴趣因为语法糖很养眼. 随着接触的项目越来越多越来越大, 我才开始感觉到了静态类型带来的巨大好处. 并且我也开始比较倾向于写面向对象化的 JavaScript 代码. 然而大家都知道的 JavaScript 对类的支持非常垃圾, 虽然也有类属性和私有属性方法的草案, 以及装饰器的草案, 但感觉进展十分缓慢. 所以我后面又打算转向 Babel 开发, 最后想想又决定直接上 TypeScript 算了, 于是就用过来了.

以上是写了一周时间 TypeScript 后个人对 TypeScript 的了解和看法. 结合 VSCode, TypeScript 的开发体验真的非常棒.