TS官方工具类型

TypeScript 中有很多预置的工具类型,它们无需导入,可在全局使用。接下来我们来了解一下这些基本的工具类型,一方面的目的是为了了解如何使用基础类型来实现这些工具类型,另一方面也能过利用这些工具类型来应对不同的业务场景,实现更复杂的类型。

Omit
它的作用是通过第二个参数去掉指定的key并且返回新的类型,下面举个🌰

1
2
3
4
5
6
7
8
9
10
11
type Omit<T, K extends keyof T> = {[P in keyof T as P extends K ? never: P] :T[P]}

interface Todo {
title: string
description: string
completed: boolean
}
type TodoPreview = Omit<Todo, 'description' | 'title'>
const todo: TodoPreview = {
completed: false,
}

在上述示例中,Omit 类型的实现使用了前面介绍的 Pick 类型。我们知道 Pick 类型的作用是选取给定类型的指定属性,那么这里的 Omit 的作用应该是选取除了指定属性之外的属性,而 Exclude 工具类型的作用就是从入参 T 属性的联合类型中排除入参 K 指定的若干属性。

Pick
它可以通过第二个参数来指定新的类型中包含哪些key值,并且返回这个新的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};

interface Todo {
title: string
description: string
completed: boolean
}
type TodoPreview = Pick<Todo, 'title' | 'completed'>
const todo: TodoPreview = {
title: 'Clean room',
completed: false,
}

我们可以发现,Pick这个工具类型接收的两个参数均为泛型,第一个 T 为原有的参数类型,而第二个参数K为需要提取的键值 key。
当有了Pick以后,Omit也可以这样来实现,如下。

1
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

Readonly
Readonly,顾名思义为将指定类型的属性全部设置为只读,这也表明返回的新的类型的属性不可以再被二次赋值。🌰如下

1
2
3
4
5
6
7
8
9
10
11
12
13
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};

interface Todo {
title: string
description: string
}
const todo: Readonly<Todo> = {
title: "Hey",
description: "foobar"
}
todo.title = "Hello" // Error: cannot reassign a readonly property

在上述示例中,经过 Readonly 处理后,todo 的 title、description属性都变成了 readonly 只读。

Partial
Partial 可以将一个类型的所有属性变为可选的,且返回的类型是给定类型的所有子集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Partial<T> = {
[P in keyof T]?: T[P];
};
interface Person {
name: string;
age?: number;
weight?: number;
}
type PartialPerson = Partial<Person>;
interface PartialPerson {
name?: string;
age?: number;
weight?: number;
}

在上述示例中,我们使用映射类型取出了传入类型的所有键值,并将其值设定为可选的。

Required
Required 工具类型将入参类型中的所有属性改为必填。🌰如下

1
2
3
4
5
6
7
8
9
type Required<T> = {
[P in keyof T]-?: T[P];
};
type RequiredPerson = Required<Person>;
interface RequiredPerson {
name: string;
age: number;
weight: number;
}

在上述示例中,我们在键值的后面采用 - 符号,- 与 ? 一起表示去除该类型的可选属性,因此传入类型的所有属性都变为了必填。

Exclude
上述在Omit的第二种实现方法里,我们使用了 Exclude 。不难看出了 Exclude的作用是从类型中去除指定的属性。

1
2
3
4
5
6
7
 type Exclude<T, U> = T extends U ? never : T;
type T = Exclude<'a' | 'b' | 'c', 'a'>; // => 'b' | 'c'
type NewPerson = Omit<Person, 'weight'>;
// 相当于
type NewPerson = Pick<Person, Exclude<keyof Person, 'weight'>>;
// 其中
type ExcludeKeys = Exclude<keyof Person, 'weight'>; // => 'name' | 'age'

在上述示例中,Exclude 的实现使用了条件类型。如果类型 T 可被分配给类型 U ,则不返回类型 T,否则返回此类型 T ,这样我们就从联合类型中去除了指定的类型。

Record
Record 的作用是生成接口类型,然后我们使用传入的泛型参数分别作为接口类型的属性和值。

1
2
3
4
5
6
7
8
9
10
11
12
13
type Record<K extends keyof any, T> = {
[P in K]: T;
};
type MenuKey = 'home' | 'about' | 'more';
interface Menu {
label: string;
hidden?: boolean;
}
const menus: Record<MenuKey, Menu> = {
about: { label: '关于' },
home: { label: '主页' },
more: { label: '更多', hidden: true },
};

Record的内部定义,接收两个泛型参数,泛型K即为第一个参数。p in xx意思就是遍历,如上将类型MenuKey进行遍历,也就是string。每个属性都是传入的T类型。
在上述示例中,Record 类型接收了两个泛型参数:第一个参数作为接口类型的属性,第二个参数作为接口类型的属性值。
需要注意:这里的实现限定了第一个泛型参数继承自keyof any。
在 TypeScript 中,keyof any 指代可以作为对象键的属性,如下示例:
type T = keyof any; // => string | number | symbol
说明:目前,JavaScript 仅支持string、number、symbol作为对象的键值。

Ts类类型

在JavaScript(ES5)中仅支持通过函数和原型链继承模拟类的实现(用于抽象业务模型、组织数据结构并创建可重用组件),自 ES6 引入 class 关键字后,它才开始支持使用与Java类似的语法定义声明类。
TypeScript 作为 JavaScript 的超集,自然也支持 class 的全部特性,并且还可以对类的属性、方法等进行静态类型检测。


在实际业务中,任何实体都可以被抽象为一个使用类表达的类似对象的数据结构,且这个数据结构既包含属性,又包含方法,比如我们在下方抽象了一个猫的类。

1
2
3
4
5
6
7
8
9
10
11
class Cat {
 name: string;
 constructor(name: string) {
   this.name = name;
}
  meow() {
   console.log('Mew! Mew!');
}
}
const cat = new Cat('Q');
cat. meow(); // => 'Mew! Mew!'

首先,我们定义了一个 class Cat ,它拥有 string 类型的 name 属性、meow 方法和一个构造器函数。然后,我们通过 new 关键字创建了一个 Cat 的实例,并把实例赋值给变量 cat。最后,我们通过实例调用了类中定义的 meow 方法。如果使用传统的 JavaScript 代码定义类,我们需要使用函数+原型链的形式进行模拟,如下代码所示

1
2
3
4
5
6
7
8
9
function Cat(name: string) {
this.name = name; //
}
Cat.prototype.meow = function () {
console.log('Mew! Mew!');
};

const cat = new Cat('orange'); //
cat.meow(); // => 'Mew! Mew!'

我们定义了 Cat 类的构造函数,并在构造函数内部定义了 name 属性,通过 Cat 的原型链添加 meow 方法。和通过 class 方式定义类相比,这种方式明显麻烦不少,而且还缺少静态类型检测。因此,类是 TypeScript 编程中十分有用且不得不掌握的工具。

类的继承
在 TypeScript 中,使用 extends 关键字就能很方便地定义类继承的抽象模式,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Animal {
 type = 'Animal';
 say(name: string) {
   console.log(`My name is ${name}~`);
}
}
class Cat extends Animal {
  meow() {
   console.log('Mew! Mew!');
}
}
const cat= new Cat();
cat. meow(); // => 'Mew! Mew!'
cat.say('orange'); // => My name is orange!
cat.type; // => Animal

cat是派生类,它派生自定义的Animal基类,此时Cat实例继承了基类Animal的属性和方法。因此,实例 cat 支持 meow、say、type 等属性和方法。
这里Cat类中我们没有写构造函数,因为派生类如果包含一个构造函数,则必须在构造函数中调用 super() 方法,这是 TypeScript 强制执行的一条重要规则。如下,因为定义的 Cat 类构造函数中没有调用 super 方法,所以提示了一个 ts(2377) 的错误。

1
2
3
4
5
6
7
8
9
class Cat extends Animal {
name: string;
constructor(name: string) {//派生类的构造函数必须包含 "super" 调用。ts(2377)
this.name = name;
}
meow() {
console.log('Mew! Mew!');
}
}

super 函数会调用基类的构造函数,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Animal {
age: number;
 type = 'Animal';
 constructor(age: number) {
   this.age = age;
}
 say(name: string) {
   console.log(`My name is ${name}~`);
}
}

class Cat extends Animal {
 name: string;
 constructor(name: string) {
   super(); // 应有 1 个参数,但获得 0 个。ts(2554)
   this.name = name;
}

meow() {
   console.log('Mew! Mew!');
}
}

Animal 类的构造函数要求必须传入一个数字类型的 age 参数,而子类实际入参为空,所以提示了一个 ts(2554) 的错误。如果我们显式地给 super 函数传入一个 number 类型的值,比如说 super(1),则不会再提示错误了。

抽象类
抽象类是一种不能被实例化仅能被子类继承的特殊类。我们可以使用抽象类定义派生类需要实现的属性和方法,同时也可以定义其他被继承的默认属性和方法,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
abstract class Multiplier {
abstract a: number;
abstract b: number;
abstract multiply(): number;
showName = 'Multiplier';
multiplyTwice(): number {
return Math.pow((this.a * this.b),2);
}
}

class NumMultiplier extends Multiplier {
a: number;
b: number;
constructor(a: number, b: number) {
super();
this.a = a;
this.b = b;
}
multiply(): number {
return this.a * this.b;
}
}

const numMultiplier = new NumMultiplier(3, 5);
console.log(numMultiplier.showName); // => "Multiplier"
console.log(numMultiplier.multiply()); // => 15
console.log(numAdder.multiplyTwice()); // => 225

通过 abstract 关键字,我们定义了一个抽象类Multiplier,并通过abstract关键字定义了抽象属性a、b及方法multiply,而且任何继承Multiplier的派生类都需要实现这些抽象属性和方法。因为抽象类不能被实例化,并且派生类必须实现继承自抽象类上的抽象属性和方法定义,所以抽象类的作用其实就是对基础逻辑的封装和抽象。

Ts函数

在 TypeScript 里定义函数时,我们可以显式指定函数参数和返回值的类型:

1
2
3
const add = (a: number, b: number): number => {
return a + b;
}

返回值类型
在 JavaScript 中,我们知道一个函数可以没有显式 return,此时函数的返回值应该是 undefined:

1
2
3
4
function func() {
// do sth
}
console.log(func()); // => undefined

需要注意的是,在 TypeScript 中,如果我们显式声明函数的返回值类型为 undfined,将会得到如下所示的错误提醒。

1
2
3
function func(): undefined { // 其声明类型不为 "void" 或 "any" 的函数必须返回值。ts(2355)
// do sth
}

此时,正确的做法是使用void 类型来表示函数没有返回值的类型,示例如下:

1
2
3
function funcA(): void {
}
funcA().doSomething(); // 类型“void”上不存在属性“doSomething”。ts(2339)

我们可以使用类似定义箭头函数的语法来表示函数类型的参数和返回值类型,此时=> 类型仅仅用来定义一个函数类型而不用实现这个函数。

需要注意的是,这里的=>与 ES6 中箭头函数的=>有所不同。TypeScript 函数类型中的=>用来表示函数的定义,其左侧是函数的参数类型,右侧是函数的返回值类型;而 ES6 中的=>是函数的实现。

如下示例中,我们定义了一个函数类型,并且使用箭头函数实现了这个类型。

1
2
type Adder = (a: number, b: number) => number; // TypeScript 函数类型定义
const add: Adder = (a, b) => a + b; // ES6 箭头函数

在对象中,除了使用这种声明语法,我们还可以使用类似对象属性的简写语法来声明函数类型的属性,如下代码所示:

1
2
3
4
5
6
7
8
9
10
interface Entity {
add: (m: number, n: number) => number;
del(m: number, n: number): number;
}
const entity: Entity = {
add: (m, n) => m + n,
del(m, n) {
return m - n;
},
};

可缺省和可推断的返回值类型
很多时候,我们不必或者不能显式地指明返回值的类型,这就涉及可缺省和可推断的返回值类型的讲解。
函数内是一个相对独立的上下文环境,我们可以根据入参对值加工计算,并返回新的值。从类型层面看,我们也可以通过类型推断加工计算入参的类型,并返回新的类型,示例如下:

1
2
3
4
5
6
7
8
function generateTypes(m: string, n: number) {
const numbers = [n];
const strings = [m]
return {
numbers,
strings
} // 返回 { numbers: number[]; strings: string[] } 的类型
}

参数类型

1
2
3
4
5
6
7
8
9
10
function log(x?: string) {
console.log(x);
}
function log1(x: string | undefined) {
console.log(x);
}
log();
log(undefined);
log1(); // 应有 1 个参数,但获得 0 个。ts(2554)
log1(undefined);

这里的 ?: 表示参数可以缺省、可以不传,也就是说调用函数时,我们可以不显式传入参数。但是,如果我们声明了参数类型为 xxx | undefined,就表示函数参数是不可缺省且类型必须是 xxx 或者 undfined。因此,在上述代码中,log1 函数如果不显示传入函数的参数,TypeScript 就会报一个 ts(2554) 的错误,即函数需要 1 个参数,但是我们只传入了 0 个参数。

this
众所周知,在 JavaScript 中,函数 this 的指向一直是一个令人头痛的问题。因为 this 的值需要等到函数被调用时才能被确定,更别说通过一些方法还可以改变 this 的指向。也就是说 this 的类型不固定,它取决于执行时的上下文。
但是,使用了 TypeScript 后,我们就不用担心这个问题了。通过指定 this 的类型(严格模式下,必须显式指定 this 的类型),当我们错误使用了 this,TypeScript 就会提示我们.
在 TypeScript 中,我们只需要在函数的第一个参数中声明 this 指代的对象(即函数被调用的方式)即可,比如最简单的作为对象的方法的 this 指向.

1
2
3
4
5
6
7
8
9
10
function say(this: Window, name: string) {
console.log(this.name);
}
window.say = say;
window.say('hi');
const obj = {
say
};
obj.say('hi'); // 类型为“{ say: (this: Window, name: string) => void; }”的 "this" 上下文不能分配给类型为“Window”的方法的 "this"。
类型“{ say: (this: Window, name: string) => void; }”缺少类型“Window”的以下属性: clientInformation, closed, customElements, devicePixelRatio 及其他 189 项。ts(2684)

在上述代码中,我们在 window 对象上增加 say 的属性为函数 say。那么调用window.say()时,this 指向即为 window 对象。
调用obj.say()后,此时 TypeScript 检测到 this 的指向不是 window,于是抛出了一个 ts(2684) 错误。
同样,定义对象的函数属性时,只要实际调用中 this 的指向与指定的 this 指向不同,TypeScript 就能发现 this 指向的错误,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
interface Person {
name: string;
say(this: Person): void;
}
const person: Person = {
name: 'captain',
say() {
console.log(this.name);
},
};
const fn = person.say;
fn(); //类型为“void”的 "this" 上下文不能分配给类型为“Person”的方法的 "this"。ts(2684)

我们要注意的是,显式注解函数中的 this 类型,它表面上占据了第一个形参的位置,但并不意味着函数真的多了一个参数,因为 TypeScript 转译为 JavaScript 后,“伪形参” this 会被抹掉,这算是 TypeScript 为数不多的特有语法。

TS字面量类型

在 TypeScript 中,类型标注声明是在变量之后(即类型后置),它不像 Java 语言一样,先声明变量的类型,再声明变量的名称。
使用类型标注后置的好处是编译器可以通过代码所在的上下文推导其对应的类型,无须再声明变量类型。在 TypeScript 中,具有初始化值的变量、有默认值的函数参数、函数返回的类型都可以根据上下文推断出来。
正是得益于 TypeScript 这种类型推导机制和能力,使得我们无须显式声明,即可直接通过上下文环境推断出变量的类型,也就是说此时类型可缺省。
如下所示,我们发现这些缺省类型注解的变量可以通过类型推断出类型。

1
2
3
4
5
6
7
8
9
10
{
let string = 'it is a string';
let number = 666;
let boolean = false;
}
{
const string = 'it is a string';
const number = 666;
const boolean = false;
}

我们通过 VS Code hover 示例中的变量查看类型,可以发现一个很神奇的事情。通过 let 和 const 定义的赋予了相同值的变量,其推断出来的类型不一样。比如同样是 ‘it is a string’,通过 let 定义的变量类型是 string,而通过 const 定义的变量类型是 ‘it is a string’’(这里表示一个字符串字面量类型)。

字面量类型

在 TypeScript 中,字面量不仅可以表示值,还可以表示类型,即所谓的字面量类型。
目前,TypeScript 支持 3 种字面量类型:字符串字面量类型、数字字面量类型、布尔字面量类型,对应的字符串字面量、数字字面量、布尔字面量分别拥有与其值一样的字面量类型,字面量类型是集合类型的子类型,它是集合类型的一种更具体的表达。比如 ‘it is a string’ (这里表示一个字符串字面量类型)类型是 string 类型(确切地说是 string 类型的子类型),而 string 类型不一定是 ‘it is astring’(这里表示一个字符串字面量类型)类型。
一般来说,我们可以使用一个字符串字面量类型作为变量的类型,如下代码所示:

1
2
let userName: 'able' = 'able';
userName = 'moriaty';//不能将类型“"moriaty"”分配给类型“"able"”。ts(2322)

实际上,定义单个的字面量类型并没有太大的用处,它真正的应用场景是可以把多个字面量类型组合成一个联合类型,用来描述拥有明确成员的实用的集合。
如下代码所示,我们使用字面量联合类型描述了一个明确、可 ‘up’ 可 ‘down’ 的集合,这样就能清楚地知道需要的数据结构了。

1
2
3
4
5
6
type sportsType = 'basketball' | 'footbal';
function exercise(type: sportsType) {
// ...
}
exercise('basketball');
exercise('run'); // 类型“"run"”的参数不能赋给类型“sportsType”的参数。ts(2345)

通过使用字面量类型组合的联合类型,我们可以限制函数的参数为指定的字面量类型集合,然后编译器会检查参数是否是指定的字面量类型集合里的成员。
因此,相较于使用 string 类型,使用字面量类型(组合的联合类型)可以将函数的参数限定为更具体的类型。这不仅提升了程序的可读性,还保证了函数的参数类型,可谓一举两得。
数字字面量类型和布尔字面量类型的使用与字符串字面量类型的使用类似,我们可以使用字面量组合的联合类型将函数的参数限定为更具体的类型。

字面量类型拓宽

所有通过 let 或 var 定义的变量、函数的形参、对象的非只读属性,如果满足指定了初始值且未显式添加类型注解的条件,那么它们推断出来的类型就是指定的初始值字面量类型拓宽后的类型,这就是字面量类型拓宽。
下面我们通过字符串字面量的示例来理解一下字面量类型拓宽:

1
2
3
4
5
6
7
{
let string = 'it is a string'; // 类型是 string
let stringFunc = (str = 'it is a string') => str; // 类型是 (str?: string) => string;
const specifiedStr = 'it is a string'; // 类型是 'it is a string'
let strNew = specifiedStr; // 类型是 'string'
let stringFuncNew = (str = specifiedStr) => str; // 类型是 (str?: string) => string;
}

因为string和stringFunc满足了 let、形参且未显式声明类型注解的条件,所以变量、形参的类型拓宽为 string(形参类型确切地讲是 string | undefined)。
因为specifiedStr的常量不可变更,类型没有拓宽,所以 specifiedStr 的类型是 ‘it is a string’ 字面量类型。
因为strNew赋予的值 specifiedStr 的类型是字面量类型,且没有显式类型注解,所以变量、形参的类型也被拓宽了。其实,这样的设计符合实际编程诉求。我们设想一下,如果 strNew 的类型被推断为 ‘this is string’,它将不可变更,因为赋予任何其他的字符串类型的值都会提示类型错误。
但是要注意一点,如果添加显示类型注解,则能控制类型拓宽行为。

1
2
3
4
{
const specifiedStr: ' it is a string' = 'it is a string'; // 类型是 '"it is a string"'
let str2 = specifiedStr; // 即便使用 let 定义,类型是 'it is a string'
}

前端三大框架对比

Angular 是一个应用设计框架与开发平台,用于创建高效、复杂、精致的单页面应用,提供了前端项目开发较完整的解决方案。
与此相对,React/Vue 则专注于构建用户界面,在一定程度上来说是一个 JavaScript 库,不能称之为框架。
由于 React/Vue 都提供了配套的页面应用解决方案和工具库,因此我们常常将它们作为前端框架与 Angular放在一起比较。
实际上,三个框架的关系可以简单用这样的公式表达。
Angular = React/Vue + 路由库(react-router/vue-router) + 状态管理(Redux/Flux/Mobx/Vuex) + 脚手架/构建(create-react-app/Vue CLI/Webpack) + …
我们先来看看 Angular。

Angular

Angular 最初的设计是针对大型应用进行的,上手成本较高,因为开发者需要掌握一些对前端开发来说较陌生的概念,其中包括依赖注入、模块化、脏检查、AOT 等设计。

依赖注入

在项目中,依赖注入体现为:项目提供了这样一个注入机制,其中有人负责提供服务、有人负责消耗服务,通过注入机制搭建了提供服务与消费服务之间的接口。Angular 通过依赖注入来帮我们更容易地将应用逻辑分解为服务,并让这些服务可用于各个组件中。前面提到,Angular 的设计是针对大型应用的,使用依赖注入可以轻松地将各个模块进行解耦,模块与模块之间不会有过多的依赖,可以轻松解决大型应用中模块难以管理的难题。所以在 Angular 中,依赖注入配合模块化组织能达到更好的效果。

模块化组织

Angular 模块把组件、指令和管道打包成内聚的功能块,每个模块聚焦一个特性区域、业务领域、工作流或通用工具。所以我们可以用Angular 模块来自行聚焦某一个领域的功能模块,也可以使用 Angular 封装好的一些功能模块,像表单模块 FormModule、路由模块 RouterModule、Http 模块,等等。通过依赖注入的方式,我们可以直接在需要的地方引入这些模块并使用。模块的组织结构为树状结构,不同层级的模块功能组成了完整的应用。通过树状的方式来,依赖注入系统可高效地对服务进行创建和销毁,管理各个模块之间的依赖关系。
状态更新:脏检查机制
在 Angular 中,触发视图更新的时机来自常见的事件如用户交互(点击、输入等)、定时器、生命周期等,大概的过程如下:
在上述时机被触发后,Angular会计算数据的新值和旧值是否有差异;
若有差异,Angular 会更新页面,并触发下一次的脏检查;
直到新旧值之间不再有差异,或是脏检查的次数达到设定阈值,才会停下来。
由于并不是直接监听数据的变动,同时每一次更新页面之后,很可能还会引起新的值改变,这导致脏检查的效率很低,还可能会导致死循环。虽然 AngularJS 有阈值控制,但也无法避免脏检查机制所导致的低效甚至性能问题。
脏检查机制在设计上存在的性能问题一直被大家诟病,在 Angular2+ 中引入了模块化组织来解决这个问题。由于应用的组织类是树结构的,脏检查会从根组件开始,自上而下对树上的所有子组件进行检查。相比 AngularJS 中的带有环的结构,这样的单向数据流效率更高,而且容易预测,性能上也有不少的提升。除了模块化组织之外,Angular2+ 同时还引入了 NgZone,提升了脏检查的性能。
在 Angular 中除了对脏检查机制进行了性能优化,还提供了其他的优化手段,AOT 编译便是其中一种。

用 AOT 进行编译

Angular 提供了预编译(AOT)能力,无须等待应用首次编译,以及通过预编译的方式移除不需要的库代码、减少体积,还可以提早检测模板错误。
除此之外,Angular提供了完备的结构和规范,新加入的成员能很快地通过复制粘贴完成功能的开发。好的架构设计,能让高级程序员和初入门的程序员写出相似的代码,Angular 通过严格的规范约束,提升了项目的维护体验。
由于 Angular 目标是提供大而全的解决方案,因此相比 Angular,React和 Vue则更专注于用户界面的构建和优化,我们继续来看一下 React。

React

React 和 Vue 都是专注于构建用户界面的 JavaSctipt 库,它们不强制引入很多工程类的功能,也没有过多的强侵入性的概念、语法糖和设计,因此它们相对 Angular 最大的优势是轻量。
而对比 Vue,React 最大的优点是灵活,对原生 JavaScript 的侵入性弱(没有过多的模板语法),不需要掌握太多的API 也可以很好地使用。
接下来,我们来看看React 中的一些核心设计和特色,首选便是虚拟 DOM的设计。

虚拟 DOM

虚拟 DOM 方案的出现,主要为了解决前端页面频繁渲染过程中的性能问题。该方案最初由 React 提出,如今随着机器性能的提升、框架之间的相互借鉴等,在其他框架(比如 Vue)中也都有使用。
虚拟 DOM的设计,大概可分成 3 个过程,下面我们分别来看看。
1.用JavaScript 对象模拟 DOM 树,得到一棵虚拟 DOM 树。
2.当页面数据变更时,生成新的虚拟 DOM 树,比较新旧两棵虚拟 DOM 树的差异。
3.把差异应用到真正的 DOM 树上。
虽然虚拟 DOM 解决了页面被频繁更新和渲染带来的性能问题,但传统虚拟 DOM 依然有以下性能瓶颈:
在单个组件内部依然需要遍历该组件的整个虚拟 DOM 树;
在一些组件整个模版内只有少量动态节点的情况下,这些遍历都是性能的浪费;
递归遍历和更新逻辑容易导致 UI 渲染被阻塞,用户体验下降。
对此,React 框架也有进行相应的优化:使用任务调度来控制状态更新的计算和渲染。

状态更新:任务调度

React 中使用协调器(Reconciler)与渲染器(Renderer)来优化页面的渲染性能。
在 React 里,可以使用ReactDOM.render/this.setState/this.forceUpdate/useState等方法来触发状态更新,这些方法共用一套状态更新机制,该更新机制主要由两个步骤组成。
找出变化的组件,每当有更新发生时,协调器会做如下工作:
调用组件render方法将 JSX 转化为虚拟 DOM;
进行虚拟 DOM Diff 并找出变化的虚拟 DOM;
通知渲染器。
渲染器接到协调器通知,将变化的组件渲染到页面上。
在 React15 及以前,协调器创建虚拟 DOM 使用的是递归的方式,该过程是无法中断的。这会导致 UI 渲染被阻塞,造成卡顿。
为此,React16 中新增了调度器(Scheduler),调度器能够把可中断的任务切片处理,能够调整优先级,重置并复用任务。调度器会根据任务的优先级去分配各自的过期时间,在过期时间之前按照优先级执行任务,可以在不影响用户体验的情况下去进行计算和更新。
通过这样的方式,React 可在浏览器空闲的时候进行调度并执行任务,篇幅关系这里不再展开。
虚拟DOM和任务调度的状态更新机制,是 React 中性能优化的两个重要解决方案。
除了性能优化以外,React 的出现同时带来了特别棒的理念和设计,包括 jsx、函数式编程、Hooks等。其中,函数式编程的无副作用等优势向来被很多程序员所推崇,Hooks 的出现更是将 React 的函数式编程理念推向了更高峰。
相比于 Angular,React 的入门门槛要低很多,但提到简单易学,就不得不说到 Vue了。

Vue

Vue 最大的特点是上手简单,框架的设计和文档对新手极其友好。但这并不代表它只是个简单的框架,当你需要实现一些更加深入的自定义功能时(比如自定义组件、自定义指令、JSX 等),你会发现它也提供了友好的支持能力。
很多人会认为 Vue 只是把 Angular 和 React 的优势结合,但 Vue 也有自身的设计和思考特色。这里,我们同样介绍一下 Vue 的设计特点。

虚拟 DOM

前面我们在介绍 React的虚拟 DOM时,提到传统虚拟 DOM的性能瓶颈,Vue 3.0 同样为此做了些优化。
在 Vue 3.0 中,虚拟 DOM通过动静结合的模式来进行突破:
通过模版静态分析生成更优化的虚拟 DOM 渲染函数,将模版切分为块(if/for/slot);
更新时只需要直接遍历动态节点,虚拟 DOM的更新性能与模版大小解耦,变为与动态节点的数量相关。
可以简单理解为,虚拟 DOM 的更新从以前的整体作用域调整为树状作用域,树状的结构会带来算法的简化以及性能的提升。

状态更新: getter/setter、Proxy

在 Vue 3.0 以前,Vue中状态更新实现主要依赖了getter/setter,在数据更新的时候就执行了模板更新、watch、computed等一些工作。
相比于之前的getter/setter监控数据变更,Vue 3.0 将会是基于Proxy的变动侦测,通过代理的方式来监控变动,整体性能会得到优化。当我们给某个对象添加了代理之后,就可以改变一些原有的行为,或是通过钩子的方式添加一些处理,用来触发界面更新、其他数据更新等也是可以的。
对比 Angular,Vue 更加轻量、入门容易。对比 React,Vue 则更专注于模板相关,提供了便利和易读的模板语法,开发者熟练掌握这些语法之后,可快速高效地搭建起前端页面。同时,Vue也在不断地进行自我演化,这些我们也能从 Vue 3.0 的响应式设计、模块化架构、更一致的API 设计等设计中观察到。

小结

前端框架很大程度地提升了前端的开发效率,同时提供了各种场景下的解决方案,使得前端开发可以专注于快速拓展功能和业务实现。。其中,Angular 属于适合大型前端项目的“大而全”的框架,而 React/Vue 则是轻量灵活的库,它们各有各的设计特点。
对于框架的升级,React 选择了渐进兼容的技术方案,而 Angular/Vue 都曾经历过“断崖式”的版本升级。

ES2021新特性

ECMAScript 2021 新特性

1 String.prototype.replaceAll()
在JavaScript中,replace()方法只替换字符串中的第一个实例。如果我们想替换字符串中的所有匹配项,唯一的办法就使用全局正则表达式。
新特性replaceAll()会返回一个新的字符串,字符串中所有的匹配项都会被替换掉。模式可以是字符串或正则表达式,替换的内容可以是字符串或是为每个匹配项执行的函数。

原本的replace()只能替换掉第一个匹配项:

1
2
3
const str = "审批 审批 审批 审批 "
const newStr = str.replace("审批", "人事")
console.log(newStr) // 人事 审批 审批 审批

如果想要完全匹配替换需要写全局正则表达式:

1
2
3
const str = "审批 审批 审批 审批"
const newStr = str.replace(/审批/g, "人事")
console.log(newStr) // 人事 人事 人事 人事

新的replaceAll()特性:

1
2
3
const str = "审批 审批 审批 审批"
const newStr = str.replaceAll("审批", "人事")
console.log(newStr) //人事 人事 人事 人事

2 Promise.any
ES2020已经通过了Promise的allSettled()方法。ES2021 Promise阵营将有一个新的成员,any()。
当Promise列表中的任意一个promise成功resolve则会短路并返回第一个resolve的结果状态, 如果所有的promise均reject,则抛出异常AggregateError,表示所有请求失败。
Promise.any()与Promise.race()十分容易混淆,务必注意区分。Promise.race() 一旦某个promise触发了resolve或者reject,就直接返回了该状态的结果。
即使Promise在resolve的之前被reject,Promise.any()仍将返回第一个resolve的结果:

1
2
3
4
5
6
7
Promise.any([
new Promise((resolve, reject) => setTimeout(reject('workflow'), 100)),
new Promise((resolve, reject) => setTimeout(resolve('staff'), 1000)),
new Promise((resolve, reject) => setTimeout(resolve('holiday'), 2000))
])
.then((value) => console.log(`结果:${value}`)) // 结果:staff
.catch((err) => console.log(err));

当所有promise都为reject会抛出异常AggregateError: All promises were rejected:

1
2
3
4
5
6
7
Promise.any([
Promise.reject('Error one'),
Promise.reject('Error two'),
Promise.reject('Error three')
])
.then((value) => console.log(`结果:${value}`))
.catch((err) => console.log(err)); // AggregateError: All promises were rejected

3 逻辑运算符和赋值表达式
在JavaScript中,有很多赋值操作符和逻辑操作符,在新的草案下,我们可以组合逻辑运算符和赋值运算符。
a &&= b 当a值存在时,将b变量赋值给a:

1
2
3
4
a &&= b
//等价于
// 1. a && (a = b)
// 2. if (a) a = b

a ||= b 当a值不存在时,将b变量赋值给a:

1
2
3
4
a ||= b
//等价于
// 1. a || (a = b)
// 2. if (!a) a = b

a ??= b 当a值为null或者undefined时,将b变量赋值给a:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let a
let b = 10
a ??= b
console.log(a) // 10

a = false
a ??= b
console.log(a) // false

const navigations = [
{
title: '工作台',
path: '/'
},
{
title: '审批',
path: '/workflow'
},
{
path: '/setting'
}
];

for (const navigation of navigations) {
page.title ??= '默认';
}

console.table(pages);

// (index) title path
// 0 "工作台" "/"
// 1 "审批" "/workflow"
// 2 "默认" "/setting"

4 Private Methods & Private Accessors

4.1 私有方法 Private Methods

私有方法只能在定义它的类内部访问,专用方法名称以#开头。由于setType()是私有方法,所以personObj.setType返回undefined,用undefined作函数会引发TypeError。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
// Private method
#setType() {
console.log('Private');
}
// Public method
show() {
this.#setType();
}
}

const personObj = new Person();
personObj.show(); // "Private"
personObj.setType(); // TypeError: personObj.setType is not a function

4.2 私有访问者 Private Accessors

可以通过在函数名称前添加#,使得访问器函数私有。在下面的代码中,name是公共的,可以像普通属性一样读取它, 而age则是私有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Person {
// Public accessor
get name() {
return 'able';
}
set name(value) {}

// Private accessor
 get #age() {
return 18;
}
set #age(value) {}
}

const personObj = new Person();
console.log(personObj.name); // "able"
console.log(personObj.age); // undefined

5 WeakRefs

当我们通过const, let, var创建一个变量时,垃圾收集器GC将永远不会从内存中删除该变量,只要它的引用仍然存在且可访问。而WeakRef对象包含针对对象的弱引用,针对对象的弱引用是不会阻止垃圾收集器GC的回收的,则GC可以在任何时候删除它。

例如有如下场景:
跟踪某个对象调用某一特定方法的次数,超过1000条则做对应提示。

如果我们使用Map,虽然可以实现需求,但是会发生内存溢出,因为传递给doSomething()的每个对象都永久保存在map中,并且不会被GC回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let map = new Map();

function doSomething(obj) {
// ...
}

function useObject(obj) {
doSomething(obj);
let called = map.get(obj) || 0;
called++;
if (called > 1000) {
console.log('调用次数已超过1000次');
}
map.set(obj, called);
}

所以,可以通过WeakMap()或者WeakSet()来使用WeakRefs。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let wmap = new WeakMap();

function doSomething(obj) {
// ...
}

function useObject(obj) {
doSomething(obj);
let called = wmap.get(obj) || 0;
called++;
if (called > 1000) {
console.log('调用次数已超过1000次');
}
wmap.set(obj, called);
}

因为是弱引用,所以WeakMap、WeakSet的键值对是不可枚举的。WeakSet和WeakMap相似,但是每个对象在WeakSet中只可能出现一次,WeakSet中所有对象都是唯一的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let ws = new WeakSet();
let foo = {};
let bar = {};

ws.add(foo);
ws.add(bar);

ws.has(foo); // true
ws.has(bar); // true

ws.delete(foo); // 删除foo对象

ws.has(foo); //false foo已删除
ws.has(bar); // bar仍存在

WeakSet与Set相比有以下两个区别:
WeakSet只能是对象集合,而不能是任何类型的任意值
WeakSet弱引用,集合中对象引用为弱引用,如果没有其他对WeakSet对象的引用,则会被GC回收

最后,WeakRef实例有一个方法deref(),返回引用的原始对象,如果原始对象被回收,则返回undefined。下面其在斐波那契数列计算中缓存的妙用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const cache = new Map();

const setValue = (key, obj) => {
cache.set(key, new WeakRef(obj));
};

const getValue = (key) => {
const ref = cache.get(key);
if (ref) {
return ref.deref();
}
};

const fibonacciCached = (number) => {
const cached = getValue(number);
if (cached) return cached;
const sum = calculateFibonacci(number);
setValue(number, sum);
return sum;
};

为什么代码没有按编写顺序执行

前端工程师算是最幸运的软件工程师,因为从一开始就可以接触到“异步”这种高级特性,比如 DOM 事件、AJAX 请求及定时器;同时也是最不幸的软件工程师,因为入门 JavaScript 的时候就要习惯异步这种高难度的开发方式,异步经常会导致输出的结果与我们的预期不一致。

异步和同步

这两个概念大家应该都比较熟悉啦,简单解释一下,要比较同步和异步,可以将调用函数的过程分成两部分:执行操作和返回结果。程序在同步调用函数的时候,会立即执行操作并等待得到返回结果后再继续运行,也就是说同步执行是阻塞的。而异步会将操作和结果在时间上分隔开来,在当下执行操作,在未来某个时刻返回结果,在这个等待返回结果的过程中,程序将继续执行后面的代码。也就是说异步执行是非阻塞的。这里就不举🌰啦。

异步与回调

我们经常调用 JavaScript 的异步函数可能会认为:异步操作都采用回调函数的形式。毕竟从浏览器端的 DOM 事件、AJAX 请求、定时器到 Node.js 端的文件读写、多进程,都是采用的回调形式。那么还会有其他case嘛,上🌰。
下面是一段简单的代码,定义了一个 JSON 对象 a,然后把它打印到控制台,最后再将对象 a 的 couter.index 属性值自增 1。

1
2
3
4
5
6
7
var a = {
counter: {
index: 1
}
};
console.log( a ); // ?
a.counter.index++;

我们在控制台里看一下,结果可能和我们的预期不一致,输出了一个JSON 对象:{conter:{index: 2}}。原因在于浏览器在运行代码的时候,把控制台打印这种涉及 I/O 的操作进行了延迟执行。可能有人会推测是不是控制台打印的只是将对象 a 进行了类似“浅拷贝”的操作,否定这种猜想很简单,此时再执行一次自增操作,就会发现被打印的对象值并没有发生变化。
既然并非所有异步都回调,那么反过来,是否所有回调函数都是异步执行的呢?答案也是否定的。比如数组原型函数 forEach,它有两个参数,第一个是回调函数,第二个是 this 指向的对象,这里的回调就是同步的。

异步原理

回顾了异步的基础概念,下面就来深入讲解异步的原理。

事件循环

对于大多数语言而言,实现异步会通过启动额外的进程、线程或协程来实现,而我们在前面已经提到过,JavaScript 是单线程的。为什么单线程还能实现异步呢?其实也没有什么特殊的黑魔法,只是把一些操作交给了其他线程处理,然后采用了一种称之为“事件循环”(也称“事件轮询”)的机制来处理返回结果。
下面我们用一段简化的代码,来描述事件循环机制。
数组 eventLoop 表示事件队列(也有称作“任务队列”),用来存放需要执行的任务事件(可以理解为回调函数),对象 event 变量表示当前需要执行的任务事件。用一个永不停止的 while 循环来表示事件循环,每一次循环称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中获取一个事件并执行,这些事件通常是回调函数的形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var eventLoop = []; // 事件队列,先进先出
var event; // 事件执行成功的回调回调函数
while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 队列中取出回调函数
event = eventLoop.shift();
try {
event();
} catch (err) {
reportError(err); 
}
}
}

那么这个事件队列里的事件是怎么来的呢?以 AJAX 请求为例,当我们发出一个 AJAX 请求时,浏览器会将请求任务分派给网络线程来进行处理,当对应的网络线程拿到返回的数据之后,就会把回调函数插入到事件队列中。setTimeout 和 setInterval 也是同样的道理,当我们执行 setTimeout 的时候并不是直接把回调函数放入事件队列中。它所做的是交给定时器线程来处理,当定时器到时后,再把回调函数放在事件队列中,这样,在未来的某轮 tick 中获取并执行这个回调函数。这么做有一个隐性的问题,如果事件队列中已经有其他事件,那么这个回调就会排队等待。所以说 setTimeout/setInterval 定时器的精度并不高。准确地说,它只能确保回调函数不会在指定的时间间隔之前运行,但可能会在那个时刻运行,也可能在那之后运行,这就要根据事件队列的状态而定。

事件队列

在讲述 setTimeout/setInterval 原理的时候也暴露了事件队列的一个缺陷:事件队列按照先进先出的顺序执行,那么如果队列较长时,排在后面的事件即使较为“紧急”,也得需要等待前面的任务先执行完成。JavaScript 解决这个问题的思路就是:设置多个队列,按照优先级来执行。
下面这段代码可以验证 JavaScript 内部拥有优先级不同的 2 个队列,我们暂时称为红色队列和绿色队列,其中红色队列优先级高于绿色队列。这段代码定义了 4 个异步函数 f1、f2、f3、f4,其中:函数 f1 通过定时器 setTimeout 向绿色队列中插入一个控制台打印任务,输出数字 1;函数 f2 通过 Promise 向红色队列中插入一个控制台打印任务,输出数字 2;函数 f3 通过定时器 setTimeout 向绿色队列中插入一个回调函数,该回调函数会调用控制台打印数字 3,并且调用函数 f2;函数 f4 通过 Promise 向红色队列中插入一个回调函数,该回调函数会调用控制台打印数字 4,并且调用函数 f1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function f1() {
  setTimeout(console.log.bind(null,1), 0)
}
function f2() {
  Promise.resolve().then(console.log.bind(null,2))
}
function f3() {
  setTimeout(() => {
    console.log(3)
    f2()
  }, 0)
}
function f4() {
  Promise.resolve().then(() => {
    console.log(4)
    f1()
  })
}
f3()
f4()

当调用函数 f3 和函数 f4 之后,绿色队列和红色队列都会被插入一个匿名回调函数。第 1 次 tick,由于红色队列优先级高,所以先执行红色匿名函数,控制台打印数字 4,然后调用函数 f1,向绿色队列中插入一个打印函数;第 2 次 tick,按照先进先出原则,此时调用匿名函数打印数字 3,并调用函数 f2,向红色队列中插入一个打印函数;第 3 次 tick,调用红色队列中的打印函数,控制台打印数字 2;第 4 次 tick,调用绿色队列中的打印函数,控制台打印数字 1。
关于红色队列和绿色队列,一般称为“宏任务队列(Macro Task Queue)”和“微任务队列(Micro Task Queue)”,不同队列优先级不同,每次事件循环时会从优先级高的队列中获取事件,只有当优先级高的队列为空时才会从优先级低的队列中获取事件,同级队列之间的事件不存在优先级,只遵循先进先出的原则。

常见的异步函数优先级如下,从上到下优先级逐层降低:

1
2
3
4
5
process.nextTick(Node.js) > 
MutationObserver(浏览器)/promise.then(catch、finnally)>
setImmediate(IE) >
setTimeout/setIntervalrequestAnimationFrame >
其他 I/O 操作 / 浏览器 DOM 事件

JSX如何变成DOM

入门React的时候,我们了解了神奇的JSX语法,当时的官方是建议使用JSX,作为小白的我当然是乖乖听话。想必现在大家都早已经习惯使用JSX,我们今天来了解一下它是如何成为DOM的。

JSX的本质

官方描述JSX是JavaScript的扩展语法,它充分具备JavaScript的能力,那么它是怎么做到的呢。这时候我能想到的就是官方霸霸给出的一句话,JSX会被编译成React.createElement(),它会返回一个叫做’React Element’的JS对象。首先编译这个动作,它是由Babel来完成的,我们知道Babel的主要功能是将ECMAScript2015+版本的代码转换成向后兼容的JavaScript语法,从而能运行在当前的浏览器中。其实,JSX也是由Babel来转换为Javascript代码的。

我随便找了一段项目里的简单组件的JSX代码放在Babel官网上,它会将其转换成React.createElement的调用。可以看到,所有的JSX标签都被转换成了React.createElement调用,大家明显能感受到,JSX相对而言不仅阅读起来友好,开发起来也比较简单(2333这个比较关键)。小结一下,JSX本质是React.createElement这个JavaScript调用的语法糖,它允许开发者用较熟悉的类HTML标签语法来创建虚拟DOM,提升了开发效率,也降低了学习成本。

读一读createElement源码

1
export function createElement(type, config, children)

这个方法有三个入参,type是节点类型,可以是div、span这样的标准HTML标签字符串,也可以是React组件类型;config是一个对象,以键值对的形式存储了组件的属性;children记录的是子节点、子元素对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//propName用于存储元素属性,props用于存储属性的键值对集合
let propName;
const props = { };
let key = null;
let ref = null;
let self = null;
let source = null;
// key、ref、self、source 均为 React 元素的属性
if (hasValidRef(config)) {
ref = config.ref;
}
if (hasValidKey(config)) {
key = '' + config.key;
}
self = config.__self === undefined ? null : config.__self;
source = config.__source === undefined ? null : config.__source;

//接下来将符合的config里的属性放入props中
for (propsName in config) {
if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
props[propName] = config[propName];
}
}

//childrenLength是在获取子元素,因此减去的两项是指type和config两个参数占用的长度
const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
props.children = children;
} else if (childrenLength > 1) {
//处理多个子元素
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
//将children赋值给props的children属性
props.children = childArray;
}

//别忘了处理节点的默认属性
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}

//最后返回调用ReactElement执行方法,传入处理后的参数
return ReactElement(
type,
key,
ref,
self,
source,
ReactCurrentOwner.current,
props,
);

看完大家会不会有些失望,原来createElement好像也没做啥哈哈哈,它并没有涉及到真实的DOM。其实它只是接受相对简单的参数然后做一次数据处理,最后调用ReactElement来创建元素。那么我们接下来继续康康ReactElement的源码。

读一读ReactElement源码,认识一下虚拟DOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//通过React.createElement中调用ReactElement方法我们能得知ReactElement的入参有哪些
const ReactElement = function(type, key, ref, self, source, owner, props) {
const element = {
// REACT_ELEMENT_TYPE是一个常量,用来标识该对象是一个ReactElement
$$typeof: REACT_ELEMENT_TYPE,

// 内置属性赋值
type: type,
key: key,
ref: ref,
props: props,

// 记录创造该元素的组件
_owner: owner,
};

//
if (__DEV__) {
// 这里是一些针对 __DEV__ 环境下的处理,不影响理解主要逻辑
}
return element;
};

我们可以发现,ReactElement的逻辑也较为简单,它只是按一定规范组装了一个element对象,通过React.createElement最终返回到了开发者。我们平常去打印一个React元素,就会发现它是一个标准的ReactElement对象实例,如图所示。

这个 ReactElement 对象实例,本质上是以 JavaScript 对象形式存在的对 DOM 的描述,也就是大家常说的虚拟Dom。既然是虚拟的,那它离页面的真实DOM还有一定距离,最终是由ReactDom.render方法来填补的。这个方法可以传入三个参数,需要渲染的元素element,元素挂载的目标容器container也就是真实的DOM节点,以及可选参数回调函数。

我真的懂this吗

上篇博客有举一个例子,我们在函数里使用的变量name是属于全局作用域下的,因为JavaScript的作用域是由词法作用域决定的,它在代码阶段就决定好了,跟函数是怎么调用的没有关系。但是在面向对象的语言中,在对象内部的方法中使用对象内部的属性是一个非常普遍的需求,JavaScript的作用域机制却对此并不支持,因此我们这时候需要this机制。
执行上下文中包含了变量环境、词法环境、外部环境,还有就是this。也就是说,每个执行上下文中都有一个this。我们主要来了解全局执行上下文中的this和函数执行上下文中的this。
全局执行上下文中的this,我们可以通过控制台打印一下,最终输出的是window对象,也就是说全局执行上下文中的this是指向window对象的。
函数执行上下文中的this,我们在一个函数内部打印this,执行函数打印的也是window对象。这可以理解为是一种缺陷,因为在实际开发中,我们并不希望函数执行上下文中的this指向全局对象,它打破了数据的边界。我们可以设计JavaScript为严格模式,这时候this的指向为怒define。我们可以通过以下几种方式来设置执行上下文中的this。

1、通过函数的call方法设置

funA.call(objB),funA函数内部的this指向了objB对象,bind、apply也可以用来改变this指向,call和apply的作用一样,只是传参方式不同。call和apply都会执行对应的函数,而bind方法不会。

2、通过对象调用方法来设置
1
2
3
4
5
6
7
var objA = {
name: 'able',
printName: function () {
console.log(this)
}
}
objA.printName();

当使用对象来调用起内部的一个方法时,该方法的this是指向对象本身的。也可以理解为在执行时将其转化成了objA.printName.call(objA);
要注意的是,隐式绑定有一个大坑,它容易丢失!如果我们把objA.printName赋给一个全局对象,然后在全局环境中调用这个对象,它内部的this是指向全局变量window的。我们可以用一个小诀窍来记住,隐式调用的格式一般是XXX.fn();fn()前边如果什么都没有,那它不是隐式绑定。

3、通过构造函数中设置

在JavaScript中,构造函对象数只是使用new时被调用的函数,它跟C++不一样,没有类的概念,因此任何一个函数都可以用new来调用,它不属于某个类,也不会实例化出一个类,只能称作是对于函数的构造调用。

1
2
3
4
function createA (name) {
this.name = name;
}
var objA = new createA ('able');

当执行new createA()我们可以分为以下几步。首先创建一个空对象objA,调用createA.call方法,将objA作为参数,createA的执行上下文创建时,它的this指向来objA对象。然后执行createA函数,此时createA函数执行上下文中的this指向objA对象,最后返回objA对象。

1
2
3
var objA  = { };
createA.call(objA);
return objA;

这样我们用new构建来一个新的对象,就会将新对象绑定到这个函数的this上。

绑定优先级

上边有列到好几种绑定规则,new绑定,通过call、apply、bind方式的显式绑定,在某个对象上触发函数调用的隐式绑定,还有默认绑定。它们的优先级为:
new绑定 > 显式绑定 > 隐式绑定 > 默认绑定

this的一些坑

嵌套函数的this不会从外层函数继承,我们来举个例子

1
2
3
4
5
6
7
8
9
10
11
var objA = {
name: 'able',
printThis: function () {
console.log(this);
function innerB () {
console.log(this);
}
innerB();
}
}
objA.printThis();

这个内部函数innerB中的this,很容易被理解为和其外层printThis函数的this是一致的。但执行后我们会发现,innerB中的this指向的是全局window对象,让人迷惑。
早期大家可能会在开发的时候在printThis函数中声明一个_this来保存this,然后在innerB函数中使用_this。当有了箭头函数之后,我们可以用箭头函数的特性来解决这个问题。因为箭头函数不会创建其自身的执行上下文,它的this取决于它的外部函数。关于箭头函数还有几点我们需要注意的,它不可以当作构造函数,不可以使用arguments对象,没有自己的this因此不能用call()等方法改变this指向。

总结

判断this指向流程走一遍:首先我们看函数是否在new中调用;然后是看函数是否通过call,apply调用,或者使用了bind(即硬绑定);、接下来看函数是否在某个上下文对象中调用(隐式绑定);如果以上都不是,那么使用默认绑定。如果在严格模式下,则绑定到undefined,否则绑定到全局对象。要注意的是如果把null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。还有如果是箭头函数,箭头函数的this继承的是外层代码块的this。

作用域链和闭包

上个博客了解了如何通过词法环境和变量环境来查找变量,这其中就涉及到作用域链了,抽象的概念不如来段代码,来,上板栗🌰。

作用域链

1
2
3
4
5
6
7
8
9
function printName() {
console.log('zunxingdaming', name);
}
function main() {
var name = 'able';
printName();
}
var name = 'moriatry';
main();

那么main函数执行的结果是什么呢?当执行到printName函数内部时,name的值到底到底是从全局执行上下文还是main函数的执行上下文中取呢?可能容易按照调用栈的顺序来查找变量,执行到printName函数内部时,从栈顶到底依次为printName函数执行上下文、main函数执行上下文、全局执行上下文,如果是这样输出结果应该是able,但实际上不是这样,这是为甚呢,让我们进入作用域链的世界探究探究。
在每个执行上下文的变量环境里都有一个外部引用,指向外部的执行上下文,可称其为outer。一段代码中使用一个变量时,js引擎会先在当前执行上下文中查找该变量,然后会继续在outer指向的执行上下文中查找。printName函数和main函数的outer都是指向全局上下文,那么上面的栗子在printName函数中要输出name变量,则会取outer指向的全局作用域中寻找,这种查找方式即为链。
可能看到这里还会有一些疑问,printName函数是main函数调用的,为啥其outer不是指向main函数呢。这里涉及到一个概念词法作用域,在js的执行过程中,作用域链是由词法作用域来决定的。词法作用域呢表示作用域是由代码中函数声明的位置来决定的,所以说它是静态的,是在代码阶段就定下来了,跟函数是怎么互相调用的没有关系。

闭包

为了更好的理解闭包,再举个板栗🌰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function foo() {
var name = 'able';
let a = 1;
let b = 2;
var innerFun = {
getName: function() {
return name;
},
setName: function(newName) {
console.log(a);
name = newName;
}
}
return innerFun;
}
var funA = foo();
funA.setName('moriaty');
funA.getName();
console.log(funA.getName());

innerFun是一个对象,里边包含两个方法,这两个方法中用到了a变量和name变量。根据词法作用域,内部的函数可以访问外部函数foo中的变量。所以当innerFun返回给全局变量funA时,它的两个方法仍可以使用foo函数中的变量。
foo函数执行完后,其执行上下文从栈顶弹出,但是其返回的两个方法中使用了变量name和a,因此这两个变量仍保存在内存中。我们可以把它想象成setName和getName两个方法的背包,无法在哪里调用这两个方法,它们都会背着这个foo函数的背包,我们可以把这个背包称为foo函数的闭包。
此时此刻,上一个比较规范的闭包定义吧。在js中,根据词法作用域的规则,内部函数可以访问外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束,但内部函数引用外部函数但变量依然保存在内存中,我们称这些变量的集合称之为外部函数的闭包。
那么当执行到funA.setName方法中的name = ‘moriaty’时,js引擎会沿着“当前执行上下文->foo函数闭包->全局执行上下文”的顺序来查找name变量,可以看下此时的调用栈如下图。所以调用setName时,会修改闭包中name的值。

关于闭包的销毁,如果引用闭包的函数是一个全局变量,那闭包会一直存在到页面关闭,但是如果这个闭包之后不实用的话,会造成内存泄漏。如果引用闭包的函数是个局部变量,等函数销毁后,下次js引擎执行垃圾回收时,判断闭包不再被使用,那么内存会被回收。