首页>前端教程>JavaScript教程

函数中的this对象的四种绑定规则和优先级!

函数中的this是一个刚开始接触比较头疼的概念,总感觉它变来变去,害怕一不留神它就不是原来的它了。

ES6用了箭头函数干脆把this固定住,不允许它变,但是在更复杂的场景中,我们恰恰需要this的多变性,所以,还是必须把this的绑定规则搞定。

1、this对象

this是JavaScript中一个很特别的关键字,被自动定义在所有函数的作用域中。在函数被调用的时候,this才具有指向性。this引用的是函数执行时的环境对象,也就是函数执行时的作用域对象。不是函数声明时的作用域对象。

1.1、调用位置

this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

在理解this的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的位置(而不是声明的位置)。

最重要的是要分析调用栈,就是为了到达当前执行位置所调用的所有函数,我们关心的调用位置就在当前正在执行的函数的前一个调用中。

// 调用栈就是当前正在执行的函数
// 调用位置就在当前正在执行的函数的前一个调用中。
function baz(){
    //当前调用栈是:baz
    // 因此,当前调用位置是全局作用域
    console.log('baz');
    bar(); //bar的调用位置
    console.log('baz');
}
function bar(){
    // 当前调用栈是baz->bar
    // 因此,当前调用位置在baz中。
    console.log('bar');
    foo();//foo的调用位置
    console.log('bar');
}
function foo(){
    //调试命令,可以让程序停留在这里
    debugger;
    // 当前调用栈是baz->bar->foo
    // 因此,当前调用位置在bar中。
    console.log('foo');
}

baz(); //baz的调用位置

1.2、绑定规则

1.2.1、默认绑定

最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其它规则时的默认规则。

function foo(){
    console.log(this.a);
}
//声明在全局作用域中的变量就是全局对象的一个属性。
var a = 2;
// 函数被调用时应用了this的默认绑定,this指向全局对象window
foo(); // 2

1.2.2、隐式绑定

另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。

function foo(){
    console.log(this.a);
}
var obj = {
    a:2,
    foo:foo
}
obj.foo(); // 2

函数foo()被当做引用属性添加到obj中,但是无论是直接在obj中定义还是先定义再添加为引用属性,这个函数严格来说都不属于obj对象。

然而,调用位置会使用obj上下文来引用函数,因为可以说函数被调用时obj对象“拥有”或者包含”它。

当foo()被调用时,它的前面加上了对obj的引用。当函数引用有上下文对象时,隐式绑定规则会把函数调用时的this绑定到这个上下文对象。

对象属性引用链中只有上一层或者说最后一层在调用位置中起作用。

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

1.2.3、隐式丢失

一个最常见的this绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把this绑定到全局对象或者undefined中。取决于是否是严格模式。(非严格模式绑定到全局对象,严格模式绑定到undefined中)

function foo(){
    console.log(this.a);
}
var obj = {
    a:2,
    foo:foo,
}
//bar是obj.foo的一个引用,但是实际上,它引用的是函数foo本身。函数引用的上下文丢失
var bar = obj.foo; // 函数别名!
var a = '全局属性的值';
//此时bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定。
bar(); // 全局属性的值

一种更常见的情况发生在传入回调函数时:

function foo(){
    console.log(this.a);
}
//传入回调函数
function doFoo(fn){
    //fn其实引用的是foo
    fn(); //foo的调用位置
}
var obj = {
    a:2,
    foo:foo
}
var a = '全局属性的值';
//传递参数其实也是一种隐式赋值,传入函数时也会被隐式赋值。
// fn = obj.foo 函数别名
doFoo(obj.foo);//全局属性的值

把函数传入语言内置的函数,情况也是一样的。

function foo(){
    console.log(this.a);
}
var obj = {
    a : 2,
    foo : foo
}
var a = "全局属性的值";
setTimeout(obj.foo,1000); //全局属性的值

//和下面的代码原理相似:
//fn = obj.foo 函数别名
function setTimeout(fn,delay){
    //等待delay毫秒
    fn(); //调用位置

}

回调函数丢失this绑定是非常常见的。那么如何固定this呢?

1.2.4、显式绑定

隐式绑定时,我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this间接绑定到这个对象上。

如果不想在对象内部包含函数引用,只想在某个对象上强制调用函数,该怎么做呢?

函数作为对象,也拥有自己的方法,call()和apply()方法,JavaScript提供的函数和自己创建的所有函数都可以使用call()和apply()方法。

方法的第一个参数是一个对象,是给this准备的,在调用函数时将其绑定到this。因为可以直接指定this的绑定对象,这种方法称之为显式绑定。

function foo(){
    console.log(this.a);
}
var obj = {
    a:2
}

foo.call(obj); // 2

通过foo.call(),可以在调用foo()函数时强制把foo里面的this绑定到obj上。

如果传入了一个原始值(字符串类型、布尔值类型、数字类型)来当作this的绑定对象,这个原始值会被转换成它的对象形式(new String()、new Boolean()、new Number()),这通常被称为“装箱”。

1、硬绑定

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2
}
//显式的强制绑定叫做硬绑定
var bar = function () {
    foo.call(obj);
}
bar(); // 2 
setTimeout(bar, 100); // 2
var a = '全局';
//硬绑定的bar不可能再修改它的this
bar.call(window); // 2

硬绑定的典型应用场景就是创建一个包裹函数,负责接收参数并返回值:

function foo(something){
    console.log(this.a,something);
    return this.a + something;
}
var obj = {
    a:2
}
var bar = function(){
    //把bar的arguments参数传递给foo,apply支持传递arguments参数。
    return foo.apply(obj,arguments);
}
var b = bar(3); // 2 3
console.log(b); // 5

另一种方法是创建一个可以重复使用的辅助函数:

function foo(something){
    console.log(this.a,something);
    return this.a + something;
}

//简单的辅助绑定函数
function bind(fn,obj){
    //返回一个新函数,形成闭包
    return function(){
        return fn.apply(obj,arguments);
    }
}
var obj = {
    a:2
}
var bar = bind(foo,obj);
var b = bar(3); // 2 3 
console.log(b); // 5

由于硬绑定是一种非常常见的模式,所以ES5提供了内置的方法Function.prototype.bind,它的用法如下:

function foo(something){
    console.log(this.a,something);
    return this.a + something;
}

var obj = {
    a:2
}
//利用JavaScript提供的bind函数直接硬绑定
var bar = foo.bind(obj);
var b = bar(3); // 2 3
console.log(b);//5
console.log(bar === foo); // false

bind()会返回一个新函数,它会把你指定的参数设置为this的上下文并调用原始函数。

2、API调用的“上下文”

第三方库的许多函数,以及JavaScript语言和宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文(context)”,其作用和bind()一样,确保你的回调函数使用指定的this。

function foo(el){
    console.log(el,this.id);
}
var obj = {
    id:'myid'
}
var id = '全局';
//调用foo时把this绑定到obj
[1,2,3].forEach(foo,obj);

这些函数实际上就是通过call()等实现了显示绑定,可以少写一些代码实现this的绑定。

1.2.5、new绑定

这是最后一条this的绑定规则。

使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:

1、创建一个全新的对象。

2、这个新对象会被执行[[prototype]]连接。

3、这个新对象会绑定到函数调用的this。

4、如果函数没有返回其它对象,那么new表达式中的函数调用会自动返回这个新对象。

function Foo(a){
    this.a = a ;
}
//使用new调用foo函数,会构造出一个新对象,并把这个新对象绑定到foo函数调用中的this上。
var bar = new Foo(2);
console.log(bar.a); // 2

在JavaScript中,构造函数只是一些使用new操作符时被调用的函数,它们并不是一种特殊的函数类型,它们只是被new操作符调用的普通函数而已。

所以,所有函数都可以用new来调用,这种函数调用被称为构造函数调用,实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。

1.3、优先级

默认绑定的优先级是四条规则中最低的。

显示绑定的优先级高于隐式绑定。

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

new绑定比隐式绑定优先级高

function foo(something){
    this.a = something;
}

var obj1 = {
    foo:foo
}
var obj2 = {};
obj1.foo(2);
console.log(obj1.a); // 2
//显示绑定比隐式绑定优先级高
obj1.foo.call(obj2,3);
console.log(obj2.a);//3
//new绑定比隐式绑定的优先级高
var bar = new obj1.foo(4);
console.log(bar.a); // 4

console.log(obj1.a);//2

new绑定的优先级高于显示绑定

function foo(something){
    this.a = something;
}

var obj1= {};
var bar = foo.bind(obj1);
bar(2);
console.log(obj1.a); // 2
var baz = new bar(3);
console.log(obj1.a); // 2
console.log(baz.a); // 3

现在可以根据优先级来判断函数在某个调用位置应用的是哪条规则。

可以按照下面的顺序进行判断:

1、函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。

var bar = new Foo()

2、函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this绑定的是指定的对象。

var bar = foo.call(obj2)

3、函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this绑定的是那个上下文对象。

var bar = obj.foo()

4、如果都不是的话,使用默认绑定。如果是在严格模式下,就绑定到undefined,否则绑定到全局对象。

var bar = foo()

1.4、绑定例外

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

function foo(){
    console.log(this.a);
}
var a = 2;
foo.call(null); // 2

什么情况下会传入null呢?

一种常用的做法是用apply()来展开一个数组,并当做参数传入一个函数。

function foo(a,b){
    console.log(a + b);
}
// 把数组展开成参数
foo.apply(null,[2,3]); // 5
//使用bind()进行柯里化
var bar = foo.bind(null,2);
bar(3); // 5

这两种方法都需要传入一个参数当做this的绑定对象,如果函数不关心this的话,仍然需要传入一个占位值,null非常方便。

let arr = [12,45,14,38];
let max = Math.max.apply(null,arr);
console.log(max); // 45

然而,总是使用null来忽略this绑定可能产生一些副作用,如果某个函数确实使用了this(比如第三方库中的一个函数),那默认绑定会把this绑定到全局对象window上,这可能会导致不可预计的后果(比如修改全局对象)。所以,使用null会导致难以分析和追踪的bug。

更安全的this

一种更安全的方法是传入一个特殊的对象,把this绑定到这个对象不会对程序产生任何副作用。

通过创建一个空对象,就不会对全局对象产生任何影响。

// 创建一个空对象,这个空对象比{}更空,因为并不会创建Object.prototype对象。
var φ = Object.create(null);
// console.log(φ,{})
function foo(a,b){
    console.log(a + b);
}
// 把数组展开成参数
foo.apply(φ,[2,3]); // 5
//使用bind()进行柯里化
var bar = foo.bind(φ,2);
bar(3); // 5

2、this词法

在ES6中有一种无法使用上面四种规则的特殊函数类型:箭头函数。

箭头函数并不是使用function关键字定义的,而是使用被称为“胖箭头”的操作符=>定义的。箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)作用域来决定this。

function foo(){
    //返回一个箭头函数
    return a => {
        //this继承自foo()
        console.log(this.a);
    };
    /* return function(a){
           console.log(this.a);
       }; */
}
let obj1 = {
    a:2
};
let obj2 = {
    a:3
};

let bar = foo.call(obj1);
bar.call(obj2);// 2

foo()内部创建的箭头函数会捕获调用时foo()的 this,由于foo()的this绑定到了obj1,bar(引用箭头函数)的this也会绑定到obj1,箭头函数的绑定无法被修改。(new也不行!)

箭头函数最常用于回调函数中,例如事件处理器或者定时器:

function foo(){
    setTimeout(()=>{
        //这里的this在词法上继承自foo()
        console.log(this.a);
    },1000)
}

let obj1 = {
    a:2
};
foo.call(obj1); // 2

箭头函数可以像bind()一样确保函数的this被绑定到指定对象上,此外,其重要性还体现在它用更常见的词法作用域取代了传统的this机制。

在ES6之前,有一种几乎和箭头函数完全一样的模式:

function foo(){
    //把this对象保存在变量中
    var _this = this;
    setTimeout(function(){
        //通过_this变量来使用保存好的this对象
        console.log(_this.a);
    },1000)
}

let obj1 = {
    a:2
};
foo.call(obj1); // 2

3、构造函数

其实,在JavaScript中,构造函数并不是一种特殊的函数,只是受到Java等面向对象,拥有类概念的语言的影响。

构造函数首字母要大写这种规矩也是来源于对Java等语言的追随。

function Foo(){
    return this;
}
let o = new Foo();

console.dir(Foo);//prototype是函数的一个属性,可以叫做原型对象。
//函数的prototype对象里面有一个constructor属性,又指向函数本身。
console.log(Foo.prototype.constructor === Foo); // true
//o是一个实例,它有一个原型链[[Prototype]]属性,指向new出自己的函数的原型对象。
console.log(o);
//通过__proto__可以访问实例的原型对象。
console.log(o.__proto__);
//o实例并没有constructor属性,但是通过对原型链的往上层访问,可以访问到原型对象里面的constructor属性。
console.log(o.constructor === Foo);// true

实际上,Foo函数与其他函数没有任何区别。函数本身不是构造函数,当在普通函数调用前面加上new关键字后,就会把这个函数调用变成一个“构造函数调用”。实际上,new会劫持所有普通函数并用构造对象的形式来调用它。

function nothingSpecial(){
    console.log('我是一个普通函数');
}

let obj = new nothingSpecial();
console.log(obj); // {}

nothingSpecial()只是一个普通函数,使用new调用的时候,就会产生一个对象并赋值给obj,使用new调用一个函数无论如何都会返回一个对象。这个new的调用是一个构造函数的调用,但是nothingSpecial()却不是一个 构造函数。

可以理解为在JavaScript中,对于“构造函数”最准确的解释是:所有带new的函数调用。

函数不是构造函数,但是当且仅当使用new时,函数调用会变成“构造函数调用”。

当函数作为构造函数调用的时候,函数内部的this指向new返回出来的对象。

点赞


1
保存到:

相关文章

发表评论:

◎请发表你卖萌撒娇或一针见血的评论,严禁小广告。

Top