【浏览器工作原理与实践】4. 浏览器中的 JavaScript 执行机制

JavaScript 代码执行流程

下面我们将 var a = 2; 分解,看看引擎和它的朋友们是如何协同工作的。编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。

  1. 编译期:遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a。
  2. 执行期:接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的 变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量(查看 1.3 节)。如果引擎最终找到了 a 变量,就会将 2 赋值给它。否则引擎就会举手示意并抛出一个异常!

编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量 a 来判断它是否已声明过。查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查找结果。 在我们的例子中,引擎会为变量 a 进行 LHS 查询。另外一个查找的类型叫作 RHS 查询

当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。

注:LHS 和 RHS 的含义是 “赋值操作的左侧或右侧” 并不一定意味着就是 “= 赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最 好将其理解为 “赋值操作的目标是谁(LHS)” 以及 “谁是赋值操作的源头 (RHS)”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var a = 2; // a 是赋值操作的目标 => LHS
var b = a; // b 是赋值操作的目标,a 是赋值操作的源头,
// 对b进行赋值操作 => LHS,查找变量 a => RHS
--------------------------
function foo(a) {
var b = a;
return a + b; }
var c = foo( 2 );

1. 找出所有的 LHS 查询(这里有 3 处!)
c = ..;
a = 2(隐式变量分配)
b = ..

2. 找出所有的 RHS 查询(这里有 4 处!)
foo(2..
= a;
a ..
.. b

LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域,沿着作用域链向上查找,最后抵达全局作用域,无论找到或没找到都将停止。

不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式 地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)。

总结:变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量 a(LHS 查询),如果能够找到就会对它赋值。

—— 《你不知道的 JavaScript(上卷)》第一部分第一章

变量提升(Hoisting)

我们习惯将 var a = 2; 看作一个声明,而实际上 JavaScript 引擎并不这么认为。它将 var a 和 a = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。

这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被 “移动” 到各自作用域的最顶端,这个过程被称为提升

变量提升有如下几个特点:

  • 声明本身会被提升,函数会首先被提升,然后才是变量
  • 而包括函数表达式的赋值在内的赋值操作并不会提升
  • 尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的
1
2
3
foo(); // 不是 ReferenceError, 而是 TypeError!
bar(); // ReferenceError
var foo = function bar() { // ... };

这段程序中的变量标识符 foo () 被提升并分配给所在作用域(在这里是全局作用域),因此 foo () 不会导致 ReferenceError。但是 foo 此时并没有赋值(如果它是一个函数声明而不 是函数表达式,那么就会赋值)。foo () 由于对 undefined 值进行函数调用而导致非法操作, 因此抛出 TypeError 异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
foo(); // 2
function foo() { console.log( 1 ); };
function foo() { console.log( 2 ); }; // 重复的函数声明,覆盖前面的
----------------
console.log(foo); // undefined 变量提升,声明变量foo,但是没有赋值,所以输出undefined
var foo = 1
var foo = 2
console.log(foo); // 2 重复的变量声明,覆盖前面的
----------------
foo(); // 3
var foo = function() { console.log( 2 ); };
function foo() { console.log( 1 ); } // 函数先被提升
function foo() { console.log( 3 ); } // 覆盖前面的函数声明

—— 《你不知道的 JavaScript(上卷)》第一部分第四章

模拟变量提升示意图
模拟变量提升示意图

JavaScript 的执行流程图
JavaScript 的执行流程图

执行上下文

参考文章:JavaScript 深入之变量对象

调用栈

JavaScript 引擎正是利用栈来管理执行上下文。在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出。

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = 2

function add(b,c){
return b+c
}

function addAll(b,c){
var d = 10
result = add(b,c)
return a+result+d
}

addAll(3,6)

调用栈的状态变化情况:

第一步,创建全局上下文,并将其压入栈底。

全局执行上下文压栈
全局执行上下文压栈

从图中你也可以看出,变量 a、函数 add 和 addAll 都保存到了全局上下文的变量环境对象中。

全局执行上下文压入到调用栈后,JavaScript 引擎便开始执行全局代码了。

首先会执行 a=2 的赋值操作,执行该语句会将全局上下文变量环境中 a 的值设置为 2。设置后的全局上下文的状态如下图所示:

赋值操作改变执行上下文中的值
赋值操作改变执行上下文中的值

第二步是调用 addAll 函数

当调用该函数时,JavaScript 引擎会编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈中。

执行 addAll 函数时的调用栈
执行 addAll 函数时的调用栈

这里先执行的是 d=10 的赋值操作,执行语句会将 addAll 函数执行上下文中的 d 由 undefined 变成了 10。

第三步,当执行到 add 函数

执行 add 函数时的调用栈
执行 add 函数时的调用栈

当 add 函数返回时,该函数的执行上下文就会从栈顶弹出,并将 result 的值设置为 add 函数的返回值,也就是 9。如下图所示:

add 函数执行结束时的调用栈
add 函数执行结束时的调用栈

紧接着 addAll 执行最后一个相加操作后并返回,addAll 的执行上下文也会从栈顶部弹出,此时调用栈中就只剩下全局上下文了。最终如下图所示:

addAll 函数执行结束时的调用栈
addAll 函数执行结束时的调用栈

调用栈是 JavaScript 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各函数之间的调用关系。

参考文章:JavaScript 深入之执行上下文栈

如何利用浏览器查看调用栈的信息

  • 通过断点来查看调用栈
  • 使用 console.trace () 来输出当前的函数调用关系
使用 trace 函数输出当前调用栈信息
使用 trace 函数输出当前调用栈信息

栈溢出

调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出。

作用域

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期

在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域:

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。全局变量在页面关闭后销毁。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

和 Java、C/C++ 不同,ES6 之前是不支持块级作用域的,因为当初设计这门语言的时候,所以只是按照最简单的方式来设计。没有了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是 JavaScript 中的变量提升

  • 块级作用域:代码块内部定义的变量在代码块外部是访问不到,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。

变量提升所带来的问题

1. 容易引起命名冲突、变量被覆盖的问题

1
2
3
4
5
6
7
8
9
var myname = "极客时间"
function showName(){
console.log(myname); // undefined
if(0){
var myname = "极客邦"
}
console.log(myname); // undefined
}
showName()
开始执行 showName 函数时的调用栈
开始执行 showName 函数时的调用栈

showName 函数的执行上下文创建后,JavaScript 引擎便开始执行 showName 函数内部的代码了。首先执行的是:

1
console.log(myname); // 先使用函数执行上下文里面的变量 myname,但是没有赋值,所以输出 undefined

2. 本应销毁的变量没有被销毁

1
2
3
4
5
6
function foo(){
for (var i = 0; i < 7; i++) {
}
console.log(i); // 7
}
foo()

在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁。

let 和 const 关键字

为了解决上面的问题,ES6 引入了 let 和 const 关键字。使用 let 关键字声明的变量是可以被改变的,而使用 const 声明的变量其值是固定的 (常量),之后任何试图修改值的操作都会引起错误,两者都可以生成块级作用域

优点:

  1. 隐藏内部实现。根据最小暴露 / 特权原则,这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都 “隐藏” 起来,这个原则可以延伸到如何选择作用域来包含变量和函数。
  2. 避免同名标识符之间的冲突。全局作用域的两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致变量的值被意外覆盖。
  3. 规避污染全局命名空间。当使用 var 声明变量时,它写在哪里都是一样的,因为它们最终都会属于外部作用域。

let 关键字可以将变量绑定到所在的任意作用域中(通常是 {..} 内部)。换句话说,let 为其声明的变量 > 隐式地了所在的块作用域。只要声明是有效的,在声明中的任意位置都可以使用 { .. } 括号来为 let 创建 > 一个用于绑定的显式的块。

1
2
3
4
5
6
7
8
9
10
var foo = true;
if (foo) {
{
// <-- 显式的快
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
}
console.log( bar ); // ReferenceError

let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不 “存在”。

1
2
3
4
{ 
console.log( bar ); // ReferenceError: a is not defined
let bar = 2;
}

在块作用域内,let 声明的变量被提升,但变量只是创建被提升,初始化并没有被提升,在初始化之前使用变量,就会形成一个暂时性死区。

** 暂时性死区 (temporal dead zone,简称 TDZ)**:当程序的控制流程在新的作用域(module function 或 block 作用域)进行实例化时,在此作用域中用 let/const 声明的变量会先在作用域中被创建出来,但因此时还未进行词法绑定,所以是不能被访问的,如果访问就会抛出错误。参考 ECMA 262 文档

1
2
3
4
5
6
7
8
9
10
11
12
13
// --------例子2-------------
{
let a = 2;
console.log( a );// 2
}
console.log( a ); // ReferenceError
// 在ES6之前,使用try catch(ES3)达到这个效果:
try{
throw 2;
}catch(a){
console.log( a ); // 2
}
console.log( a ); // ReferenceError

for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

1
2
3
4
5
6
7
8
9
10
11
12
for (let i = 0; i < 10; i++) { 
console.log( i );
}
console.log( i ); // ReferenceError
// 执行过程:
{
let j;
for (j=0; j<10; j++) {
let i = j; // 每个迭代重新绑定!
console.log( i );
}
}

由于 let 声明附属于一个新的作用域而不是当前的函数作用域(也不属于全局作用域), 当代码中存在对于函数作用域中 var 声明的隐式依赖时,就会有很多隐藏的陷阱,如果用 let 来替代 var 则需要在代码重构 > 的过程中付出额外的精力。

考虑以下代码:

1
2
3
4
5
6
7
var foo = true, baz = 10;
if (foo) {
var bar = 3;
if (baz > bar) {
console.log( baz );
}// ...
}

这段代码可以简单地被重构成下面的同等形式:

1
2
3
4
5
6
7
var foo = true, baz = 10;
if (foo) {
var bar = 3; // ...
}
if (baz > bar) {
console.log( baz );
}

但是在使用块级作用域的变量时需要注意以下变化:

1
2
3
4
5
6
7
var foo = true, baz = 10;
if (foo) {
let bar = 3;
if (baz > bar) { // <-- 移动代码时不要忘了 bar!
console.log( baz );
}
}

—— 《你不知道的 JavaScript(上卷)》第一部分第三章

词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

词法作用域
词法作用域

整个词法作用域链的顺序是:foo 函数作用域 —> bar 函数作用域 —> main 函数作用域 —> 全局作用域。

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

大部分标准语言编译器的第一个工作阶段叫作词法化(也叫单词化)。回忆一下,词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。

简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息 来查找标识符的位置。

作用域气泡
作用域气泡

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作 “遮蔽效应”(内部的标识符 “遮蔽” 了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。 window.a 通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到。

JavaScript 中有两个机制可以 “欺骗” 词法作用域:eval (..) 和 with。前者可以对一段包含一个或多个声明的 “代码” 字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。

这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

—— 《你不知道的 JavaScript(上卷)》第一部分第二章

参考文章:JavaScript 深入之词法作用域和动态作用域

作用域链

当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级 (词法层面上的父级) 执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

JavaScript 语言的作用域链是由词法作用域决定的,而词法作用域是由代码结构来确定的。

1
2
3
4
5
6
7
8
9
function bar() { 
console.log(myName)
}
function foo() {
var myName = "极客邦"
bar()
}
var myName = "极客时间"
foo()

其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。当一段代码使用了一个变量时,JavaScript 引擎首先会在 “当前的执行上下文” 中查找该变量,比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。我们把这个查找的链条就称为作用域链。

在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。

带有外部引用的调用栈示意图
带有外部引用的调用栈示意图

参考文章:JavaScript 深入之作用域链

闭包

1
2
3
4
5
6
7
8
9
10
11
12
13
var fn;
function foo() {
var a = 2;
function baz() {
console.log( a );
}
fn = baz; // 将 baz 分配给全局变量
}
function bar() {
fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

1
2
3
4
5
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}

这段代码在运行时会以每秒一次的频率输出五次 6,输出显示的是循环结束时 i 的最终值。

尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i,所有函数共享一个 i 的引用。

改造一下这段代码,使其符合我们的预期:

  1. IIFE 会通过声明并立即执行一个函数来创建作用域

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    for (var i=1; i<=5; i++) { 
    (function(j) {
    setTimeout( function timer() {
    console.log( j );
    }, j*1000 );
    })( i );
    }
    -------------------------------
    for (var i=1; i<=5; i++) {
    let j = i; // 是的,闭包的块作用域!
    setTimeout( function timer() {
    console.log( j );
    }, j*1000 );
    }

    在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

  2. 使用 let 声明,可以用来劫 持块作用域,并且在这个块作用域中声明一个变量。

    1
    2
    3
    4
    5
    for (let i=1; i<=5; i++) { 
    setTimeout( function timer() {
    console.log( i );
    }, i*1000 );
    }

再看这段代码:

1
2
3
4
5
6
7
8
9
10
11
var data = [];

for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}

data[0]();
data[1]();
data[2]();

当执行到 data [0] 函数的时候,for 循环已经执行完了,i 是全局变量,此时的值为 3。

1
2
for (var i = 0; i < 3; i++) {}
console.log(i) // 3

在 for (…){} 中,括号中的内容属于外部域,花括号内是内部域

参考文章:JavaScript 深入之闭包

this

执行上下文中的 this
执行上下文中的 this

从图中可以看出,this 是和执行上下文绑定的,也就是说每个执行上下文中都有一个 this。

全局执行上下文的 this

全局执行上下文中的 this 是指向 window 对象的。这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。

函数执行上下文的 this

设置函数执行上下文中的 this:

  1. call

    在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var myName = '极客'
    let bar = {
    myName : "极客邦",
    test1 : 1
    }
    function foo(){
    this.myName = "极客时间"
    }
    foo.call(bar) // 将 foo 函数内部的 this 绑定到 bar 对象
    console.log(bar) // 极客时间
    console.log(myName) // 极客
  2. bind

  3. apply

  4. 通过对象调用方法设置:

    通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    var name = '极客'
    var myObj = {
    name : "极客时间",
    showThis: function(){
    console.log(name) // 极客
    console.log(this.name) // 极客时间,this指向 myObj 对象
    }
    }
    myObj.showThis()
  5. 通过构造函数中设置

    通过 new 关键字构建好了一个新对象,并且构造函数中的 this 其实就是新对象本身。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    var name = '极客'
    function CreateObj(){
    this.name = "极客时间"
    }
    CreateObj() // 直接调用函数
    console.log(myObj.name) // 极客时间
    console.log(name) // 极客时间

    -------------------------

    var name = '极客'
    function CreateObj(){
    this.name = "极客时间"
    }
    var myObj = new CreateObj() // 通过 new 关键字构建新对象
    console.log(myObj.name) // 极客时间
    console.log(name) // 极客
    • 首先创建了一个空对象 tempObj
    • 接着调用 CreateObj.call 方法,并将 tempObj 作为 call 方法的参数,这样当 CreateObj 的执行上下文创建时,它的 this 就指向了 tempObj 对象
    • 然后执行 CreateObj 函数,此时的 CreateObj 函数执行上下文中的 this 指向了 tempObj 对象
    • 最后返回 tempObj 对象

    代码演示:

    1
    2
    3
    var tempObj = {} 
    CreateObj.call(tempObj)
    return tempObj

this 的设计缺陷以及应对方案

嵌套函数中的 this 不会从外层函数中继承

1
2
3
4
5
6
7
8
9
10
11
var myObj = { 
name : "极客时间",
showThis: function(){
console.log(this) // this 指向 myObj 对象
function bar(){
console.log(this) // this 指向全局 window 对象
}
bar()
}
}
myObj.showThis()

你现在应该知道了 this 没有作用域的限制,这点和变量不一样,所以嵌套函数不会从调用它的函数中继承 this,这样会造成很多不符合直觉的代码。要解决这个问题,你可以有两种思路:

  • 第一种是把 this 保存为一个 self 变量,再利用变量的作用域机制传递给嵌套函数
  • 第二种是继续使用 this,但是要把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this。
声明一个变量 self 用来保存 this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var name = '极客'
var myObj = {
name : "极客时间",
showThis: function(){
console.log(this) // this 指向 myObj 对象
var self = this // 保存 this, self 指向 myObj 对象
function bar(){
self.name = "极客邦" // => 更改的是 myObj.name
}
bar()
}
}
myObj.showThis()
console.log(myObj.name) // 极客邦
console.log(window.name) // 极客
这个方法的的本质是把 this 体系转换为了作用域的体系。
通过 ES6 的箭头函数,继承调用函数中的 this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var name = '极客'
var myObj = {
name : "极客时间",
showThis: function(){
console.log(this) // this 指向 myObj 对象
var bar = () => { // 箭头函数没有自己的执行上下文,所以箭头函数的 this 就是它外层函数的 this
this.name = "极客邦" // => myObj.name = 极客邦
console.log(this) // = showThis() 函数的 this = myObj 对象
}
bar()
}
}
myObj.showThis()
console.log(myObj.name) // 极客邦
console.log(window.name) // 极客
ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。

普通函数中的 this 默认指向全局对象 window

上面我们已经介绍过了,在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。

不过这个设计也是一种缺陷,因为在实际工作中,我们并不希望函数执行上下文中的 this 默认指向全局对象,因为这样会打破数据的边界,造成一些误操作。如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。

这个问题可以通过设置 JavaScript 的 “严格模式” 来解决。在严格模式下,默认执行一个函数,其函数的执行上下文中的 this 值是 undefined,这就解决上面的问题了。

总结

  • 当函数作为对象的方法调用时,函数中的 this 就是该对象
  • 在全局环境中调用一个函数,在严格模式下,this 值是 undefined,非严格模式下函数内部的 this 指向的是全局变量 window,如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用
  • 嵌套函数中的 this 不会继承外层函数的 this 值,可以声明变量 self = this,利用变量的作用域机制保存 this、利用 ES6 的箭头函数来规避这一特性,箭头函数没有自己的执行上下文,所以箭头函数的 this 就是它外层函数的 this。

参考文章:JavaScript 深入之从 ECMAScript 规范解读 this