前言
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代码风格,后缀名为tstypings目录用于放置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.tsimport { 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.tsimport { Application } from 'egg';export default (app: Application) => {const { router, controller } = app;router.get('/', controller.home.index);};
Service
// app/service/news.tsimport { 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.tsimport { Context } from 'egg';export default function robotMiddleware() {return async (ctx: Context, next: any) => {await next();};}
因为 Middleware 定义是支持入参的,第一个参数为同名的 Config,如有需求,可以用完整版:
// app/middleware/news.tsimport { 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.tsimport { Context } from 'egg';export default {isAjax(this: Context) {return this.get('X-Requested-With') === 'XMLHttpRequest';},}// app.tsexport 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.tsimport { EggAppInfo, EggAppConfig, PowerPartial } from 'egg';// 提供给 config.{env}.ts 使用export type DefaultConfig = PowerPartial<EggAppConfig & BizConfig>;// 应用本身的配置 Schemeexport 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.tsimport { 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.tstype PowerPartial<T> = {[U in keyof T]?: T[U] extends {}? PowerPartial<T[U]>: T[U]};
Plugin
// config/plugin.tsimport { 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.tsimport 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.ts 到 typings/{app,config}/ 下,请勿自行修改,避免被覆盖。
后续该工具也会考虑支持 js 版 egg 应用的分析,可以一定程度上提升 js 开发体验。
Unit Test && Cov
单元测试当然少不了:
// test/app/service/news.test.tsimport * 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.tsimport News from '../../../app/service/News';declare module 'egg' {// 扩展 serviceinterface IService {news: News;}// 扩展 appinterface Application {}// 扩展 contextinterface Context {}// 扩展你的配置interface EggAppConfig {}// 扩展自定义环境type EggEnvType = 'local' | 'unittest' | 'prod' | 'sit';}
上层框架
定义:
// {framework_root}/index.d.tsimport * 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 -vVersion 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:
写在最后
早在一年多前,阿里内部就有很多 BU 在实践 TS + Egg 了。
随着 TS 的完善,终于能完美解决我们的开发者体验问题,也因此才有了本文。
本来以为只需要 2 个 PR 搞定的,结果变为 Hail Hydra,好长的 List:[RFC] TypeScript tool support 。
终于完成了 Egg 2.0 发布时的一大承诺,希望能通过这套最佳实践规范,提升社区开发者的研发体验。
