最近在做运营侧中台项目的重构,目前的选型是 koa2+typescript。在实际生产中,切实体会到了 typescript 类型带来的好处。
为了更形象说明 typescript 的优势,还是先来看一个场景吧:
BUG 现场
作为一门灵活度特别大的语言,坏处就是:复杂逻辑编写过程中,数据结构信息可能由于逻辑复杂、人员变更等情况而丢失,从而写出来的代码含有隐含错误。
比如这次我在给自己的博客编写node 脚本的时候就遇到了这种情况:
1 | const result = []; |
result 保存了递归遍历的所有文件的 path、check、content 信息,其中 content 信息会被传给prettier.js
的check(content: string, options: object)
方法。
显然,上述代码是有错误的,但是极难发现。只有运行它的时候,才能通过堆栈报错来进行定位。但如果借助 ts,就可以立即发现错误,保持代码稳健。
这个问题放在文章最后再说,下面看看 ts 在 koa 项目中的运用吧。
项目目录
由于没有历史包袱,整个项目的架构还是非常清爽的。如下所示:
1 | . |
typescript 编译与 npm 配置
因为是用 ts 来编写代码,因此需要专门编写 typescript 的配置文件:tsconfig.json
。根据个人习惯,以及之前组内的 ts 项目,配置如下:
1 | { |
对于一些有历史遗留的项目,或者说用 js 逐步重构为 ts 的项目来说,由于存在大量的 js 遗留代码,因此allowJs
这里应该为true
,noImplicitAny
应该为false
。
在package.json
中,配置两个脚本,一个是 dev 模式,另一个是 prod 模式:
1 | { |
在 dev 模式下,需要 tsc 监听配置中include
中指定的 ts 文件的变化,并且实时编译。bin/dev.js
是根据项目需要编写的监听脚本,它会监听dist/
目录中编译后的 js 文件,一旦有满足重启条件,就重启服务器。
类型声明文件
koajs 与常见插件的类型声明都要在@types 下安装:
1 | npm i --save-dev @types/koa @types/koa-router @types/koa2-cors @types/koa-bodyparser |
区分 dev/prod 环境
为了方便之后的开发和上线,src/config/
目录如下:
1 | . |
配置分为 prod 和 dev 两份。dev 模式下,向控制台打印信息;在 prod 下,需要向指定位置写入日志信息。类似的,dev 下不需要进行身份验证,prod 下需要内网身份验证。因此,利用 ts 的extends
特性来复用数据声明:
1 | // mode: dev |
在 index.ts 中,通过process.env.NODE_ENV
变量值来判断模式,进而导出对应的配置。
1 | import { devConf } from "./dev"; |
如此,外界直接引入即可。但在开发过程中,例如身份认证中间件。虽然 dev 模式下不会开启,但编写它的时候,引入的config
类型是ConfigScheme
,在访问ProdConfigScheme
上的字段时候 ts 编译器会报错。
这时候,ts 的断言就派上用场了:
1 | import config, { ProdConfigScheme } from "./../config/"; |
中间件编写
对于整体项目,和 koa 关联较大的业务逻辑主要体现在中间件。这里以运营系统必有的「操作留存中间件」的编写为例,展示如何在 ts 中编写中间件的业务逻辑和数据逻辑。
引入 koa 以及编写好的轮子:
1 | import * as Koa from "koa"; |
操作留存中需要留存的数据字段有:
1 | staffName: 操作人 |
ts 中借助 interface 直接约束字段类型即可。一目了然,对于之后的维护者来说,基本不需要借助文档,即可理解我们要和 db 交互的数据结构。
1 | interface LogScheme { |
最后,编写中间件函数逻辑,参数需要指明类型。当然,直接指明参数是 any 类型也可以,但这样和 js 就没差别,而且也体会不到 ts 带来文档化编程的好处。
因为之前已经安装了@types/koa
,因此这里不需要我们手动编写 .d.ts
文件。并且,koa 的内置数据类型已经被挂在了前面 import 进来的Koa
上了(是的,ts 帮我们做了很多事情)。上下文的类型就是 Koa.BaseContext
,回调函数类型是() => Promise<any>
1 | async function logger(ctx: Koa.BaseContext, next: () => Promise<any>) { |
单元函数
这里以一个日志输出的单元函数为例,说一下「索引签名」的应用。
首先,通过联合类型约束了日志级别:
1 | type LogLevel = "log" | "info" | "warning" | "error" | "success"; |
此时,打算准备一个映射:日志等级 => 文件名称 的数据结构,例如 info 级别的日志对应输出的文件就是 info.log
。显然,这个 object 的所有 key,必须符合 LogLevel。写法如下:
1 | const localLogFile: { |
如果对于 log 级别的日志,不需要输出到文件仅仅需要打印到控制台。那么localLogFile
应该没有log
字段,如果直接去掉log
字段,ts 编译器报错如下:
1 | Property 'log' is missing in type '{ info: string; warning: string; error: string; success: string; }' but required in type '{ log: string | void; info: string | void; warning: string | void; error: string | void; success: string | void; }'. |
根据错误,这里将索引签名字段设置为「可选」即可:
1 | const localLogFile: { |
关于 export
使用export
导出复杂对象时候,请加上类型声明,不要依赖与 ts 的类型推断。
index.ts
:
1 | import level0 from "./level0"; |
level0.ts
:
1 | import { ApiSet } from "./index"; |
回到开头
回到开头的场景,如果用 typescript,我们会先声明result
中每个对象的格式:
1 | interface FileInfo { |
此时,你会发现 typescript 编译器已经给出了报错,在 content: fs.readFileSync(file)
这一行中,报错信息如下:
1 | 不能将类型“Buffer”分配给类型“string”。 |
如此,在编写代码的时候,就能立即发现错误。而不是写了几百行,然后跑起来后,根据堆栈报错一行行去定位问题。
仔细想一下,如果是 30 个人合作的大型 node/前端项目,出错的风险会有多高?定位错误成本会有多高?所以,只想说 ts 真香!