JavaScript 之 this 深入解析

这篇博客深入探讨了JavaScript中this关键字的工作原理,包括默认绑定、隐式绑定、显式绑定、new绑定以及它们的优先级。文章通过实例解释了各种绑定规则的适用场景,如函数调用方式、上下文对象、回调函数、硬绑定和软绑定等。此外,还介绍了箭头函数如何改变this的常规行为,以及如何安全地处理this的绑定,以防止意外修改全局对象。

this

MDN中对 this 的介绍是:
在 JavaScript 中的函数的 this 关键字与其他语言相比,表现略有不同,此外,在严格模式和非严格模式之间也会有一些差别。

在绝大多数情况下,函数的调用方式决定了 this 的值(运行时绑定)。this 不能在执行期间被赋值,并且在每次函数被调用时 this 的值也可能会不同。ES5 引入了 bind 方法来设置函数的 this 值,而不用考虑函数如何被调用的。ES2015 引入了箭头函数,箭头函数不提供自身的 this 绑定(this 的值将保持为闭合词法上下文的值)。

this 调用位置

在函数内部,this的值取决于函数被调用的方式。

function baz() {
    // 当前调用栈是:baz
    // 因此,当前调用位置是全局作用域

    console.log("baz");
    bar();// <--bar的调用位置
}
function bar() {
    //当前调用栈是 baz -> bar
    //因此当前调用位置在baz中

    console.log("bar");
    foo(); // <-- foo的调用位置
}

function foo() {
    debugger
    // 当前调用栈是 baz -> bar -> foo
    // 因此当前调用位置在bar中

    console.log("foo");
}

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

绑定规则

默认绑定

这是最常用的函数调用类型:独立函数调用。可以把这条规则看做是无法应用其它规则时的默认规则。
思考下面代码

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

var a = 2;
foo(); // 2

这里需要注意的第一件事就是,声明在全局作用域中的变量就是全局对象的一个同名属性。
接下来当调用 foo() 时,this.a 被解析成立全局变量a。
为什么?
这是因为函数调用时应用了 this默认绑定,因此 this 指向全局对象。
怎么知道应用了默认绑定?
通过分析调用位置来看 foo() 是如何调用的,foo()是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其它规则。

这里有一个细节,虽然 this 绑定规则完全取决于调用位置,但是只有 foo() 运行在非 strict mode下时,默认绑定才能绑定到全局对象;

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

var a = 2;

foo(); //TypeError: this is undefined

严格模式调用不影响默认绑定

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

var a = 2;

(function(){
    "use strict";
    foo(); // 2
})();

通常来说不应该在代码中混合使用strict模式和非strict模式。

隐式绑定

隐式绑定需要考虑的规则时调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含

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

var obj = {
    a: 2,
    foo: foo
}

obj.foo(); //2

调用位置会使用 obj 上下文来引用函数,因此可以说函数被调用时 obj 对象"拥有"或者"包含"函数引用。
foo()被调用时,它的前面确实加上了对obj的引用。
当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。
因为调用 foo()this 被绑定到了 obj,因此 this.aobj.a 是一样的。

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

function foo(){
    console.log(this.a)
}
var obj2 = {
    a: 42,
    foo: foo //调用位置
};

var obj1 = {
    a: 2,
    obj2:obj2
};

obj1.obj2.foo(); //42

隐式丢失

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

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

var obj = {
    a: 2,
    foo: foo
};

var bar = obj.foo; //函数别名
var a = "oops, global"; // a 是全局对象的属性

bar(); // "oops, global"

虽然 barobj.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"

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

把函数传入JS内置的函数,结果也是一样的。

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

var obj = {
    a: 2,
    foo: foo
};

var a = "oops, global"; // a 是全局对象的属性

setTimeout(obj.foo,100); // "oops, global"

JavaScript 环境中内置的setTimeout函数实现类似下面的伪代码:
function setTimeout(fn,delay){
    // 等待 delay 毫秒
    fn(); // <-- 调用位置!
}

回调函数丢失 this 绑定是非常常见的。

同时调用回调函数的的函数可能会修改 this,在一些流行的 JavaScript 库中事件处理器常会把回调函数的 this强制绑定到触发事件的DOM元素上,通常这些工具无法选择是否启用这个行为。

在这些情况下,this 的改变都是意想不到的,实际上无法控制回调函数的执行方式,因此也没有办法控制调用位置来得到期望的绑定(固定 this 可以修复这个问题)。

显式绑定

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

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

JavaScript 中的"所有"函数都有一些有用的特性(这和它们的 [[Prototype]] 有关),可以用来解决这个问题。

具体来说,可以使用函数的 call(...)apply 方法。

严格的说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们并没有这两个方法。

但是这样的函数非常好久,JavaScript 提供的绝大多数函数以及我们自己创建的所有函数都可以使用 call(...)apply(...) 方法。

它们是怎么工作的呢?

它们的第一个参数是一个对象,是给 this 准备的,接着在调用函数时将其绑定到 this

因为我们可以直接制定 this 的绑定对象,所有我们称之为显式绑定。

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(...)),这通常被称为装箱。

从this绑定的角度来说,call(...)apply(...) 是一样的,它们的区别在于其它参数上

可惜,显式绑定无法解决之前所说的丢失绑定问题。

硬绑定

显式绑定的一个变种 “硬绑定” 可以解决这个问题。

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

var obj = {
	a:2
};

var bar = function(){
	foo.call(obj);
};

bar(); //2
setTimeout(bar,100); //2

//硬绑定的bar不能在修改它的this
bar.call(window);//2

我们看看这个变种到底是怎样工作的,我们创建了一个函数 bar(),并在它的内部手动调用了 foo.call(obj) ,因此强制把 foothis 绑定到了 obj,无论之后如何调用函数 bar,它总会手动在obj 上调用 foo

这种绑定就是一种显式的强制绑定,因此我们成为硬绑定。

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

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

var bar = function(){
    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
}

var bar = foo.bind(obj);

var b = bar(3); // 2 3
console.log(b); // 5

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

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

这些函数实际上就是通过 call(...) 或者 apply(...) 实现了显示绑定,这样可以少写一些代码。

new 绑定

最后一条 this 绑定规则,在了解它之前需要先了解一个非常常见的关于 JavaScript 中函数和对象的误解。

在传统的面向类的语言中,"构造函数"是类的一些特殊方法,使用 new 初始化类是会调用类中的构造函数。

通常形式是这样的:something = new MyClass(...);

JavaScript 中也有 new 操作符,使用方法看起来和面向类的语言一样,大部分开发者认为 JavaScript 中的 new 的机制也是和那些语言一样的。

然而,JavaScriptnew 的机制实际上和面向类的语言完全不同。

JavaScript 中构造函数只是一些使用 new 操作符时被调用的函数。

它们并不会属于某个类,也不会实例化一个类。

实际上它们甚至不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。

包括内置对象(比如 Number(...))在内的所有函数都可以用 new 来调用,这种函数调用被称为构造函数调用。

这里有一个非常重要但是非常细微的区别:实际上并不存在"构造函数",只有对于函数的"构造调用"。

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

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

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

使用 new 来调用 foo(...) 时,我们会构造一个新对象并把它绑定到 foo(...) 调用中的 this上。

new 是最后一种可以影响函数调用时 this 绑定的方法,我们称为 new 绑定。

优先级

当我们了解了函数调用中 this 绑定的四条规则,需要做的就是找到函数的调用位置并判断应当应用那条规则。

但是当某个调用位置可以应用多条规则怎么办?

为了解决这个问题就必须给这些规则设定优先级。

首先,默认绑定的优先级是四条规则中最低的,先不考虑。

隐式绑定和显式绑定那个优先级更高?

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

var bar = new obj1.foo(4);
console.log(obj1.a); // 2
console.log( bar.a ); //4

可以看到 new 绑定比隐式绑定优先级更高,但是 new 绑定和显式绑定谁的优先级更高呢?

newcall/apply 无法一起使用,因此无法通过 new foo.call(obj1)来直接进行猜测是。但是可以使用硬绑定来测试它们的优先级。

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

bar 被硬绑定到了 obj1 上,但是 new bar(3) 并没有像预期的那要把 obj1.a 修改为3

相反,new 修改了硬绑定(得到 obj1 的)调用 bar(...) 中的 this

因为使用了 new 绑定,我们得到了一个名字为baz的新对象,并且baz.a的值是 3.

再来看看之前介绍的辅助函数 bind

// 伪代码,ES5 内置的 Function.prototype.bind(...)更加复杂
function bind(fn,obj){
	return function(){
		fn.apply(obj,arguments)
	}
}

看上去在辅助函数里面 new 操作符的调用无法修改 this 绑定,但是在刚刚的代码中 new 确实修改了 this 绑定。

下面是 new 修改 this 的相关代码:

this instanceof FNOP &&
oThis ? this : oThis

// ... 以及:

FNOP.prototype = this.prototype;
fBound.prototype = new FNOP();

这段代码会判断硬绑定函数是否被 new 调用,如果是就会使用新创建的 this 代替 硬绑定的 this

那么,为什么要在 new 中使用硬绑定函数呢?

之所以要在 new 中使用硬绑定函数,主要目的是预先设置函数的一些参数,这样在使用 new 进行初始化的时候就可以只传入其余的参数。

bind(...) 的功能之一就是可以把除了第一个参数(第一个参数用于绑定 this)之外的其它参数都传给下层的函数(这种技术称为"部分应用",是"柯里化"的一种)。

例子:

function foo(p1,p2){
    this.val = p1 + p2;
}

// 之所以使用 null 是因为目前我们并不关心硬绑定的 this 是什么
// 反正使用new时 this 会被修改
var bar = foo.bind(null,"p1");

var baz = new bar("p2");

baz.val; // 'p1p2'

判断 this

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

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

var bar = new foo()

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

var bar = foo.call(obj2)

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

var bar = obj1.foo()

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

var bar = foo()

对于正常的函数调用来说,理解这些就可以明白 this 的绑定原理了,不过,凡事总有例外。

绑定例外

在某些场景下 this 的绑定行为会出乎意料,你认为应该应用其他绑定规则时,实际上应用的可能是默认绑定规则。

被忽略的 this

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

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

var a = 2;

foo.call( null ); //2

那么什么情况可能会需要传 null 呢?

常见的做法是使用 apply(...) 来 “展开” 一个数组,并当作参数传入一个函数。

类似的,bind(...) 可以对参数柯里化(预先设置一些参数),这种方法有时非常有用。

function foo(a,b){
	console.log("a:"+ a + ", b:" + b);
}
// 把数组 "展开" 成参数
foo.apply( null, [2,3]); //a:2,b:3

// 使用 bind(...) 进行柯里化
var bar = foo.bind(null, 2);
bar(3); // a:2,b:3

这两种方法都需要传入一个参数当做 this 的绑定对象。

如果函数并不关心 this 的话,仍然需要传入一个占位符,这是 null 是一个不错的选择。

在 ES6 中,可以用 … 操作符代替 apply(…)来 “展开” 数组。

foo(…[1,2]) 和 foo(1,2) 是一样的,这样可以避免不必要的 this 绑定,但是在ES6中没有柯里化相关语法,所有还是需要 bind(…)

总是使用 null 忽略 this 绑定可能产生一些副作用。

如果某个函数确实使用了 this(比如第三方库中某个函数),那默认绑定会把 this 绑定到全局对象(浏览器中是 window),这将导致一些可能的后果(比如修改全局对象)。

更安全的 this

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

就想网络一样,我们可以创建一个 "DMZ"(demilitarized zone) 对象 —— 它就是一个空的非委托对象。

如果在忽略 this 绑定时总是传一个 DMZ 对象,那么就不要担心了,因为任何对 this 的使用都会被限制在这个空对象中,不会对全局对象产生任何影响。

在JavaScript中创建一个空对象最简单的方法是 Object.create(null)

Object.create(null){} 很像,但是并不会穿件 Object.prototype 这个委托,所以它比 {} “更空”

function foo(a,b){
	console.log("a:"+ a + ", b:" + b);
}

// DMZ空对象
var zero = Object.create(null);

// 把数组展开成参数
foo.apply(zero,[2,3]); // a:2,b:3

//使用 bind(...)进行柯里化
var bar = foo.bind(zero,2);
bar(3);//a:2,b:3

间接引用

需要注意你可能(有意或无意)创建一个函数的 “间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。

间接引用最容易在赋值时发生:

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()

根据之前的了解,这里会应用默认绑定。

注意:对于默认绑定来说,决定this绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。

如果函数体处于严格模式,this会被绑定到 undefined,否则 this会被绑定到全局对象。

软绑定

硬绑定这种方式可以把 this 强制绑定到执行的对象(除了使用 new 时),防止函数调用应用默认绑定规则。

问题在于硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 this

如果可以给默认绑定制定一个全局对象和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(...) 的其它原理和 ES5 内置的 bind(...) 类似。

它会对指定的函数进行封装,首先检查调用时的 this,如果 this绑定到全局对象或者 undefined,那就把制定的默认对象 obj 绑定到 this,否则不会修改 this

此外这段代码还支持可选柯里化。

下面我们看看 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 <--- 应用了软绑定

可以看到,软绑定版本的 foo() 可以手动将 this 绑定到 obj2 或者 obj3上,但如果默应用认绑定,则会将 this 绑定到 obj

this 语法

之前介绍的四条规则已经可以包含所有正常的函数。

但是 ES6 中新增了一张无法使用这些规则的特殊函数类型:箭头函数

箭头函数并不是使用 function 关键字定义的,而是使用被称为"胖箭头"的操作符 => 来定义的。

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

我们来看看箭头函数的语法作用域:

function foo(){
	// 返回一个箭头函数
	return (a) => {
		// this 继承自 foo()
		console.log( this.a );
	};
}

var obj1 = {
	a:2
};

var obj2 = {
	a:3
};

var bar = foo.call(obj1);
bar.call( obj2 ); //2 ,不是3!

foo() 内部创建的箭头函数会捕获调用时 foo()this

由于foo()this 绑定到 obj1bar (引用箭头函数)的this也会绑定到 obj1,箭头函数的绑定无法被修改(new 也不行)。

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

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

var obj = {
	a:2
};
foo.call(obj);//2

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

实际上,在 ES6 之前我们就已经在使用一张几乎和箭头函数完全一样的模式。

function foo(){
	var self = this; 
    setTimeout(function(){
        console.log(self.a);
    },100)
}

var obj = {
    a:2
};

foo.call(obj);//2

虽然 self = this和箭头函数看起来都可以取代 bind(...),但实际上,它们想替代的是 this 机制。

总结

如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。

找到之后就可以应用项目这四条规则来判断 this的绑定对象。

  1. new 调用?绑定到新创建的对象。
  2. call 或者apply(或者 bind )调用?绑定到指定的对象。
  3. 由上下文对象调用?绑定到那个上下文对象。
  4. 默认:在严格模式绑定到 undefined,否则绑定到全局对象。

要注意,有些调用可能在吴彦祖使用默认绑定规则。如果想"更安全"地好了this绑定,可以使用一个 DMZ 对象,比如 zero = Object.create(null),以保护全局对象。

ES6的箭头函数并不会使用这四条标准规则,而是根据当前的词法作用域来决定 this ,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值