1.1 关于 this
当模式越来越复杂时,显示传递上下文对象会让代码变得越来越混乱,使用this则不会这样。可自动引用合适的上下文对象非常重要。
1.2 误解
1.2.1 指向自身
this并不像我们所想的那样指向函数本身
function foo(num){
console.log("foo: " + num);
// 记录 foo 被调用的次数
this.count++;
}
foo.count = 0;
var i;
for(i=0; i<10; i++){
if( i > 5){
foo(i);
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log(foo.count); // 0
执行foo.count=0
时,的确向函数对象foo添加了一个属性count。但是函数内部代码this.count
中的this
并不指向那个函数对象,所以虽然属性名相同,根对象却并不相同。
这段代码在无意中创建了一个全局变量count,它的值为NaN。
如果要从函数对象内部引用它自身,那只用this是不够的。一般来说你需要通过一个指向函数对象的词法标识符来引用它。
function foo() {
foo.count = 4; // foo 指向它自身
}
setTimeout( function(){
// 匿名函数无法指向自身
}, 10);
所以,另一种解决方法是使用foo 标识符替代this来引用函数对象:
function foo(num){
console.log("foo: " + num);
// 记录 foo 被调用的次数
foo.count++;
}
foo.count = 0;
var i;
for(i=0; i<10; i++){
if( i > 5){
foo(i);
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log(foo.count); // 4
然而,这种方法回避了this的问题,另一种方法是强制this指向foo函数对象:
function foo(num){
console.log("foo: " + num);
// 记录 foo 被调用的次数
this.count++;
}
foo.count = 0;
var i;
for(i=0; i<10; i++){
if( i > 5){
foo.call(foo, i);
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
// foo 被调用了多少次?
console.log(foo.count); // 4
这次我们接受了this,没有回避它。
1.2.2 它的作用域
作用域“对象”无法通过JS代码访问,它存在于JS引擎内部。this在任何情况下都不指向函数的词法作用域
function foo() {
var a = 2;
this.bar();
}
function bar() {
console.log(this.a);
}
foo(); // ReferenceError: a is not defined
首先,这段代码试图通过this.bar()
来引用bar()
函数。这是绝对不可能成功的(后面解释)。调用bar()
最自然的方法是省略前面的this,直接使用词法标识符。
此外,还试图使用 this 联通 foo()
和 bar()
的词法作用域,从而让 bar()
可访问 foo()
作用域里的变量a。这是不可能实现的,你不能使用 this 来引用一个词法作用域内部的东西。
1.3 this到底是什么
this是在运行时绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动记录(上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。
2 this 全面解析
2.1 调用位置
最重要的是要分析调用栈。我们关系的调用位置就在当前正在执行的函数的前一个调用中。
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的调用位置
你可把调用栈想象成一个函数调用链。另一种查看调用栈的方法是使用浏览器的调试工具。
2.2 绑定规则
2.2.1 默认绑定
首先是最常用的函数调用类型:独立函数调用。可看做是默认规则。
function foo() {
console.log(this.a);
}
var a = 2;
foo(); //2
全局作用域中的变量a
,就是全局对象的一个同名属性。它们本质上就是同一个东西。
当调用foo()
时,this.a
被解析成了全局变量a。因为函数调用时应用了this
的默认绑定,因此this
指向位居对象。
如果使用严格模式(strict mode),那么全局对象将无法使用默认绑定,因此
this
会绑定到undefined
function foo() {
"use strict";
console.log(this.a);
}
var a = 2;
foo(); // TypeError: this is undefined
2.2.2 隐式绑定
另一个需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含:
function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); //2
调用位置会使用obj上下文来引用函数,因此你可说函数被调用时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
隐式丢失
一个常见的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";
doFoo(obj.foo); // "oops, global";
参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值。所以和上面结果是一样的。
回调函数丢失
this
是非常常见的。
2.2.3 显示绑定
即call()
和 apply()
方法
function foo() {
console.log( this.a );
}
var obj = {
a: 2
};
foo.call(obj); //2
可惜,显式绑定仍然无法解决我们之前提出的丢失绑定问题。
- 硬绑定
但是显式绑定的一个变种可解决这个问题。
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)
,因此强制把foo
的this
绑定到了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
另一个使用方法是创建一个 i 可重复使用的辅助函数:
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的上下文并调用原始函数。
2.2.4 new绑定
先澄清一个误解。在传统面向类的语言中,“构造函数”是类中的一些特殊方法,使用new初始化类时会调用类中的构造函数。
JS也有一个new操作符,使用方法看起来一样。然而,JS中的new的机制实际上和面向类的语言完全不同。
我们来重新定义下JS 中的“构造函数”。在JS中,构造函数只是一些使用new操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被new操作符调用的普通函数而已。
实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
使用new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
- 创建(或构造)一个全新的对象。
- 这个新对象会被执行[[原型]]连接。
- 这个新对象会绑定到函数调用的this。
- 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log(bar.a); //2
使用new
来调用foo(..)
时,我们会构造一个新对象并把它绑定到foo(..)
调用中的this
上。new
是最后一种可影响函数调用时this
绑定行为的方法,我们称为new绑定
。
#2.3 优先级
默认绑定是优先级中最低的。
显式绑定高于隐式绑定
new绑定高于隐式绑定
new绑定 和 显式绑定呢?
new 和 call/apply 无法一起使用,因此无法通过 new foo.call(obj1); 来直接测试。但可用硬绑定来测试它俩的优先级。
先回忆下硬绑定是如何工作的。Function.prototype.bind(..)会创建一个新的包装函数,这个函数会忽略它当前的this绑定(无论绑定的对象是什么),并把它们提供的对象绑定到this上。
这样看起来硬绑定似乎比new绑定的优先级更高,无法使用new来控制this绑定。
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修改了硬绑定(到obj1的)调用bar(..)中的this。因为使用了new绑定,我们得到了一个名字为baz的新对象,并且baz.a 的值是 3
在new中使用硬绑定函数。主要目的是预先设置函数的一些参数,这样在使用new进行初始化时就可只传入其余的参数。bind(..)都功能之一就是可把除了第一个参数(用于绑定this)之外的其他参数都传给下层函数(这种技术称为“部分应用”,是“柯里化”的一种):
function foo(p1, p2) {
this.val = p1 + p2;
}
// 使用 null 是因为本例中不关心硬绑定的this是什么
// 反正使用new时也会被修改
var bar = foo.bind(null, "p1");
var baz = new bar("p2");
baz.val; // p1p2
判断this
可根据优先级来判断函数在某个调用位置应用的是哪条规则。
- 函数是否在new中调用?是:this绑定的是新创建的对象
var bar = new foo() - 函数是否通过call / apply (显式绑定)或硬绑定调用?是:this绑定的是指定对象。
var bar = foo.call(obj2); - 函数是否在某个上下文对象中调用(隐式绑定)?是:this绑定的是那个上下文对象。
var bar = obj1.foo(); - 如果都不是的话,使用默认绑定。严格模式下,绑定到undefined,否则绑定到全局对象。
var bar = foo();
2.4 绑定例外
2.4.1 被忽略的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
在ES6中,可用...操作符来展开数组。foo(...[1,2])和foo(1,2)是一样的。
然而,总是使用null来忽略this绑定可能产生副作用 。如确实使用了this会被默认绑定到全局对象。会导致难以分析和追踪的bug.
更安全的this
我们可创建一个DMZ(demilitarized zone,非军事区)对象,它就是一个空的非委托的对象。
如果在忽略this绑定时总是传入一个DMZ对象,那就什么都不用担心了,因为任何对this的使用都会被限制在这个空对象中。
这个空对象可用 ø 命名,在mac可用 option + o 来打出这个符号。
在JS中创建一个空对象最简单的方法是 Object.create(null)
。它和{} 很像,但是并不会创建 Object.prototype这个委托,所以“更空”:
function foo(a, b) {
console.log("a: " + a + ", b:" + b);
}
var ø = Object.create(null);
foo.apply(ø, [2, 3]); // a:2, b:3
var bar = foo.bind(ø, 2);
bar( 3 ); //a:2, b:3
2.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()。
2.4.3 软绑定
硬绑定会大大降低函数的灵活性,使用硬绑定后就不可在使用隐式或显式绑定来修改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;
}
}
首先检查调用时的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 <----- look!
fooOBJ.call( obj3 ); // name: obj3 <---- look!
setTimeout( obj2.foo, 10 );
// name: obj <---- 应用了软绑定
2.5 this词法
箭头函数不使用this的四种标准规则,而是根据外层作用域来决定this。常用于回调函数中。
function foo() {
setTimeout(() => {
// 这里的this 在此法上继承自foo()
console.log( this.a );
})
}
var obj = {
a: 2
}
foo.call(obj); //2
如果你经常编写this风格的代码,但是绝大部分时候都会用self = this 或者箭头函数来否定 this 机制,那你或许应当:
- 只使用词法作用域并完全抛弃错误this风格代码;
- 完全采用 this 风格,在必要时使用bind(..),尽量避免使用 self = this 和箭头函数。