[TOC]

TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.

TypeScript 的静态类型检查,智能提示,IDE 友好性等特性,对于大规模企业级应用,是非常的有价值的。详见:TypeScript体系调研报告

然而,此前使用 TypeScript 开发 Egg ,会遇到一些影响 开发者体验 问题:

  • Egg 最精髓的 Loader 自动加载机制,导致 TS 无法静态分析出部分依赖。
  • Config 自动合并机制下,如何在 config.{env}.js 里面修改插件提供的配置时,能校验并智能提示?
  • 开发期需要独立开一个 tsc -w 独立进程来构建代码,带来临时文件位置纠结以及 npm scripts 复杂化。
  • 单元测试,覆盖率测试,线上错误堆栈如何指向 TS 源文件,而不是编译后的 js 文件。

本文主要阐述:

  • 应用层 TS 开发规范
  • 我们在工具链方面的支持,是如何来解决上述问题,让开发者几乎无感知并保持一致性。

具体的折腾过程参见:[RFC] TypeScript tool support


快速入门

通过骨架快速初始化:

  1. $ npx egg-init --type=ts showcase
  2. $ cd showcase && npm i
  3. $ npm run dev

上述骨架会生成一个极简版的示例,更完整的示例参见:eggjs/examples/hackernews-async-ts

tegg.gif


目录规范

一些约束:

  • Egg 目前没有计划使用 TS 重写。
  • Egg 以及它对应的插件,会提供对应的 index.d.ts 文件方便开发者使用。
  • TypeScript 只是其中一种社区实践,我们通过工具链给予一定程度的支持。
  • TypeScript 最低要求 2.8+ 版本。

整体目录结构上跟 Egg 普通项目没啥区别:

  • typescript 代码风格,后缀名为 ts
  • typings 目录用于放置 d.ts 文件(大部分会自动生成)
  1. showcase
  2. ├── app
  3. ├── controller
  4. └── home.ts
  5. ├── service
  6. └── news.ts
  7. └── router.ts
  8. ├── config
  9. ├── config.default.ts
  10. ├── config.local.ts
  11. ├── config.prod.ts
  12. └── plugin.ts
  13. ├── test
  14. └── **/*.test.ts
  15. ├── typings
  16. └── **/*.d.ts
  17. ├── README.md
  18. ├── package.json
  19. ├── tsconfig.json
  20. └── tslint.json

Controller

  1. // app/controller/home.ts
  2. import { Controller } from 'egg';
  3. export default class HomeController extends Controller {
  4. public async index() {
  5. const { ctx, service } = this;
  6. const page = ctx.query.page;
  7. const result = await service.news.list(page);
  8. await ctx.render('home.tpl', result);
  9. }
  10. }

Router

  1. // app/router.ts
  2. import { Application } from 'egg';
  3. export default (app: Application) => {
  4. const { router, controller } = app;
  5. router.get('/', controller.home.index);
  6. };

Service

  1. // app/service/news.ts
  2. import { Service } from 'egg';
  3. export default class NewsService extends Service {
  4. public async list(page?: number): Promise<NewsItem[]> {
  5. return [];
  6. }
  7. }
  8. export interface NewsItem {
  9. id: number;
  10. title: string;
  11. }

Middleware

  1. // app/middleware/robot.ts
  2. import { Context } from 'egg';
  3. export default function robotMiddleware() {
  4. return async (ctx: Context, next: any) => {
  5. await next();
  6. };
  7. }

因为 Middleware 定义是支持入参的,第一个参数为同名的 Config,如有需求,可以用完整版:

  1. // app/middleware/news.ts
  2. import { Context, Application } from 'egg';
  3. import { BizConfig } from '../../config/config.default';
  4. // 注意,这里必须要用 ['news'] 而不能用 .news,因为 BizConfig 是 type,不是实例
  5. export default function newsMiddleware(options: BizConfig['news'], app: Application) {
  6. return async (ctx: Context, next: () => Promise<any>) => {
  7. console.info(options.serverUrl);
  8. await next();
  9. };
  10. }

Extend

  1. // app/extend/context.ts
  2. import { Context } from 'egg';
  3. export default {
  4. isAjax(this: Context) {
  5. return this.get('X-Requested-With') === 'XMLHttpRequest';
  6. },
  7. }
  8. // app.ts
  9. export default app => {
  10. app.beforeStart(async () => {
  11. await Promise.resolve('egg + ts');
  12. });
  13. };

Config

Config 这块稍微有点复杂,因为要支持:

  • 在 Controller,Service 那边使用配置,需支持多级提示,并自动关联。
  • Config 内部, config.view = {} 的写法,也应该支持提示。
  • config.{env}.ts 里可以用到 config.default.ts 自定义配置的提示。
  1. // app/config/config.default.ts
  2. import { EggAppInfo, EggAppConfig, PowerPartial } from 'egg';
  3. // 提供给 config.{env}.ts 使用
  4. export type DefaultConfig = PowerPartial<EggAppConfig & BizConfig>;
  5. // 应用本身的配置 Scheme
  6. export interface BizConfig {
  7. news: {
  8. pageSize: number;
  9. serverUrl: string;
  10. };
  11. }
  12. export default (appInfo: EggAppInfo) => {
  13. const config = {} as PowerPartial<EggAppConfig> & BizConfig;
  14. // 覆盖框架,插件的配置
  15. config.keys = appInfo.name + '123456';
  16. config.view = {
  17. defaultViewEngine: 'nunjucks',
  18. mapping: {
  19. '.tpl': 'nunjucks',
  20. },
  21. };
  22. // 应用本身的配置
  23. config.news = {
  24. pageSize: 30,
  25. serverUrl: 'https://hacker-news.firebaseio.com/v0',
  26. };
  27. return config;
  28. };

简单版:

  1. // app/config/config.local.ts
  2. import { DefaultConfig } from './config.default';
  3. export default () => {
  4. const config: DefaultConfig = {};
  5. config.news = {
  6. pageSize: 20,
  7. };
  8. return config;
  9. };

备注:

  • TS 的 Conditional Types 是我们能完美解决 Config 提示的关键。
  • 有兴趣的可以看下 egg/index.d.ts 里面的 PowerPartial 实现。
  1. // {egg}/index.d.ts
  2. type PowerPartial<T> = {
  3. [U in keyof T]?: T[U] extends {}
  4. ? PowerPartial<T[U]>
  5. : T[U]
  6. };

Plugin

  1. // config/plugin.ts
  2. import { EggPlugin } from 'egg';
  3. const plugin: EggPlugin = {
  4. static: true,
  5. nunjucks: {
  6. enable: true,
  7. package: 'egg-view-nunjucks',
  8. },
  9. };
  10. export default plugin;

Typings

该目录为 TS 的规范,在里面的 **/*.d.ts 文件将被自动识别。

  • 开发者需要手写的建议放在 typings/index.d.ts 中。
  • 工具会自动生成 typings/{app,config}/**.d.ts ,请勿自行修改,避免被覆盖。(见下文)

开发期

ts-node

egg-bin 已经内建了 ts-nodeegg loader 在开发期会自动加载 *.ts 并内存编译。

目前已支持 dev / debug / test / cov

开发者仅需简单配置下 package.json

  1. {
  2. "name": "showcase",
  3. "egg": {
  4. "typescript": true
  5. }
  6. }

egg-ts-helper

由于 Egg 的自动加载机制,导致 TS 无法静态分析依赖,关联提示。

幸亏 TS 黑魔法比较多,我们可以通过 TS 的 Declaration Merging 编写 d.ts 来辅助。

譬如 app/service/news.ts 会自动挂载为 ctx.service.news ,通过如下写法即识别到:

  1. // typings/app/service/index.d.ts
  2. import News from '../../../app/service/News';
  3. declare module 'egg' {
  4. interface IService {
  5. news: News;
  6. }
  7. }

手动写这些文件,未免有点繁琐,因此我们提供了 egg-ts-helper 工具来自动分析源码生成对应的 d.ts 文件。

只需配置下 package.json :

  1. {
  2. "devDependencies": {
  3. "egg-ts-helper": "^1"
  4. },
  5. "scripts": {
  6. "dev": "egg-bin dev -r egg-ts-helper/register",
  7. "test-local": "egg-bin test -r egg-ts-helper/register",
  8. "clean": "ets clean"
  9. }
  10. }

开发期将自动生成对应的 d.tstypings/{app,config}/ 下,请勿自行修改,避免被覆盖

后续该工具也会考虑支持 js 版 egg 应用的分析,可以一定程度上提升 js 开发体验。

Unit Test && Cov

单元测试当然少不了:

  1. // test/app/service/news.test.ts
  2. import * as assert from 'assert';
  3. import { Context } from 'egg';
  4. import { app } from 'egg-mock/bootstrap';
  5. describe('test/app/service/news.test.js', () => {
  6. let ctx: Context;
  7. before(async () => {
  8. ctx = app.mockContext();
  9. });
  10. it('list()', async () => {
  11. const list = await ctx.service.news.list();
  12. assert(list.length === 30);
  13. });
  14. });

运行命令也跟之前一样,并内置了 错误堆栈和覆盖率 的支持:

  1. {
  2. "name": "showcase",
  3. "scripts": {
  4. "test": "npm run lint -- --fix && npm run test-local",
  5. "test-local": "egg-bin test -r egg-ts-helper/register",
  6. "cov": "egg-bin cov -r egg-ts-helper/register",
  7. "lint": "tslint ."
  8. }
  9. }

Debug

断点调试跟之前也没啥区别,会自动通过 sourcemap 断点到正确的位置。

  1. {
  2. "name": "showcase",
  3. "scripts": {
  4. "debug": "egg-bin debug -r egg-ts-helper/register",
  5. "debug-test": "npm run test-local -- --inspect"
  6. }
  7. }

部署

构建

  • 正式环境下,我们更倾向于把 ts 构建为 js ,建议在 ci 上构建并打包。

配置 package.json :

  1. {
  2. "egg": {
  3. "typescript": true
  4. },
  5. "scripts": {
  6. "start": "egg-scripts start --title=egg-server-showcase",
  7. "stop": "egg-scripts stop --title=egg-server-showcase",
  8. "tsc": "ets && tsc -p tsconfig.json",
  9. "ci": "npm run lint && npm run cov && npm run tsc",
  10. "clean": "ets clean"
  11. }
  12. }

对应的 tsconfig.json :

  1. {
  2. "compileOnSave": true,
  3. "compilerOptions": {
  4. "target": "es2017",
  5. "module": "commonjs",
  6. "strict": true,
  7. "noImplicitAny": false,
  8. "experimentalDecorators": true,
  9. "emitDecoratorMetadata": true,
  10. "charset": "utf8",
  11. "allowJs": false,
  12. "pretty": true,
  13. "noEmitOnError": false,
  14. "noUnusedLocals": true,
  15. "noUnusedParameters": true,
  16. "allowUnreachableCode": false,
  17. "allowUnusedLabels": false,
  18. "strictPropertyInitialization": false,
  19. "noFallthroughCasesInSwitch": true,
  20. "skipLibCheck": true,
  21. "skipDefaultLibCheck": true,
  22. "inlineSourceMap": true,
  23. "importHelpers": true
  24. },
  25. "exclude": [
  26. "app/public",
  27. "app/web",
  28. "app/views"
  29. ]
  30. }

注意:

  • 当有同名的 ts 和 js 文件时,egg 会优先加载 js 文件。
  • 因此在开发期, egg-ts-helper 会自动调用清除同名的 js 文件,也可 npm run clean 手动清除。

错误堆栈

线上服务的代码是经过编译后的 js,而我们期望看到的错误堆栈是指向 TS 源码。
因此:

  • 在构建的时候,需配置 inlineSourceMap: true 在 js 底部插入 sourcemap 信息。
  • egg-scripts 内建了处理,会自动纠正为正确的错误堆栈,应用开发者无需担心。

具体内幕参见:


插件/框架开发指南

指导原则:

  • 不建议使用 TS 直接开发插件/框架,发布到 npm 的插件应该是 js 形式。
  • 当你开发了一个插件/框架后,需要提供对应的 index.d.ts
  • 通过 Declaration Merging 将插件/框架的功能注入到 Egg 中。
  • 都挂载到 egg 这个 module,不要用上层框架。

插件

可以参考 egg-ts-helper 自动生成的格式

  1. // {plugin_root}/index.d.ts
  2. import News from '../../../app/service/News';
  3. declare module 'egg' {
  4. // 扩展 service
  5. interface IService {
  6. news: News;
  7. }
  8. // 扩展 app
  9. interface Application {
  10. }
  11. // 扩展 context
  12. interface Context {
  13. }
  14. // 扩展你的配置
  15. interface EggAppConfig {
  16. }
  17. // 扩展自定义环境
  18. type EggEnvType = 'local' | 'unittest' | 'prod' | 'sit';
  19. }

上层框架

定义:

  1. // {framework_root}/index.d.ts
  2. import * as Egg from 'egg';
  3. // 将该上层框架用到的插件 import 进来
  4. import 'my-plugin';
  5. declare module 'egg' {
  6. // 跟插件一样拓展 egg ...
  7. }
  8. // 将 Egg 整个 export 出去
  9. export = Egg;

开发者使用的时候,可以直接 import 你的框架:

  1. // app/service/news.ts
  2. // 开发者引入你的框架,也可以使用到提示到所有 Egg 的提示
  3. import { Service } from 'duck-egg';
  4. export default class NewsService extends Service {
  5. public async list(page?: number): Promise<NewsItem[]> {
  6. return [];
  7. }
  8. }