TypeScript 详解之 TypeScript 中那些奇怪的符号

细数 TS 中那些奇怪的符号

本文将分享在学习 TypeScript 过程中,遇到的 10 大 “奇怪” 的符号。其中有一些符号,很多人第一次见的时候也觉得 “一脸懵逼”,希望本文对学习 TypeScript 的小伙伴能有一些帮助。

好的,下面我们来开始介绍。

一、! 非空断言操作符

在上下文中当类型检查器无法断定类型时,一个新的后缀表达式操作符 ! 可以用于断言操作对象是非 null 和非 undefined 类型。具体而言,x! 将从 x 值域中排除 null 和 undefined 。

那么非空断言操作符到底有什么用呢?下面我们先来看一下非空断言操作符的一些使用场景。

1.1 忽略 undefined 和 null 类型

function myFunc(maybeString: string | undefined | null) {

  const onlyString: string = maybeString;
  const ignoreUndefinedAndNull: string = maybeString!;
}

1.2 调用函数时忽略 undefined 类型

type NumGenerator = () => number;

function myFunc(numGenerator: NumGenerator | undefined) {

  const num1 = numGenerator();
  const num2 = numGenerator!();
}

因为 ! 非空断言操作符会从编译生成的 JavaScript 代码中移除,所以在实际使用的过程中,要特别注意。比如下面这个例子:

const a: number | undefined = undefined;
const b: number = a!;
console.log(b);

以上 TS 代码会编译生成以下 ES5 代码:

"use strict";
const a = undefined;
const b = a;
console.log(b);

虽然在 TS 代码中,我们使用了非空断言,使得 const b: number = a!; 语句可以通过 TypeScript 类型检查器的检查。但在生成的 ES5 代码中,! 非空断言操作符被移除了,所以在浏览器中执行以上代码,在控制台会输出 undefined

1.3 确定赋值断言

在 TypeScript 2.7 版本中引入了确定赋值断言,即允许在实例属性和变量声明后面放置一个 ! 号,从而告诉 TypeScript 该属性会被明确地赋值。为了更好地理解它的作用,我们来看个具体的例子:

let x: number;
initialize();

console.log(2 * x);

function initialize() {
  x = 10;
}

很明显该异常信息是说变量 x 在赋值前被使用了,要解决该问题,我们可以使用确定赋值断言:

let x!: number;
initialize();
console.log(2 * x);

function initialize() {
  x = 10;
}

通过 let x!: number; 确定赋值断言,TypeScript 编译器就会知道该属性会被明确地赋值。

二、?. 运算符

TypeScript 3.7 实现了呼声最高的 ECMAScript 功能之一:可选链(Optional Chaining)。有了可选链后,我们编写代码时如果遇到 nullundefined 就可以立即停止某些表达式的运行。可选链的核心是新的 ?. 运算符,它支持以下语法:

obj?.prop
obj?.[expr]
arr?.[index]
func?.(args)

这里我们来举一个可选的属性访问的例子:

const val = a?.b;

为了更好的理解可选链,我们来看一下该 const val = a?.b 语句编译生成的 ES5 代码:

var val = a === null || a === void 0 ? void 0 : a.b;

上述的代码会自动检查对象 a 是否为 nullundefined,如果是的话就立即返回 undefined,这样就可以立即停止某些表达式的运行。你可能已经想到可以使用 ?. 来替代很多使用 && 执行空检查的代码:

if(a && a.b) { }

if(a?.b){ }

但需要注意的是,?.&& 运算符行为略有不同,&& 专门用于检测 falsy 值,比如空字符串、0、NaN、null 和 false 等。而 ?. 只会验证对象是否为 nullundefined,对于 0 或空字符串来说,并不会出现 “短路”。

2.1 可选元素访问

可选链除了支持可选属性的访问之外,它还支持可选元素的访问,它的行为类似于可选属性的访问,只是可选元素的访问允许我们访问非标识符的属性,比如任意字符串、数字索引和 Symbol:

function tryGetArrayElement(arr?: T[], index: number = 0) {
  return arr?.[index];
}

以上代码经过编译后会生成以下 ES5 代码:

"use strict";
function tryGetArrayElement(arr, index) {
    if (index === void 0) { index = 0; }
    return arr === null || arr === void 0 ? void 0 : arr[index];
}

通过观察生成的 ES5 代码,很明显在 tryGetArrayElement 方法中会自动检测输入参数 arr 的值是否为 nullundefined,从而保证了我们代码的健壮性。

2.2 可选链与函数调用

当尝试调用一个可能不存在的方法时也可以使用可选链。在实际开发过程中,这是很有用的。系统中某个方法不可用,有可能是由于版本不一致或者用户设备兼容性问题导致的。函数调用时如果被调用的方法不存在,使用可选链可以使表达式自动返回 undefined 而不是抛出一个异常。

可选调用使用起来也很简单,比如:

let result = obj.customMethod?.();

该 TypeScript 代码编译生成的 ES5 代码如下:

var result = (_a = obj.customMethod) === null
  || _a === void 0 ? void 0 : _a.call(obj);

另外在使用可选调用的时候,我们要注意以下两个注意事项:

  • 如果存在一个属性名且该属性名对应的值不是函数类型,使用 ?. 仍然会产生一个 [TypeError](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/TypeError) 异常。
  • 可选链的运算行为被局限在属性的访问、调用以及元素的访问 —— 它不会沿伸到后续的表达式中,也就是说可选调用不会阻止 a?.b / someMethod() 表达式中的除法运算或 someMethod 的方法调用。

三、?? 空值合并运算符

在 TypeScript 3.7 版本中除了引入了前面介绍的可选链 ?. 之外,也引入了一个新的逻辑运算符 —— 空值合并运算符 ??当左侧操作数为 null 或 undefined 时,其返回右侧的操作数,否则返回左侧的操作数

与逻辑或 || 运算符不同,逻辑或会在左操作数为 falsy 值时返回右侧操作数。也就是说,如果你使用 || 来为某些变量设置默认的值时,你可能会遇到意料之外的行为。比如为 falsy 值(''、NaN 或 0)时。

这里来看一个具体的例子:

const foo = null ?? 'default string';
console.log(foo);

const baz = 0 ?? 42;
console.log(baz);

以上 TS 代码经过编译后,会生成以下 ES5 代码:

"use strict";
var _a, _b;
var foo = (_a = null) !== null && _a !== void 0 ? _a : 'default string';
console.log(foo);

var baz = (_b = 0) !== null && _b !== void 0 ? _b : 42;
console.log(baz);

通过观察以上代码,我们更加直观的了解到,空值合并运算符是如何解决前面 || 运算符存在的潜在问题。下面我们来介绍空值合并运算符的特性和使用时的一些注意事项。

3.1 短路

当空值合并运算符的左表达式不为 nullundefined 时,不会对右表达式进行求值。

function A() { console.log('A was called'); return undefined;}
function B() { console.log('B was called'); return false;}
function C() { console.log('C was called'); return "foo";}

console.log(A() ?? C());
console.log(B() ?? C());

上述代码运行后,控制台会输出以下结果:

A was called
C was called
foo
B was called
false

3.2 不能与 && 或 || 操作符共用

若空值合并运算符 ?? 直接与 AND(&&)和 OR(||)操作符组合使用 ?? 是不行的。这种情况下会抛出 SyntaxError。

null || undefined ?? "foo";

true && undefined ?? "foo";

但当使用括号来显式表明优先级时是可行的,比如:

(null || undefined ) ?? "foo";

3.3 与可选链操作符 ?. 的关系

空值合并运算符针对 undefined 与 null 这两个值,可选链式操作符 ?. 也是如此。可选链式操作符,对于访问属性可能为 undefined 与 null 的对象时非常有用。

interface Customer {
  name: string;
  city?: string;
}

let customer: Customer = {
  name: "Semlinker"
};

let customerCity = customer?.city ?? "Unknown city";
console.log(customerCity);

前面我们已经介绍了空值合并运算符的应用场景和使用时的一些注意事项,该运算符不仅可以在 TypeScript 3.7 以上版本中使用。当然你也可以在 JavaScript 的环境中使用它,但你需要借助 Babel,在 Babel 7.8.0 版本也开始支持空值合并运算符。

四、?: 可选属性

在面向对象语言中,接口是一个很重要的概念,它是对行为的抽象,而具体如何行动需要由类去实现。 TypeScript 中的接口是一个非常灵活的概念,除了可用于对类的一部分行为进行抽象以外,也常用于对「对象的形状(Shape)」进行描述

在 TypeScript 中使用 interface 关键字就可以声明一个接口:

interface Person {
  name: string;
  age: number;
}

let semlinker: Person = {
  name: "semlinker",
  age: 33,
};

在以上代码中,我们声明了 Person 接口,它包含了两个必填的属性 nameage。在初始化 Person 类型变量时,如果缺少某个属性,TypeScript 编译器就会提示相应的错误信息,比如:

let lolo: Person  = {
  name: "lolo"
}

为了解决上述的问题,我们可以把某个属性声明为可选的:

interface Person {
  name: string;
  age?: number;
}

let lolo: Person  = {
  name: "lolo"
}

4.1 工具类型

4.1.1 Partial

在实际项目开发过程中,为了提高代码复用率,我们可以利用 TypeScript 内置的工具类型 Partial 来快速把某个接口类型中定义的属性变成可选的:

interface PullDownRefreshConfig {
  threshold: number;
  stop: number;
}

type PullDownRefreshOptions = Partial

是不是觉得 Partial 很方便,下面让我们来看一下它是如何实现的:

type Partial = {
  [P in keyof T]?: T[P];
};

4.1.2 Required

既然可以快速地把某个接口中定义的属性全部声明为可选,那能不能把所有的可选的属性变成必选的呢?答案是可以的,针对这个需求,我们可以使用 Required 工具类型,具体的使用方式如下:

interface PullDownRefreshConfig {
  threshold: number;
  stop: number;
}

type PullDownRefreshOptions = Partial

type PullDownRefresh = Required>

同样,我们来看一下 Required 工具类型是如何实现的:

type Required = {
  [P in keyof T]-?: T[P];
};

原来在 Required 工具类型内部,通过 -? 移除了可选属性中的 ?,使得属性从可选变为必选的。

五、& 运算符

在 TypeScript 中交叉类型是将多个类型合并为一个类型。通过 & 运算符可以将现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。

type PartialPointX = { x: number; };
type Point = PartialPointX & { y: number; };

let point: Point = {
  x: 1,
  y: 1
}

在上面代码中我们先定义了 PartialPointX 类型,接着使用 & 运算符创建一个新的 Point 类型,表示一个含有 x 和 y 坐标的点,然后定义了一个 Point 类型的变量并初始化。

5.1 同名基础类型属性的合并

那么现在问题来了,假设在合并多个类型的过程中,刚好出现某些类型存在相同的成员,但对应的类型又不一致,比如:

interface X {
  c: string;
  d: string;
}

interface Y {
  c: number;
  e: string
}

type XY = X & Y;
type YX = Y & X;

let p: XY;
let q: YX;

在上面的代码中,接口 X 和接口 Y 都含有一个相同的成员 c,但它们的类型不一致。对于这种情况,此时 XY 类型或 YX 类型中成员 c 的类型是不是可以是 stringnumber 类型呢?比如下面的例子:

p = { c: 6, d: "d", e: "e" };
image.png
q = { c: "c", d: "d", e: "e" };
image.png

为什么接口 X 和接口 Y 混入后,成员 c 的类型会变成 never 呢?这是因为混入后成员 c 的类型为 string & number,即成员 c 的类型既可以是 string 类型又可以是 number 类型。很明显这种类型是不存在的,所以混入后成员 c 的类型为 never

5.2 同名非基础类型属性的合并

在上面示例中,刚好接口 X 和接口 Y 中内部成员 c 的类型都是基本数据类型,那么如果是非基本数据类型的话,又会是什么情形。我们来看个具体的例子:

interface D { d: boolean; }
interface E { e: string; }
interface F { f: number; }

interface A { x: D; }
interface B { x: E; }
interface C { x: F; }

type ABC = A & B & C;

let abc: ABC = {
  x: {
    d: true,
    e: 'semlinker',
    f: 666
  }
};

console.log('abc:', abc);

以上代码成功运行后,控制台会输出以下结果:

image.png

由上图可知,在混入多个类型时,若存在相同的成员,且成员类型为非基本数据类型,那么是可以成功合并。

六、| 分隔符

在 TypeScript 中联合类型(Union Types)表示取值可以为多种类型中的一种,联合类型使用 | 分隔每个类型。联合类型通常与 nullundefined 一起使用:

const sayHello = (name: string | undefined) => {  };

以上示例中 name 的类型是 string | undefined 意味着可以将 stringundefined 的值传递给 sayHello 函数。

sayHello("semlinker");
sayHello(undefined);

此外,对于联合类型来说,你可能会遇到以下的用法:

let num: 1 | 2 = 1;
type EventNames = 'click' | 'scroll' | 'mousemove';

示例中的 12'click' 被称为字面量类型,用来约束取值只能是某几个值中的一个。

6.1 类型保护

当使用联合类型时,我们必须尽量把当前值的类型收窄为当前值的实际类型,而类型保护就是实现类型收窄的一种手段。

类型保护是可执行运行时检查的一种表达式,用于确保该类型在一定的范围内。换句话说,类型保护可以保证一个字符串是一个字符串,尽管它的值也可以是一个数字。类型保护与特性检测并不是完全不同,其主要思想是尝试检测属性、方法或原型,以确定如何处理值。

目前主要有四种的方式来实现类型保护:

6.1.1 in 关键字

interface Admin {
  name: string;
  privileges: string[];
}

interface Employee {
  name: string;
  startDate: Date;
}

type UnknownEmployee = Employee | Admin;

function printEmployeeInformation(emp: UnknownEmployee) {
  console.log("Name: " + emp.name);
  if ("privileges" in emp) {
    console.log("Privileges: " + emp.privileges);
  }
  if ("startDate" in emp) {
    console.log("Start Date: " + emp.startDate);
  }
}

6.1.2 typeof 关键字

function padLeft(value: string, padding: string | number) {
  if (typeof padding === "number") {
      return Array(padding + 1).join(" ") + value;
  }
  if (typeof padding === "string") {
      return padding + value;
  }
  throw new Error(`Expected string or number, got '${padding}'.`);
}

typeof 类型保护只支持两种形式:typeof v === "typename"typeof v !== typename"typename" 必须是 "number""string""boolean""symbol"。 但是 TypeScript 并不会阻止你与其它字符串比较,语言不会把那些表达式识别为类型保护。

6.1.3 instanceof 关键字

interface Padder {
  getPaddingString(): string;
}

class SpaceRepeatingPadder implements Padder {
  constructor(private numSpaces: number) {}
  getPaddingString() {
    return Array(this.numSpaces + 1).join(" ");
  }
}

class StringPadder implements Padder {
  constructor(private value: string) {}
  getPaddingString() {
    return this.value;
  }
}

let padder: Padder = new SpaceRepeatingPadder(6);

if (padder instanceof SpaceRepeatingPadder) {

}

6.1.4 自定义类型保护的类型谓词(type predicate)

function isNumber(x: any): x is number {
  return typeof x === "number";
}

function isString(x: any): x is string {
  return typeof x === "string";
}

七、_ 数字分隔符

TypeScript 2.7 带来了对数字分隔符的支持,正如数值分隔符 ECMAScript 提案中所概述的那样。对于一个数字字面量,你现在可以通过把一个下划线作为它们之间的分隔符来分组数字:

const inhabitantsOfMunich = 1_464_301;
const distanceEarthSunInKm = 149_600_000;
const fileSystemPermission = 0b111_111_000;
const bytes = 0b1111_10101011_11110000_00001101;

分隔符不会改变数值字面量的值,但逻辑分组使人们更容易一眼就能读懂数字。以上 TS 代码经过编译后,会生成以下 ES5 代码:

"use strict";
var inhabitantsOfMunich = 1464301;
var distanceEarthSunInKm = 149600000;
var fileSystemPermission = 504;
var bytes = 262926349;

7.1 使用限制

虽然数字分隔符看起来很简单,但在使用时还是有一些限制。比如你只能在两个数字之间添加 _ 分隔符。以下的使用方式是非法的:

3_.141592
3._141592

1_e10
1e_10

_126301

126301_

0_b111111000

0b_111111000

当然你也不能连续使用多个 _ 分隔符,比如:

123__456

7.2 解析分隔符

此外,需要注意的是以下用于解析数字的函数是不支持分隔符:

  • Number()
  • parseInt()
  • parseFloat()

这里我们来看一下实际的例子:

Number('123_456')
NaN
parseInt('123_456')
123
parseFloat('123_456')
123

很明显对于以上的结果不是我们所期望的,所以在处理分隔符时要特别注意。当然要解决上述问题,也很简单只需要非数字的字符删掉即可。这里我们来定义一个 removeNonDigits 的函数:

const RE_NON_DIGIT = /[^0-9]/gu;

function removeNonDigits(str) {
  str = str.replace(RE_NON_DIGIT, '');
  return Number(str);
}

该函数通过调用字符串的 replace 方法来移除非数字的字符,具体的使用方式如下:

removeNonDigits('123_456')
123456
removeNonDigits('149,600,000')
149600000
removeNonDigits('1,407,836')
1407836

八、 语法

8.1 TypeScript 断言

有时候你会遇到这样的情况,你会比 TypeScript 更了解某个值的详细信息。通常这会发生在你清楚地知道一个实体具有比它现有类型更确切的类型。

通过类型断言这种方式可以告诉编译器,“相信我,我知道自己在干什么”。类型断言好比其他语言里的类型转换,但是不进行特殊的数据检查和解构。它没有运行时的影响,只是在编译阶段起作用。

类型断言有两种形式:

8.1.1 “尖括号” 语法

你可能感兴趣的:(TypeScript 详解之 TypeScript 中那些奇怪的符号)