type
Post
status
Published
date
May 27, 2023
slug
typescript-learn-01
summary
TypeScript已经取代直接使用JavaScript,成为中大型前端项目开发的首选语言。一句话,静态类型写起来就是爽!鉴于自己从业以来并没有系统性地跟随某项课程学习过TypeScript,对其的了解是从项目和文档中逐步获取的,因此在知识体系方面可以说是比较差劲,因此有必要系统性地去重新学习一遍TypeScript。
tags
开发
前端
category
学习思考
icon
password
Property
Oct 8, 2023 09:02 AM
TypeScript已经取代直接使用JavaScript,成为中大型前端项目开发的首选语言。一句话,静态类型写起来就是爽!鉴于自己从业以来并没有系统性地跟随某项课程学习过TypeScript,对其的了解是从项目和文档中逐步获取的,因此在知识体系方面可以说是比较差劲,因此有必要系统性地去重新学习一遍TypeScript。
这个“重新学习TypeScript”计划,我会跟随FrontendMasters网站上Mike North教授的课程来学习,第一步是重新梳理TypeScript最基础的概念和特性https://frontendmasters.com/courses/typescript-v3/
这里强烈推荐FrontendMasters这个网站,里面有大量优质课程帮助前端工程师们充电。开课的讲师人如其名,许多都是领域内大师级的专家,甚至包括了Getify、尤雨溪这样的社区大拿。讲解TypeScript系列课程的Mike North是LinkedIn前端基础架构团队的Tech Lead,对TypeScript有极为丰富的经验。
本文是对第一阶段基础课程的要点记录。

编译TypeScript

这没什么好讲的,搭建一个最简单的TypeScript Playground只需要几分钟。在任意一个初始化好了的前端项目中(包含一个package.json文件),安装TypeScript依赖(yarn add —dev typescript)。
{ "name": "typescript-playground", "version": "1.0.0", "description": "A learning project to play with typescript", "main": "src/index.ts", "author": "kqhasaki", "license": "MIT", "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/parser": "^5.59.7", "eslint": "^8.41.0", "typescript": "^5.0.4" }, "scripts": { "dev": "tsc --watch --preserveWatchOutput" } }
package.json
可以先无视掉eslint部分。(了解TypeScript的读者肯定知道是干啥的,主要是用来警告我们在TypeScript中不要显式声明any类型)
另外,创建一个tsconfig.json文件,没啥好说的,用来告诉tsc编译器我们想要哪些配置。这些配置需要在实践过程中慢慢掌握,不过最基本的配置很容易看明白。例如:告诉编译器编译哪些文件?编译的输出结果放在哪里?目标JavaScript代码的版本?用哪个模块系统?是否输出类型声明文件?等等。
{ "compilerOptions": { "outDir": "dist", "target": "ES2015", "lib": [ "ES2020", "DOM" ], "declaration": true, "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true }, "include": [ "src" // which files to compile ], }
tsconfig.json
上面package.json中我们定义了一个npm script,主要作用是让tsc编译器监听源码的变更,一旦发生变化直接编译出新的结果,便于我们学习过程中随时查看。

元组

TypeScript基本的语法和一些基本类型这里就没必要再赘述。提一下Tuple(元组),Python里面是有元组这种类型的,元组可以理解为指定的若干对象构成的序列。JavaScript里面并没有元组,TypeScript提供元组这个类型,它实际上仅是一个数组,编译为JavaScript后其实不存在这个类型。
元组我们在TypeScript日常开发中实际上常用,例如React中useState钩子的返回值就是一个元组,它总是返回一个两个元素的数组,第一个元素永远是当前state的值,第二个元素永远是用于更改state的函数。
定义元组类型可以方便我们使用数组解构语法来直接获取指定位置上的值。
type MyUseStateReturnValue<T> = [T, (arg: T) => void]; function myUseState<T>(initialValue: T): MyUseStateReturnValue<T> { let value = initialValue; return [ value, (arg: T) => { value = arg; }, ]; } // 使用解构语法,直接获取值和函数 const [count, setCount] = myUseState(0);

静态类型、结构类型、鸭子类型?

TypeScript是静态类型语言,意味着类型定义直接写在源代码里,在编译阶段编译器就能捕获类型错误,因此带来了动态类型语言(只能在运行时校验变量类型)无法比拟的开发体验。这个不用多说了。
TypeScript的类型和Java、C++这样的静态类型语言有本质区别,即TypeScript不采用名义类型。何为“名义类型”?即判断某个实例的类型,必须看它的类是谁?假设在Java中定义两个一摸一样的类,它们的实例也不会是同一个类型。但是TypeScript采用的是结构类型,它并不在乎某个对象的构造函数是谁,它只会去看这个对象是否具有某个类型声明的成员,如果类型指定的成员它都具备,那么它就是这个类型。
有一个说法叫做鸭子类型(duck type),即如果一个东西“看上去像个鸭子,游起来像个鸭子,叫声像个鸭子,那么它就是一只鸭子”。鸭子类型通常用来描述动态类型系统,但这里TypeScript的结构化类型系统也很符合。

接口和类型别名

TypeScript提供两个关键字分别用来定义接口和类型别名,即interfacetype
接口只能用来定义“对象类型”,不能用来描述基本类型。而类型别名可以指向任何类型。也就是说如果我们需要定义的类型并不是一个对象,那么只能用类型别名。
同样因为这个原因,接口只能是对象类型,所以在使用下面的语法时,最好implement的都是接口:
interface CarLike { make: string; model: string; year: number; } class Car implements CarLike { constructor(public make: string, public model: string, public year: number) {} }
另外,接口有一个特性,就是支持扩展。多个接口定义是被TypeScript语法接受的,这些定义会被合并。
window.document; // an existing property window.exampleProperty = 42; // tells TS that `exampleProperty` exists interface Window { exampleProperty: number; }

递归类型

在定义类型时,可以使用递归。例如类型别名是一个联合类型(表示值可以是多种类型中的一种),其中的一个类型就可以是自身。对于接口来说,定义的成员的类型也可以是自身。
我们来看一个例子,这个例子用TypeScript实现了一个打平数组的flat函数。
type Nested<T> = T | Nested<T>[]; function flat<T>(inputArr: Nested<T>[]): T[] { const outArr: T[] = []; const visit = (node: Nested<T>) => { if (Array.isArray(node)) { node.forEach((subNode) => visit(subNode)); } else { outArr.push(node); } }; visit(inputArr); return outArr; } function test() { const testCases: Nested<number | string>[][] = [ [1, [23, 3, [3, 3, 1]]], ["a", "c", ["d", "de", ["f", "gg"]]], ]; testCases.forEach((testCase) => { console.log(testCase, flat(testCase)); }); } test();
像这种对于可以嵌套到任意深度的数组,我们可以使用递归类型来定义。
定义一个JSON类型(可以序列化为JSON字符串的JavaScript对象):
type JSONPrimitive = string | number | boolean | null type JSONObject = { [k: string]: JSONValue } type JSONArray = JSONValue[] type JSONValue = JSONArray | JSONObject | JSONPrimitive ////// DO NOT EDIT ANY CODE BELOW THIS LINE ////// function isJSON(arg: JSONValue) {} // POSITIVE test cases (must pass) isJSON("hello") isJSON([4, 8, 15, 16, 23, 42]) isJSON({ greeting: "hello" }) isJSON(false) isJSON(true) isJSON(null) isJSON({ a: { b: [2, 3, "foo"] } }) // NEGATIVE test cases (must fail) // @ts-expect-error isJSON(() => "") // @ts-expect-error isJSON(class {}) // @ts-expect-error isJSON(undefined) // @ts-expect-error isJSON(new BigInt(143)) // @ts-expect-error isJSON(isJSON)

函数重载

TypeScript支持函数重载,这在JavaScript中是不支持的,因为JavaScript中并没有函数签名的概念,同名函数定义会由后面的覆盖前面的。不过TypeScript的函数重载并非真正定了多个函数,而是定义了多个函数签名,编译器会判断调用时对应拿个函数签名,从而获取到正确的参数和返回值类型。但是函数的具体实现只有一个。
给出一个例子:
// Overload signatures function greet(person: string): string; function greet(persons: string[]): string[]; // Implementation signature function greet(person: unknown): unknown { if (typeof person === "string") { return `Hello, ${person}!`; } else if (Array.isArray(person)) { return person.map((name) => `Hello, ${name}!`); } throw new Error("Unable to greet"); }
当然,方法也可以重载:
class Greeter { message: string; constructor(message: string) { this.message = message; } // Overload signatures greet(person: string): string; greet(persons: string[]): string[]; // Implementation signature greet(person: unknown): unknown { if (typeof person === 'string') { return `${this.message}, ${person}!`; } else if (Array.isArray(person)) { return person.map(name => `${this.message}, ${name}!`); } throw new Error('Unable to greet'); } }

指定this类型

某些特定情况下,我们需要在函数定义时就指定this的类型,TypeScript提供了相应的语法。
function myClickHandler( this: HTMLButtonElement, event: Event ) { this.disabled = true }

类参数属性

为了避免冗余写法,TypeScript允许我们在定义类的时候,直接把一些成员定义直接写在构造函数的参数列表中:
class Car { constructor( public make: string, public model: string, public year: number ) {} } const myCar = new Car("Honda", "Accord", 2017)

anyunknownnever

any类型和unkown类型都描述可以取值为任意类型。不同的是unkown类型的变量不能被直接使用,必须提前确定其类型。
unkown类型和类型保护(type guard)是分不开的,只有在具有类型保护的代码中,我们才能使用那些一开始是unkown类型的变量。
never类型主要在分支语句中使用,当一个分支语句必须穷举所有可能的情况时,我们可以在类型检查条件下加入一个永远不可能到达的分支,在其中将待检查变量赋值给一个never类型。如果在类型上我们没有穷举掉所有可能性,那么TypeScript会抛出错误,这意味着在类型检查上我们做得不够详尽,没能穷尽变量所有的类型。
class Car { drive() { console.log("vroom") } } class Truck { tow() { console.log("dragging something") } } class Boat { isFloating() { return true } } type Vehicle = Truck | Car | Boat let myVehicle: Vehicle = obtainRandomVehicle() // The exhaustive conditional if (myVehicle instanceof Truck) { myVehicle.tow() // Truck } else if (myVehicle instanceof Car) { myVehicle.drive() // Car } else { // NEITHER! const neverValue: never = myVehicle // Type 'Boat' is not assignable to type 'never'. }
上面的代码会导致编译错误,原因是分支语句到最后一个else,myVehicle变量类型并非never,而只有never类型才能赋值给另一个never类型,否则就会产生编译错误。

类型保护

类型保护是TypeScript中一个很重要的特性,TypeScript内置了许多类型保护,例如分支语句的判断条件可能符合某个内置的类型保护,导致编译器确定了分支块内变量的类型。
let value: | Date | null | undefined | "pineapple" | [number] | { dateRange: [Date, Date] } // instanceof if (value instanceof Date) { value // let value: Date } // typeof else if (typeof value === "string") { value // let value: "pineapple" } // Specific value check else if (value === null) { value // let value: null } // Truthy/falsy check else if (!value) { value // let value: undefined } // Some built-in functions else if (Array.isArray(value)) { value // let value: [number] } // Property presence check else if ("dateRange" in value) { value let value: { dateRange: [Date, Date]; } } else { value // let value: never }
除了使用typeofinstanceofArray.isArrayin运算符、布尔运算符等TypeScript设定的内置类型保护外,用户可以自定义类型保护。
例如我们可以写一个函数来检查某个变量是否为某个指定类型,如果是,还希望通知TypeScript编译器,让编译器在接下来的代码块中识别它为该类型。
interface CarLike { make: string model: string year: number } let maybeCar: unknown // the guard function isCarLike( valueToTest: any ): valueToTest is CarLike { return ( valueToTest && typeof valueToTest === "object" && "make" in valueToTest && typeof valueToTest["make"] === "string" && "model" in valueToTest && typeof valueToTest["model"] === "string" && "year" in valueToTest && typeof valueToTest["year"] === "number" ) } // using the guard if (isCarLike(maybeCar)) { maybeCar // let maybeCar: CarLike }
特别注意这里的函数返回值采用了特殊语法:value is Foo它实际上告诉编译器,如果这个函数的返回值为真,那么接下来就应该认为value的类型就是Foo了。
另一种语法也可以实现类似的类型保护,即asserts value is Foo
它的作用是,如果执行流走完这个语句不抛出异常,那么接下来的代码就应该认为value的类型就是Foo。如果这个语句抛出了异常,下面的语句也不会执行。我们可以理解它是一个会在运行时抛出异常的类型断言(但是注意,TypeScript并不能保证类型保护代码里正确抛出了运行时异常,必须由开发者自己保证)。
interface CarLike { make: string model: string year: number } let maybeCar: unknown // the guard function assertsIsCarLike( valueToTest: any ): asserts valueToTest is CarLike { if ( !( valueToTest && typeof valueToTest === "object" && "make" in valueToTest && typeof valueToTest["make"] === "string" && "model" in valueToTest && typeof valueToTest["model"] === "string" && "year" in valueToTest && typeof valueToTest["year"] === "number" ) ) throw new Error( `Value does not appear to be a CarLike${valueToTest}` ) }
类型保护是链接编译时检查和运行时的桥梁,它必须被非常小心地编写。在我们使用类型保护后,TypeScript就会接受我们断言的类型,如果我们没有在类型保护的代码逻辑中正确进行判断,那么效果会适得其反。

泛型

这是TypeScript类型系统中最强大的部分,没啥好说的,必须掌握。这里给一个例子:实现三个函数,用来处理任意类型的字典,分别对应数组的mapfilterreduce方法。
interface Dict<T> { [k: string]: T; } function mapDict<T, S>( inputDict: Dict<T>, mapFunction: (dictItem: T, key: string) => S ): Dict<S> { const outDict: Dict<S> = {}; for (const [key, value] of Object.entries(inputDict)) { outDict[key] = mapFunction(value, key); } return outDict; } function filterDict<T>( inputDict: Dict<T>, filterFunction: (dictItem: T, key: string) => boolean ): Dict<T> { const outDict: Dict<T> = {}; for (const [key, value] of Object.entries(inputDict)) { if (filterFunction(value, key)) { outDict[key] = value; } } return outDict; } function reduceDict<T, S>( inputDict: Dict<T>, reducer: (prev: S, curr: T, key: string) => S, initialValue: S ): S { let value = initialValue; for (const [key, thisValue] of Object.entries(inputDict)) { value = reducer(value, thisValue, key); } return value; } const fruits = { apple: { color: "red", mass: 100 }, grape: { color: "red", mass: 5 }, banana: { color: "yellow", mass: 183 }, lemon: { color: "yellow", mass: 80 }, pear: { color: "green", mass: 178 }, orange: { color: "orange", mass: 262 }, raspberry: { color: "red", mass: 4 }, cherry: { color: "red", mass: 5 }, }; console.log( mapDict(fruits, (item, name) => ({ ...item, kg: 0.001 * item.mass, name })) ); console.log(filterDict(fruits, (item) => item.mass > 100)); console.log( reduceDict( fruits, (prev, curr, _) => { return prev + 1; }, 0 ) ); /* { apple: { color: 'red', mass: 100, kg: 0.1, name: 'apple' }, grape: { color: 'red', mass: 5, kg: 0.005, name: 'grape' }, banana: { color: 'yellow', mass: 183, kg: 0.183, name: 'banana' }, lemon: { color: 'yellow', mass: 80, kg: 0.08, name: 'lemon' }, pear: { color: 'green', mass: 178, kg: 0.178, name: 'pear' }, orange: { color: 'orange', mass: 262, kg: 0.262, name: 'orange' }, raspberry: { color: 'red', mass: 4, kg: 0.004, name: 'raspberry' }, cherry: { color: 'red', mass: 5, kg: 0.005, name: 'cherry' } } { banana: { color: 'yellow', mass: 183 }, pear: { color: 'green', mass: 178 }, orange: { color: 'orange', mass: 262 } } 8 */
对于类型参数,我们可以施加约束,例如规定T extends Foo
 
V8引擎中的Ignition和TurboFanROS基本概念