this指向&执行上下文&作用域

92 阅读15分钟

JavaScript 执行环境的三座大山

this 指向、执行上下文和作用域这些概念在 JavaScript 中都是紧密相关的,它们共同构成了 JavaScript 的执行环境或作用域机制,让我们来一个个翻越它们。

首先,什么是 this?

this 是 JavaScript 中的一个关键字,它在函数执行时可用,其值基于函数的调用方式动态确定。
我们知道,当一个函数调用时,会创建一个执行上下文,这个上下文包括函数调用的一些信息(调用栈,传入参数,调用方式),this 就指向这个执行上下文。

this 不是静态的,也并不是在编写的时候绑定的,而是在运行时绑定的。它的绑定和函数声明的位置没有关系,只取决于函数调用的方式。

我们可以发现在解释 this 是什么的时候,发现了一个熟悉的名词:执行上下文

我们来重新认识一下它,那什么是执行上下文呢?

执行上下文 是代码产生于编译阶段的,用于描述代码执行的环境。抽象地讲,可以理解为一个运行环境盒子,JavaScript 代码在这个盒子里运行

说到执行上下文我们不免就会想到执行栈,一个个运行环境盒子就存储在执行栈中。

执行栈 可以理解为其他编程语言中的 “调用栈”,是一种拥有 LIFO (后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。

举个例子:

// 例1
function addFirstName(){
    console.log('jack ' + this.surName);
}

function printName(){
    var surName = 'zhao';
    addFirstName();
}

var surName = 'wu';

printName();
  1. 上面这段代码在执行的时候,会先创建一个全局执行上下文,压入执行栈中,index = 0。
  2. 当运行 printName() 时会创建一个函数上下文,再压入执行栈中,index = 1。
  3. 运行到 addFirstName() 时会创建一个函数上下文,再压入执行栈中,index = 2。
  • printName 执行栈图解

printName执行栈图解.png

注:this 并不直接指向执行上下文本身,而是指向执行上下文中的一个特定对象,这个对象取决于函数是如何被调用的。

  • 直接调用形式(作为普通函数被调用),this 指向全局对象
  • 当函数作为对象的一个属性(即方法)被调用时,this 通常指向该对象。但是,这里有一个重要的点要注意:只有当该方法被明确以对象属性的方式调用时,this 才会指向该对象,即 A.B 的形式

答案

jack wu

那如果我们想要让上面的代码输出 'jack zhao',该如何做呢? 两种方法 1.传参 2.闭包

// 例2
function printName(){
    var surName = 'zhao';
    //将'zhao'作为参数传递进去
    function addFirstName(surName){
        console.log('jack ' + surName);
    }

    addFirstName(surName);
}

var surName = 'wu';

printName();
// 例3
function printName(){
    var surName = 'zhao';
    //通过闭包获取'zhao'
    function addFirstName(){
        console.log('jack ' + surName);
    }

    addFirstName(surName);
}

var surName = 'wu';

printName();

细心的朋友可能发现了,在输出 'jack zhao' 的时候我们把 this.surName 里的 this 去掉了,如果加上 this 结果依然会是 'jack wu'。

surName 加上 this 后我们来看这段代码:

// 例4
function printName(){
    var surName = 'zhao';
    function addFirstName(){
        console.log('jack ' + this.surName);
    }
    addFirstName();
}

var surName = 'wu';

printName();

答案

jack wu

通过刚才的图解我们可以知道 addFirstName() 是在 printName() 的执行上下文中的,那为什么 surName 取的是 'wu' 而不是 'zhao' 呢?
其实答案就在文档刚开始的时候,是 this 的锅

this 的指向是基于函数的调用方式而定的,而不是基于函数在哪里被定义。 在上例中,addFirstName() 是在 printName() 内部被调用的,但这一点并不直接影响 addFirstName() 中 this 的指向。也就是说 addFirstName() 它本身是独立的,并不根属printName() ,此时 this 指向 window。

可能这时候你还是有疑问,凭什么 addFirstName() 是在 printName() 内部被定义的,怎么就不能用 addFirstName() 里的 surName 呢?

我们把上面的话换个角度理解:

  • addFirstName() 确实可以通过闭包访问到 printName() 作用域内的变量 surName
  • 但我们要访问的不是 surName ,而是 this 通过根据 作用域链 拿到的结果。
  • 因为 addFirstName()作为普通函数 被调用(也就是 this 属于默认绑定),当你在 addFirstName() 中使用 this.surName 时,你实际上是在尝试访问一个通过 this 指向的对象上的属性 surName ,即 window 对象上的 surName

总结一下

addFirstName() 之所以 “看不到” printName() 作用域里的 surName 变量(使用 this.surName 的方式),是因为 this 并不指向 printName() 的作用域或 printName() 本身,而是指向了全局对象。如果你想让 addFirstName() 访问 printName() 作用域内的 surName 变量,你应该直接使用变量名 surName 而不是 this.surName

好,细心的朋友可能又发现了,我们又谈到一个熟悉的词:作用域链
这里我们把作用域和作用域链一起介绍。

那什么是作用域,什么是作用域链呢?我们先来了解几个概念

作用域:函数身上的属性 [[scope]],用于存储函数中的有效标识符,也就是变量与函数的可访问范围。
  1. 词法作用域:(静态作用域)在函数定义时所在的作用域。(JS 中采用的就是词法作用域
  • 词法作用域的特点:词法作用域在代码编写阶段就确定了,不会在运行时改变;内部作用域可以访问外部作用域中的变量,但外部作用域不能访问内部作用域中的变量。

  • 我们拿例 3 举例:首先执行 printName() 函数,在 printName() 函数中执行 addFirstName() 函数, addFirstName() 函数中输出 'jack' + surName 的值。它首先会查找当前作用域中是否有 surName ,如果没有,则会向外一层查找,在 printName() 函数中找到了 surName 的值,所以输出 'jack zhao' ,如果没有找到的话,则会再向外一层查找(即到了全局作用域),最后会输出 'jack wu'

  1. 全局作用域:在代码任何地方都能访问到的对象拥有全局作用域,具体来说全局作用域指的是在代码中任何函数、类或语句块之外定义的变量和函数的作用域。
  • 最外层函数和在最外层函数外面定义的变量
  • 所有未定义直接赋值的变量
  • window 对象的属性
  1. 函数作用域: 指在函数内部定义的变量和函数只能在该函数内及其嵌套的子函数内被访问,函数外部无法直接访问。

  2. 块级作用域: 指在代码块(如 if 语句、for 循环、while 循环等)内定义的变量和函数只能在该代码块内被访问,一旦离开该代码块,这些变量和函数将不再可用。块级作用域可通过 let 和 const 声明,声明后的变量再指定块级作用域外无法被访问。

作用域链:作用域是执行期上下文对象的集合,这种集合呈链式连接。
  • 当所需要的变量在所在的作用域中查找不到的时候,它会一层一层向上查找,直到找到全局作用域还没有找到的时候,就会放弃查找。这种一层一层的关系,就是作用域链。

  • 不过并不是在调用栈中从上往下查找,而是当前执行上下文变量环境中的 outer 指向来定,而 outer 指向的规则是,我的词法作用域在哪里, outer 就指向哪里。

这时候细心的小伙伴又会发现了,词法作用域是静态的,但是 this 指向是动态的,这不会出现问题吗?

事实上,this 并不与词法作用域直接相关。this 的值取决于函数的调用方式,而不是它的定义方式或它所在的词法作用域。 在同一个函数里时,如果我们没有很好的理解这些概念,就会造成这些我们以为是问题的“问题”,比如我们上文所提的不能输出 'jack zhao' 就是因为这个原因。

小试牛刀

// 例5
var a = 1;
function foo() {
    var a = 2;
    console.log(this.a);
}

var obj = {a: 3, foo}

var obj1 = {
    a: 0,
    obj2: {
        a: 4,
        foo(){
            console.log(this.a)
        }
    }
}

foo();
obj.foo();
obj1.obj2.foo();
var obj3 = obj.foo;
obj3();
var obj4 = { a: 5, foo: obj.foo };
obj4.foo();

答案

1
3
4
1
5

隐式绑定
函数的调用是在某个对象上触发的,即调用位置存在上下文对象,通俗点说就是**XXX.func()**这种调用模式。
此时 func 的 this 指向 XXX,但如果存在链式调用,例如 XXX.YYY.ZZZ.func,记住一个原则:this 永远指向最后调用它的那个对象。

我们发现例 5 中的 var obj3 = obj.foo; obj3() 在赋值后,obj3() 输出的值是 1 而不是 3,
var obj4 = { a: 5, foo: obj.foo }; obj4.foo();obj4.foo() 输出的值是 5 而不是 3 这是为什么呢? 这里涉及到一个新的点:隐式绑定丢失

隐式绑定丢失

隐式绑定丢失之后,this 的指向会启用默认绑定。隐式绑定丢失通常有这两种情况

  • 使用另一个变量作为函数别名,之后使用别名执行函数
  • 将函数作为参数传递时会被隐式赋值
函数别名方式

由于 JavaScript 对于引用类型,其地址指针存放在栈内存中,真正的本体是存放在堆内存中的。
上面将 obj.foo 赋值给 obj3 ,就是将 obj3 也指向了 obj.foo 所指向的堆内存,此后再执行 obj3 ,相当于直接执行的堆内存的函数,与 obj 无关,foo 为默认绑定。笼统的记,只要 fn 前面什么都没有,肯定不是隐式绑定。

函数作为参数传递方式

obj.foo 作为实参,其值赋值给形参 fn ,是将 obj.foo 指向的地址赋给了 fn (也就是上述中 obj4foo ) ,此后 fn 执行不会与 obj 产生任何关系。fn 为默认绑定。


我们来看另一个例子

// 例6
var name = 'conor';
function introduce(){
    console.log('Hello, My name is ', this.name);
}
const Tom = {
    name: 'TOM',
    introduce: function(){
        setTimeout(function(){
            console.log(this)
            console.log('Hello, My name is ',this.name);
        })
    }
}
const Mary = {
    name: 'Mary',
    introduce
}
const Bob = {
    name: 'Bob',
    introduce
}

Tom.introduce();
setTimeout(Mary.introduce, 100);
setTimeout(function(){
    Bob.introduce();
},200);

setTimeout 是异步调用的,只有当满足条件并且同步代码执行完毕后,才会执行它的回调函数。

  • Tom.introduce():执行 console 位于 setTimeout 的回调函数中,回调函数的 this 指向 window 。( 回调函数是作为 setTimeout 的参数传递的,而不是作为某个对象的方法直接调用的。当 setTimeout 的延迟时间到达后,JavaScript 引擎会将回调函数放入事件循环的队列中,等待当前执行栈清空后执行。但是,当这个回调函数最终被执行时,它并没有任何关于它最初是从哪个对象或上下文中来的信息。)
  • Mary.introduce: 直接作为 setTimeout 的函数参数,会发生隐式绑定丢失,this 为默认绑定
  • Bob.introduce(): 执行虽然位于 setTimeout 的回调函数中,但保持 xxx.fn 模式,introduce 中 this 为隐式绑定,指向前面的 xx,即 Bob。

答案

Window {…}
Hello, My name is  conor
Hello, My name is  conor
Hello, My name is  Bob

如果我们想输出 Tom 或者 Mary,那我们该如何做呢?
其实只需略施小计,我们可以事先把对象内 this 储存起来或更改为显示绑定。

// 例7
var name = 'conor';

const Tom = {
    name: 'TOM',
    introduce: function(){
        _self = this
        setTimeout(function(){
            console.log('Hello, My name is ',_self.name);
        })
    }
}
Tom.introduce()
const Mary = {
    name: 'Mary',
    introduce
}

function introduce(){
    console.log(this.name);
}

setTimeout(Mary.introduce().bind(Mary), 100);

小思考:这个 .bind 换成 .call 或者 .apply 是否可以?


细心的你可能已经发现 .bind、 .call 和 .apply 这三位老熟人了,我们再来介绍一下它们吧

三者都可以修改 this 指向,我们把它叫作显式调用

  • .apply ( this 指向,[])
  • .call ( this 指向,x,y,z,...)
  • .bind ( this 指向,x,y,z,...)

区别是

.call 和 .apply 会立即执行原函数,改变函数执行上下文,返回原函数调用的结果。
.bind 不执行原函数,立即返回一个新的函数实例。当新函数被调用时,会按照 bind 方法指定的 this 值和参数来调用函数

我们来个例子和它们叙叙旧

// 例8
var obj = { a: 0 }
var obj1 = { a: 1 }
var obj2 = {
    a: 2,
    bar: function () {
        console.log(this.a)
    },
    foo: function () {
        console.log(this)
        setTimeout(function () {
            console.log(this.a)
        }, 0)
    }
}
var a = 3
function foo () {
    console.log(this.a)
    return function() {
        console.log(this.a)
    }
}

foo()
foo.call(obj)
foo().call(obj)
foo().bind(obj1)
obj2.bar.apply(obj1)
var obj3 = obj2.foo.bind(obj)
obj3()

代码看着有点长,我们来分析一下:

我们以()为间隔符来看

  • foo():为普通调用,this 是默认绑定指向 window,所以执行 this.a 输出 3,并返回一个未执行的函数 Fn
  • foo.call(obj): 使用 call 改变 foo 的指向,this 是显示绑定,指向更新的对象 obj, 所以 this.a 输出 0,并返回一个未执行的函数 Fn
  • foo().call(obj): 先执行 foo,this 是默认绑定指向 window,所以执行 this.a 输出 3,对 foo()的返回值使用 call 改变指向,this 是显示绑定,指向更新的对象 obj, .call 会立即执行原函数, 所以输出 0
  • foo().bind(obj1): 先执行 foo,this 是默认绑定指向 window,所以执行 this.a 输出 3,对 foo()的返回值使用 bind 改变指向,this 是显示绑定,指向更新的对象 obj1, 但由于 bind 不执行原函数,会返回一个 this 指向 obj1 的新的函数实例,所以没有输出值

    小思考:想一下如果这里换成 foo.bind(obj1) 会输出什么?

  • obj2.bar.apply(obj1):为对象函数调用,this 为隐式绑定,指向 obj2。但由于使用 apply 改变指向,this 为显示绑定,指向更新的对象 obj1,.apply 会立即执行原函数, 所以输出 1
  • obj3():通过显示绑定将 obj2.foo() 的 this 指向了 obj 。故第一个 console 输出 { a: 0 } ,setTimeout 里的 this 在回调函数中,为默认绑定指向 window,故输出 3

答案

3
0
3
0
3
1
{a: 0}
3

对于对象里的 setTimeout,我们可不可以更改它的 this 指向呢?
答案也是可以的,我们拿 obj2 举例

// 例9
var obj = { a: 0 }
var obj1 = { a: 1 }
var obj2 = {
    a: 2,
    bar: function () {
        console.log(this.a)
    },
    foo: function () {
        console.log(this)
        setTimeout(function () {
            console.log(this.a)
        }.call(obj1), 0)
    }
}
obj2.foo()
var obj3 = obj2.foo.bind(obj)
obj3()
  • 这里解释一下 obj3() 为什么也会输出 1。是因为 .bind 更改的是 obj2.foo 的指向,.call 改变的是 setTimeout 中回调函数的 this 指向,二者并不冲突。

答案

{a: 2, bar: bar(), foo: foo()}
1
{a: 0}
1

“大”试牛刀

打 boos 之前我们先来升级下技能

1. "提升"
// 例10
printNum();
var printNum = function(){ console.log(1); };
function printNum(){ console.log(2) };
printNum();
  • 这段代码在执行 printNum() 之前会进行 “函数提升” 和 “变量提升”, 且 “函数提升” 的优先级最高。
  • “提升” 会将函数声明和变量声明提升到其所在作用域顶部
//提升以后的代码
function printNum(){ console.log(2) };
var printNum //还未赋值 此时是undefined
printNum();
printNum = function(){ console.log(1); };
printNum();
  • 同时 存在函数声明和同名的变量声明时,函数声明会 “覆盖” 变量声明。但这里说的“覆盖”并不是在运行时发生的,而是在编译/解析阶段就确定了。函数声明会优先于同名的变量声明被处理,因此在作用域中,函数声明会 “隐藏” 同名的变量声明(直到变量被赋值)。
  • 执行流到达 printNum = function(){ console.log(1); }; 此时,printNum 变量被赋予了一个新的函数值。这个赋值操作发生在第一个 printNum() 调用之后,但它会 “覆盖” 之前由函数声明创建的同名函数。

    注:变量提升/函数提升发生在全局执行上下文创建期间,所以它不会改变执行上下文的结构

答案

2
1
2. New

使用 new 来构建函数,会执行如下四部操作:

  • 创建一个空的简单 JavaScript 对象(即{});
  • 为步骤 1 新创建的对象添加属性 _proto_,将该属性链接至构造函数的原型对象 ;
  • 执行构造函数,并将 this 指向新对象
  • 返回新对象
// 例11
function User (name, age) {
  this.name = name;
  this.age = age;
  this.introduce = function () {
    console.log(this.name)
  }
  this.howOld = function () {
    return function () {
      console.log(this.age)
    }
  }
}
var name = 'Tom';
var age = 18;
var cz = new User('cz', 24)
cz.introduce()
cz.howOld()
  • cz.introduce() : cz 是 new 创建的实例对象,this 为隐式绑定,指向 cz ,console 打印 cz
  • cz.howOld() : cz.howOld() 返回一个匿名函数,匿名函数为默认绑定,因此 console 打印 18 (大家永远 18)

相信大家经过这些例子已经有了深刻的体会, 接下来我们来道综合题

function Foo(){
    printNum = function(){ console.log(1); };
    return this;
}
Foo.printNum = function(){ console.log(2); };
Foo.prototype.printNum = function(){ console.log(3); };
var printNum = function(){ console.log(4); };
function printNum(){ console.log(5) };

Foo.printNum();
printNum();
Foo().printNum();
printNum();
new Foo.printNum();
new Foo().printNum();
  • 首先我们先把提升后的代码写出来
// 例12
function Foo(){
    printNum = function(){ console.log(1); };
    return this;
}
function printNum(){ console.log(5) };
var printNum
Foo.printNum = function(){ console.log(2); };
Foo.prototype.printNum = function(){ console.log(3); };
printNum = function(){ console.log(4); };

Foo.printNum();
printNum();
Foo().printNum();
printNum();
new Foo.printNum();
new Foo().printNum();
  • 我们还是以()为间隔来分析
  • Foo.printNum():执行 Foo 对象上的 printNum 方法,打印 2
  • printNum():执行为变量赋值的函数,它“覆盖”了之前由函数声明创建的同名函数, 打印 4
  • Foo().printNum():执行 Foo() 函数 中的 printNum() 打印 1 , 同时由于 printNum 是未定义直接赋值的变量所以它会变成一个全局变量,将全局中原本的 printNum 的值覆盖为 function(){ console.log(1); }
  • printNum()printNum() 执行 function(){ console.log(1); } ,打印 1

  • 在看下面的输出之前我们先来了解下运算符优先级的关系
    运算符优先级图解 运算符优先级.png

    从上图可以看到,部分优先级如下:new(带参数列表) = 成员访问 = 函数调用 > new (不带参数列表)


  • new Foo.printNum():首先从左往右看:new Foo 属于不带参数列表的 new (优先级 17),Foo.printNum 属于成员访问(优先级 18), printNum() 属于函数调用 (优先级 18),同样优先级遵循从左往右执行。
    1. Foo.printNum 执行,获取到 Foo 上的 printNum 属性
    2. 此时原表达式变为 new (Foo.printNum)()new (Foo.printNum)() 为带参数列表 (优先级 18),(Foo.printNum)() 属于函数调用 (优先级 18),从左往右执行
    3. new (Foo.printNum)() 执行,打印 2,并返回一个以 Foo.printNum() 为构造函数的实例
  • new Foo().printNum():先执行 new Foo() ,返回一个以 Foo 为构造函数的实例, Foo 的实例对象上没有 printNum 方法,沿原型链查找到 Foo.prototype.printNum 方法,打印 3

    小思考:为什么 Foo 的实例对象上没有 printNum 方法?

答案

2
4
1
1
2
3