为什么我总学不好TS?

33,276 阅读6分钟

大家好,我卡颂。

有没有同学 学TS的步骤和我一样:

  1. 先看TS文档(或各种入门教材),学学各种类型的定义

  2. 为现有项目中的JS代码增加类型

  3. 随着增加的类型越来越多,类型报错越来越多,不得已改为any类型,或者增加// @ts-ignore注释

最后,使用TS的成本(改各种类型报错耽误的时间)超过了收益(TS带来的类型安全),TypeScript也学成了AnyScript

上述历程我反复经历了两次。痛定思痛,决定系统学一遍TS

经过这次系统学习,我终于明白我为什么总学不好TS。希望这篇文章对和我有同样经历的同学有帮助。

打包领取卡颂原创React教程、加入人类高质量前端群

学不好的原因

想必你听过一句话 —— TS是JS的超集。这句话本身是没错的,TSJS的基础上扩展了类型系统与语法。

但如果我们以这句话为基础开始学习TS,很容易形成一个惯性:以JS为起点,逐步学习TS知识。

也就是下图中从红圈(JS)逐渐向外学习(蓝圈),目标是最终覆盖绿圈(TS)。

从这个思路出发的学习步骤就是我们开篇提到的学习步骤。

按这个步骤学习的问题出在哪呢?当我们只把TS看作JS超集时,会忽略TS本身就是一门语言这一事实。作为一门语言,TS有自己的语法规范,与JS相比:

  • TS作为语言,操作的单位是类型,语法规范定义的是类型之间的操作逻辑,工作在编译时

  • JS作为语言,操作的单位是变量,语法规范定义的是变量之间的操作逻辑,工作在运行时

如果我们只从JS出发,是可以理解TSJS兼容的部分(类型部分)。但不兼容的部分(TS作为语言本身的语法规则)会成为我们进阶路上的绊脚石。

一个例子

举个例子,下面三个都是TS中合法的类型:

  • object:对应引用类型

  • {}:空对象字面量对应的类型

  • ObjectObject构造函数对应的类型

请问下面三个类型别名的结果是什么?

extends关键字在条件类型语句 a extends b ? 中用于判断a是否是bb的子类型

type r0 = {} extends object ? true : false;
type r1 = object extends Object ? true : false;
type r2 = {} extends Object ? true : false;

即使没有TS经验,从JS语法出发,也能得到答案:

  1. {}是对象字面量,肯定属于对象类型的子类型,所以r0true

  2. Object处于JS原型链的顶端,所有对象类型肯定是他的子类型,所以r1true

  3. 有了前两个结果,r2显然也为true

为什么没有TS经验也能得出正确结果呢?因为TS在类型方面是兼容JS的。我们从JS角度出发就能得到正确的TS结果(注意上述r0~r2的结果都是编译时由TS计算出的)

但是,如果我们不学习TS作为语言本身的规则,理解下面代码时就会产生困惑(我们将上述三段代码中extends前后的类型调换下,得到的结果仍然都为true):

type r0 = object extends {} ? true : false;
type r1 = Object extends object ? true : false;
type r2 = Object extends {} ? true : false;

JS出发是很难理解这个结果的。要理解他,我们需要从TS出发。

类型与类型系统

JS中我们定义不同变量后,可以按照语法规则对变量进行不同操作:

const num1 = 1;
const num2 = 2;
// 对变量的操作逻辑
console.log(num1 + num2); // 3

同样,在TS中,我们定义不同类型后,也能按照语法规则对类型进行不同操作:

type A = 1;
type B = 2;
// 对类型的操作逻辑
type C = A | B; // 1 | 2

TS的语法规则被称为结构化类型系统,与JS类比如下:

TS中,类型结构化类型系统的关系可以用我们中学学到的集合的概念来类比,其中:

  • 类型是一类值的集合,比如number是数字字面量的集合,interface A是满足接口A规范的对象的集合

  • 结构化类型系统是集合之间兼容性判断的规则,比如怎么判断交集、怎么判断并集、怎么判断差集?

具体来讲,结构化类型又叫鸭子类型,这是编程中一个很常见的术语,即 —— 如果一只动物看起来像鸭子,叫起来像鸭子,走起来像鸭子,那他就是鸭子。

同样,结构化类型系统在判断两个类型是否存在父子类型关系时,也是通过对象成员是否有相同结构来判断的。

比如在下面代码中,我们定义CatDog类型,以及接收Cat类型参数的feedCat函数。在调用feedCat时,传入Dog的实例并不会报错:

class Cat {
    eat() {}
}

class Dog {
    eat() {}
}

function feedCat(cat: Cat) {}

feedCat(new Dog) // 不会报错

这是因为CatDog的成员结构一致(都只包括返回值一致的eat方法)。根据鸭子类型,既然成员结构一致,那CatDog就是同类,所以feedCat不会报错。

结构化类型系统(鸭子类型)相对的是指称类型系统。在指称类型系统中,类名必须一致才会被判定为同类,类之间必须有明确的继承关系(extends)才会被判定为父子关系。

回到我们的代码:

type r0 = object extends {} ? true : false;
type r1 = Object extends object ? true : false;
type r2 = Object extends {} ? true : false;

{}代表一个没有任何成员的对象。那么,换句话说,任何有成员的对象都能在{}的基础上延伸出来,比如下面的接口A可以看作是在{}的基础上增加了name属性:

interface A {name: string}

所以,根据结构化类型系统{}是任何对象的父类,所以r0r2Object是构造函数,函数也属于对象)为true

实际上,任何基础类型都有对应的包装类型,比如:

  • number对应Number

  • string对应String

  • boolean对应Boolean

包装类型都是对象,所以{}也是任何基础类型的父类(鸭子类型),即:

type r3 = 1 extends {} ? true : false; // true
type r4 = 'hello' extends {} ? true : false; // true
type r5 = true extends {} ? true : false; // true

对于r1,上面提到,Object是构造函数,函数也属于对象,所以是object的子类。

总结

TS的出现为JS带来静态分析能力。从这个角度看,TS是兼容JS的。所以从JS出发学习TS,在初期不会有很大阻力。

但是,TS本身也是一门语言,这门语言的操作对象是类型,语法规则叫结构化类型系统

所以,当我们想深入使用TS时,必然会触碰TS语言本身的规则,此时我们需要从TS出发学习。

只有这样,才能真的学懂、用好TS

最后推荐下林不渡的《TypeScript 全面进阶指南》小册,讲的通俗易懂,是不错的教程。