一探究竟!JS中的this到底指向谁

219 阅读13分钟

在JS中,有许多关键字。今天,我们就来一探究竟this这个关键字。

1. this存在的意义

我们先来通过一个例子认识一下this。

function identify(context) {
    return context.name.toUpperCase()
}
function speak(context) {
    var gretting = 'Hello, I am ' + identify(context)
    console.log(gretting);
}

var me = {
    name: 'Tom'
}

speak(me)

我们先定义了一个函数speak,里面var了一个变量gretting,值为'Hello, I am '。我们想让它输出'Hello, I am Tom'并且想让Tom变成大写。

那我们就再定义一个对象me,里面存一个key为name值为‘Tom’的属性,让它作为实参传给函数speak的形参context。

我们又想让‘Tom’变成大写的‘TOM’,于是我们又写了一个函数identify,让它返回context.name的大写。

我们来看看输出结果。

image.png

这样就达到了我们想要的效果。

但我们一眼看上去这段代码,我们用了好几个形参和实参,当代码和函数数量越来越多时,可能会造成混乱。有没有什么更简洁的方法呢?

我们可以用this关键字改造一下这段代码。

function identify() {
    return this.name.toUpperCase()
}

function speak() {
    var gretting = 'Hello, I am ' + identify.call(this)
    console.log(gretting);
}

var me = {
    name: 'Tom'
}

speak.call(me)

我们先来直观的感受一下this的作用,之后你就能看懂这段代码。这样也能达成一样的效果。

这就是this存在的一个重大意义:this 让函数可以自动引用合适的上下文对象

接下来我们就来详细的剖析一下this。

2. this的指向

我们先在v8引擎中输出一下this,看看它的输出结果。

image.png

我们发现,直接在全局中输出这个this,它会指向window,也就是全局。

所以对于this,我们有两个结论:

  1. this 是一个代词,在JS中永远代指某一个域,且this只存在于域中才有意义
  2. this 在全局下指向的是 window

2.1 默认绑定

了解了这两个结论之后,我们来探一下this在其它场景下的指向。

function foo() {
    let person = {
        name: '阿美',
        age: 18
    }
    console.log(this);
}
foo()

我们定义了一个函数foo,在它里面又定义一个对象person,给对象添加了两个属性,然后在foo里面输出this,在全局触发掉这个函数foo。

请问此时this指向的是谁?我们刚刚在全局试了一下输出this,发现它是指向全局的。那么在这里,我们在foo的作用域里输出了this,它会指向foo吗?我们来看一下效果。

image.png

我们发现,this指向的还是全局,说明this的指向规则远没有这么简单。那么它到底是怎么指向的呢?

我们再来扩展一下上面那段代码。

function foo() {
    let person = {
        name: '阿美',
        age: 18
    }
    console.log(this);
}

function bar() {
    let person = {
        name: '管总',
        age: 18
    }
    foo()
}
bar()

我们又写一个函数bar,里面同样定义一个对象person。注意此时我们把函数foo拿到函数bar中调用了,上一次我们是在全局中调用了函数foo,这次我们在函数作用域中调用foo会有什么不一样吗?this到底会指向谁呢?

image.png

我们发现this依旧指向的是全局。

这是为什么呢?其实this的指向完全取决于拥有它的函数是怎么被调用的

什么意思呢?我们发现,在上面两段代码里,不管函数foo写在了哪个域里,他都是被独立调用的。独立调用指的是函数自己触发掉了,没有对象去调用它,如:obj.foo(),这就不叫独立调用。

当一个函数是独立调用的,它里面的this一定指向全局,与函数写在哪里没有关系。

所以this的第一种指向:默认绑定:函数独立调用,this指向window

2.2 隐式绑定

那要是函数不是独立调用的,this是如何指向的呢?我们来看一下。

function foo() {
    console.log(this);
}

const obj = {
    a: 1,
    foo: foo
}

obj.foo()

我们写了一个函数foo,里面输出this,我们说this就是属于函数foo的,然后又定义了一个对象obj,里面有两个属性:一个key为a值为1;另一个key为foo值为函数foo。此时函数foo就是属于对象obj的了,我们obj.foo()去调用这个函数,这就是函数的非独立调用。那么this会指向谁呢?

我们来看一下输出结果。

屏幕截图 2024-11-20 234212.png

我们发现输出的this指向了对象obj,里面是一个a和函数foo。

这就是this的第二种指向:隐式绑定:当函数的引用有上下文对象时(当函数被某一个对象所拥有且调用),this指向该上下文对象

这种隐式绑定有一个特点,叫隐式丢失。我们来看一下。

function foo() {
    console.log(this);
}

const obj = {
    a: 1,
    foo: foo
}

const obj2 = {
    a: 2,
    obj: obj
}

obj2.obj.foo()

我们又定义了一个对象obj2,给它添加了两个属性:一个key为a值为2;一个key为obj值为对象boj。此时我们去调用函数foo,写成obj2.obj.foo()。那么此时,this指向谁呢?foo现在也是非独立调用。那么它是指向obj还是obj2呢?我们来看一下结果。

image.png

我们发现,this指向的还是obj。

这就是隐式绑定的特点:隐式丢失:当函数的引用有一连串的上下文对象,this指向最近的那个对象。俗称:就近原则

2.3 显示绑定

我们再来介绍this的第三种指向:显示绑定。这种绑定方法可以人为的去改变this的指向。我们通过一个例子来看一下。

function foo() {
    console.log(this.a);
}

var obj = {
    a: 1,
}

foo()

我们写了一个函数foo,里面输出this.a。还写了一个对象obj,里面有个属性a值为1.然后在全局调用了这个函数。我们现在知道,此时foo触发的是默认绑定,因为它是独立调用的,所以this指向window,而在window中没有a,所以输出结果应该是undefined。

image.png

那如果我们想让this指向obj应该怎么做呢?我们这样做。

function foo() {
    console.log(this.a);
}

var obj = {
    a: 1,
}

foo.call(obj)

JS官方给我们提供了一个方法call,它能强行的把foo里面的this指向obj,就是这么简单粗暴。所以此时输出结果就为1了。

image.png

那如果函数foo有接收参数呢?我们就这样使用。

function foo(x, y) {
    console.log(this.a, x + y);
}

var obj = {
    a: 1,
}

foo.call(obj, 2, 3)

直接在方法call后面加上你要接收的参数。所以输出结果应该是1和5。

image.png

显示绑定还有两种方法可以使用。方法apply和方法bind。它们是怎么使用的呢?

function foo(x, y) {
    console.log(this.a, x + y);
}

var obj = {
    a: 1,
}

// foo.call(obj, 2, 3)

foo.apply(obj, [2, 3])

当函数foo有接收参数时,我们用数组接收参数放在apply里,输出结果还是1和5。再来看方法bind。

function foo(x, y) {
    console.log(this.a, x + y);
}

var obj = {
    a: 1,
}

// foo.call(obj, 2, 3)

// foo.apply(obj, [2, 3])

let bar = foo.bind(obj)
bar(2, 3)

用方法bind返回的是一个函数体,我们用一个变量bar去接受这个函数,然后调用这个函数。当函数foo有接收参数时,我们既可以在bind里面接收这个参数,也可以在bar里接收参数,而在bind里没找到它才会去bar里找,bind的优先级更高。

这就是this的第三种指向:显示绑定:call、apply、bind 显示的将函数的this绑定到一个对象上。

2.4 new 绑定

new这个关键字也能去绑定。

我们在原型那篇文章中提到过new创建实例对象的四个步骤:

1. 创建一个this对象

2. 让构造函数中的逻辑正常执行,这就相当于往this对象上添加属性

3. 让this对象上的__proto__= 构造函数的prototype

4. return this对象

但其实有5个步骤,因为当时没有提到this,所以省略了一个步骤。

我们再来分析一下new的实现原理,它是怎么去绑定this的呢?

function Person() {
    this.name = '阿伟'
    this.age = 18
}
let p = new Person()

console.log(p);

我们创建了一个构造函数Person,然后new调用这个构造函数创建一个实例对象p,这里有一个小细节要提。当构造函数返回的是引用类型(数组、函数、对象)时,new的执行结果就是这个引用类型的数据。

我们来分析一下new的执行原理。

function Person() {
    // let obj = {
    //     name: '阿伟',
    //     age: 18
    // }
    // Person.call(obj)
    // obj.__proto__ = Person.prototype
    // 判断 Person() 值如果是引用类型,则采用
    // return Person() instanceof 'object' ? Person() : obj
    this.name = '阿伟'
    this.age = 18
}
let p = new Person()

console.log(p);

首先new会在构造函数里创建一个空对象,我们姑且叫它obj,然后构造函数里的代码正常运行。this.name = '阿伟'、this.age = 18,我们想让属性name和age出现在obj里,所以我们就要让this指向obj,而this是属于Person的,所以在这里我们用显示绑定,让Person里的this指向obj。于是构造函数里的代码正常运行,对象obj里就多了两个属性。

然后让对象的隐式原型对于构造函数的显示原型,此时判断一下构造函数的返回结果,如果是引用类型,则采用。最后就返回对象obj。于是变量p就被赋值成一个实例对象了。

1. 创建一个空对象obj

2. 将构造函数里的this指向obj

3. 正常运行构造函数里的代码

4. obj的隐式原型等于构造函数的显示原型

5. 返回obj

这就是new在创建实例对象执行的改变this指向的操作。

它会让构造函数里的this指向实例对象

2.5 开头代码复盘

了解完这四种this指向后,我们再回到文章开头的那段代码,现在你一定能够看得懂了。

function identify() {
    return this.name.toUpperCase()
}

function speak() {
    var gretting = 'Hello, I am ' + identify.call(this)
    console.log(gretting);
}

var me = {
    name: 'Tom'
}

speak.call(me)

我们首先执行了speak.call(me),将speak中的this指向了me。所以speak中的this就为me。然后在speak里面,又执行了identify.call(this),此时speak中的this相当于me,将identify里面的this又指向了me,所以identify里的this就为me。所以在identify里可以去调用me。

3. 箭头函数

我们得再来介绍一下箭头函数,它有点特殊。我们来看一下。

function foo() {
    let bar = function () {
        let baz = () => {
        console.log(this);
        }
        baz()
    }
    bar()
}
foo()

我们定义了一个函数foo,在函数foo里又定义了一个函数bar,在bar里又定义了一个箭头函数baz,在baz里面输出this。我们说过,要想搞清楚this的指向,首先得搞清楚this是属于谁的。而在这里,虽然this写在了baz里,但它并不是属于baz的,它是属于bar的。因为箭头函数是没有this的。bar是独立调用的,所以this指向全局。

所以箭头函数的第一个特点:箭头函数没有this,写在了箭头函数中的this指向外部非箭头函数的this

let Foo = () => {
    this.name = '廖总'
}
let foo = new Foo()

我们再来看这段代码,我们创建了一个函数Foo当构造函数去使用了。但我们刚刚说过,在箭头函数里没有this这个概念,而我们知道,在new的执行原理里就得用到this去给创建的空对象赋值。所以在这里,这段代码会报错,因为箭头函数里没有this,所以它就不能当作构造函数去使用。

image.png

箭头函数的第二个特点:箭头函数不能作为构造函数使用

4. 方法call的源代码

我们知道,方法call可以强行改变this的指向。那么它到底是怎么实现的呢?

我们一起来写一下它的源代码。

function foo(x) {
    console.log(this.a, x);
}
const obj = {
    a: 1
}

Function.prototype.myCall = function () {
    
    
    
}

foo.myCall(obj, 2, 3, 4)

我们写一个函数myCall来充当官方的call。应该怎么写呢?

我们先用自己的话来分析一下这个过程。我们想用myCall这个方法去让foo中的this指向obj,那在这里是不是就得用到隐式绑定了。我们得想个办法让obj去调用foo,这样foo就是非独立调用了,foo中的this就会指向obj。最后我们还得移除掉obj上的foo,因为我们不是真的想在obj上加上foo,我们只是想用隐式绑定

function foo(x) {
    console.log(this.a, x);
}
const obj = {
    a: 1
}

Function.prototype.myCall = function () {
    // 将foo引用到obj上
    // 让obj调用foo
    // 移除obj上的foo
    
    
}

foo.myCall(obj, 2, 3, 4)

首先我们不知道myCall会接收多少个参数,所以在这里我们可以用‘...’加一个变量去接收myCall传来的参数,它叫rest参数,它会把传来的参数存在数组里。

这个数组的第一位就是obj,所以我们再用个变量去接收这个obj,数组剩下的那些值我们再用个数组接收。

function foo(x) {
    console.log(this.a, x);
}
const obj = {
    a: 1
}

Function.prototype.myCall = function (...args) {
    // 将foo引用到obj上
    // 让obj调用foo
    // 移除obj上的foo
    const context = args[0]
    const arg = args.slice(1)  // [2,3,4]
    
}

foo.myCall(obj, 2, 3, 4)

接下来我们就接得将foo引用到obj上,此时obj就是这个context了,然后我们这样写:context.fn = this,此时的this是不是指向foo?因为foo.myCall()中的myCall它是非独立调用的,如果myCall里有this它一定会指向foo,所以context.fn = this就可以将foo引用到obj上了。

然后因为foo中传了参数进来,我们就把参数传给context.fncontext.fn此时就是foo了。所以我们这么写:

function foo(x) {
    console.log(this.a, x);
}
const obj = {
    a: 1
}

Function.prototype.myCall = function (...args) {
    // 将foo引用到obj上
    // 让obj调用foo
    // 移除obj上的foo
    const context = args[0]
    const arg = args.slice(1)  // [2,3,4]
    context.fn = this
    const res = context.fn(...arg)
}

foo.myCall(obj, 2, 3, 4)

三个点可以将零散的量存放到数组里,它逆过来用又可以将数组里的值变成零散的量。我们将数组arg又转换成零散的量供foo去使用,然后用一个变量res去接收foo的返回结果。此时foo就相当于被obj调用了,foo是非独立调用,所以foo中的this就会指向obj,达到了我们的目的。

执行完了这些操作,我们就得把obj里的foo删除掉,然后返回res。

function foo(x) {
    console.log(this.a, x);
}
const obj = {
    a: 1
}

Function.prototype.myCall = function (...args) {
    // 将foo引用到obj上
    // 让obj调用foo
    // 移除obj上的foo
    const context = args[0]
    const arg = args.slice(1)  // [2,3,4]
    context.fn = this
    const res = context.fn(...arg)
    delete context.fn
    return res
}

foo.myCall(obj, 2, 3, 4)

这样我们就手写了一个call方法出来,输出结果应该是1和2才对,我们来看一下。

image.png

成功输出了1和2,这样,call的源代码就被我们搞懂了。apply和bind原理也大差不差。各位可以自行探索。