whxaxes / blog

📖 My personal blog
https://wanghx.cn/
194 stars 12 forks source link

Egg TS 改造总结 #12

Open whxaxes opened 6 years ago

whxaxes commented 6 years ago

最近参与了 egg 的 ts 支持改造工作,写篇文章总结一二。

在上一篇文章 Typescript 在 Egg + Vue 应用中的实践 中在 Egg 项目中做了尝试,但是那时候还不够完善,比如 ts 需要通过 tsc 来生成 js,会产生大量中间文件,而且 scripts 又长又丑。再比如那时实现 controller 等的注入是需要手动写 d.ts 来实现。

所以为了能让我们用 ts 开发 egg 更方便,我们做了以下这些事。

ts-node 支持

loader 改造

我们期望在开发期不产生 js 文件就能够把应用跑起来,而业界刚好有这么个工具 ts-node,能够支持不生成中间文件,直接运行 ts 代码,于是兴冲冲的进行了尝试,结果却注意到因为 egg 是自动加载各个模块,并且 egg-loader 很多是 hardcode 写死了加载 *.js 的,所以为了让 egg 支持 ts-node,做的第一件事就是改造 egg-loader,于是提了个 PR 让 egg-loader 支持了 typescript。

简单来说,就是增加了一个 typescript 的入参,如果该值为 true,并且 require.extensions 中包含 .ts 的处理逻辑,loader 就会去加载 *.(ts|js)

开发工具改造

在改造之前的 scripts 是这样的

{
  "scripts": {
     "dev": "npm run tsc && concurrently -r \"npm run tsc:w\" \"egg-bin dev\"",
     "debug": "npm run tsc && concurrently -r \"npm run tsc:w\" \"egg-bin debug\"",
     "tsc": "tsc -p tsconfig.json",
     "tsc:w": "tsc -p tsconfig.json -w"
  }
}

又长又臭,因此仅 egg-loader 支持加载 ts 代码之后还不够,我们平时开发习惯性用 egg-bin 进行开发,而 egg-bin 启动 egg 是通过 egg-cluster 启动的,所以我们需要给 egg-bin 加上一个入参,能够透传到 egg-cluster 从而开启 egg-loader 的 typescript。

最终天猪那边提了 N 个 PR 之后,也完成了 egg-bin 对 ts 的支持。现在我们的 scripts 可以精简到了这样

{
  "scripts": {
    "dev": "egg-bin dev --ts",
    "debug": "egg-bin debug --ts"
  }
}

或者这样(更建议下面这种方式)

{
  "scripts": {
    "dev": "egg-bin dev",
    "debug": "egg-bin debug",
  },
  "egg": {
    "typescript": true
  }
}

模块注入

我们都知道,egg 加载各个模块是直接通过 loader 自动加载的,也就是模块注入是自动的,也正因为如此,ts 是没法知道 controller,service 等目录下的代码被挂载到了 egg 下,会导致在编译或者代码提示上都会有不同层面的影响,而要解决这个问题主要有两种方案,一种是使用 shepherdwind 写的 egg-di 提供的装饰器来做依赖注入,还有一种就是通过 ts 提供的 Declaration Merging 能力来编写 d.ts ,从而告诉 egg 这些模块其实是已经被引入了。

由于我个人习惯于 egg 的自动加载,所以平时项目中都选择了第二种方式,也就是通过编写 d.ts 来实现注入。而注入的原理很简单,比如在 egg 的 d.ts 中申明的 Application 对象。

declare module 'egg' {
  export interface IController {}

  export interface Application {
    controller: IController;
  }
}

其中 IController 这个 service 可以当成是一个 slot,可以在应用中通过 Declaration Merging 给 IController 添加属性,然后这些属性也就自动添加进了 app.controller 中。这个也是 egg ts 最早的实践者 shepherdwind 他们想出来的办法。

但是这个又带来个问题,每次新增一个 controller、service、config 等都需要手动去编写 d.ts 啊,所以又想干脆写个工具来自动生成算了,于是我就开发了一个小工具 egg-ts-helper 来减少手动编写 d.ts 的工作。

自动生成的原理很简单,其实每一个生成都是有规律可循的。比如 controller、service,我只需要知道目录结构就能够生成 d.ts。因为每个 controller 和 service 都是直接 export default 相关 class 的。

因此我只需要遍历 controller、service 的目录,根据 egg 的 loader 命名规范,将所有的 controller、service import 到 d.ts,然后再挂载到 IControllerIService 下即可。

ts

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

export default class HomeController extends Controller {
  public async index() {
    this.ctx.body = 'ok';
  }
}

typings

// app/typings/app/controller/index.d.ts
import Home from '../../../app/controller/home';

declare module 'egg' {
  interface IController {
    home: Home;
  }
}

而 config 和 extend 就相对要麻烦一些,得去解析代码生成语法树并且做分析,好在 typescript 用来解析代码生成 AST 还是相当方便的,typescript 中提供了很多便利的工具方法用于遍历 AST、节点判断。

比如生成 AST 只需要调用 createSourceFile 即可。

import * as ts from 'typescript';
const sourceFile = ts.createSourceFile(f, code, ts.ScriptTarget.ES2017, true);

然后就可以对 AST 进行遍历操作并且分析,对 AST 的一些操作文档可以看 typescript 的 wiki:Using-the-Compiler-API 。不过文档写的相对比较简略,很多情况下还是需要自己摸索,有兴趣的可以参考 egg-ts-helper 的源码 了解如何使用。

有了 AST 大法之后,就可以解析 extend 目录下的代码,分析 export 出来的对象以及其属性,并且将这些属性加到 d.ts 中,从而实现 extend 的代码的代码提示。

ts

// app/extend/context.ts
export default {
  doSomething() {
    console.info('do something');
  }
};

typings

// app/typings/app/controller/index.d.ts
import ExtendObject from '../../../app/extend/context';

declare module 'egg' {
  interface Context {
    doSomething: typeof ExtendObject.doSomething;
  }
}

而 config 的话,因为刚好 typescript 2.8 提供了 ReturnType 这个利器,因此也是可以很方便的分析 config export 出来的如果是对象,就直接用 typeof,否则则用 ReturnType。

ts

// config/config.default.ts
export default function() {
  return {
    keys: '123456'
  }
}

typings

// app/typings/config/index.d.ts
import { EggAppConfig } from 'egg';
import ExportConfigDefault from '../../config/config.default';
type ConfigDefault = ReturnType<typeof ExportConfigDefault>;
type NewEggAppConfig = EggAppConfig & ConfigDefault;

declare module 'egg' {
  interface Application {
    config: NewEggAppConfig;
  }

  interface Controller {
    config: NewEggAppConfig;
  }

  interface Service {
    config: NewEggAppConfig;
  }
}

然后还有最后一个,就是 plugin.ts 的生成啦,也很简单,就是直接分析 plugin 中引入的 package 名称,然后生成 d.ts 并且将 package import 进来。

ts

// config/plugin.ts
export default {
  cors: {
    enable: true,
    package: 'egg-cors',
  },
  static: {
    enable: true,
    package: 'egg-static',
  }
}

typings

// app/typings/config/plugin.d.ts

import 'egg-cors';
import 'egg-static';

代码提示

让 egg 支持模块注入之后,基本上该有的代码提示都有了,但是我们还想做的更多,其中一个就是 config 编写中的代码提示。也就是,当我们在写配置的时候,能够有代码提示告诉我们有哪些配置可以选择写!所以首先想到的是

// config/config.default.ts
import { EggAppConfig } from 'egg';

export default () => {
  const config = {} as EggAppConfig;
  config.static = {
    defaultEngines: 'xxx'
  };
  return config;
}

但是这样会报错,为什么呢,因为 EggAppConfig 中的所有配置,都是不带 ? 这个 modifier 的,因此每一个配置,都必须要将申明的配置每个都写一遍才行。这样也是不能忍受的,那该如何是好,把 EggAppConfig 的配置全部加上 ? 么。但是如果加上了,在使用这些配置的时候,又要么得

if (app.config.static) {
  // use app.config.static
}

要么就使用 !.

app.config.static!.defaultEngines

因为加上了 ? 之后,配置的类型在 ts 看来都可能是 undefined,所以要么使用 !. 要么就只能通过 if else 来判断存在后才能使用。

然后又多亏了 typescript 2.8 提供了 Conditional Types 的能力,我们在 Egg 中提供了一个 PowerParitial 的类型。

export type PowerPartial<T> = {
    [U in keyof T]?: T[U] extends object
      ? PowerPartial<T[U]>
      : T[U]
  };

能够将多层的对象申明都添加上 ?。于是我们的 config 就可以这么写也不会报错了,而且也能得到代码提示。

// config/config.default.ts
import { EggAppConfig, PowerPartial } from 'egg';

export default () => {
  const config = {} as PowerPartial<EggAppConfig>;
  config.static = {
    defaultEngines: 'xxx'
  };
  return config;
}

然后如果我想将业务配置,也加到代码提示当中呢?总结下来,有两种方案:

如果不嫌烦的话,可以写一个 BizConfig 的 interface

// config/config.default.ts
import { EggAppConfig, PowerPartial } from 'egg';

interface BizConfig {
  news: {
    pageSize: number;
    pageUrl: string;
  }
}

export default () => {
  const config = {} as PowerPartial<EggAppConfig> & BizConfig;
  config.news = {
    pageSize: 123,
    pageUrl: 'xxxx'
  };
  return config;
}

由于上面生成的 d.ts 会将 config 返回的类型注入到 app.config 中,因此这样返回之后,也可以直接在业务代码中通过 app.config.news.pageSize 获得。

如果觉得写 interface 很麻烦,那么还有第二种方法,不过就得将业务配置以及 Egg 配置分开写

// config/config.default.ts
import { EggAppConfig, PowerPartial } from 'egg';

export default () => {
  const config = {} as PowerPartial<EggAppConfig>;
  config.static = {
    ...
  }

  const bizConfig = {
    news: {
      pageSize: 123,
      pageUrl: 'xxxx'
    }
  };

  return { 
    ...config,
    ...bizConfig
  };
}

在 config.default.ts 中写的业务配置,如果我想在 config.local.ts 以及 config.prod.ts 中也有代码提示的话,也很简单,直接通过 ReturnType 就可以拿到 config.default 中返回的配置类型了。

// config/config.prod.ts
import { EggAppConfig, PowerPartial } from 'egg';
import defaultConfig from './config.default';
type DefaultConfig = ReturnType<typeof defaultConfig>;

export default () => {
  const config = {} as PowerPartial<DefaultConfig>;
  config.news = {
    pageSize: 30
  };
  return config;
}

由于我们在 config 中的配置,也能够被同名的 middleware 消费,而如果我们想在 middleware 中拿到配置的代码提示就可以这么写:

// middleware/news.ts
import defaultConfig from './config.default';
type DefaultConfig = ReturnType<typeof defaultConfig>;

// 这里注意,只能用 DefaultConfig['news'],不能用 DefaultConfig.news
// 因为 DefaultConfig 是类型,不是实例,所以不能用 .
export default (options: DefaultConfig['news']) => {
  return async function(ctx, next) {
    console.info(options.pageSize);
    await next();
  }
}

除了 config,我们还期望写 plugin 配置的时候也能有代码提示,所以我也给 egg 加了个 EggPlugin 的声明,因此写 plugin 配置的时候,也能够有代码提示了:

import { EggPlugin } from 'egg';

const pluginList = {} as EggPlugin;
pluginList.static = true;

export default pluginList;

以上均可以在我自己用来测试的项目 egg-boilerplate-d-ts 中进行体验测试。

最后

至此,就是此次 egg ts 改造所做的一些事了,不合理的地方欢迎指出,共同提升 egg 的 ts 开发体验。

duncup commented 6 years ago

期待支持 model 目录

dengnan123 commented 6 years ago

怒赞

hhpop commented 6 years ago

强烈支持

atzcl commented 6 years ago

照着改造了自己的项目,方便了不少~:satisfied:

IndexXuan commented 6 years ago

感谢,收益匪浅。请问目前 dev 下, ts 稍微报错进程就会崩掉,没有了 js 开发 egg 那种重启子进程,这块有好的办法吗 ?

whxaxes commented 6 years ago

@IndexXuan 在我的项目( https://github.com/whxaxes/egg-boilerplate-d-ts )中是可以的哦,如果你在出错后将代码改成正确的,就不会崩掉呀。但是如果一直不改正的话,egg 是会自动退出的。所以解决方案是,当写错代码后,尽快改正代码,再或者通过 tsconfig 配置将一些严格的规则给关掉

clancytom commented 6 years ago

赞~(≧▽≦)/~

whxaxes commented 6 years ago

@duncup 已经支持 egg-sequelize 的 model 生成。

byeval commented 6 years ago

@whxaxes 但是 egg-mongoose 也是用的 model 文件夹,现在写死了 sequelize 。。

whxaxes commented 6 years ago

@xuhong 因为现在 egg-mongoose 没有提供声明,所以我这边要自动生成也不知道怎么生成,再者就是现在不是写死了 sequelize ,而是只写了 sequelize ,你可以看一下生成的声明文件内容。这个对你使用 mongoose 没影响

rpig commented 4 years ago

PowerPartial & BizConfig 我用这个 为啥有些属性变成了undefined 我自己定义了类型了

whxaxes commented 4 years ago

@zhu353071655 少了个 typeof 只是多了个 undefined 类型啊,因为是 ?:

rpig commented 4 years ago

测试代码,而且app.config也没关联上

// app special config scheme
export interface BizConfig {
  sourceUrl: string;
  news: {
    pageSize: number;
    serverUrl: string;
  };
  logger: {
    level?: 'info' | 'all';
  };
}

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

  // app special config
  config.sourceUrl = `https://github.com/eggjs/examples/tree/master/${appInfo.name}`;
  config.news = {
    pageSize: 30,
    serverUrl: 'https://hacker-news.firebaseio.com/v0',
  };
  config.logger = {
    level: 'info' 
  }
  // override config from framework / plugin
  config.keys = appInfo.name + '123456';

  config.view = {
    defaultViewEngine: 'nunjucks',
    mapping: {
      '.tpl': 'nunjucks',
    },
  };

  config.siteFile = {
    '/favicon.ico': fs.readFileSync(path.join(appInfo.baseDir, 'app/public/favicon.png')),
  };

  return config;
};
whxaxes commented 4 years ago

@zhu353071655 EggAppConfig 里本身就有 level 的属性,用了 & 之后 ts 就取了公用类型,也就是 undefined ... ,你试一下 type bb = ('info' | 'all' | undefined) & ('a' | 'b' | undefined); 就知道了,这个是 ts 的语法。自己去看文档吧

而至于你下面说的,你跑了 ets 了生成声明了么?

rpig commented 4 years ago

@whxaxes 你是说这个么 生成了呀

// This file is created by egg-ts-helper@1.25.6
// Do not modify this file!!!!!!!!!

import 'egg';
import { EggAppConfig } from 'egg';
import ExportConfigDefault from '../../config/config.default';
type ConfigDefault = ReturnType<typeof ExportConfigDefault>;
type NewEggAppConfig = ConfigDefault;
declare module 'egg' {
  interface EggAppConfig extends NewEggAppConfig { }
}
whxaxes commented 4 years ago

@zhu353071655 别发代码了,直接给个可复现 repo 吧

rpig commented 4 years ago

@whxaxes 有时间帮看下哈,代码提交了,主要改了config.default 和home.ts https://github.com/zhu353071655/egg-boilerplate-d-ts/commit/060477e0649704f1375195115dc586f811ea5eb8

rpig commented 4 years ago

现在主要是关联不上的问题,再问一个,有没有类型和值集都取交集的方式,&只能类型交集 值集并集,|只能类型并集值集交集,extend又不能重复定义

rpig commented 4 years ago

咱们的egg ts生产环境是建议直接编译在同文件路径下面么,不用统一编译到dist这种目录下面么

rpig commented 4 years ago

@whxaxes 还有个问题就是egg插件挂载方法到ctx.app之后开发打包成npm引入后 Application的依赖还得手动再定义一次么,不然业务工程里找不到。例如 ctx.app.mongo

rpig commented 4 years ago

还有两个问题很奇怪,在开发环境模式下用egg-bin启动服务,会有两种报错: 1、插件里的middleware提示import失败(非middleware没问题),把同级的ts文件删掉才会访问同名的js,暂时是这样解决的(官方不是说优先读js文件么,同名的情况下) 2、业务工程里会提示 can't overwrite property 'example' from /xxx/example.js by /xxxx/example.ts at /xxx/node_modules/egg-core/lib/loader/file_loader.js:78:47(目前解决方案是本地开发的时候先统一rm掉js文件,为啥会overwrite,egg-bin不是集成了tsnode么) (上面编译都是编译到同级目录,tsconfig都跟demo一致)