【JavaScript】this指向详解及自定义call、apply、bind

817 阅读4分钟

引言

JS中的this指向灵活,这属于JS语言中的特性。传统的面向对象语言规定this只出现在类中,而JS中没有类,JS设计者便将this放在独立函数中,这样全局函数、对象中的函数、构造函数等都可以调用this,这过于自由导致了混乱。

this指向混乱是公认问题,搞不明白this指向,无论是开发时还是阅读源码中,都比较吃力,基于此本文对this相关知识进行了梳理,如果你对this的了解不是那么清晰,可以通过本文来复习一下。

1 this是什么

当一个函数被调用时,会创建一个执行上下文,其中this就是执行上下文的一个属性,this是函数在调用时JS引擎向函数内部传递的一个隐含参数。

Js基于词法作用域,我们把函数写在哪,它就活在哪个位置。而函数调用是动态的,可以在任意地方调用,JS中在函数体内定义了this关键字来获取当前调用环境

2 this指向类型

this指向完全是由它的调用位置决定,而不是声明位置。除箭头函数外,this指向最后调用它的那个对象,因此我们第一步是找到函数调用位置

// 声明位置
var obj = {
  name: '张三',v
  sayHi: function() {
    console.log('你好我是' + this.name)
  }
}
// 调用位置
obj.sayHi() //你好我是张三

this 在函数中指向有以下几种场景:

  • 全局作用域中;
  • 普通函数调用;
  • 对象方法使用;
  • 构造函数调用;
  • 匿名函数;
  • 计时器;
  • 事件绑定方法;
  • 箭头函数;

下面我们分别来讨论一下这些场景中 this 的指向。

2.1 默认指向

全局作用域内,this指向window

console.log(this === window); //true 
var name='张三'; 
console.log(this.name); //张三 

普通函数调用,this指向window

function showThis() {
  console.log(this)
}
showThis() // window

我们来做个小实战,这段代码输出啥呢?

var name: '李四',
var obj = {
  name: '张三',
  sayHi: function() {
    console.log('你好我是' + this.name)
  }
}
var name = obj.sayHi();
name();   // 李四

上面这段代码,将name挂载全局对象上,name()也就是等价于window.name(),所以this指向的是全局中的name,所以输出全局name

再做一个小实战,输出啥?

var obj = {
  name: '张三',
  sayHi: function() {
    console.log(`你好,我是${this.name}`)
  }
}

var obj2 = {
  name: '李四',
  sayHi: function() {
    var targetFunc = obj.sayHi
    targetFunc()
  }
}

var name = '王五'

obj2.sayHi()  // 王五

在这段代码中,Obj2sayHi方法调用, targetFunc函数执行,虽然targetFunc这个函数是在obj2函数中sayHi方法中,但是在targetFunc最后调用时targetFunc是作为一个普通函数调用,进而targetFunc中this指向的是window对象。

如果开启了严格模式,全局代码中的 this 也指向window

'use strict'
console.log(this) // window

而普通函数中的this在严格模式下则会指向undefined

 'use strict'
 function showThis() {
   console.log(this)
}

showThis() // undefined

这里需要特别注意,像这样处于全局代码中的 this, 不管它是否处于严格模式下,它的 this 都指向 Window,只有普通函数中的this才会指向undefined

2.2 对象方法

对象方法调用,this指向该方法所属对象。

  var obj = {
    name:'张三',
    sayHi:function(){
      console.log('你好我是'+this.name)
    }
  }
  
  var obj2 = {
    name:'李四',
    sayHi:obj.sayHi
  }
  
  obj.sayHi(); 
  obj2.sayHi(); 

上面这段代码中sayHi这个函数分别被objobj2调用,因此,两次调用的this也就分别指向了obj1obj2,输出结果如下:

image.png

2.3 构造函数

构造函数形式调用,this指向实例对象。

function Obj(){
    this.name = '李四';
}

var result = new Obj();
console.log(result.name);

上面这段代码中,使用new来调用obj()时,Js引擎会在底层创建一个result对象,并把result当做this。因此,输出结果如下:

image.png

特别注意的是,如果在构造函数中有return,会有两种情景:

function Obj(){
    this.name = '李四';
    const obj = {};
    return obj;
}

var result = new Obj();
console.log(result.name);

此时将会输出 undefined,此时 result 返回的是空对象 obj

function Obj(){
    this.name = '李四';
    return 1;
}

var result = new Obj();
console.log(result.name);

将会输出李四,result仍然指向目标对象实例。

总结:构造函数中this一般情况下指向实例对象,如果构造函数中有returnreturn一个对象,此时this就指向返回的这个对象,如果return不是一个对象,this仍然指向实例。

2.4 匿名函数

匿名函数在函数声明时调用,匿名函数中的this指向window

var name = '王五'
var obj = {
    name: '张三',
    sayHi: function () {
        console.log('你好我是' + this.name)
    },
    sayHello: function () {
        (function (cb) {
            // 调用位置
            cb()
        })(this.sayHi)
    }
}
obj.sayHello();

上面这段代码中,输入结果如下,我们不难发现输出的是window.name

image.png

2.5 计时器

计时器有setTimeoutsetInterval,这两种计时器指向机制是一样的,都是指向window

var name = '王五'

var obj = {
  name: 'xiuyan',
  sayHi: function() {
    setTimeout(function() {
      console.log('你好我是'+this.name)
    })
  }
}

obj.sayHi() // 你好,王五

上面这段代码中,不难发现输出的是全局的name。在计时器中里面传入的函数,指向全局window

2.6 事件方法

事件方法调用,this指向事件源。

<body>
    <button id="btn">点我</button>
</body>
<script>
    var btn = document.getElementById('btn');
    btn.onclick = function () {
       console.log(this);
    }
</script>

上面这段代码,输出结果如下:

image.png

2.7 箭头函数

箭头函数没有自己的this,箭头函数的this在书写阶段就绑定到他的父级作用域this上。无论我们后续如何调用它,他都无法再次指定新的目标对象,箭头函数的this指向是静态的,一次便是一生。

箭头函数this指向包裹箭头函数的第一个普通函数。

var name = '张三'
var obj = {
  name: '李四',
  sayHi: () => {
      console.log('你好我是'+ this.name)
  }
}
me.sayHi() 

上面这段代码,this在书写的时候,他所在作用域时全局的,所以this指向的是window,输出结果如下:

image.png

我们通过一道综合案例实战,问:以下函数调用的输出结果是什么?

var a = 1
var obj = {
  a: 2,
  func2: () => {
    console.log(this.a)
  },
  
  func3: function() {
    console.log(this.a)
  }
}

// func1
var func1  = () => {
  console.log(this.a)
}

// func2
var func2 = obj.func2
// func3
var func3 = obj.func3

func1()
func2()
func3()
obj.func2()
obj.func3()
  

输出结果是:1、1、1、1、2 你做对了吗?

3 改变指向

以上我们不难发现,this严格遵循调用者走(除箭头函数指向执行上下文),这实在太被动了,我们想让this按照我们的想法指定对象,这就是我们接下来说的call、apply、bind

3.1 call、apply、bind

callapplybind,都是用来改变this指向的,三者是属于大写 Function原型上的方法,只要是函数都可以使用。

callapply的区别,体现在对入参的要求不同,call的实参是一个一个传递,apply的实参需要封装到一个数组中传递。

// thisArg是this指向的对象
// arg1,arg2是func需要的参数

func.call(thisArg,arg1,arg2,arg3)
func.apply(thisArg,[arg1,arg2,arg3])

callapplybind之间的区别,前者在于在改变this指向的同时,会把目标函数执行,后者则只负责改造this不执行函数,所以bind 在调用的时候,需要变量接收,接收后在调用。我们通过下面这个案例,来看一下三者的基本使用:

function f1(a, b) {
    console.log(this);
    console.log(a+b);
  }

var obj = {
    name: '张三',
}

f1.call(obj, 100, 200);
// {name: "张三"}  300

f1.apply(obj, [100, 200])  
// {name: "张三"}  300

var ff = f1.bind(obj);
ff()
// {name: "张三"}  300

如果call的第一个参数传null,代表直接执行这个函数:

function f1(a,b){
    console.log(this);
    console.log(a+b);
}

var obj = {
    name:'张三'
}

f1.call(null,100,200);
// window 300

3.2 应用场景

使用call最典型的应用就是实现继承, 在调用父构造函数同时,把父构造函数this指向子构造函数中this,字构造函数就可以使用name/age

function Father(name, age) {
    this.name = name;
    this.age = age;
}

function Son(name, age) {  
    // 这个时候的Father中的this已经被Son所代替
    Father.call(this, name, age);
    console.log(this);
    // Son {name: "张三", age: 18}
}

var son = new Son('张三', 18);
console.log(son);

使用apply可以求数组中最大值:

   // Math.max用法
   var max = Math.max(5,10);
   console.log(max);
   
   // 求最大值
   var arr = [1,5,4,3,8];
   max = Math.max.apply(Math,arr);
   console.log(max)

bind方法不会立即调动该函数,在三个函数中应用最为广泛,最常见的是:我们有一个按钮,当我们点击了之后,就会禁用这个按钮,3s后在开启按钮。

传统的方案是声明一个新的变量把this缓存下来:

<body>
    <button>点我</button>
    <button>点我</button>
    <button>点我</button>
<script>
    var btns = document.getElementsByTagName('button');
    for (var i = 0; i < btns.length; i++) {
        btns[i].onclick = function () {
            // 使用_this缓存this
            var _this = this
            this.disabled = true;
            setTimeout(function () {
                _this.disabled = false;
            }, 3000)
        }
    }
</script>
</body>

我们也可以使用bind实现,在计时器外面绑定bind方法,此时bind中的this是在计时器的外面,在onclick方法的里面,所以指向是btn

<body>
    <button>点我</button>
    <button>点我</button>
    <button>点我</button>
<script>
    var btns = document.getElementsByTagName('button');
    for (var i = 0; i < btns.length; i++) {
        btns[i].onclick = function () {
            this.disabled = true;
            setTimeout(function () {
                this.disabled = false;
            // 在计时器外部绑定this
            }.bind(this), 3000)
        }
    }
</script>
</body>

3.3 自定义call

在实现 call 方法之前,我们先来看一个 call 的调用示范:

var me = {
  name: '张三'
}

function showName() {
  console.log(this.name)
}

showName.call(me) // 张三

前面我们说过call方法是大写Function中方法,所有的函数都可以继承使用,所以我们自定义call 方法应该定义在 Function.prototype上,这里我们定义一个myCall

Function.prototype.myCall=function(){
}

我们想,如果用myCall方法进行绑定,就相当于在传入的对象(这里是me)里面添加了一个原本的函数,然后在使用对象.函数调用,也就是:

var me ={
  name :'张三',
  person:function(){
    console.log(this.name)
  }
}

me.person()

根据这个思路,我们往原型对象中添加内容:

// context:我们传入的对象
Function.prototype.newCall = function(context){
  //  person.newcall调用,也就是函数.方法调用,JS中函数也是对象,所以对象方法调用,指向该方法所属对象,也就是person。
  // 注意!这里的this是person,我们还没开始绑定呢
  console.log(this)
  
  // 1、我们为传入的对象添加属性
  context.fnkey = this;
  // 2、调用函数
  context.fnkey();
  // 3、执行完,方法删除,我们不能改写对象
  delete context.fnkey 
}
person.newCall(me)

当我们为形参变量添加属性时,此时的代码就如下,然后在调用这个函数,因为是对象方法调用所以this指向了me,也就是obj

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

var me = {
    name:'张三',
    fnkey:function(){
      console.log(this.name);
    }
}

现在我们的mycall就实现了call的基本能力——改变this指向,第二步让我们的mycall具备读取函数入参能力,也就是读取call方法第二个到最后一个入参,这里我们用到ES6中的剩余参数...args

剩余参数可以帮助我们将不定数量的入参变成数组,具体用法如下:

 function readArr(...args) {
    console.log(args)
}
readArr(1,2,3) // [1,2,3]

我们通过args这个数组拿到我们想要的入参,再把 args数组代表目标入参展开,传入目标方法,一个call方法就实现了。

Function.prototype.myCall = function(context, ...args) {
    context.fnkey = this;
    context.fnkey(...args);
    delete context.fnkey;
}

以上,就实现了mycall的基本框架~~

但是上面的mycall还并不完善,比如说第一个参数传了null怎么办?是不是默认给他指到windowglobal上去;第一个参数不是对象怎么办?我们改如何保证为对象?如果context里面有这个属性怎么办?我们怎样保证属性的唯一性?

我们进行以下补充优化:

 Function.prototype.myCall = function (context, ...args) {
    // 补充1 如果第一个参数没传,默认指向window / Global
    // globalThis浏览器环境中指window,node.js环境中指向global
    if (context == null) context = globalThis

    // 补充2:如果第一个参数传的值类型,数字类型,或者布尔类型
    // 我们通过new Object 生成一个值类型对象,数字类型对象,布尔类型对象
    if (typeof context !== 'objext') context = new Object(context)

    // 补充3:防止传入对象作为属性,与context重名属性覆盖
    // symbol类型不会出现属性名称覆盖
    const fnkey = Symbol();
    context[fnkey] = this
    globalThis  // window/global

    console.log(new Object('哈哈'));// String {"哈哈"}
    console.log(new Object(1)); // Number { 1 }
    console.log(new Object(true)); //Boolean { true }
    console.log(new Object(undefined));// {}

    let symbol1 = Symbol(); //Symbol()
    let symbol2 = Symbol(); //Symbol()
    consoele.log(symbol1 === symbol2);//false 

这样,我们就实现了完整mycall方法,使用mycall调用时,就相当于在传入的对象里面添加了一个原本的函数,这是实现mycall的核心,一定要理解。完整版mycall方法如下:

  Function.prototype.myCall = function (context, ...args) {
    // 补充1 如果第一个参数没传,默认指向window / Global
    // globalThis浏览器环境中指window,node.js环境中指向global
    if (context == null)  context = globalThis

    // 补充2:如果第一个参数传的值类型,数字类型,或者布尔类型
    // 我们通过new Object 生成一个值类型对象,数字类型对象,布尔类型对象
    if (typeof context !== 'objext')  context = new Object(context)

    // 补充3:防止传入对象作为属性,与context重名属性覆盖
    // symbol类型不会出现属性名称覆盖
    const fnkey = Symbol();
    
    // step1: 给传入对象添加原函数(this就是我们要改造的原函数)
    context[fnkey] = this
    // step2: 执行函数,并传递参数
    context[fnkey](...args)
    // step3: 删除 step1 中挂到目标对象上的函数
    delete context[fnkey].
}

// 测试如下:
function showFullName(secondName) {
    console.log(`${this.name} ${secondName}`)
}
var me = {
    name: '张三'
}

showFullName.myCall(me, '李四') // 张三 李四
showFullName.myCall(null, '李四') // 李四
showFullName.myCall(1, '李四') // undefined 李四

理解了call,那么实现applybind方法就小菜一碟了,apply方法关键在于更改参数的读取方式,bind方法关键在于延迟目标函数的执行时机。

3.4 自定义apply

  Function.prototype.myCall = function (context, ...args) {
    if (context == null) context = globalThis
    if (typeof context !== 'objext') context = new Object(context)
    const fnkey = Symbol();
    context[fnkey] = this;
    // 此时,传入的数组,不需要对数组进行拆包
    context.fnkey(args);
    delete context[fnkey];
}

// 测试如下:
function showFullName(secondName) {
    console.log(`${this.name} ${secondName}`)
}
var me = {
    name: '张三'
}

showFullName.myCall(me, ['李四','王五']) // 张三 李四 王五

3.5 自定义bind

前面我们说过,bind方法不会立即执行函数,实际上bind方法是返回了一个原函数的拷贝,函数体内的参数会和bind方法第一个以外的其他参数合并。

在实现 bind 方法之前,我们先来看一个 bind 的调用示范:

var me = {
    value: 1
}

function person(name, age) {
    return {
        value: this.value,
        name: name,
        age: age
    }
}

var bar = person.bind(me, '张三', 18);
console.log(bar);
// 这里将会输出person函数
console.log(bar());
// {value: 1, name: "张三", age: 18}

var bar2 = person.bind(me, '张三');
console.log(bar2(18));
// {value: 1, name: "张三", age: 18}

完整版myBind如下:

  Function.prototype.myBind = function (context, ...args) {    
    // step1: 保存下当前 this(这里的 this 就是我们要改造的的那个函数)
    const self = this;
    
    // step2: 返回一个函数
    // bind整体上会return一个函数,并还可以接受参数
    return function (...argus) {
        // step3: 拼接完整参数,将bind执行参数和函数调用时传入参数拼接
        const fullArgs = args.concat(argus)
        // step4: 调用函数
        return self.apply(context,fullArgs)
    }
}

// 测试如下:
function showFullName(secondName) {
    console.log(`${this.name} ${secondName}`)
}
var me = {
    name: '张三'
}

var result = showFullName.myBind(me, '李四')
result() // 张三 李四

4 优先级

前面讲解了this不同情境下指向规则,你需要做的就是找到调用位置,然后在判断调用方式。但是,有时候,在一个调用位置可能使用了多条规则,这里就需要判断规则的优先级。

function foo() {
    console.log( this.a );
}
var obj1 = {
    a: 2,
    foo: foo
};
var obj2 = {
    a: 3,
    foo: foo
}
obj1.foo();		//2
obj1.foo.call( obj2 );	//3

从上边的代码可以看出来,call的优先级要高于对象方法调用,下边再看bindnew绑定的优先级:

function foo(a) {
    this.a = a; 
} 
var obj = {} 

var bar = foo.bind(obj); 
bar(2);
console.log(obj.a); // 2 

var baz = new bar(3)
console.log(baz.a) // 3

这段代码中,bar函数本身是通过bind方法构造的函数,其内部已经将this绑定为obj,它在作为构造函数,通过new调用时,返回的实例已经与obj解绑,也就是说明new绑定的优先级高于bind绑定。

综上,我们得出结论:

new > call/apply/bind > 对象方法 > 默认

5 总结

  • this是什么:执行上下文的一个属性,在函数调用时,JS引擎向函数内部传递的一个隐含参数。
  • 特别注意两点:一是要看函数最后的调用者;二是如果将一个变量挂载到全局中执行,也是普通函数调用的一种;
  • 全局作用域中,无论是否严格模式都指向window
  • 普通函数调用,指向window;严格模式下指向undefined
  • 对象方法使用,该方法所属对象;
  • 构造函数调用,指向实例化对象;
  • 匿名函数中,指向window
  • 计时器中,指向window
  • 事件绑定方法,指向事件源;
  • 箭头函数指向其上下文中this
  • callapplybind都可以改变this指向,三者属于大写Function中的方法,任何函数都可以使用;
  • apply相比call方法,入参需要传递一个数组;
  • callapply相比bind方法,函数不会执行,所以我们需要定义一个变量去接收执行;
  • 手写call:3个优化+3步骤;
  • 手写apply:关键在于参数传递方式,不需要进行拆包;
  • 手写bind:关键在于延迟目标函数的执行时机;
  • this指向优先级:new>call/apply/bind>对象方法>默认。

结语

本篇文章就到此为止啦,由于本人经验水平有限,难免会有纰漏,对此欢迎指正。如觉得本文对你有帮助的话,欢迎点赞❤❤❤,写作不易,持续输出的背后是无数个日夜的积累,您的点赞是持续写作的动力,感谢支持