流利说 TypeScript 实践
Posted 流利说技术团队
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了流利说 TypeScript 实践相关的知识,希望对你有一定的参考价值。
流利说 Platform 前端团队从18年年底开始,逐步采用 TypeScript 进行开发,到19年,所有新项目以及在维护的项目基本转至 TypeScript 进行开发。本篇文章主要面向那些对 TypeScript 持观望态度,犹豫是否需要尝试引入 TypeScript 的开发者,以较为简单的方式,快速对 TypeScript 建立起一个认识。
< 笔者最爱的春娇与志明 >
TypeScript 是什么
1. 是微软开发的一项开源技术;
2. 属于 javascript 类型的超集,即 JavaScript 支持的 TypeScript 全都支持,但是 TypeScript 支持的 JavaScript 原生不一定支持;
3. 给予原生 JavaScript 类型支持,原生 JavaScript 其实是动态类型的编程语言,有一句话大家应该听说过,「动态类型一时爽,代码重构火葬场」;而 TypeScript 的存在,就相当于对 JavaScript 在大型项目的开发上,给予有力的支撑。
为什么选择 TypeScript
辅助开发流
我们在进行软件开发的时候,通常都是接口先行的(这里的接口不是狭义上的 interface )。 具体到前端来说,面对一个页面,即是自顶向下的,设计组件。 这其中就包括组件功能的划分,组件之间的交互,以及理解如何组合组件形成一个新的子系统,各个新的子系统又联系在一起,形成一个更大的系统,最终有效的结合在一起,组成最后的页面。 在这些逻辑关系中,接口都发挥着重要的作用。而现如今的前端,除了需要处理 view 层逻辑,还需要处理 service 逻辑以及页面中间态逻辑,这其中 TypeScript 能发挥巨大价值。 没有理清楚接口就尝试写代码,相当于一开始就陷入细节的陷阱里。 其实不仅是写代码,如果有熟悉写书的朋友一定知道,在写一本书之前,最先做的就是写目录,一本书的目录写好了,那么这本书的整体方向脉络基本就决定,而剩下要去做的,就是填充内容了。
更早发现错误
前端业务里,有大部分的 bug 是由于调用方对实现方所需的类型的不确定导致的。
rollbar 是一个前端监控平台,通过其给出的 数据 可以看出,大部分问题基本都是类型问题,而通过 TypeScript 的类型校验,可以直接在编译阶段直接规避该问题。
显著提升的代码效率
当你的项目中使用 TypeScript 时,大部分时候我们都不需要关注所调用的代码具体是怎么实现的,依赖了哪些参数,只要通过类型就可以初步判断。 并且在 vscode 编辑器强大的支持下,我们可以实现诸如代码自动引入,类型未编译即可校验等强大功能。
快速入门
JavaScript vs TypeScript
通过 JavaScript 与 TypeScript 的对比,相信大家能快速理解并学习 TypeScript 。
变量声明
// JavaScript
let name = 'Mike';
let age = 18;
name = 10; // 正常运行
// TypeScript
let name:string = 'Mike';
let age:number = 18;
name = 10; // 由于 name 的类型已经定义为 string ,因此会抛出异常
// 通常变量类型不需要显示的声明,TypeScript 会自己解析变量类型
let height = 20; // 此时 height 为 number 类型
函数声明
// JavaScript
function func(params1: number, params2: string) {
// ...
}
func(1, '1'); // 编译通过
func(1, 1); // 编译通过
// TypeScript
function func(params1: number, params2: string) {
// ...
}
func(1, '1'); // 编译通过
func(1, 1); // 编译失败
React 组件
// .jsx file
import React from 'react';
import T from 'prop-types';
class App extends React.Components {
static propTypes = {
name: T.string.isRequired,
};
state = {
age: 13,
};
render() {
return (
<div>
{this.props.name} age:{this.state.age}
</div>
);
}
}
// .tsx file
import React from 'react';
interface AppProps {
name: string;
}
interface AppState {
age: number;
}
class App extends React.Components<AppProps, AppState> {
state = {
age: 13,
};
render() {
return (
<div>
{this.props.name} age:{this.state.age}
</div>
);
}
}
理解重载
重载是 Java ,C++ 等语言具有的一种特性。在写代码的过程中,我们常常会有一个函数,需要根据不同的参数,进行不同的逻辑处理。而重载可以定义参数不同的函数,根据变量名的不同,自动执行对应变量名的函数。 下面以 Java 为例
public class Overloading {
public int test(){
System.out.println("test1");
return 1;
}
public void test(int a){
System.out.println("test2");
}
//以下两个参数类型顺序不同
public String test(int a,String s){
System.out.println("test3");
return "returntest3";
}
public String test(String s,int a){
System.out.println("test4");
return "returntest4";
}
public static void main(String[] args){
Overloading o = new Overloading();
System.out.println(o.test());
o.test(1);
System.out.println(o.test(1,"test3"));
System.out.println(o.test("test4",1));
}
}
然而 TypeScript 里的重载并没有那么智能,其重载的主要目的还是做静态类型检查。
function getItself(it: string): string;
function getItself(it: number): number;
function getItself(it: string | number) {
let result;
if (typeof it === 'string') {
result = '123';
return result;
} else {
result = 123;
return result;
}
}
const a = getItself(123); // a 此时为 number 类型
const b = getItself('123'); // b 此时为 string 类型
理解 interface
在 TypeScript 文档里,花了很大的篇幅去描述 interface ,即接口。可以说明 interface 是 TypeScript 里非常重要的概念。
One of TypeScript’s core principles is that type-checking focuses on the shape that values have. This is sometimes called “duck typing” or “structural subtyping”. In TypeScript, interfaces fill the role of naming these types, and are a powerful way of defining contracts within your code as well as contracts with code outside of your project.
duck typing ,也就是鸭子类型,摘自维基百科里说,「When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck.」 其实在我们平时写代码的过程中,广泛的使用了鸭子类型,例如
const coordinates = { x: 1, y: 3 };
function printCoordinates(params) {
console.log(`x:${params.x};y:${params.y}`);
}
printCoordinates(coordinates);
如上对于 coordinates
对象的使用便是鸭子类型,即我们不关心这个变量是从哪个类继承来的,只要它有 x 坐标,y 坐标即可。 正是由于我们代码中广泛的使用,且其非常容易发生错误( rollbar 报错排行榜第一名),因此 TypeScript 引入 interface 帮助我们加以辅助。
const coordinates = { x: 1, y: 3 };
function addCoordinates(params) {
return params.x + params.y + params.z;
}
addCoordinates(coordinates);
由于调用方并不知道 addCoordinates
参数对象,还需要一个 z 值,便会导致代码直接报错,页面 crash。
const coordinates = { x: 1, y: 3 };
interface ParamsInterface {
x: number;
y: number;
z: number;
}
function addCoordinates(params: ParamsInterface) {
return params.x + params.y + params.z;
}
addCoordinates(coordinates); // 编译不通过
可以看到使用了 TypeScript 加持后,这类问题可以直接在编译时,甚至加上编辑器的支持,可以直接在写代码的过程中,就能发现错误。
理解泛型
假设我们要实现一个方法,其作用就是将对象包装成一个数组,用 JavaScript 实现即时:
function toArray(params) {
return [params];
}
toArray({ name: 'Mike' });
然而当使用 TypeScript 实现的时候,我们需要在执行前就定义好函数返回的类型,但是我们又不能确定这个对象到底是什么类型,这里就可以借助泛型来实现:
function toArray<T>(params: T): T[] {
return [params];
}
toArray<{ name: string }>({ name: 'Mike' });
其实可以简单地将泛型理解为类型的变量,在这里,通过给 toArray
提供一个泛型变量,让 toArray
可以根据不同类型,返回不同的类型。
高级技巧
索引访问操作符T[K]
function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
return o[name]; // o[name] is of type T[K]
}
getProperty({ age: 18 }, 'age');
如上 T[K]
返回的类型就是 number
。 首先泛型 T 代表传入参数,即 { age: number }
,第二个 name 被约束成 keyof T
,也就是 T 对象的 key
,在这里就是 age
。那么返回值 T[K]
就能很直白的推断出为 number 类型。
泛型约束
通过对泛型进行 extends ,对类型进行一个约束。 例如希望类型都具有 length
类型
interface Lengthwise {
length: number;
}
function loggingIdentity<T extends Lengthwise>(arg: T): T {
console.log(arg.length); // Now we know it has a .length property, so no more error
return arg;
}
映射类型 [P in Keys]
type Keys = 'option1' | 'option2';
type Flags = { [K in Keys]: boolean };
等价于
type Flags = {
option1: boolean;
option2: boolean;
};
将所有属性变成可选属性
type Option<T> = { [P in keyof T]?: T[P] };
interface Person {
name: string;
age: number;
}
type OptionPerson = Option<Person>;
接受两个类型,去除 T 中的 U
Exclude
Exclude<'age'|'name','age'> // 'age'
同样接受两个类型,提取 T 中的 U
Extract
Extract<'age'|'name'|'height','age'|'weight'> // 'age'
一些踩过的坑
这块内容主要来自 Bill 血和泪的教训。
Never 类型
const list = {
data: [],
total: 0,
};
list.data = ['1'];
// Type 'string' is not assignable to type 'never'.
假设这样定义 list
,并且初始化 list.data
为一个空数组,则 TS 的类型推导会认为 list.data
的类型是 Array<never>
。在这种情况下,没有任何一种类型可以被赋值给 list.data
,包括 any
。
// 方法一
interface IList {
data: Array<string>;
total: number;
}
const list: IList = {
data: [],
total: 0,
};
// 方法二
const list = {
data: [] as Array<string>,
total: 0,
};
可以通过上面两种方法进行类型定义,使用 interface
或者直接给list.data
定义类型。个人推荐还是使用 interface
定义比较好,因为可以复用,提示也比较好,对之后的扩展也比较方便。
‘relative’ 不一定是 string
const style = {
position: 'relative' as 'relative';
}
const Div = <div style={style}>Hello</div>
这种坑经常会在写 React 的时候遇到。在 React 的 CSSProperties
定义中,position
的类型定义为 'relative'|'absolute'|...
,这种类型是字符串字面量类型,并不是 string
,所以会报错。使用 as
关键字,把 string
类型的 relative
转换一下,这样就可以了。
Webpack ts-loader 和 babel 7 的兼容问题
在之前对 TypeScript 进行编译都是使用 ts-loader
或者 awesome-typescript-loader
,然后加上 Babel。然而 Babel 升级到了 7 之后就开始坑了。无论是 ts-loader
还是 awsome-typescript-loader
都无法与 babel 7 放在一起使用。最终只能放弃,只使用 babel 进行代码编译,也就是 @babel/preset-typescript
,然后又出现了新的问题。@babel/preset-typescript 并不会读取 tsconfg.json
中定义的 path
,也就是引入包的别名,需要使用 babel-plugin-module-resolver 去解决,同时 tsconfig.json 中的 path
也需要留着,给 VSCode 进行代码提示和检查使用。
在整个配置 Webpack 的过程中遇到了各种各样的坑,大部分都是因为 Babel 升级到 7 之后与原有的 loader 不兼容出现的。历史的教训告诉我们,这种大版本的升级还是要慎重啊。
随时都会想用 any
一把梭
其实代码的问题都有解决方案,都不是问题,最大的问题还是在写代码的人。由于业务需求紧、代码维护困难、懒等等各种各样的原因,在写 TS 的时候总会想着直接把类型定义为 any
,偷懒把代码写完。这样也就失去了使用 TypeScript 的意义,而且维护这坨代码的成本越来越高,然后就忍不住把代码重构了,然后又没忍住使用了 any
,然后又重构,进入了一个恶性循环。
更多可能性
现在开发流程,前后端都各自维护一个系统,彼此独立且随着业务进展越来越大。而前后端之间唯一的桥梁就是接口文档(例如 proto
),这个桥梁看似可行,其实是非常脆弱的。 为什么这么说,因为其中一端产生变化,另外一端其实是无感的。 往往只能通过人为的方式去告知另一端发生的变化,(而这里便是沟通成本),并且由于经常有时候,一个后端服务被 N 个应用调用,因此其需要通知到 N 个相关维护人员,类似广播事件。而对于被告知的一方,其也需要人肉的方式去检查代码由于接口变更所引起的变化。 对于需要人肉检查代码引起的变化,可以通过 TypeScript 对接口 modal 进行类型规范,很大程度上加以改善;但是对于变更维护来说,仍然有许多人工的,重复性的劳动在里面。 这是大家需要解决的问题。
以上是关于流利说 TypeScript 实践的主要内容,如果未能解决你的问题,请参考以下文章
英语流利说 Level5 Unit3 Part 4 Learning Magnetic Field Reversal
英语流利说懂你英语 Level6 Unit2 Part1 Reading2