解锁this、apply、call、bind新姿势🍊

1,427 阅读13分钟

this是什么?

this在JavaScript中一个非常重要的概念,也是特别令人迷惑的。this是什么?首先记住this不是指向自身!this 就是一个指针,总是指向最后调用它的对象,即代表着它的直接调用者。

普通函数中this

默认绑定(全局作用域)

  1. 默认情况
var name = 'xiaoming';
function hello() {
  console.log('Hello,', this.name); 
  //window:Hello, xiaoming;
  //node:Hello, undefined
}
hello();
  1. 严格模式
var name = 'xiaoming';
function hello() {
   'use strict';
  console.log('Hello,', this.name); 
  //TypeError: Cannot read property 'name' of undefined
}
hello();

直接调用hello()时,应用了默认绑定,在全局作用域/全局环境中,this指向的就是全局变量

  • 在浏览器里,指向window对象
  • 在Node.js里,指向global对象
  • 严格模式下,this指向undefined

隐式绑定(对象方法)

函数的调用是在某个对象上触发的,即调用位置上存在上下文对象。典型的形式为 XXX.fun()

当一个函数被调用时,应该立马看()左边的部分。

  • 如果()左边是一个引用,那么,函数的this指向的就是这个引用所属的对象
  • 否则this指向的就是全局对象(默认绑定)
var name = 'country'
const china = {
  name: 'china',
  year: 1949,

  describe() {
    console.log(`${this.name} was built in ${this.year}`);
  },
  details: {
    currency: 'RMB',

    printDetails() {
      console.log(`the currency is ${this.currency}`);
    },
  },
};
china.describe();
// china was built in 1949
// ()左边的describe属于china,describe里的this指向china
china.details.printDetails(); 
// the currency is RMB
// ()左边是printDetails,printDetails属于china.details,所以this就是china.details
var describe = china.describe;
describe(); 
// country was built in undefined
//执行describe(),()左边是describe,这个时候this执行的是默认绑定,this指向的是全局对象window.这就是隐式绑定丢失

如果代码都那么简单,那么this的指向也就简单明了了。来点复杂点的看看:

const obj = {
  name: 'spike',
  friends: ['deer', 'cat'],
  loop: function() {
    this.friends.forEach( // 这个this指向obj
      function( friend ) {
        console.log(`${this.name} knows ${friend}`);
        console.log(this === window); // 在浏览器环境下,全局对象为window
      }
    )
  }
}

obj.loop();
// ()左边是loop,属于obj,所以loop函数中的this指向obj

输出

$ node test
undefined knows dear
true
undefined knows cat
true

可以看到,在forEach中的this并不是期待的那样指向obj,而是指向全局对象了

可以用上面提到的,还是看()左边,在forEach中,()左边是function,而不是一个引用, 匿名函数没有直接调用者,不属于任何对象,他不是一个对象的方法,在浏览器环境中他的this指向window

我们来看下面一个例子:

function sayHi(){
    console.log('Hello,', this.name);
}
var person1 = {
    name: 'john',
    sayHi: function(){
        setTimeout(function(){
            console.log('Hello,',this.name);
        })
    }
}
var person2 = {
    name: 'Christina',
    sayHi: sayHi
}
var name='Wiliam';
person1.sayHi();
// Hello, Wiliam
// setTimeout的回调匿名函数没有直接调用者,this使用的是默认绑定,非严格模式下,执行的是全局对象
setTimeout(person2.sayHi,100);
// Hello, Wiliam
// setTimeout(fn,delay){ fn(); },相当于是将person2.sayHi赋值给了一个变量,最后执行了变量,这个时候,sayHi中的this显然和person2就没有关系了。
setTimeout(function(){
    person2.sayHi();
},200);
// Hello, Christina
// 这是执行的是person2.sayHi()使用的是隐式绑定,因此这是this指向的是person2,跟当前的作用域没有任何关系。

再看一个 arguments 的例子: image.png

在使用obj.foo(temp)时,将temp函数当成了参数传递到foo中,把一个函数当成参数传递到另一个函数的时候,会发生隐式丢失的问题,且与包裹着它的函数的this指向无关。在非严格模式下,会把该函数的this绑定到window上,严格模式下绑定到undefined

传入进去的temp会被arguments所搜集,所以可以使用arguments0这样的形式调用。而arguments是一个类数组,数组或者类数组的调用下标就像是对象调用属性一样,因此此时temp内的this就是arguments了,而在arguments内是没有age这个属性的,所以会打印出undefined

扩展

JS(ES5)里面有三种函数调用形式:

func(p1, p2) 
obj.child.method(p1, p2)
func.call(context, p1, p2) // 先不讲 apply

其实第三种调用形式,才是正常调用形式,其他两种都是语法糖,可以等价地变为 call 形式,this就是第三种形式中的 context参数

举几个「转换代码」示例:

1func(p1, p2) 等价于
//func.call(undefined, p1, p2),fun普通函数里头的this 就是 undefined在浏览器中是window,严格模式下是undefined
// 如果你希望这里的 this 不是 window,很简单:
func.call(obj) // 那么里面的 this 就是 obj 对象了

2、obj.foo() // 转换为 obj.foo.call(obj),foo普通函数里头的this 就是 obj

3、obj.child.method(p1, p2) 
//等价于obj.child.method.call(obj.child, p1, p2)

4、arr[0]() 
//假想为    arr.0()
//然后转换为 arr.0.call(arr)
//那么里面的 this 就是 arr
this 就是你 call 一个函数时,传入的第一个参数。

绑定优先级

如果同时应用了多种规则,怎么办?

显然,我们需要了解哪一种绑定方式的优先级更高,这四种绑定的优先级为:

new绑定 > 显式绑定 > 隐式绑定 > 默认绑定

显式绑定(call,apply,bind)

显式绑定比较好理解,就是通过call,apply,bind的方式,显式的指定this所指向的对象。

call,apply和bind的第一个参数,就是对应函数的this所指向的对象,本质都是改变 this 的指向。不同点 call、apply 是直接调用函数,bind 是返回一个新的函数。call和apply都会执行对应的函数,而bind方法需要手动调用。call和apply的作用一样,只是传参方式不同。

使用 call,apply

function sayHi() {
  console.log('Hello,', this.name);
}
var person = {
  name: 'john',
  sayHi: sayHi,
};
var name = 'Wiliam';
var Hi = person.sayHi;
Hi();
// Hello, Wiliam
// 默认绑定,this指向window
Hi.call(person); //Hi.apply(person)
// Hello, john
// 因为使用明确将this显式绑定在了person上。

然而使用显示绑定也会出现隐式绑定所遇到的绑定丢失

function sayHi() {
  console.log('Hello,', this.name);
}
var person = {
  name: 'john',
  sayHi: sayHi,
};
var name = 'Wiliam';
var Hi = function (fn) {
  fn();
};
Hi.call(person, person.sayHi);
// Hello, Wiliam

Hi.call(person, person.sayHi)的确是将this绑定到Hi中的this了,但是在执行fn的时候,person.sayHi已经被赋值给fn了。相当于直接调用了sayHi方法,()左边是sayHi,对应的是默认绑定。

如果希望绑定不会丢失,要怎么做?很简单,调用fn的时候,也给它显式绑定。
function sayHi(){
    console.log('Hello,', this.name);
}
var person = {
    name: 'john',
    sayHi: sayHi
}
var name = 'Wiliam';
var Hi = function(fn) {
    fn.call(this);
}
Hi.call(person, person.sayHi);
// Hello, john
// 因为person被绑定到Hi函数中的this上,fn又将这个对象绑定给了sayHi的函数。这时,sayHi中的this指向的就是person对象。

快速记忆

call和apply两个函数用法差不多,都是劫持另外一个对象的属性和函数,区别只在参数的写法上面。但是恰恰是这个区别,很多人就记不住,所以有了下面这个技巧。

  • call 打电话 一个一个输入号码 ,参数一个一个传
  • apply 数组的英文 array 首字母都是a ,传数组。
  var obj = {
    num1: 1
  }

  function count(num2, num3) {
    console.log(this.num1 + num2 + num3);
  }

  count.call(obj, 1, 1);    // 3
  count.apply(obj, [1, 1]);    // 3

和bind的区别

将刚刚的例子使用 bind 试一下

 var obj = {
    num1: 1
  }

  function count(num2, num3) {
    console.log(this.num1 + num2 + num3);
  }

  count.bind(obj, 1, 1); 

我们会发现并没有输出,这也应了前面说的bind需要手动调用

 var obj = {
    num1: 1
  }

  function count(num2, num3) {
    console.log(this.num1 + num2 + num3);
  }

  count.bind(obj, 1, 1)();    // 3

如果我们将null或者是undefined作为this的绑定对象传入call、apply或者是bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。

var foo = {
    name: 'Selina'
}
var name = 'Chirs';
function bar() {
    console.log(this.name);
}
bar.call(null); //Chirs 

new 绑定(构造函数)

参考JavaScript高级程序设计,我们可以知道new操作符做了四件事:

  1. 创建一个新对象
  2. 将构造函数的作用域赋给了新对象(因此this指向了该对象)
  3. 执行构造函数中的代码(为这个新对象添加属性)
  4. 返回新对象

因此,我们使用new来调用函数的时候,就会新对象绑定到这个函数的this上。

var _that;

function Country(name, year) {
  this.name = name;
  this.year = year;
  // 保存构造函数中的this
  _that = this;
  console.log(`${this.name} was built in ${this.year}`); // China was built in 1949
}

// 通过new关键字执行构造函数
var china = new Country('China', 1949);
// 构造函数中的this指向的就是新创建的对象实例china
console.log(_that === china); // true

如果你没有用new关键字去执行构造函数,那么就要分析函数被调用时所属的作用域了

    function Point(x, y) {
        this.x = x;
        this.y = y;
    }
    var p = Point(7, 5); // 没有用new关键字去执行构造函数!
    
    console.log(p === undefined); 
    // true
    //没有用new,所以构造函数没有返回一个实例对象, 所以p === undefined
    
    // 没有用new关键字,Point(7,5);就只是把函数执行了一遍
    // ()左边是Point,属于全局对象,所以this指向全局对象
    console.log(x); // 7
    console.log(y); // 5

DOM绑定事件处理器(event handler)中this的指向

<div id="test">I am an element with id #test</div>
function doAlert() { 
    alert(this.innerHTML); 
} 

doAlert(); // undefined 
// doAlert()属于全局对象

var myElem = document.getElementById('test'); 
myElem.onclick = doAlert; 

alert(myElem.onclick === doAlert); // true 
myElem.onclick(); // I am an element
// ()左边是onclick也就是doAlert,属于myElem,所以this指向myElem

哪个元素触发事件,this就指向哪个元素

小结

以上,对于普通函数中的this,通过查看()左边所属的对象去确定,真的很好用。强烈推荐call语法糖的代码转换方式👍一眼就能看懂

实质上,this是在创建函数的执行环境时,在创建阶段确定的,因此,弄透执行环境,去思考执行环境创建阶段的this的指向,this的指向就不会弄错了吧。

原理

摘自:阮一峰老师
原文:JavaScript 的 this 原理
原文地址:http://www.ruanyifeng.com/blog/2018/06/javascript-this.html

取其中的函数部分讲

var obj = { foo: function () {} };

JavaScript 引擎会先在内存里面,生成一个对象,然后把这个对象的内存地址赋值给变量obj,原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象(图中黄色部分展示foo属性的值保存在属性描述对象的value属性里面)。JavaScript引擎会将函数单独保存在内存中,然后再将函数的地址赋值给foo属性的value属性。

由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行。

var f = function () {
  console.log(this.x);
}

var x = 1;
var obj = {
  f: f,
  x: 2,
};

// 单独执行
f() // 1

// obj 环境执行
obj.f() // 2

函数体里面的this.x就是指当前运行环境的x。上面代码中,函数f在全局环境执行,this.x指向全局环境的x

在obj环境执行,this.x指向obj.x。

obj.foo()是通过obj找到foo,所以就是在obj环境执行。一旦var foo = obj.foo,变量foo就直接指向函数本身,所以foo()就变成在全局环境执行。

箭头函数中this

实际上箭头函数里并没有 this,如果你在箭头函数里看到 this,你直接把它当作箭头函数外面的 this 即可。外面的 this 是什么,箭头函数里面的 this 就还是什么,因为箭头函数本身不支持 this,箭头函数内外 this 就是同一个东西

  • 默认指向定义它时,所处上下文的对象的this指向。即ES6箭头函数里this的指向就是上下文里对象this指向,偶尔没有上下文对象,this就指向window
  • 即使是call,apply,bind等方法也不能改变箭头函数this的指向
a(() => {
  console.log(this);
});
obj.a(() => {
  console.log(this);
});
//这两个this直接就是他的函数作用域window了,不用管后面传入到哪里去
const obj = {
    num: 10,
   hello: function () {
    console.log(this);    // obj
    setTimeout(() => {
      console.log(this);    // obj
    });
   }    
}
obj.hello()//obj.hello.call(obj)   hello的作用域是obj
//setTimeout里头箭头函数指向的就是他的上级函数作用域hello的this
const obj = {
  radius: 10,
  diameter() {
    return this.radius * 2;
    // 普通函数,里面的this指向直接调用它的对象obj。
  },
  perimeter: () => 2 * Math.PI * this.radius,
  // 箭头函数,this应该指向上下文函数this的指向,这里上下文没有函数对象,就默认为window,而window里面没有radius这个属性,就返回为NaN。
};
console.log(obj.diameter()); // 20
console.log(obj.perimeter()); // NaN

小结

箭头函数的this在定义的时候就确定,逐级向上查找找到最近的函数作用域的this,直到window

手写new

// Con:构造函数
// ...args:其余参数
function create(Con, ...args) {
  let obj = Object.create(Con.prototype);
  let result = Con.apply(obj, args);
  return result instanceof Object ? result : obj;
}
  1. 内部创建一个空对象 obj
  2. 使用Object.create()方法创建一个新对象,使用现有的对象Con来提供新创建的对象的__proto__。让obj 对象可以访问到构造函数原型链上的属性,这段代码等同于 let obj = {}; obj.__proto__ = Con.prototype
  3. Con.apply(obj, args) 将 obj 绑定到构造函数上,将构造函数的作用域赋给了新对象,并且传入剩余的参数,为这个新对象添加属性
  4. 判断构造函数返回值是否为对象,如果为对象就使用构造函数返回的值,否则使用 obj,这样就实现了忽略构造函数返回的原始值

测试:

function create(Con, ...args) {
  let obj = Object.create(Con.prototype);
  let result = Con.apply(obj, args);
  return result instanceof Object ? result : obj;
}
function Test(name, age) {
  this.name = name;
  this.age = age;
}
Test.prototype.sayName = function () {
  console.log(this.name);
};
const a = create(Test, 'JohnYu', 27);
console.log(a.name); // 'JohnYu'
console.log(a.age); // 27
a.sayName(); // 'JohnYu'

关于原型和原型链可以参考我的另一篇文章:🎉👨‍👩‍👧‍👧图解Javascript原型

手写call

Function.prototype.myCall = function (context, ...args) {
  // 1.如果有对象则this指向对象,否则this指向window
  var context = context || window;
  // 2.mycall方法的调用者是bar,this就是bar
  // 把调用者bar函数命名为fn,被foo对象或者window所拥有的
  context.fn = this;
  // 3.call后面的参数分别传到fn或者window所拥有的bar函数中,变成调用obj.bar('JohnYu', 27),此时bar函数内this指向foo
  var result = context.fn(...args);
  delete context.fn;
  return result;
};
value = 2;
let foo = {
  value: 1,
};
let bar = function (name, age) {
  console.log(name, age, this.value);
};
bar.myCall(foo, 'JohnYu', 27); //JohnYu 27 1
bar.myCall(null, 'JohnYu', 27); //JohnYu 27 2

手写apply

applycall思路一致,只是对参数进行不同处理即可:

Function.prototype.myAplly = function (context, args) {
  var context = context || window;
  context.fn = this;
  var result = context.fn(...args);
  delete context.fn;
  return result;
};
value = 2;
let foo = {
  value: 1,
};
let bar = function (name, age) {
  console.log(name, age, this.value);
};
bar.myAplly (foo, ['JohnYu', 27]); //JohnYu 27 1
bar.myAplly (null, ['JohnYu', 27]); //JohnYu 27 2

手写bind

Function.prototype.myBind = function (context) {
  // 判断是否是一个函数
  if (typeof this !== 'function') {
    throw new TypeError('Not a Function');
  }
  // 保存调用bind的函数
  const _this = this;
  // 保存参数
  const args = Array.prototype.slice.call(arguments, 1);
  // 返回一个函数
  return function F() {
    // 判断是不是new出来的,如果被new创建实例,不会被改变上下文!
    if (this instanceof F) {
      // 如果是new出来的
      // 返回一个空对象,且使创建出来的实例的__proto__指向_this
      return new _this(...args, ...arguments);
    } else {
      // 如果不是new出来的改变this指向,且完成函数柯里化
      return _this.apply(context, args.concat(...arguments));
    }
  };
};

value = 2;
let foo = {
  value: 1,
};
let bar = function (name, age) {
  console.log(name, age, this.value);
};
let fn = bar.myBind(foo, 'JohnYu', 27);
let a = new fn(); // JohnYu 27 undefined
console.log(a.__proto__); //bar {}
bar.myBind(foo, 'JohnYu', 27)(); // JohnYu 27 1

案例

var num = 1;
let obj = {
    num: 2,
    add: function() {
        this.num = 3;
        // 这里的立即指向函数,因为我们没有手动去指定它的this指向,所以都会指向window
        (function() {
            // 所有这个 this.num 就等于 window.num
            console.log(this.num);
            this.num = 4;
        })();
        console.log(this.num);
    },
    sub: function() {
        console.log(this.num)
    }
}
// 下面逐行说明打印的内容

/**
 * 在通过obj.add 调用add 函数时,函数的this指向的是obj,这时候第一个this.num=3
 * 相当于 obj.num = 3 但是里面的立即指向函数this依然是window,
 * 所以 立即执行函数里面console.log(this.num)输出1,同时 window.num = 4
 *立即执行函数之后,再输出`this.num`,这时候`this`是`obj`,所以输出3
 */
obj.add() // 输出 1 3

// 通过上面`obj.add`的执行,obj.name 已经变成了3
console.log(obj.num) // 输出3
// 这个num是 window.num
console.log(num) // 输出4
// 如果将obj.sub 赋值给一个新的变量,那么这个函数的作用域将会变成新变量的作用域
const sub = obj.sub
// 作用域变成了window window.num 是 4
sub() // 输出4

注意:闭包里面的自执行匿名函数不属于任何对象,他不是一个对象的方法

总结

我们来回顾一下

如何准确判断this指向的是什么?

  1. 函数是否在new中调用(new绑定),如果是,那么this绑定的是新创建的对象。
  2. 函数是否通过call,apply,bind调用,如果是,那么this绑定的就是指定的对象。
  3. 函数是否在某个上下文对象中调用(隐式绑定),如果是的话,this绑定的是那个上下文对象。一般是obj.foo()
  4. 如果以上都不是,那么使用默认绑定。如果在严格模式下,则绑定到undefined,否则绑定到全局对象。
  5. 如果把Null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。
  6. 如果是箭头函数,箭头函数的this继承的是外层代码块的this。

参考文章 📜

this 的值到底是什么?一次说清楚

嗨,你真的懂this吗?