精华 当 Egg 遇到 TypeScript,收获茶叶蛋一枚
发布于 6 年前 作者 atian25 16623 次浏览 来自 分享

slice

前言

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


快速入门

通过骨架快速初始化:

$ npx egg-init --type=ts showcase
$ cd showcase && npm i
$ npm run dev

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


目录规范

一些约束:

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

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

  • typescript 代码风格,后缀名为 ts
  • typings 目录用于放置 d.ts 文件(大部分会自动生成)
showcase
├── app
│   ├── controller
│   │   └── home.ts
│   ├── service
│   │   └── news.ts
│   └── router.ts
├── config
│   ├── config.default.ts
│   ├── config.local.ts
│   ├── config.prod.ts
│   └── plugin.ts
├── test
│   └── **/*.test.ts
├── typings
│   └── **/*.d.ts
├── README.md
├── package.json
├── tsconfig.json
└── tslint.json

Controller

// app/controller/home.ts
import { Controller } from 'egg';

export default class HomeController extends Controller {
  public async index() {
    const { ctx, service } = this;
    const page = ctx.query.page;
    const result = await service.news.list(page);
    await ctx.render('home.tpl', result);
  }
}

Router

// app/router.ts
import { Application } from 'egg';

export default (app: Application) => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
};

Service

// app/service/news.ts
import { Service } from 'egg';

export default class NewsService extends Service {
  public async list(page?: number): Promise<NewsItem[]> {
    return [];
  }
}

export interface NewsItem {
  id: number;
  title: string;
}

Middleware

// app/middleware/robot.ts

import { Context } from 'egg';

export default function robotMiddleware() {
  return async (ctx: Context, next: any) => {
    await next();
  };
}

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

// app/middleware/news.ts

import { Context, Application } from 'egg';
import { BizConfig } from '../../config/config.default';

// 注意,这里必须要用 ['news'] 而不能用 .news,因为 BizConfig 是 type,不是实例
export default function newsMiddleware(options: BizConfig['news'], app: Application) {
  return async (ctx: Context, next: () => Promise<any>) => {
    console.info(options.serverUrl);
    await next();
  };
}

Extend

// app/extend/context.ts
import { Context } from 'egg';

export default {
  isAjax(this: Context) {
    return this.get('X-Requested-With') === 'XMLHttpRequest';
  },
}

// app.ts
export default app => {
  app.beforeStart(async () => {
    await Promise.resolve('egg + ts');
  });
};

Config

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

  • 在 Controller,Service 那边使用配置,需支持多级提示,并自动关联。
  • Config 内部, config.view = {} 的写法,也应该支持提示。
  • config.{env}.ts 里可以用到 config.default.ts 自定义配置的提示。
// app/config/config.default.ts
import { EggAppInfo, EggAppConfig, PowerPartial } from 'egg';

// 提供给 config.{env}.ts 使用
export type DefaultConfig = PowerPartial<EggAppConfig & BizConfig>;

// 应用本身的配置 Scheme
export interface BizConfig {
  news: {
    pageSize: number;
    serverUrl: string;
  };
}

export default (appInfo: EggAppInfo) => {
  const config = {} as PowerPartial<EggAppConfig> & BizConfig;

  // 覆盖框架,插件的配置
  config.keys = appInfo.name + '123456';
  config.view = {
    defaultViewEngine: 'nunjucks',
    mapping: {
      '.tpl': 'nunjucks',
    },
  };

  // 应用本身的配置
  config.news = {
    pageSize: 30,
    serverUrl: 'https://hacker-news.firebaseio.com/v0',
  };

  return config;
};

简单版:

// app/config/config.local.ts
import { DefaultConfig } from './config.default';

export default () => {
  const config: DefaultConfig = {};
  config.news = {
    pageSize: 20,
  };
  return config;
};

备注:

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

Plugin

// config/plugin.ts
import { EggPlugin } from 'egg';

const plugin: EggPlugin = {
  static: true,
  nunjucks: {
    enable: true,
    package: 'egg-view-nunjucks',
  },
};

export default plugin;

Typings

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

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

现在 Egg 自带的 d.ts 还有不少可以优化的空间,遇到的同学欢迎提 issue 或 PR。


开发期

ts-node

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

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

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

{
  "name": "showcase",
  "egg": {
    "typescript": true
  }
}

egg-ts-helper

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

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

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

// typings/app/service/index.d.ts
import News from '../../../app/service/News';

declare module 'egg' {
  interface IService {
    news: News;
  }
}

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

只需配置下 package.json :

{
  "devDependencies": {
    "egg-ts-helper": "^1"
  },
  "scripts": {
    "dev": "egg-bin dev -r egg-ts-helper/register",
    "test-local": "egg-bin test -r egg-ts-helper/register",
    "clean": "ets clean"
  }
}

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

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

Unit Test && Cov

单元测试当然少不了:

// test/app/service/news.test.ts
import * as assert from 'assert';
import { Context } from 'egg';
import { app } from 'egg-mock/bootstrap';

describe('test/app/service/news.test.js', () => {
  let ctx: Context;

  before(async () => {
    ctx = app.mockContext();
  });

  it('list()', async () => {
    const list = await ctx.service.news.list();
    assert(list.length === 30);
  });
});

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

{
  "name": "showcase",
  "scripts": {
    "test": "npm run lint -- --fix && npm run test-local",
    "test-local": "egg-bin test -r egg-ts-helper/register",
    "cov": "egg-bin cov -r egg-ts-helper/register",
    "lint": "tslint ."
  }
}

Debug

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

{
  "name": "showcase",
  "scripts": {
    "debug": "egg-bin debug -r egg-ts-helper/register",
    "debug-test": "npm run test-local -- --inspect"
  }
}

部署

构建

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

配置 package.json :

{
  "egg": {
    "typescript": true
  },
  "scripts":  {
    "start": "egg-scripts start --title=egg-server-showcase",
     "stop": "egg-scripts stop --title=egg-server-showcase",
     "tsc": "ets && tsc -p tsconfig.json",
     "ci": "npm run lint && npm run cov && npm run tsc",
     "clean": "ets clean"
  }
}

对应的 tsconfig.json :

{
  "compileOnSave": true,
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "strict": true,
    "noImplicitAny": false,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "charset": "utf8",
    "allowJs": false,
    "pretty": true,
    "noEmitOnError": false,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "allowUnreachableCode": false,
    "allowUnusedLabels": false,
    "strictPropertyInitialization": false,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "skipDefaultLibCheck": true,
    "inlineSourceMap": true,
    "importHelpers": true
  },
  "exclude": [
    "app/public",
     "app/web",
    "app/views"
  ]
}

注意:

  • 当有同名的 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 自动生成的格式

// {plugin_root}/index.d.ts

import News from '../../../app/service/News';

declare module 'egg' {

  // 扩展 service
  interface IService {
    news: News;
  }

  // 扩展 app
  interface Application {

  }

  // 扩展 context
  interface Context {

  }

  // 扩展你的配置
  interface EggAppConfig {

  }

  // 扩展自定义环境
  type EggEnvType = 'local' | 'unittest' | 'prod' | 'sit';
}

上层框架

定义:

// {framework_root}/index.d.ts

import * as Egg from 'egg';

// 将该上层框架用到的插件 import 进来
import 'my-plugin';

declare module 'egg' {
  // 跟插件一样拓展 egg ...
}

// 将 Egg 整个 export 出去
export = Egg;

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

// app/service/news.ts

// 开发者引入你的框架,也可以使用到提示到所有 Egg 的提示
import { Service } from 'duck-egg';

export default class NewsService extends Service {
  public async list(page?: number): Promise<NewsItem[]> {
    return [];
  }
}

其他

TypeScript

最低要求 2.8+ 版本,依赖于新支持的 Conditional Types ,黑魔法中的黑魔法。

$ npm i typescript tslib --save-dev
$ npx tsc -v
Version 2.8.1

VSCode

由于 VSCode 自带的 TypeScript 版本还未更新,需手动切换:

F1 -> TypeScript: Select TypeScript Version -> Use Workspace Version 2.8.1

之前为了不显示编译后的 js 文件,会配置 .vscode/settings.json ,但由于我们开发期已经不再构建 js,且 js 和 ts 同时存在时会优先加载 js,因为__建议__「不要」配置此项

// .vscode/settings.json
{
  "files.exclude": {
    "**/*.map": true,
    // 光注释掉 when 这行无效,需全部干掉
    // "**/*.js": {
    //  "when": "$(basename).ts"
    // }
  },
  "typescript.tsdk": "node_modules/typescript/lib"
}

package.json

完整的配置如下:

{
  "name": "hackernews-async-ts",
  "version": "1.0.0",
  "description": "hackernews showcase using typescript && egg",
  "private": true,
  "egg": {
    "typescript": true
  },
  "scripts": {
    "start": "egg-scripts start --title=egg-server-showcase",
    "stop": "egg-scripts stop --title=egg-server-showcase",
    "dev": "egg-bin dev -r egg-ts-helper/register",
    "debug": "egg-bin debug -r egg-ts-helper/register",
    "test-local": "egg-bin test -r egg-ts-helper/register",
    "test": "npm run lint -- --fix && npm run test-local",
    "cov": "egg-bin cov -r egg-ts-helper/register",
    "tsc": "ets && tsc -p tsconfig.json",
    "ci": "npm run lint && npm run tsc && egg-bin cov --no-ts",
    "autod": "autod",
    "lint": "tslint .",
    "clean": "ets clean"
  },
  "dependencies": {
    "egg": "^2.6.0",
    "egg-scripts": "^2.6.0"
  },
  "devDependencies": {
    "@types/mocha": "^2.2.40",
    "@types/node": "^7.0.12",
    "@types/supertest": "^2.0.0",
    "autod": "^3.0.1",
    "autod-egg": "^1.1.0",
    "egg-bin": "^4.6.3",
    "egg-mock": "^3.16.0",
    "egg-ts-helper": "^1.5.0",
    "tslib": "^1.9.0",
    "tslint": "^4.0.0",
    "typescript": "^2.8.1"
  },
  "engines": {
    "node": ">=8.9.0"
  }
}

高级用法

装饰器

通过 TS 的装饰器,可以实现 依赖注入 / 参数校验  / 日志前置处理 等。

import { Controller } from 'egg';

export default class NewsController extends Controller {
  @GET('/news/:id')
  public async detail() {
    const { ctx, service } = this;
    const id = ctx.params.id;
    const result = await service.news.get(id);
    await ctx.render('detail.tpl', result);
  }
}

目前装饰器属于锦上添花,因为暂不做约定。 交给开发者自行实践,期望能看到社区优秀实践反馈,也可以参考下:egg-di

友情提示:要适度,不要滥用。

tegg

未来可能还会封装一个上层框架 tegg,具体 RFC 还没出,还在孕育中,敬请期待。

名字典故:typescript + egg -> ts-egg -> tea egg -> 茶叶蛋

Logo:image.png | left | 225x225


写在最后

早在一年多前,阿里内部就有很多 BU 在实践 TS + Egg 了。

随着 TS 的完善,终于能完美解决我们的开发者体验问题,也因此才有了本文。

本来以为只需要 2 个 PR 搞定的,结果变为 Hail Hydra,好长的 List:[RFC] TypeScript tool support

终于完成了 Egg 2.0 发布时的一大承诺,希望能通过这套最佳实践规范,提升社区开发者的研发体验。

33 回复

@atian25 知乎 404。。

@yviscool 知乎的 bug,好像要等审核

@yviscool 可以了,是知乎的 bug

这可能是cnode最长的帖子吧,错误堆栈功能简直及其需要及其好用

不错,正是需要的

来的正是时候

我是来点赞欢呼滴。 坐等TS进一步封装。EGG,使用了一段时间,总统相比之前自己东拉西扯,整合一个架构,是好得多。对于egg更加底层的定制化框架开发,这块还是要仔细研究和实验。不过,就整体简单应用,是没问题了。

期待已久的功能!

你这屏幕录制功能怎么做的?

还是茶叶蛋(tegg)更有味道,不知道什么时候开搞,可以看到它一步步的诞生和成长。

来自酷炫的 CNodeMD

@hhpop8 删除依赖,重装依赖,不要锁版本

@atian25 没有锁版本的,全都最新版

C:_DEV\showcase\node_modules\ts-node\src\index.ts:327 throw new TSError(formatDiagnostics(diagnosticList, cwd, ts, lineOffset)) ^ TSError: [egg-core] load file: C:_DEV\showcase\config\plugin.ts, error: ⨯ Unable to compile TypeScript config\plugin.ts (3,7): Type ‘{ static: boolean; nunjucks: { enable: true; package: string; }; }’ is not assignable to type ‘EggPlugin’. Types of property ‘static’ are incompatible. Type ‘boolean’ is not assignable to type ‘EggPluginItem | undefined’. (2322)

@hhpop8 d.ts 有问题,https://github.com/eggjs/egg/blob/master/index.d.ts#L934 等会 PR 修复下,你可以先不用类型

大大,目前用JS学2D游戏开发,用哪个框架好?

@DoubleCG egret / cocos / layabox,推荐 egret

最近尝试使用typescript+egg,发现一个问题是在使用插件的时候不是很明白如何使用。 以使用egg-redis为例,配置完成config.default.ts和plugin.ts后,在Controller中使用redis,还是提示类型错误信息。

const { ctx, logger, app } = this;
logger.debug('new request comming...');
const value = await app.redis.get('key');

错误信息:

showcase/node_modules/ts-node/src/index.ts:327
        throw new TSError(formatDiagnostics(diagnosticList, cwd, ts, lineOffset))
              ^
TSError: [egg-core] load file: showcase/app/controller/home.ts, error: ⨯ Unable to compile TypeScript
app/controller/home.ts (18,29): Property 'redis' does not exist on type 'Application'. (2339)

这是否是配置存在错误导致的?

具体配置内容如下:

// config.default.ts
config.redis = {
    client: {
      port: 6379,          // Redis port
      host: '127.0.0.1',   // Redis host
      password: null,
      db: 0,
    },
  };
  
// plugin.ts
const plugin: EggPlugin = {
  redis: {
    enable: true,
    package: 'egg-redis',
  },
};

@ipfans 因为 egg-redis 不是内置插件,你需要自己写个 d.ts,然后 PR 给 egg-redis 吧

@atian25 明白了,多谢

我的router写在其他文件该怎么引入呢

学习了😊

来自拉风的 Taro-cnode

这个不错 学习了

点赞,正在学习使用

测试测试

回到顶部