TypeScript 写久了,越来越觉得定义各种类型,接口啊其实都是在写编辑器自动提示的配置而已。而且现在 ts 的各种高级类型越来越多,整个类型系统甚至可以看作是一套函数式工具库。用它不难,用好它其实挺难的,其中的差别我觉得就像前端从面向 dom 的编程和转变为面向数据驱动编程那样,你得首先有类型思维,因为它一定程度上还会反过来影响你的 api 设计和数据结构。

先谈下关于用不用 TypeScript 的个人观点:

首先从个人的学习成长来说,一定要用!毕竟是一条目前很多公司都挺看重的技能点,而且学习使用的过程中多多少少还会提升你编码的严谨性和 api 的设计能力。

然后从团队来说,一句话:量力而为!实话实说,对于开发效率的提升影响不大,甚至是负面的:因为对于团队当中可能没有类型思维,没有强迫症,也不愿意去为了实现某个完备的类型提示而花功夫去搜索学习的同学,让他们写 ts 代码,说实话就是在降低开发效率和恶心其他同学,他还会反感设计这一套方案的人。。

另外觉得网上很多所谓 TypeScript 解决的若干痛点其实多少有些夸大其词了,比如:提前发现一些可能由于 undefined,类型不匹配导致的数据引用错误这一点其实仔细想想首先出现的概率没那么大,就算出现了解决该问题的时间也会小于你定义类型的时间(ts 之前大家不都是这么过来的么。。)然后上述优点的背后其实是你在使用某个数据之前,设计某个函数之前,全量地思考过类型隐患,然后去做了各种定义。换句话说,你原本就对类型敏感,能写出一份完备类型定义,就算不用 ts ,也没啥问题;而那些 any,类型推导直接干的就算用了 ts,也有这问题啊!

用 TypeScript 只是因为爽!

实际开发当中真正因类型的引入收益最多的部分还是得回归到 ts 的类型提示上面来,也就是标题上说的面向编辑器编程,各种代码提示我觉得才是广大程序员的真正爽点,别人问我为什么用 ts,我就只会说:哪怕我要花点时间甚至花大时间去定义一个类型,当我在编辑器上输入了一个括号,一个点,编辑器就知道我要干啥的时候是真爽啊!提升效率?不存在的!

还有对于喜欢造轮子的同学,ts 有一个天然的好处就是你写的文档会省好多事,甚至类型约束本身比文档来的更好用,编辑器就会直接告诉调用者该传哪些参数,返回什么数据

闲聊篇

一段有问题的代码

前两天维护过这么一段有问题的代码:

1
2
3
4
5
6
7
8
authors.map((author) => {
if (author.name === user) {
return {
name,
status: 'xx',
}
}
})

很明显,返回的 name 字段有问题,但浏览器不会报错,因为 name 存在于 window 对象中。凭直觉应当修改为:

1
2
3
4
{
name: author.name,
status: 'xx'
}

可是又由于上面有这么一段:

1
author.name === user

理论上变量被命名成 user,它应该是个对象类型,而返回的 name 应当是个字符串类型。这种时候为了保险就只能去 review 上下文或者调试输出了。这种时候是确确定定觉得 ts 大法好啊,如果上面那个函数体是 ts,并定义了返回值类型,首先就不会有这个错误,就算有我改起来也很有信心了。

any 是不是任何时候都不推荐使用?

好多人已经到了谈 any 色变的程度了,这其实是又走向了另一个极端。any 被设计出来肯定是有使用场景的,合理使用是可以减少一些开发负担。比如下面这个神奇的类:

1
2
3
4
5
6
7
8
9
10
11
12
class Test {
private x: number | string = 'hello'

private test1() {
;(this.x as any).toFixed()
}

test() {
this.x = 3.1415926
this.test1()
}
}

显然调用 test1 的唯一入口就是外部调用该类的 公共方法 test,而该入口一定会把属性值 x 设置成 number 类型,所以你在 test1 这个私有方法中把 x as 成 number 或者 any 没啥区别。而且这里只是为了举个例子,实际 x 的类型可能会引用自某个三方包里的某些定义起来非常繁杂的类型,as 起来会很不方便,这时候是推荐你用 any 直接干的。

还有类似这种情况:

1
2
3
4
5
6
7
8
9
class SomeComponent extends Component {
private data: YourDataType

async initData() {
// 假设 fetch 在不传泛型的情况下返回 any 类型
const { id } = await fetch('/xxx')
this.data = await fetch(`/data?id=${id}`)
}
}

假设第一次 fetch 的数据在当前场景下只用到了少数几个字段,且在本次请求后就再也没有使用过。那么对于 initData 这个函数来说,第一次 fetch 回来的数据类型你完全可以使用默认的 any 类型,而不必专门去定义一个通篇只有这一处会使用到的类型;而 this.data 这种显然在组件生命周期中会多次引用的数据,类型的声明就很有必要了。

当然了,理论上你应当在业务中提前定义好获取各类数据的方法,或者各类常用数据的类型,那么上述代码很可能就是下面这样的,也就不用纠结是否使用 any 了。

1
2
3
4
5
6
7
import { User } from 'types'
async function initData() {
// 返回值自带类型
// const { id } = await getCurrentUser()
// 其他模块中定义过该类型
const { id } = await fetch<User>('/user')
}

而换句话说,如果:

  • 某些数据不是完全由你托管的私有数据
  • 你不只是想调用某个方法,修改某个数据,而是需要完整的类型提示来引导之后的编码
  • 该数据你不止一处会引用到

那最好就不要 any 了,老老实实去声明成目标类型,这种才是常态

善用类型推导:

1
2
3
4
5
function test() {
return {a: 1, b: '2'}
}
const data = test()
data. // 这个时候编辑器就已经会提示你 data 内部会有哪些数据,分别是什么类型

而什么时候才需要明确地声明上面 test 函数的返回值?就是未来这个函数很大可能会被别人维护,如同我开篇提到的第一个例子,这种情况下返回值类型的定义就有必要了。

几个关于类型思维的问题:

一、设计一个函数

参数:

  1. 一个任意函数
  2. 该函数原本需要接收的参数

返回值:

第一个参数接受的函数它原本会返回的值

先拍脑袋写一个:

1
2
3
function test(fun: Function, ...args: any[]) {
return fun.apply(null, args)
}

好吧,上述就是典型的用 ts 写出来的 js 代码,只是实现了上述需求的功能,基本没有实现上述每一条需求所隐含的类型信息,我们来逐条看:

  • 一个任意函数:约束了第一个参数的的接受值类型为 Function
  • 该函数原本需要接收的参数:约束了其他参数的数量和类型都应当与传入函数参数保持一致
  • 返回值为第一个参数接受的函数它原本会返回的值:约束了返回值类型为传入函数调用后的返回值类型

下面是相对正确的写法:

1
2
3
4
5
6
7
type AnyFunction = (...args: any[]) => any
function test<T extends AnyFunction, P extends Parameters<T>>(
fun: T,
...args: P
): ReturnType<T> {
return fun.apply(null, args)
}

相关阅读:strictBindCallApply

通过上面的示例,所谓的类型思维的一个体现就是:你能不能察觉到一个功能需求中隐含的类型诉求。而面向编辑器或是面向类型编程就是你这个函数实现了之后在编辑器里再真实地调用一下,看看代码提示,错误警告等是否符合你的预期,如果不符合你愿不愿意花时间去完善或者去查资料。

二、基于 express 设计一个 action 函数

这个问题的背景是:希望通过 action 函数来统一返回 express-router 的 callback。作为后端项目的 RequestHandler 来说,常见的需求就是统一 Response 格式,鉴权处理等。这里想专门说一下它的鉴权部分,因为对于后端服务来说,不是所有的 api 都需要鉴权,因此该函数能够通过某种方式来设置是否需要鉴权,刚开始想的方案是:

1
2
const getUser = action((req, user) => user) // 这是一个需要鉴权的行为
const getBooks = action((req) => getSomeSthByReq(req)) // 这是一个不需要鉴权的行为

很容易看出,这是一个很经典的通过 callback 函数参数数量来控制 action 函数的行为设计。而且这里的 user 参数既可以控制是否鉴权,又恰好可以把鉴权结果,也就是用户信息提供给 callback 函数,一石二鸟,简直完美!虽然目前为止和 typescript 的关系还不是很大。

然而,随着 api 的逐渐增加,发现该设计会有以下问题,比如:

1
2
3
4
5
6
const getUser = action((req, user) => user) // req 参数没有被用到

// 希望登陆的用户才可以查看 productions
// 可是 productions 的获取并不依赖 user 参数,所以结果就是 req 和 user 两个参数都没有用到
// 参数的浪费造成的可能不仅仅是内存浪费问题,首先要面临的就是各种 linter 的报错...
const getProductions = action((req, user) => getProductionsFromDb())

基于以上,首先咱们来解决参数的利用问题,不难想到把 req 和 user 参数放到一个对象中,然后把该对象作为一个参数传出去:

1
const getUser = action(({ user }) => user) // 通过使用 es6 的结构,简洁而优雅

解决了参数的利用问题,再来解决鉴权的控制问题。显然如果你不使用什么黑科技的话你是无法通过 callback 的对象参数的字段来控制 action 的行为了,而且即使可以,也同样会有上述 getProductionsuser 参数的浪费问题。所以再给 action 提供一个参数吧,就叫它 authorize

1
2
// 通过设置 authorize 为 true 来控制鉴权
const getProductions = action(() => getProductionsFromDb(), true)

好了,到现在依然没有和 typescript 扯上什么关系,不过接下来。。。

1
const getXX = action(({ user }) => getXXFromXX(user.id))

忙中难免出错,上述代码用到了 user,却没有设置 authorize 为 true,这么干肯定会导致错误,而且这个错误只有在运行时才会被发现(又有人要喊你回去改 bug 了)。

那么,上 typescript 大法吧!上述场景的 ts 解决方案非函数重载莫属了(我就说 ts 的文档有问题吧,比如”函数重载”这四个字我就没法直接给个官方文档的链接)

1
2
3
4
5
6
7
8
function action(callback: (data: { req: Request }) => ResponseData): Action
function action(
callback: (data: { req: Request; user: User }) => ResponseData,
authorize: boolean,
): Action
function action(callback: any, authorize?: boolean) {
// 实现略
}

有了对 action 函数的类型约束,如果你再用到了 user 参数,而没有设置 authorize,那么请接受 ts 类型检查的问候吧!

三、针对 React 的 高阶组件的若干问题

平时使用 React 的同学对 HOC 肯定不陌生,如果你使用的是 tsx,是否考虑过如下问题:

  • HOC 后的组件的 props 类型你是否认真考虑过注入,修改,删除这三个场景的类型实现?
  • HOC 函数本身是否对传入组件的类型做过约束?比如该函数只接受 Input 类组件。
  • 如果原始组件具有泛型,HOC 后如何保持泛型传入?
  • 如果原始组件具有静态方法/属性,HOC 后如何保持引用?
  • 这些问题由于时间关系,我准备就挑一个来说:网上好多关于 ts 的 HOC 实现一般都要求传入原组件的 Props 作为泛型参数,可实际开发中不是所有的组件都会老老实实导出一个 Props 类型给你用,这种时候你可以借助 React.ComponentProps 来拿到。

最后,总感觉 TypeScript 的 官方文档 写的有问题啊,查阅起来不是很方便,有内置的很多工具类型或语法,比如:Exclude、Pick、keyof 等分散在各个页面中,甚至就没有提到。我想知道有没有一个统一的地方可以查阅以上所有。

拓展阅读

TypeScript 疑难杂症


 评论