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

前言

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 代码风格,后缀名为 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.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.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"}
}
  • 使用 VSCode 进行调试
  • VSCode 调试 Egg 完美版 - 进化史

部署

构建

  • 正式环境下,我们更倾向于把 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 内建了处理,会自动纠正为正确的错误堆栈,应用开发者无需担心。

具体内幕参见:

  • https://zhuanlan.zhihu.com/p/26267678
  • eggjs/egg-scripts#19

插件/框架开发指南

指导原则:

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

当 Egg 遇到 TypeScript,收获茶叶蛋一枚 #27相关推荐

  1. 今日工作收获(2018/2/27)

    1. php.ini中memory_limit字段可以设置php内存max值,解决allowed  memory size of 134217728 bytes exhausted 问题. 2.字面量 ...

  2. 从转载阿里开源项目 Egg.js 技术文档引发的“版权纠纷”,看宽松的 MIT 许可该如何用?

    作者 | 苏宓.彭慧中 出品 | CSDN(ID:CSDNnews) 开源迅速发展的这两年,很多内部问题逐渐凸显出来,如安全.版权.协议使用等. 近日,来自V2EX社区中一位开发者 @an168ban ...

  3. 学习egg.js,看这一篇就够了!

    egg 介绍 egg 是什么? egg 是阿里出品的一款 node.js 后端 web 框架,基于 koa 封装,并做了一些约定. 为什么叫 egg ? egg 有孕育的含义,因为 egg 的定位是企 ...

  4. 【腾讯圣诞晚会TEG节目】这里的黎明静悄悄

    2018腾讯圣诞晚会·全新出发 梦里好成功 如果你什么都没有,至少得有点想象力. 我们今年的男主角郝成功,就是一直生活在想象的美好中.他每天乘坐价值上亿的交通工具(地铁)上下班,在五星级餐厅享用奢华早 ...

  5. 你想每天定时向你的女朋友发一句早安吗 ?教你实现一个微信机器人

    ❝ 你想每天定时向你的女朋友发一句早安吗? ❞ wechaty 是一个使用 typescript 开发的机器人,我已经使用 wechaty[1] 做了很多关于有趣的自动化的工作. 你可以通过 wech ...

  6. 颜宁:批评一下当年的「颜宁同学」

    想起之前郝琦.和渊毕业时我写的博客文章 >>>> 赫然发现,我竟然用了"美女",所以说,人是需要不断成长的,批评一下当年的颜宁同学[顶][顶][顶] 贴过来 ...

  7. 汽车计算机控制的电路有哪些特点,在电磁继电器工作电路中的电压和电流比起控制电路来说一般是...

    二OO八年佳木斯市初中毕业学业考试 物理试卷 考生注意: 1.考试时间120分钟. 2.全卷共六道大题,总分100分 一.单项选择题(每小题2分,共24分.每小题只有一个选项是正确的,请把正确选项的字 ...

  8. delphi 企业微信消息机器人_GitHub - guoxianlong/insight: Insight是一个可以管理企业微信群机器人的小工具,可以非常方便的往群里发布即时消息和定时消息。...

    最科幻的企业微信群机器人管理工具 非常方便的发布群即时消息和定时消息,解放双手,提升沟通效率 部署教程 更新日志(2020.05.31) 修复设置为智能跳过工作日时,周日依然提醒问题. 前端修复定时成 ...

  9. 【公告】淘宝 npm 域名即将切换 npmmirror 重构升级

    镜像下载.域名解析.时间同步请点击 阿里云开源镜像站 前言 本文将包括两部分内容: 淘宝 npm 域名即将停止解析 npmmirror 镜像站大重构升级 原淘宝 npm 域名即将停止解析 正如在< ...

最新文章

  1. 怎么截取图片大小 html,详解html2canvas截图不能截取圆角图片的解决方案
  2. U盘启动盘恢复为普通U盘
  3. Python 模块之heapq
  4. iOS网络编程开发-数据加密
  5. e3 v3服务器芯片组,请问e3 1231 v3搭配下面哪款主板性价比最高?最适合?为什么?
  6. free命令里的buffers/cache
  7. c#水晶报表连接mysql_C# 水晶报表打印 绑定数据库表
  8. php怎么改背景图片,php - 如何借助php中的url参数更改背景图片? - SO中文参考 - www.soinside.com...
  9. 漂亮的不像实力派--锤子新品“坚果手机”发布会
  10. 程序员如何准备简历以及面试的要求
  11. 龙贝格积分——matlab实现
  12. Skippr – 轻量、快速的 jQuery 幻灯片插件
  13. Excel如何把同一列的内容拆分为两列?
  14. HEVC帧内预测参考相邻帧代码解析
  15. 人工客服真的是真人吗?
  16. 数据结构与算法(C语言版)----运动会管理系统
  17. YUV/YCbCr/YPbPr
  18. 飞鸽传书——短信接口
  19. 全栈工程师需要具备哪些技能
  20. .net Core 6.0 部署到欧拉(Linux)系统上,“The type initializer for ‘Gdip‘ threw an exception” 报错的解决方案

热门文章

  1. CC.Net 全接触系列之三: CQ.Net: CC.Net 最佳伴侣
  2. WordPress基础教学:绝对必装的JetPack外挂
  3. CSS、JavaScript和Ajax实现图片预加载的三大方法及优缺点分析
  4. Ubuntu中useradd和adduser的区别
  5. Comet:基于 HTTP 长连接的“服务器推”技术 (实例)
  6. python——文本简单可逆加密
  7. 矩阵的旋转和翻转——Python
  8. 【CVPR2019】Workshops 研讨会列表及链接
  9. 【今日CV 视觉论文速览】Part2 1 Feb 2019
  10. 考勤信息管理系统 需求说明