當node遇上Egg遇上TypeScript

快速入門

通過骨架快速初始化:

$ 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 發佈時的一大承諾,希望能通過這套最佳實踐規範,提升社區開發者的研發體驗。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章