懂王系列(六)之彻底搞懂JavaScript中的this

351 阅读7分钟

作为一名前端小白,不知道大家是否遇到和我一样的问题。看了一道面试题的解析,当时觉得会了,可是过两天以后再看又不会了;盲目追求各种新技术,感觉什么都会点,但是一上手就不行了... 痛定思痛后,我终于认识到了问题所在,开始专注于基本功的修炼。近半年来通读了(其实是囫囵吞枣)《JavaScript高级程序设计》、《你不知道的JavaScript上、中、下》等书籍,本系列文章是我读书过程中对知识点的一些总结。喜欢的同学记得帮我点个赞😁。

懂王系列(一)之彻底搞懂JavaScript函数执行机制
懂王系列(二)之彻底搞懂JavaScript作用域
懂王系列(三)之彻底搞懂JavaScript对象
懂王系列(四)之彻底搞懂JavaScript类
懂王系列(五)之彻底搞懂JavaScript原型
懂王系列(六)之彻底搞懂JavaScript中的this
懂王系列(七)之彻底搞懂JavaScript数据类型
懂王系列(八)之彻底搞懂JavaScript语句
懂王系列(九)之彻底搞定JavaScript类型转换

1. 对this的误解

1.1 指向自身

人们很容易把 this 理解成指向函数自身,这个推断从英语的语法角度来说是说得通的。

那么为什么需要从函数内部引用函数自身呢?常见的原因是递归(从函数内部调用这个函数)或者可以写一个在第一次被调用后自己解除绑定的事件处理器。

1.2 指向它的作用域

在某种情况下它是正确的,但是在其他情况下它却是错误的
需要明确的是,this 在任何情况下都不指向函数的词法作用域。JavaScript作用域确实和对象类似,可见的标识符都是它的属性。但是作用域“对象”无法通过 JavaScript代码访问,它存在于 JavaScript 引擎内部。

function foo() { 
    var a = 2; 
    this.bar(); 
} 
function bar() { 
    console.log( a ); 
} 
foo(); // ReferenceError: a is not defined

1.3 this到底是什么

this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。 当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。this 就是这个记录的一个属性,会在函数执行的过程中用到

2. this绑定规则

每个函数的 this 是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)

2.1 默认绑定

独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。

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

直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定

在严格模式(strict mode), this 会绑定到undefined,否则绑定到全局对象window

2.2 隐式绑定

调用位置是否有上下文对象,或者说是否被某个对象拥有或者包

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

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

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 foo() 时 this 被绑定到 obj,因此 this.a 和 obj.a 是一样的

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

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

来看一个默认绑定与隐式绑定的例子:

function fn() {
	console.log(this);
}
let obj = {
	name: 'OBJ',
	fn: fn
};
fn();
obj.fn();
console.log(obj.hasOwnProperty('name')); //=>hasOwnProperty方法中的this:obj  TRUE
console.log(obj.__proto__.hasOwnProperty('name')); //=>hasOwnProperty方法中的this:obj.__proto__(Object.prototype)  FALSE
console.log(Object.prototype.hasOwnProperty.call(obj, 'name')); // TRUE <=> obj.hasOwnProperty('name')

隐式丢失

function foo() { 
    console.log( this.a ); 
} 
var obj = { 
    a: 2, 
    foo: foo 
}; 
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"

虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。

函数回调同样会产生隐式丢失:

function foo() { 
    console.log( this.a ); 
} 
function doFoo(fn) { 
    // fn 其实引用的是 foo 
    fn(); // <-- 调用位置!
}
var obj = { 
    a: 2, 
    foo: foo 
}; 
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一个例子一样

2.3 显示绑定

JavaScript 提供的绝大多数函数以及你自己创建的所有函数都可以使用 call(..) 和 apply(..) 方法

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

通过 foo.call(..),我们可以在调用 foo 时强制把它的 this 绑定到 obj 上。

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

  1. 硬绑定
    硬绑定的 bar 不可能再修改它的 this
function foo() { 
    console.log( this.a ); 
}
var obj = { 
    a:2 
}; 
var bar = function() { 
    foo.call( obj ); 
}; 
bar(); // 2 
setTimeout( bar, 100 ); // 2
bar.call( window ); // 2

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

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

var obj = { 
    a:2 
}; 
var bar = foo.bind( obj ); 
var b = bar( 3 ); // 2 3 
console.log( b ); // 5

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

  1. API调用上下文

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

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

2.4 new 绑定

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

  1. 创建(或者说构造)一个全新的对象。
  2. 这个新对象会被执行 [[Prototype]] 连接。
  3. 这个新对象会绑定到函数调用的 this。
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。

3. 优先级

  1. 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
var bar = new foo()
  1. 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。
var bar = foo.call(obj2)
  1. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。
var bar = obj1.foo()
  1. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到全局对象。
var bar = foo()

4. 绑定例外

4.1 忽略this

如果函数并不关心 this ,但是又需要传入一个占位值,可以使用null

foo.call( null )

但是总是使用 null 来忽略 this绑定可能产生一些副作用。如果某个函数确实使用了this(比如第三方库中的一个函数),那默认绑定规则会把 this 绑定到全局对象(在浏览器中这个对象是 window)

为了解决上述问题,我们可以使用以下方式:

传入一个特殊的对象,把 this 绑定到这个对象不会对你的程序产生任何副作用

function foo(a,b) { 
    console.log( "a:" + a + ", b:" + b ); 
} 
// 我们的 DMZ 空对象
var ø = Object.create( null ); 
// 把数组展开成参数
foo.apply( ø, [2, 3] ); // a:2, b:3 
// 使用 bind(..) 进行柯里化
var bar = foo.bind( ø, 2 ); 
bar( 3 ); // a:2, b:3

使用变量名 ø 不仅让函数变得更加“安全”,而且可以提高代码的可读性,因为 ø 表示“我希望 this 是空”

4.2 间接引用

function foo() { 
    console.log( this.a ); 
}
var a = 2; 
var o = { a: 3, foo: foo }; 
var p = { a: 4 }; 
o.foo(); // 3 
(p.foo = o.foo)(); // 2

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 而不是p.foo() 或者 o.foo()

4.3 软绑定

如果可以给默认绑定指定一个全局对象和undefined以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力

if (!Function.prototype.softBind) { 
    Function.prototype.softBind = function(obj) { 
         var fn = this; 
         // 捕获所有 curried 参数
        var curried = [].slice.call( arguments, 1 ); 
        var bound = function() { 
            return fn.apply( 
                (!this || this === (window || global)) ? 
                    obj : this,
                curried.concat.apply( curried, arguments ) 
            ); 
        }; 
        bound.prototype = Object.create( fn.prototype ); 
        return bound; 
    }; 
}

下面我们看看 softBind 是否实现了软绑定功能:

function foo() { 
    console.log("name: " + this.name); 
} 
var obj = { name: "obj" }, 
    obj2 = { name: "obj2" }, 
    obj3 = { name: "obj3" }; 
var fooOBJ = foo.softBind( obj ); 
fooOBJ(); // name: obj 
obj2.foo = foo.softBind(obj); 
obj2.foo(); // name: obj2 <---- 看!!!
fooOBJ.call( obj3 ); // name: obj3 <---- 看!
setTimeout( obj2.foo, 10 ); 
// name: obj <---- 应用了软绑定

4.4 箭头函数

箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this。箭头函数的绑定无法被修改

箭头函数没有prototype,也无法new。箭头函数没有this,它的this取决于外层函数的this,如果没有外层函数,就是全局作用域。

4.5 给元素的某个事件行为绑定方法

给元素的某个事件行为绑定方法,事件触发,方法执行,此时方法中的THIS一般都是当前元素本身


btn.onclick = function anonymous() {
	console.log(this); //=>元素
};
// =>DOM2
btn.addEventListener('click', function anonymous() {
	console.log(this);  //=>元素
}, false);
btn.attachEvent('onclick',function anonymous(){
	// <= IE8浏览器中的DOM2事件绑定
	console.log(this); //=>window
});

function fn() {	
	console.log(this);
}
btn.onclick = fn.bind(window); //=>fn.bind(window)首先会返回一个匿名函数(AM),把AM绑定给事件;点击触发执行AM,AM中的THIS是元素,但是会在AM中执行FN,FN中的THIS是预先指定的WINDOW

5. 实现apply, calll, bind

if (!Function.prototype.bind) { 
    Function.prototype.bind = function(oThis) { 
        if (typeof this !== "function") { 
             // 与 ECMAScript 5 最接近的
             // 内部 IsCallable 函数
            throw new TypeError( 
             "Function.prototype.bind - what is trying to be bound is not callable"
            ); 
        } 
        var aArgs = Array.prototype.slice.call( arguments, 1 ), 
            fToBind = this, 
            fNOP = function(){}, 
            fBound = function(){ 
                return fToBind.apply( 
                ( 
                    this instanceof fNOP && 
                    oThis ? this : oThis 
                ), 
                 aArgs.concat( 
                    Array.prototype.slice.call( arguments ) 
                ); 
            } 
        ; 
        fNOP.prototype = this.prototype; 
        fBound.prototype = new fNOP(); 
        return fBound; 
    }; 
}

这种 bind(..) 是一种 polyfill 代码(polyfill 就是我们常说的刮墙用的腻子)