快速入門
通過骨架快速初始化:
$ 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.ts
到 typings/{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:
寫在最後
早在一年多前,阿里內部就有很多 BU 在實踐 TS + Egg 了。
隨着 TS 的完善,終於能完美解決我們的開發者體驗問題,也因此纔有了本文。
本來以爲只需要 2 個 PR 搞定的,結果變爲 Hail Hydra,好長的 List:[RFC] TypeScript tool support 。
終於完成了 Egg 2.0 發佈時的一大承諾,希望能通過這套最佳實踐規範,提升社區開發者的研發體驗。