老生常谈之this
this
是我们在面试或者开发过程中都会经常遇到的,本文依据《你不知道的JavaScript》上卷中提到的this相关的知识,进行统一整理。
为什么要用this
this
是 js
中非常复杂的机制,他真的值得我们付出时间和精力去学习吗?存在的意义到底是什么?
使代码简洁并且易于复用
this
提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将API
设计得更加简洁并且易于复用。
比如:
function identify() {
return this.name.toUpperCase();
}
function speak() {
var greeting = "Hello, I'm " + identify.call(this);
console.log(greeting);
}
var me = { name: "Kyle" };
var you = { name: "Reader" };
identify.call(me); // KYLE
identify.call(you); // READER
speak.call(me); // Hello, I'm KYLE
speak.call(you); // Hello, I'm READER
如果不使用this,那就需要给identify()和speak()显式传入一个上下文对象:
function identify(context) {
return context.name.toUpperCase();
}
function speak(context) {
var greeting = "Hello, I'm " + identify (context);
console.log(greeting);
}
identify(you); // READER
speak(me); //hello, I'm KYLE
对于this的误解
误解一:指向自身
我们经常会以为this
指向函数本身,从英语语法的角度这是说的通的,但是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 -- 什么?!
因为在js
中,函数也是对象,所以当执行foo.count = 0;
时,会向函数对象foo
中添加一个count
属性。
但是函数中的this.count
并没有指向函数对象foo
,所以即使函数调用了4次,foo.count
并没有变化。
话外音:如何在函数中正确的引用自身?
1.具名函数使用函数名
function foo() {
foo.count = 4; // foo指向它自身
}
2.匿名函数使用arguments.callee
这是唯一一种可以从匿名函数对象内部引用自身的方法。然而,更好的方式是避免使用匿名函数,至少在需要自引用时使用具名函数(表达式)。arguments.callee
已经被弃用,不应该再使用它。
误解二:指向函数的作用域
第二种常见的误解是,this
指向函数的作用域。这个问题有点复杂,因为在某种情况下它是正确的,但是在其他情况下它却是错误的。
需要明确的是,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
不可能在词法作用域中查到什么。
this到底是什么
this
是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this
的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。this
就是这个记录的一个属性,会在函数执行的过程中用到。
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的调用位置
绑定规则
要想确定this
的绑定对象,你必须找到调用位置,然后判断需要应用下面四条规则中的哪一条。我们首先会分别解释这四条规则,然后解释多条规则都可用时它们的优先级如何排列。
1.默认绑定
首先要介绍的是最常用的函数调用类型:独立函数调用。可以把这条规则看作是无法应用其他规则时的默认规则。
function foo() {
console.log(this.a);
}
var a = 2;
foo(); // 2
foo
函数的调用位置是全局作用域,所以this.a
中的this
指向全局。
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
绑定到这个上下文对象。因为调用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
隐式丢失
一个最常见的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"
3.显式绑定
通过call
、apply
将函数的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(..)
)。这通常被称为“装箱”。
硬绑定
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
由于硬绑定是一种非常常用的模式,所以ES5
提供了内置的方法Function.prototype.bind
,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
4.new绑定
使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
- 创建(或者说构造)一个全新的对象。
- 这个新对象会被执行[[Prototype]]连接。
- 这个新对象会绑定到函数调用的this。
- 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
function foo(a) {
this.a = a;
}
var bar = new foo(2);
console.log(bar.a); // 2
也就是说new
的时候会将this
绑定到新创建的对象,然后将新创建的对象返回。所以上例中this
指向bar
。
优先级
之前我们已经了解了四条规则,确定this
指向需要做的就是找到函数的调用位置并判断应当应用哪条规则。
但是,如果某个调用位置可以应用多条规则该怎么办?为了解决这个问题就必须给这些规则设定优先级。
- 函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。
- 函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话,this绑定的是指定的对象。
- 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this绑定的是那个上下文对象。
- 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到全局对象。
按照上述规则进行判断,大多情况下会让我们正确找到this
的指向,之所以说是大多情况,那是因为凡事都有例外。接下来就说说例外。
绑定例外
被忽略的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
可能是一个不错的选择,就像代码所示的那样。
但在ES6中,可以用...
操作符代替apply(..)
来“展开”数组,foo(...[1,2])
和foo(1,2)
是一样的,这样可以避免不必要的this
绑定。可惜,在ES6
中没有柯里化的相关语法,因此还是需要使用bind(..)
。
然而,总是使用null来忽略this绑定可能产生一些副作用。如果某个函数确实使用了this(比如第三方库中的一个函数),那默认绑定规则会把this绑定到全局对象(在浏览器中这个对象是window),这将导致不可预计的后果(比如修改全局对象)。显而易见,这种方式可能会导致许多难以分析和追踪的bug。一种“更安全”的做法是传入一个特殊的对象,把this绑定到这个对象不会对你的程序产生任何副作用。
间接引用
有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。间接引用最容易在赋值时发生:
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强制绑定到指定的对象(除了使用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
绑定到thi
s,否则不会修改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;
};
}
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 <---- 应用了软绑定
箭头函数
箭头函数不使用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
绑定到obj1
, bar
(引用箭头函数)的this
也会绑定到obj1
,箭头函数的绑定无法被修改。(new也不行!)
总结
如果要判断一个运行中函数的this
绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断this的绑定对象。
- 由new调用?绑定到新创建的对象。
- 由call或者apply(或者bind)调用?绑定到指定的对象。
- 由上下文对象调用?绑定到那个上下文对象。
- 默认:在严格模式下绑定到undefined,否则绑定到全局对象。
一定要注意,有些调用可能在无意中使用默认绑定规则。如果想“更安全”地忽略this绑定,你可以使用一个DMZ对象,比如ø = Object.create(null),以保护全局对象。
ES6中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的this绑定(无论this绑定到什么)。这其实和ES6之前代码中的self = this
机制一样。
转载自:https://juejin.cn/post/7254384497659347003