JavaScript => TypeScript 迁移体验

JavaScript => TypeScript 迁移体验

前言

如果你使用 JavaScript 没出现什么问题,那吾辈就不推荐你迁移到 TypeScript!

  • JavaScript 不能无缝迁移到 TypeScript
  • JavaScript 不能无缝迁移到 TypeScript
  • JavaScript 不能无缝迁移到 TypeScript

重要的话说三遍,TypeScript 是 JavaScript 的超集,所以有很多人认为(并宣称)JavaScript 可以很容易迁移到 TypeScript,甚至是无缝迁移的!
导致了 JavaScript 开发者满心欢喜的入坑了 TypeScript(包括吾辈),然后掉进了坑里,甚至差点爬不出来。。。

原因

  • 问: 为什么吾辈用 JavaScript 用的好好的,偏偏自找麻烦去入坑了 TypeScript 了呢?
  • 答: JavaScript 因为一些固有问题和主流编辑器 VSCode 支持不力,导致代码写起来会感觉很不方便
  • 问: 具体谈谈
  • 答: 有很多令人不满意的地方,这里只谈几点:
    • JavaScript 没有类型,所以写 JSDoc 感觉很麻烦,但不写又不太好。然而,JavaScript 代码写的太顺利的话就可能忘记加上 JSDoc,之后代码就很难维护。
    • VSCode 支持不好,这点或许才是最重要的: VSCode 使用 TypeScript 编写,并基于 TypeScript 实现的语法提示功能,虽然也支持根据 JSDoc 的注释进行提示,然而当你去做一个开源项目,并将之发布到 npm 之后,情况发生了变化。。。当一个用户使用 npm/yarn 安装了你的项目之后,发现并没有任何代码提示,如此你会怎么做?
    • 复杂的类型很难使用 JSDoc 表达出来并清晰地告诉调用者,例如高阶函数。
    • 等等。。。。

是的,TypeScript 确实解决了以上的一些问题,却同时带入了另外一些问题。

  • TypeScript 有类型了,然而即便有类型推导,还是要加很多类型,而且有时候 TypeScript 和我们的想法不同的时候还要用 !/(t as unkonwn) as R 这种 hack 技巧
  • VSCode 天生支持 TypeScript,但 TypeScript 的 API Doc 生成工具实在谈不上多好,例如 typedoc 相比于 ESDoc 不过是个半吊子。。。
  • 事实上,即便使用 TypeScript 写的项目,只要使用者没有在 jsconfig.json 中进行配置的话,提示仍然默认不存在
  • TypeScript 的类型系统是把双刃剑,实在太复杂了,当然有理由认为是为了兼容 JavaScript。然而在 TypeScript 想要正确的表达类型也是一件相当困难的事情。

类型系统踩坑

如何声明参数与返回值类型相同?

例如一个函数接受一个参数,并返回一个完全相同类型的返回值。

1
2
3
function returnItself(obj: any): any {
return obj
}

假使这样写的话,类型系统就不会发挥作用了,调用函数的结果将是 any,意味着类型系统将没有效果。

例如下面的代码会被 ts 认为是错误

1
2
// 这段代码并不会有提示
console.log(returnItself('abc').length)

需要写成

1
2
3
function returnItself<T = any>(obj: T): T {
return obj
}

这里主要声明了参数和返回值是同一类型,默认为 any,但具体取决于参数的不同而使得返回值也不同,返回值不会丢失类型信息。

如何声明参数与返回值类型有关联?

例如一个计算函数执行时间的函数 timing,接受一个函数参数,有可能是同步/异步的,所以要根据函数的返回值确定 timing 的返回值为 number/Promise<number>

1
2
3
4
5
6
7
8
9
10
export function timing(
fn: (...args: any[]) => any | Promise<any>,
): number | Promise<number> {
const begin = performance.now()
const result = fn()
if (!(result instanceof Promise)) {
return performance.now() - begin
}
return result.then(() => performance.now() - begin)
}

然而在使用时你会发现返回值类型不太对,因为 timing 的返回值是 number | Promise<number> 这种复合类型

1
2
3
// 这里会提示类型错误
const res: number = timing(() => sleep(100))
expect(res).toBeGreaterThan(99)

解决方案有二

  1. 使用函数声明重载
  2. 使用类型判断

使用函数声明重载

1
2
3
4
5
6
7
8
9
10
11
12
export function timing(fn: (...args: any[]) => Promise<any>): Promise<number>
export function timing(fn: (...args: any[]) => any): number
export function timing(
fn: (...args: any[]) => any | Promise<any>,
): number | Promise<number> {
const begin = performance.now()
const result = fn()
if (!(result instanceof Promise)) {
return performance.now() - begin
}
return result.then(() => performance.now() - begin)
}

感觉函数声明顺序有点奇怪是因为 Promise<any> 属于 any 的子类,而函数声明重载必须由具体到宽泛。当然,我们有方法可以在 any 中排除掉 Promise<any>,这样顺序就对了!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function timing(
fn: (...args: any[]) => Exclude<any, Promise<any>>,
): number
export function timing(fn: (...args: any[]) => Promise<any>): Promise<number>
export function timing(
fn: (...args: any[]) => any | Promise<any>,
): number | Promise<number> {
const begin = performance.now()
const result = fn()
if (!(result instanceof Promise)) {
return performance.now() - begin
}
return result.then(() => performance.now() - begin)
}

使用类型判断

1
2
3
4
5
6
7
8
9
10
11
export function timing(
fn: (...args: any[]) => any | Promise<any>,
// 函数返回类型是 Promise 的话,则返回 Promise<number>,否则返回 number
): R extends Promise<any> ? Promise<number> : number {
const begin = performance.now()
const result = fn()
if (!(result instanceof Promise)) {
return (performance.now() - begin) as any
}
return result.then(() => performance.now() - begin) as any
}

思考

可以看出来,第一种方式的优点在于可以很精细的控制每个不同参数对应的返回值,并且,可以处理特别复杂的情况,缺点则是如果写 doc 文档的话需要为每个声明都写上,即便,它们有大部分注释是相同的。
而第二种方式,则在代码量上有所减少,而且不必使用函数声明重载。缺点则是无法应对特别复杂的情况,另外一点就是使用了 any,可能会造成重构火葬场

TypeScript 类型系统就是认为吾辈错了怎么办?

有时候,明明自己知道是正确的,但 TypeScript 偏偏认为你写错了。思考以下功能如何实现?

将 Array 转换为 Map,接受三个参数

  1. 需要转换的数组
  2. 将数组元素转换为 Map key 的函数
  3. 将数组元素转换为 Map value 的函数,可选,默认为数组元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function returnItself<T = any>(obj: T): T {
return obj
}

export type ArrayCallback<T, R> = (item: T, index: number, arr: T[]) => R

export function arrayToMap<T, K, V>(
arr: T[],
kFn: ArrayCallback<T, K>,
vFn: ArrayCallback<T, V> = returnItself,
): Map<K, V> {
return arr.reduce(
(res, item, index, arr) =>
res.set(kFn(item, index, arr), vFn(item, index, arr)),
new Map<K, V>(),
)
}

可能有以上代码,然而实际上 returnItself 无法直接赋值给 ArrayCallback<T, V>。当然,我们知道,这一定是可以赋值的,但 TypeScript 却无法编译通过!

1
2
3
4
5
6
7
8
9
10
11
12
export function arrayToMap<T, K, V>(
arr: T[],
kFn: ArrayCallback<T, K>,
// 是的,这里添加 as any 就好了
vFn: ArrayCallback<T, V> = returnItself as any,
): Map<K, V> {
return arr.reduce(
(res, item, index, arr) =>
res.set(kFn(item, index, arr), vFn(item, index, arr)),
new Map<K, V>(),
)
}

或者,如果 returnItself 用的比较多的话(例如吾辈),可以使用另一种方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 修改 returnItself 的返回值
function returnItself<T, R = T>(obj: T): R {
return obj as any
}

export type ArrayCallback<T, R> = (item: T, index: number, arr: T[]) => R

export function arrayToMap<T, K, V>(
arr: T[],
kFn: ArrayCallback<T, K>,
vFn: ArrayCallback<T, V> = returnItself,
): Map<K, V> {
return arr.reduce(
(res, item, index, arr) =>
res.set(kFn(item, index, arr), vFn(item, index, arr)),
new Map<K, V>(),
)
}

如何强制调用非空时对象上的函数?

当有时候你得到一个对象可能为空时,无法直接调用其上的函数,会提示函数不存在。
例如下面从数组中查询字符串,然后获取长度,在 TypeScript 中便会报错,因为 str 的类型为 string/undefined。

1
2
3
4
const arr = ['a', 'b', 'c']
const str = arr.find(s => s === 'b')
//
console.log(str.length)

之前使用 JavaScript 从未遇到过这种事情,事实上确实有可能为空,但 JavaScript 太过于动态,并不会提示错误,而 TypeScript 就会提示这种低级错误,因为类型系统。
但是啊,凡事都有例外,当吾辈确实想调用 string 上的函数时报错真的是有点讨厌,那么有什么办法呢?

  1. 使用 ! 强制调用

    1
    2
    3
    const arr = ['a', 'b', 'c']
    const str = arr.find(s => s === 'b')
    console.log(str!.length)
  2. 使用 (str as any) 转换为 any 类型之后再随意调用任何函数

    1
    2
    3
    const arr = ['a', 'b', 'c']
    const str = arr.find(s => s === 'b')
    console.log((str as any).length)
  3. 使用注释 // @ts-ignore 忽略错误(非常强力,少用)

    1
    2
    3
    4
    const arr = ['a', 'b', 'c']
    const str = arr.find(s => s === 'b')
    // @ts-ignore
    console.log(str.length)

注意: 三种方式推荐程度逐渐降低,因为后两种实际上都会忽略类型系统,导致编写代码没有提示!

总结

截至目前为止,吾辈已经着手使用 TypeScript 重构工具函数库 rx-util 两周了,基本上打包配置,文档生成,类型定义基本上算是大致完成,感觉之后的公共项目大概都会用 TypeScript 实现了,毕竟前端主流开发工具 VSCode 对其的支持真的很好,而且 TypeScript 的接口这种概念真的太有用了!

一些吐槽

使用了有一段时间了,这里不得不再次声明一下,TypeScript 的类型系统复杂度超乎想象,如果你没有准备好在生产系统中使用,那就最好不要使用。缺少关于类型系统(尤其是原生类型,例如 PromiseLike 居然没有人讲过)的说明,使得 TypeScript 的类型系统很多时候看起来都只是为了好玩而已。而且稍微复杂一点的情况思考如何设计类型的时间将会超过具体的代码实现,使用它请务必再三慎重考虑!

TypeScript 的类型系统为了兼容 JavaScript 缺陷实在太大了。

参见某个知乎用户的话:

  1. ts 写不出一个合并对象的方法

    下面是一个 js 合并对象的方法

    1
    2
    3
    function extend(dest, ...sources) {
    return Object.assign(dest, ...sources)
    }

    这么一个简单的方法,ts 写不出不丢失类型信息的实现。

    下面贴的是 typescript 源码中对 Object.assign 的声明,我相信都能看出有多傻:

    1
    2
    3
    4
    assign<T, U>(target: T, source: U): T & U;
    assign<T, U, V>(target: T, source1: U, source2: V): T & U & V;
    assign<T, U, V, W>(target: T, source1: U, source2: V, source3: W): T & U & & W;
    assign(target: object, ...sources: any[]): any;

    按这个实现,多于 4 个参数就直接丢掉类型信息了,建议 ts 至少把 A-Z 都作为泛型量用上…

  2. 一些很明显的类型推断却推断不出来

    用 assert 方法做参数检查是很常用的做法,一个简单的 assert 方法:

    1
    2
    3
    function assert(condition, msg) {
    if (condition) throw new Error(msg)
    }

    然后看这样一段代码:

    1
    2
    3
    4
    function foo(p: number | string) {
    assert(typeof p === 'number', 'p is a number')
    p.length // 这里报错,ts 竟然不知道到这一步 p 必定是 string 类型
    }