JavaScript 箭头函数探索

想要明白箭头函数与普通函数,匿名函数的区别,首先要搞清楚三个概念,原型链,作用域,上下文。这三个概念很重要,也很容易混淆。

总的来说,作用域阐述的是变量起作用的范围,原型链表述的是我们按照什么样的路径去查找一个变量,上下文则大部分情况下指的是this是谁,通过this去获取一个变量不会像原型链那样一路向上查找。

关于原型链和作用域的部分是截取了这篇博客的部分内容,并加上了一些个人的理解和解释

基础概念

原型链

1
2
3
4
5
6
function Person() {

}
var person = new Person();
person.name = 'Ray';
console.log(person.name) // Kevin

上述代码中Person就是一个构造函数(js中没有专门的类和构造函数,只要你可以通过new去调用某个函数,这个函数就可以是构造函数)。我们通过new创建了一个Person的实例person,并给这个实例添加了一个属性name。

但是如果我们再new一个新的Person,这个新的实例中是不会有name这个属性的。

那么我们如何让每个Person的实例上都有一个name属性呢?

答案就是将name属性写在原型上

prototype

每个函数都有一个 prototype 属性

1
2
3
4
5
6
7
8
9
10
function Person() {

}
// 虽然写在注释里,但是你要注意:
// prototype是函数才会有的属性
Person.prototype.name = 'Kevin';
var person1 = new Person();
var person2 = new Person();
console.log(person1.name) // Kevin
console.log(person2.name) // Kevin

这个函数的prototype指向的是一个对象,这个对象就是调用构造函数创建的实例的原型,也就是所有通过构造函数创建的实例的共有的原型

proto

这是每一个JavaScript对象(除了 null )都具有的一个属性,叫__proto__,这个属性会指向该对象的原型。

为了证明这一点,我们可以在火狐或者谷歌中输入:

1
2
3
4
5
function Person() {

}
var person = new Person();
console.log(person.__proto__ === Person.prototype); // true

constructor

原型无法指向构造函数,因为一个构造函数可以生成多个实例,但是原型指向构造函数倒是有的,这就要讲到第三个属性:constructor,每个原型都有一个 constructor 属性指向关联的构造函数。

为了验证这一点,我们可以尝试:

1
2
3
4
function Person() {

}
console.log(Person === Person.prototype.constructor); // true

实例与原型

当读取实例的属性时,如果找不到,就会查找与对象关联的原型中的属性,如果还查不到,就去找原型的原型,一直找到最顶层为止。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person() {

}

Person.prototype.name = 'Kevin';

var person = new Person();

person.name = 'Daisy';
console.log(person.name) // Daisy

delete person.name;
console.log(person.name) // Kevin

在这个例子中,我们给实例对象 person 添加了 name 属性,当我们打印 person.name 的时候,结果自然为 Daisy。

但是当我们删除了 person 的 name 属性时,读取 person.name,从 person 对象中找不到 name 属性就会从 person 的原型也就是 person.proto ,也就是 Person.prototype中查找,幸运的是我们找到了 name 属性,结果为 Kevin。

原型的原型

在前面,我们已经讲了原型也是一个对象,既然是对象,我们就可以用最原始的方式创建它,那就是:

1
2
3
var obj = new Object();
obj.name = 'Kevin'
console.log(obj.name) // Kevin

其实原型对象就是通过 Object 构造函数生成的,结合之前所讲,实例的 proto 指向构造函数的 prototype ,所以我们再更新下关系图:

原型链

那 Object.prototype 的原型呢?

null,我们可以打印:

1
console.log(Object.prototype.__proto__ === null) // true

然而 null 究竟代表了什么呢?null 表示“没有对象”,即该处不应该有值。

所以 Object.prototype.proto 的值为 null 跟 Object.prototype 没有原型,其实表达了一个意思。

所以查找属性的时候查到 Object.prototype 就可以停止查找了。

图中由相互关联的原型组成的链状结构就是原型链,也就是最右侧的这条线。

作用域

作用域是指程序源代码中定义变量的区域。

作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。

JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。

因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。

而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。

让我们认真看个例子就能明白之间的区别:

1
2
3
4
5
6
7
8
9
10
11
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo();
}
bar();

// 结果是 ???

假设JavaScript采用静态作用域,让我们分析下执行过程:

执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码,也就是 value 等于 1,所以结果会打印 1。

假设JavaScript采用动态作用域,让我们分析下执行过程:

执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。

前面我们已经说了,JavaScript采用的是静态作用域,所以这个例子的结果是 1。

上下文

上下文的讲解是翻译的一篇国外大佬的博客,这是链接

执行上下文是评估和执行 JavaScript 代码的环境的抽象概念。每当 Javascript 代码在运行的时候,它都是在执行上下文中运行。

执行上下文的类型

JavaScript 中有三种执行上下文类型。

  • 全局执行上下文 — 这是默认或者说基础的上下文,任何不在函数内部的代码都在全局上下文中。它会执行两件事:创建一个全局的 window 对象(浏览器的情况下),并且设置 this 的值等于这个全局对象。一个程序中只会有一个全局执行上下文。
  • 函数执行上下文 — 每当一个函数被调用时, 都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序(将在后文讨论)执行一系列步骤。
  • Eval 函数执行上下文 — 执行在 eval 函数内部的代码也会有它属于自己的执行上下文,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let a = 'Hello World!';

function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}

function second() {
console.log('Inside second function');
}

first();
console.log('Inside Global Execution Context');

当上述代码在浏览器加载时,JavaScript 引擎创建了一个全局执行上下文并把它压入当前执行栈。当遇到 first() 函数调用时,JavaScript 引擎为该函数创建一个新的执行上下文并把它压入当前执行栈的顶部。

当从 first() 函数内部调用 second() 函数时,JavaScript 引擎为 second() 函数创建了一个新的执行上下文并把它压入当前执行栈的顶部。当 second() 函数执行完毕,它的执行上下文会从当前栈弹出,并且控制流程到达下一个执行上下文,即 first() 函数的执行上下文。

first() 执行完毕,它的执行上下文从栈弹出,控制流程到达全局执行上下文。一旦所有代码执行完毕,JavaScript 引擎从当前栈中移除全局执行上下文。

现在让我们了解 JavaScript 引擎是怎样创建执行上下文的。

创建执行上下文有两个阶段:1) 创建阶段2) 执行阶段

The Creation Phase

在 JavaScript 代码执行前,执行上下文将经历创建阶段。在创建阶段会发生三件事:

  1. this 值的决定,即我们所熟知的 This 绑定
  2. 创建词法环境组件。
  3. 创建变量环境组件。

所以执行上下文在概念上表示如下:

1
2
3
4
5
ExecutionContext = {
ThisBinding = <this value>,
LexicalEnvironment = { ... },
VariableEnvironment = { ... },
}
This 绑定:

在全局执行上下文中,this 的值指向全局对象。(在浏览器中,this引用 Window 对象)。

在函数执行上下文中,this 的值取决于该函数是如何被调用的。如果它被一个引用对象调用,那么 this 会被设置成那个对象,否则 this 的值被设置为全局对象或者 undefined(在严格模式下)。例如:

1
2
3
4
5
6
7
8
9
10
let foo = {
baz: function() {
console.log(this);
}
}
foo.baz(); // 'this' 引用 'foo', 因为 'baz' 被
// 对象 'foo' 调用
let bar = foo.baz;
bar(); // 'this' 指向全局 window 对象,因为
// 没有指定引用对象
词法环境

官方的 ES6 文档把词法环境定义为

词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。

简单来说词法环境是一种持有标识符—变量映射的结构。(这里的标识符指的是变量/函数的名字,而变量是对实际对象[包含函数类型对象]或原始数据的引用)。

现在,在词法环境的内部有两个组件:(1) 环境记录器和 (2) 一个外部环境的引用

  1. 环境记录器是存储变量和函数声明的实际位置。
  2. 外部环境的引用意味着它可以访问其父级词法环境(作用域)。

词法环境有两种类型:

  • 全局环境(在全局执行上下文中)是没有外部环境引用的词法环境。全局环境的外部环境引用是 null。它拥有内建的 Object/Array/等、在环境记录器内的原型函数(关联全局对象,比如 window 对象)还有任何用户定义的全局变量,并且 this的值指向全局对象。
  • 函数环境中,函数内部用户定义的变量存储在环境记录器中。并且引用的外部环境可能是全局环境,或者任何包含此内部函数的外部函数。

环境记录器也有两种类型(如上!):

  1. 声明式环境记录器存储变量、函数和参数。
  2. 对象环境记录器用来定义出现在全局上下文中的变量和函数的关系。

简而言之,

  • 全局环境中,环境记录器是对象环境记录器。
  • 函数环境中,环境记录器是声明式环境记录器。

注意 — 对于函数环境声明式环境记录器还包含了一个传递给函数的 arguments 对象(此对象存储索引和参数的映射)和传递给函数的参数的 length

抽象地讲,词法环境在伪代码中看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
}
outer: <null>
}
}

FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
}
outer: <Global or outer function environment reference>
}
}
变量环境:

它同样是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系。

如上所述,变量环境也是一个词法环境,所以它有着上面定义的词法环境的所有属性。

在 ES6 中,词法环境组件和变量环境的一个不同就是前者被用来存储函数声明和变量(letconst)绑定,而后者只用来存储 var 变量绑定。

我们看点样例代码来理解上面的概念:

1
2
3
4
5
6
7
8
9
10
let a = 20;
const b = 30;
var c;

function multiply(e, f) {
var g = 20;
return e * f * g;
}

c = multiply(20, 30);

执行上下文看起来像这样:

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
GlobalExectionContext = {

ThisBinding: <Global Object>,

LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
}
outer: <null>
},

VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
c: undefined,
}
outer: <null>
}
}

FunctionExectionContext = {
ThisBinding: <Global Object>,

LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>
},

VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
g: undefined
},
outer: <GlobalLexicalEnvironment>
}
}

注意 — 只有遇到调用函数 multiply 时,函数执行上下文才会被创建。

可能你已经注意到 letconst 定义的变量并没有关联任何值,但 var 定义的变量被设成了 undefined

这是因为在创建阶段时,引擎检查代码找出变量和函数声明,虽然函数声明完全存储在环境中,但是变量最初设置为 undefinedvar 情况下),或者未初始化(letconst 情况下)。

这就是为什么你可以在声明之前访问 var 定义的变量(虽然是 undefined),但是在声明之前访问 letconst 的变量会得到一个引用错误。

这就是我们说的变量声明提升。

箭头函数探索

箭头函数与匿名函数的区别

箭头函数看上去是匿名函数的一种简写,但实际上,箭头函数和匿名函数有个明显的区别:箭头函数内部的this是词法作用域,由上下文确定。

回顾前面的例子,由于JavaScript函数对this绑定的错误处理,下面的例子无法得到预期结果:

1
2
3
4
5
6
7
8
9
10
var obj = {
birth: 1990,
getAge: function () {
var b = this.birth; // 1990
var fn = function () {
return new Date().getFullYear() - this.birth; // this指向window或undefined
};
return fn();
}
};

现在,箭头函数完全修复了this的指向,this总是指向词法作用域,也就是外层调用者obj

1
2
3
4
5
6
7
8
9
var obj = {
birth: 1990,
getAge: function () {
var b = this.birth; // 1990
var fn = () => new Date().getFullYear() - this.birth; // this指向obj对象
return fn();
}
};
obj.getAge(); // 25

如果使用箭头函数,以前的那种hack写法:

1
var that = this;

就不再需要了。

由于this在箭头函数中已经按照词法作用域绑定了,所以,用call()或者apply()调用箭头函数时,无法对this进行绑定,即传入的第一个参数被忽略:

1
2
3
4
5
6
7
8
9
var obj = {
birth: 1990,
getAge: function (year) {
var b = this.birth; // 1990
var fn = (y) => y - this.birth; // this.birth仍是1990
return fn.call({birth:2000}, year);
}
};
obj.getAge(2015); // 25

普通函数与箭头函数的区别

箭头函数的this指向:

1. 箭头函数没有prototype,所以箭头函数本身没有this
1
2
const a = () => {};
console.log(a.prototype); // undefined
2. 箭头函数的this指向它定义的时候继承自外层的第一个普通函数的this

这里的定义指的不是我们编码时的定义代码,而是真正运行到定义函数的位置时的在内存中的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
let a, barObj = { msg: 'bar的this指向' };
fooObj = { msg: 'foo的this指向' };
bar.call(barObj); // 将bar的this指向barObj
foo.call(fooObj); // 将foo的this指向fooObj
function foo() {
a(); // 结果:{ msg: 'bar的this指向' }
}
function bar() {
a = () => {
console.log(this, 'this指向定义的时候外层第一个普通函数'); //
}; // 在bar中定义 this继承于bar函数的this指向
}

上述代码中在bar函数中定义箭头函数a,在foo中执行,那么在bar.call()执行的时候传入的对象就是箭头函数this指向的对象,因为bar.call执行的时候定义了箭头函数,而这个时候箭头函数的外层函数的this被call改成了barObj。

3. 我们无法直接改变箭头函数的this指向
1
2
3
4
let fnObj = { msg: '尝试直接修改箭头函数的this指向' };
function foo() {
a.call(fnObj); // 结果:{ msg: 'bar的this指向' }
}

只能通过重新调用定义的外层函数并改变外层函数的this指向来改变

4. 箭头函数外层没有普通函数,严格模式和非严格模式下它的this都会指向window(全局对象)
5. 箭头函数只有[[call]]而没有[[constructor]]属性,所以无法作为构造函数

箭头函数内部没有arguments

箭头函数不能使用yield命令

参考文章

https://juejin.im/post/5c76972af265da2dc4538b64

https://github.com/mqyqingfeng/Blog

https://juejin.im/post/5ba32171f265da0ab719a6d7