《你不知道的JavaScript(上)》笔记——this全面解析

首先要理解调用位置: 调用位置就是函数在代码中被调用的位置(而不是声明的位置)。

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

function baz() {
	// 当前调用栈是: baz
	// 因此, 当前调用位置是全局作用域
    
	console.log( "baz" );
	bar(); // <-- bar 的调用位置
}

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

function foo() {
	// 当前调用栈是 baz -> bar -> foo
	// 因此, 当前调用位置在 bar 中
    
	console.log( "foo" );
} 
baz(); // <-- baz 的调用位置
绑定规则
  1. 默认绑定。

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

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

如果使用严格模式(strict mode), 那么全局对象将无法使用默认绑定, 因此 this 会绑定到 undefined:

function foo() {
	"use strict";
	console.log( this.a );
}
var a = 2;
foo(); // TypeError: this is undefined
  1. 隐式绑定

    另一条需要考虑的规则是调用位置是否有上下文对象, 或者说是否被某个对象拥有或者包含, 不过这种说法可能会造成一些误导。

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

首先需要注意的是 foo() 的声明方式, 及其之后是如何被当作引用属性添加到 obj 中的。但是无论是直接在 obj 中定义还是先定义再添加为引用属性, 这个函数严格来说都不属于obj 对象。
然而, 调用位置会使用 obj 上下文来引用函数, 因此你可以说函数被调用时 obj 对象“拥有” 或者“包含” 它。

当函数引用有上下文对象时, 隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。

隐式丢失:一个最常见的 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

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

参数传递其实就是一种隐式赋值, 因此我们传入函数时也会被隐式赋值,回调函数丢失 this 绑定是非常常见的。

  1. 显式绑定

    像call, apply, bind这三种可以直接指定 this 的绑定对象的方法,我们称之为显式绑定。

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

  1. new绑定

    JavaScript 中 new 的机制实际上和面向类的语言完全不同。

在 JavaScript 中, 构造函数只是一些使用 new 操作符时被调用的函数。 它们并不会属于某个类, 也不会实例化一个类。 实际上,它们甚至都不能说是一种特殊的函数类型, 它们只是被 new 操作符调用的普通函数而已。

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

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

现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。 可以按照下面的顺序来进行判断:

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

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

  • 如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、 apply 或者 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 可能是一个不错的选择, 就像代码所示的那样。

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

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

在 JavaScript 中创建一个空对象最简单的方法都是 Object.create(null),Object.create(null) 和 {} 很 像, 但 是 并 不 会 创 建 Object.prototype 这个委托, 所以它比 {}“更空” :

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
  • 间接应用

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

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。
如果可以给默认绑定指定一个全局对象和 undefined 以外的值, 那就可以实现和硬绑定相同的效果, 同时保留隐式绑定或者显式绑定修改 this 的能力。这个就是软绑定。

实现如下:

if (!Function.prototype.softBind) {
	Function.prototype.softBind = function(obj) {
		var fn = this; // fn就是调用的函数
        
		// 捕获所有 curried 参数
		var curried = [].slice.call( arguments, 1 ); 
        
		var bound = function() {
			return fn.apply(
				(!this || this === (window || global)) ?
					obj : this
				curried.concat.apply( curried, arguments ) //这里的argements是bound的arguments,也就是说在softBind的时候可以传参一次,后面可以再传一次,参数会在这里合并起来
			);
		};
		bound.prototype = Object.create( fn.prototype ); // 原型链继承过来
		return bound;
	};
}

首先检查调用时的 this, 如果 this 绑定到全局对象或者 undefined, 那就把指定的默认对象 obj 绑定到 this, 否则不会修改 this。

应用场景:

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。

posted @ 2019-06-15 12:30  simple小前端  阅读(314)  评论(0编辑  收藏  举报