atian25 / blog

天猪部落阁 http://atian25.github.io
1.59k stars 107 forks source link

当 Egg 遇到 TypeScript,收获茶叶蛋一枚 #27

Open atian25 opened 6 years ago

atian25 commented 6 years ago

slice

前言

TypeScript is a typed superset of JavaScript that compiles to plain JavaScript.

TypeScript 的静态类型检查,智能提示,IDE 友好性等特性,对于大规模企业级应用,是非常的有价值的。详见:TypeScript体系调研报告

然而,此前使用 TypeScript 开发 Egg ,会遇到一些影响 开发者体验 问题:

本文主要阐述:

具体的折腾过程参见:[RFC] TypeScript tool support


快速入门

通过骨架快速初始化:

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

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


目录规范

一些约束:

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

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 这块稍微有点复杂,因为要支持:

// 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;
};

备注:

// {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 文件将被自动识别。

现在 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"
  }
}

部署

构建

配置 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"
  ]
}

注意:

错误堆栈

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

具体内幕参见:


插件/框架开发指南

指导原则:

插件

可以参考 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 发布时的一大承诺,希望能通过这套最佳实践规范,提升社区开发者的研发体验。

comeUpWithItLater commented 6 years ago

今天试了一下, 有两个问题:

  1. 在docker中开发时, egg-bin dev -r egg-ts-helper/registernpm run dev 在编辑器(vs code)保存 .ts文件后进程没有自动重启,意思是需要手动重启后才能看到.ts文件改动后的运行效果。

  2. 参照文档 https://eggjs.org/zh-cn/basics/schedule.html

    import { Subscription } from 'egg';    //  [ts] Module ''egg'' has no exported member 'Subscription'.

    npm run dev tsc 编译也失败

atian25 commented 6 years ago

第一个问题,是 egg-watcher 那块,似乎在 docker 里面是拿不到 node 的 watch 事件的,加个 https://github.com/eggjs/egg-watcher-chokidar 看看。

whxaxes commented 6 years ago

第二个问题,Subscription 这个是漏了,已经有人提了 PR: https://github.com/eggjs/egg/pull/2321 ,等 merged 后就可以了

LiJoah commented 6 years ago

在Linux 环境中、使用这个脚手架生成项目结构出现一个权限的问题,像config service 等文件是root的权限,普通用户不能对这些文件进行操作, 而不使用这个脚手架来搭项目结构,在import egg 这个module的时候,会出现[ts] Cannot find module 'egg'. 在ts 中使用import * as Egg from 'egg'; 这种方式引入

atian25 commented 6 years ago

这属于 linux 的权限问题,自己看下 linux 相关资料吧。估计你是用 root 执行的 egg-init

gaoziqi commented 5 years ago

使用pnpm安装后执行npm run dev报错,default的plugin路径会出问题,找不到egg-onerror等基础plugin,建议可以修复一下egg-core的plugin.js的路径解析的机制,支持一下pnpm

atian25 commented 5 years ago

我们日常用的 cnpm 的底层是 cnpm/npminstall,包路径跟 pnpm 应该是一致的才对,提供下可复现方案吧。

cc @whxaxes

gaoziqi commented 5 years ago

D:\code\npm\login\node_modules\.registry.npm.taobao.org\egg-core\4.12.0\node_modules\egg-core\lib\loader\mixin\plugin.js:356 throw new Error(Can not find plugin ${name} in "${lookupDirs.join(', ')}"); ^ Error: Can not find plugin egg-onerror in "D:\code\npm\login\node_modules, D:\code\npm\login\node_modules\.registry.npm.taobao.org\egg\2.14.1\node_modules\egg\node_modules, D:\code\npm\login\node_modules"

path中缺少了D:\code\npm\login\node_modules.registry.npm.taobao.org\egg\2.14.1\node_modules

@atian25

atian25 commented 5 years ago

提供个最小可复现仓库