• TypeScript
  • 快速入门
  • 目录规范
    • 控制器(Controller)
    • 路由(Router)
    • 服务(Service)
    • 中间件(Middleware)
    • 扩展(Extend)
    • 配置(Config)
    • 插件(Plugin)
    • 生命周期(Lifecycle)
    • TS 类型定义(Typings)
  • 开发期
    • ts-node
    • egg-ts-helper
    • 单元测试和覆盖率(Unit Test and Cov)
    • 调试(Debug)
  • 部署(Deploy)
    • 构建(Build)
    • 错误堆栈(Error Stack)
  • 插件 / 框架开发指南
    • 插件
    • 上层框架
  • 常见问题
    • 运行 npm start 不会加载 ts
    • 使用了 egg 插件后发现没有对应插件挂载的对象
    • 在 tsconfig.json 中配置了 paths 无效
    • 给 egg 插件提交声明的时候如何编写单测?

    TypeScript

    TypeScript 是 JavaScript 类型的超集,它可以编译成纯 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. $ mkdir showcase && cd showcase
    2. $ npm init egg --type=ts
    3. $ npm i
    4. $ 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. string;
    11. }

    中间件(Middleware)

    1. import { Context } from 'egg';
    2. // 这里是你自定义的中间件
    3. export default function fooMiddleware(): any {
    4. return async (ctx: Context, next: () => Promise<any>) => {
    5. // 你可以获取 config 的配置:
    6. // const config = ctx.app.config;
    7. // config.xxx....
    8. await next();
    9. };
    10. }

    当某个 Middleware 文件的名称与 config 中某个属性名一致时,Middleware 会自动把这个属性下的所有配置读取过来。

    我们假定你有一个 Middleware,名称是 uuid,其 config.default.js 中配置如下:

    1. 'use strict';
    2. import { EggAppConfig, PowerPartial } from 'egg';
    3. export default function(appInfo: EggAppConfig) {
    4. const config = {} as PowerPartial<EggAppConfig>;
    5. config.keys = appInfo.name + '123123';
    6. config.middleware = ['uuid'];
    7. config.security = {
    8. csrf: {
    9. ignore: '123',
    10. },
    11. };
    12. const bizConfig = {
    13. local: {
    14. msg: 'local',
    15. },
    16. uuid: {
    17. name: 'ebuuid',
    18. maxAge: 1000 * 60 * 60 * 24 * 365 * 10,
    19. },
    20. };
    21. return {
    22. ...config,
    23. ...bizConfig,
    24. };
    25. }

    在对应的 uuid 中间件中:

    1. // app/middleware/uuid.ts
    2. import { Context, Application, EggAppConfig } from 'egg';
    3. export default function uuidMiddleWare(options: EggAppConfig['uuid'], app: Application): any {
    4. return async (ctx: Context, next: () => Promise<any>) => {
    5. // name 就是 config.default.js 中 uuid 下的属性
    6. console.info(options.name);
    7. await next();
    8. };
    9. }

    注意:Middleware 目前返回值必须都是 any,否则使用 route.get/all 等方法的时候因为 Koa 的 IRouteContext 和 Egg 自身的 Context 不兼容导致编译报错。

    扩展(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. export default (appInfo: EggAppInfo) => {
    4. const config = {} as PowerPartial<EggAppConfig>;
    5. // 覆盖框架,插件的配置
    6. config.keys = appInfo.name + '123456';
    7. config.view = {
    8. defaultViewEngine: 'nunjucks',
    9. mapping: {
    10. '.tpl': 'nunjucks',
    11. },
    12. };
    13. // 应用本身的配置
    14. const bizConfig = {};
    15. bizConfig.news = {
    16. pageSize: 30,
    17. serverUrl: 'https://hacker-news.firebaseio.com/v0',
    18. };
    19. // 目的是将业务配置属性合并到 EggAppConfig 中返回
    20. return {
    21. // 如果直接返回 config ,将该类型合并到 EggAppConfig 的时候可能会出现 circulate type 错误。
    22. ...config as {},
    23. ...bizConfig,
    24. };
    25. };

    注意,上面这种写法,将 config.default.ts 中返回的配置类型合并到 egg 的 EggAppConfig 类型中需要 egg-ts-helper 的配合。

    当 EggAppConfig 合并 config.default.ts 的类型后,在其他 config.{env}.ts 中这么写就也可以获得在 config.default.ts 定义的自定义配置的智能提示:

    1. // app/config/config.local.ts
    2. import { EggAppConfig, } from 'egg';
    3. export default () => {
    4. const config = {} as PowerPartial<EggAppConfig>;
    5. // 这里就可以获得 news 的智能提示了
    6. config.news = {
    7. pageSize: 20,
    8. };
    9. return config;
    10. };

    备注:

    • 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;

    生命周期(Lifecycle)

    1. // app.ts
    2. import { Application, IBoot } from 'egg';
    3. export default class FooBoot implements IBoot {
    4. private readonly app: Application;
    5. constructor(app: Application) {
    6. this.app = app;
    7. }
    8. configWillLoad() {
    9. // Ready to call configDidLoad,
    10. // Config, plugin files are referred,
    11. // this is the last chance to modify the config.
    12. }
    13. configDidLoad() {
    14. // Config, plugin files have loaded.
    15. }
    16. async didLoad() {
    17. // All files have loaded, start plugin here.
    18. }
    19. async willReady() {
    20. // All plugins have started, can do some thing before app ready.
    21. }
    22. async didReady() {
    23. // Worker is ready, can do some things
    24. // don't need to block the app boot.
    25. }
    26. async serverDidReady() {
    27. // Server is listening.
    28. }
    29. async beforeClose() {
    30. // Do some thing before app close.
    31. }
    32. }

    TS 类型定义(Typings)

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

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

    开发期

    ts-node

    egg-bin 已经内建了 ts-node ,egg 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. "egg": {
    3. "declarations": true
    4. },
    5. "scripts": {
    6. "dev": "egg-bin dev",
    7. "test-local": "egg-bin test",
    8. "clean": "ets clean"
    9. }
    10. }

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

    目前该工具已经能支持 ts 以及 js 的 egg 项目,均能获得相应的智能提示。

    单元测试和覆盖率(Unit Test and 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. "egg": {
    4. "declarations": true
    5. },
    6. "scripts": {
    7. "test": "npm run lint -- --fix && npm run test-local",
    8. "test-local": "egg-bin test",
    9. "cov": "egg-bin cov",
    10. "lint": "tslint ."
    11. }
    12. }

    调试(Debug)

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

    1. {
    2. "name": "showcase",
    3. "egg": {
    4. "declarations": true
    5. },
    6. "scripts": {
    7. "debug": "egg-bin debug",
    8. "debug-test": "npm run test-local"
    9. }
    10. }
    • 使用 VSCode 进行调试
    • VSCode 调试 Egg 完美版 - 进化史

    部署(Deploy)

    构建(Build)

    • 正式环境下,我们更倾向于把 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 手动清除。

    错误堆栈(Error Stack)

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

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

    具体内幕参见:

    • https://zhuanlan.zhihu.com/p/26267678
    • https://github.com/eggjs/egg-scripts/pull/19

    插件 / 框架开发指南

    指导原则:

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

    插件

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

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

    上层框架

    定义:

    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. }

    常见问题

    汇集一些有不少人提过的 issue 问题并统一解答。

    运行 npm start 不会加载 ts

    npm start 运行的是 egg-scripts start,而我们只在 egg-bin 中集成了 ts-node,也就是只有在使用 egg-bin 的时候才允许直接运行 ts 。

    egg-scripts 是用于在生产环境下运行 egg 的 cli ,在生产环境下我们建议将 ts 编译成 js 之后再运行,毕竟在线上是需要考虑应用的健壮性和性能的,因此不建议在线上环境使用 ts-node 来运行应用。

    而在开发期 ts-node 能降低 tsc 编译产生的文件带来的管理成本,并且 ts-node 带来的性能损耗在开发期几乎可以忽略,所以我们在 egg-bin 集成了 ts-node。

    总结:如果项目需要在线上运行,请先使用 tsc 将 ts 编译成 js ( npm run tsc )再运行 npm start

    使用了 egg 插件后发现没有对应插件挂载的对象

    遇到该问题,一般是两种原因:

    1. 该 egg 插件未定义 d.ts 。

    如果要在插件中将某个对象挂载到 egg 的类型中,需要按照上面写的 插件 / 框架开发指南 补充声明文件到对应插件中。

    如果需要上线想快速解决这个问题,可以直接在项目下新建个声明文件来解决。比如我使用了 egg-dashboard 这个插件,这个插件在 egg 的 app 中挂载了个 dashboard 对象,但是这个插件没有声明,直接使用 app.dashboard 又会有类型错误,我又急着解决该问题,就可以在项目下的 typings 目录下新建个 index.d.ts ,并且写入以下内容

    1. // typings/index.d.ts
    2. import 'egg';
    3. declare module 'egg' {
    4. interface Application {
    5. dashboard: any;
    6. }
    7. }

    即可解决,当然,我们更期望你能给缺少声明的插件提 PR 补声明,方便你我他。

    2. egg 插件定义了 d.ts ,但是没有引入。

    如果 egg 插件中正确无误定义了 d.ts ,也需要在应用或者框架层显式 import 之后 ts 才能加载到对应类型。

    如果使用了 egg-ts-helper ,egg-ts-helper 会自动根据应用中开启了什么插件从而生成显式 import 插件的声明。如果未使用,就需要开发者自行在 d.ts 中显式 import 对应插件。

    1. // typings/index.d.ts
    2. import 'egg-dashboard';

    注意:必须在 d.ts 中 import,因为 egg 插件大部分没有入口文件,如果在 ts 中 import 的话运行会出问题。

    在 tsconfig.json 中配置了 paths 无效

    这个严格来说不属于 egg 的问题,但是问的人不少,因此也在此解答一下。原因是 tsc 将 ts 编译成 js 的时候,并不会去转换 import 的模块路径,因此当你在 tsconfig.json 中配置了 paths 之后,如果你在 ts 中使用 paths 并 import 了对应模块,编译成 js 的时候就有大概率出现模块找不到的情况了。

    解决办法是,要么不用 paths ,要么使用 paths 的时候只用来 import 一些声明而非具体值,再要么就可以使用 tsconfig-paths 来 hook 掉 node 中的模块路径解析逻辑,从而支持 tsconfig.json 中的 paths。

    使用 tsconfig-paths 可以直接在 config/plugin.ts 中引入,因为 plugin.ts 不管在 App 中还是在 Agent 中都是第一个加载的,因此在这个代码中引入 tsconfig-paths 即可。

    1. // config/plugin.ts
    2. import 'tsconfig-paths/register';
    3. ...

    给 egg 插件提交声明的时候如何编写单测?

    由于有不少开发者在给 egg 插件提交声明的时候,不知道如何编写单测来测试声明的准确性,因此也在这里说明一下。

    当给一个 egg 插件编写好声明之后,就可以在 test/fixures 下创建个使用 ts 写的 egg 应用,参考 ( https://github.com/eggjs/egg-view/tree/master/test/fixtures/apps/ts ),记得在 tsconfig.json 中加入 paths 的配置从而方便在 fixture 中 import ,比如 egg-view 中的

    1. "paths": {
    2. "egg-view": ["../../../../"]
    3. }

    同时记住不要在 tsconfig.json 中配置 "skipLibCheck": true ,如果配置了该属性为 true ,tsc 编译的时候会忽略 d.ts 中的类型校验,这样单测就无意义了。

    然后再添加一个用例用来验证插件的声明使用是否正确即可,还是拿 egg-view 来做示例。

    1. describe('typescript', () => {
    2. it('should compile ts without error', () => {
    3. return coffee.fork(
    4. require.resolve('typescript/bin/tsc'),
    5. [ '-p', path.resolve(__dirname, './fixtures/apps/ts/tsconfig.json') ]
    6. )
    7. // .debug()
    8. .expect('code', 0)
    9. .end();
    10. });
    11. });

    可参考单测的项目:

    • https://github.com/eggjs/egg
    • https://github.com/eggjs/egg-view
    • https://github.com/eggjs/egg-logger