Javascript中的this的全面解析

——《你不知道的Javascript(上卷)》总结

整   理:肖雅君

时   间:2016-10-21

说   明:Javascript中的this的全面解析

this关键字的js最复杂的机制,是一个很特别的关键字,被自动定义在所有的函数作用域中,但是实际使用的时候很难说出this到底指向什么。下面将分2部分介绍this,每一部分都有各自解析的重点和主要内容。

一、this的含义和误区
1.为什么使用this
2.两种常见的对this的误解
3.this到底是什么

二、this的深入解析
1.什么是调用位置
2.绑定规则
(1)默认绑定
(2)隐式绑定
(3)显式绑定
(4)new绑定
3.this词法

【this的含义和误区】

一、为什么使用this
书中通过两段代码的对比来解释为什么要使用this,第一段代码如下:这段代码在不同的上下文对象(you和me)中重复使用函数identify()和speak(),不用针对不同的对象编写不同版本的函数:
function identify(){
return this.name.toUpperCase();
}
function speak(){
var greeting = "Hello, I am" + " " +identify.call(this);
console.log(greeting);
}
var me = {name:"Jay"};
var you = {name:"reader"};

identify.call(me);//JAY
identify.call(you);//READER
speak.call(me);//Hello, I am JAY
speak.call(you);//Hello, I am READER

如果不使用this,那就需要给identify()和speak()显示传入一个上下文对象:

function identify(cxt){
return cxt.name.toUpperCase();
}
function speak(cxt){
var greeting = "Hello, I am" + " " +identify(cxt);
console.log(greeting);
}
var me = {name:"Jay2"};
var you = {name:"reader2"};

identify(you);//READER2
speak(me);//Hello, I am JAY2

对比发现:this提供了一种更好的方式在隐式“传递”一个对象引用。因为随着模式越来越复杂,显式传递上下文对象会让代码变得越来越混乱。因此,通过使用this可以将API设计的冯家简洁,并且易于复用。

二、两种常见的对于this的误解

1-误解1:指向函数本身

把this理解成指向函数本身,这种推断是按照英语语法的角度来推断的。
常见的在函数内部引用自身的情况有:递归或者是一个在第一次被调用后自己解除绑定的事件处理器。新手会认为:既然可以把函数看作一个对象,那就可以在调用函数时存储状态(属性的值)。
下面来通过记录分析函数foo被调用的次数来证明下this并不是指向的函数本身:

function foo(num){
console.log("foo:" + num);
this.count++;//记录foo被调用的次数
}
foo.count = 0;
for(var i = 0; i < 10; i++){
if(i>5){
foo(i);
}
}
//foo:6
//foo:7
//foo:8
//foo:9
console.log(foo.count); //0

为什么会是0呢?
foo()函数中的console.log语句产生了4条输出,证明foo()确实被调用了4次,但是foo.count仍然是0,所以,从字面上理解,this指向函数本身是错的。那么,问题的原因是什么呢?
foo()函数是在全局作用域下执行的,this在这段代码中其实指向window,并这段代码再无意中创建了一个全局变量count,他的值是NaN。
那么为什么ths的行为和预期的不一致呢?书中提供了三种解决方法,其中前两种方法,回避了this的含义和工作原理。代码如下:

方法一:运用作用域(词法作用域)方法,该方法解决了我们遇到的问题,但是没有直面this.

function foo(num){
console.log("foo" + num);
data.count++;//记录foo被调用的次数
}
var data = {count:0};

for (var i = 0; i < 10; i++){
if(i > 5){
foo(i);
}
}
//foo6
//foo7
//foo8
//foo9
console.log(data.count);//4

===========================拓展分界线===========================

补充:这边涉及到一个作用域的问题,js中所说的词法作用域其实就是静态作用域,我随便扒了一段代码,来解释下js里的词法作用域和其他语言的作用域的区别。

比如说,在c#中有一段代码:
static void Main(string[] args)
{
if(true)
{
int num = 10;
}
Console.Write(num);
}
//报错: 因为上下文中不存在num这个变量。因为这边的作用域是用花括号决定的,叫做块级作用域。

换种方式:
static void Main(string[] args)
{
if(true)
{
int num = 10;
System.Console.WriteLine(num);
}
}
而在js中:
if(true) {
var num = 10;
}
alert(num);
//10

弹出框10,所以在js中变量的作用范围是怎么限定的呢?

1.函数限定变量作用域:
在JS中,只有函数可以限定一个变量的作用范围。
也就是说,在JS中,在函数里面定义的变量,可以在函数里面被访问,但是在函数外无法访问。看如下代码:
var func = function() {
var num = 10;
};
try{
alert(num);
}catch( e ){
alert( e );
};
//抛出异常:num is not defined

这段代码再执行的时候,会抛出异常:num is not defined;因为num是在函数里面定义的,在函数外则不能使用。也就是说,在函数里面定义的变量在函数外不能使用,在函数内可随意使用,即使是在赋值之前。如下代码:(注:调试的时候在浏览器控制台调试不会抛出异常,会正常弹出10,需在本地测试。)

var func = function(){
alert(num);
var num = 10;
alert(num);
};
try{
func();
}catch(e){
alert(e);
}
这段代码运行后,不会抛出错误,会产生两次弹窗,一次undefined一次10(下文解释为什么,这里面涉及到“变量名提升和函数名提升”的概念)。从这里可以看出,变量只有在函数中可以被访问,同理在改函数中的函数也可以访问。

2.子域访问父域
之前说了,函数可以限定变量的作用域,那么在函数中的函数就可以成为该作用已的子域。在子域中的代码可以访问到父域中的变量,代码如下:
var func = function(){
var num = 10;
var sub_func = function(){
alert(num);
};
sub_func();
};
func();//10
这段代码的执行结果是10,可看出子欲可访问父域中的变量num。但是子域中访问父域的代码也是有条件的,如下:

var func = function(){
var num = 10;
var sub_func = function(){
var num = 20;
alert(num);
}
sub_func();
}
func();//20
执行结果是20,此时的num输出的是子域里的值。

由此可见js中的变量访问规律:js解释器现在当前的作用域中搜索是否有该变量的定义,如果有,则使用这个变量;如果无,则到父级中去寻找。层层往上,直到顶级作用域,如果顶级中也不存在,则抛出异常“变量未定义”。
综合如下:
(function() {
var num = 10;
(function() {
var num = 20;
(function(){
alert(num);
})()
})();
})();

//这段代码执行后打印出20. 如果将"var num = 20;"去掉,那么打印的就是10. 同样,如果再去掉"var num = 10",那么就会出现未定义的错误。

3.变量名提升和函数名提升
变量名提升:
var num = 10;
var func = function() {
alert(num);
var num = 20;
alert(num);
};
func();
//undefined 20

像这样将代码定义在后面,而在前面使用的情况在Js中很常见,如:
var num = 10;
var func = function(){
var num;// 感觉就是这里已经定义了,但是没有赋值一样
alert(num);
var num = 20;
alert(num);
};
func();
这种现象就被称为变量名提升。

同样有函数名提升:
var func = function() {
alert("调用外面的函数");
};

var foo = function() {
func();
var func = function() {
alert("调用内部的函数");
};
func();
};

===========================拓展分界线END===========================

方法二 :创建一个指向函数对象的词法标识符(变量)来引用它。同样该方法仍旧回避了this的问题。
function foo(num){
console.log("foo: " + num);
foo.count++; // foo指向它自身
}
foo.count = 0;
for(var i=0; i<10; i++){
if(i > 5){
foo(i);
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
console.log(foo.count);// 4

方法三: 因为this在foo函数执行时指向了别处,所以可强制this指向foo函数。
function foo(num){
console.log("foo: " + num);
this.count++;
}
foo.count = 0;
for(var i=0; i<10; i++){
if(i > 5){
foo.call(foo, i); //使用call()可以确保this指向函数本身
}
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
console.log(foo.count);// 4

2-误解2:指向函数作用域

第二种常见的误解是:this指向函数作用域。这个问题有点复杂,因为在某种情况下它是正确的,但在其他情况下他却是错误的。
但一定要明白,this在任何情况下都不指向函数的作用域,在javascript内部作用域和对象确实很相似,可见的标识符都是他的属性,但作用域“对象”无法通过JavaScript代码访问,它存在于JavaScript引擎内部。
function foo(){
var a = 2;
this.bar();
}
function bar(){
console.log(this.a);
}
foo();
// undefined
这段代码试图通过this联通foo()和bar()的词法作用域,从而让bar()可以访问foo()作用域的变量a。这是错误的。

三、this到底是个什么鬼
排除以上的几种误解,可以得出以下结论:
1.this是在运行时进行绑定的,并不是在编写的时候绑定的。
2.this的绑定和函数的声明的位置没有关系,只取决于函数的调用方式。

具体细节是:当一个函数被调用的时候,会创建一个活动记录,这个活动记录会包含一些信息,如:函数在哪里被调用、函数的调用方式、传入的参数等等,而this就算这个记录的一个属性,会在函数执行的时候被用到。

四、总结
1.随着程序使用的模式越来越复杂,显示传递上下文对象会让代码显得很乱,因此通过this隐式传递可以将API设计的更加简洁并且利于复用。
2.this既不指向函数自身,也不指向函数的作用域。
3.this实际上是函数被调用时发生的绑定,它的指向完全取决于函数在哪里被调用。

关于变量的作用域的小结:
在js中词法作用域又叫做静态作用域,作用域的访问规则可以画成一个作用域链:
1) 作用域链就是对象的数组
2) 全部script是0级链,每个对象占一个位置
3) 凡是看到函数延伸一个链出来,一级级展开
4) 访问首先看当前函数,如果没有定义往上一级链检查
5) 如此往复,直到0级链

【this的深入解析】

调用位置
调用位置:就是函数在代码中被调用的位置(而不是声明位置)。要想回答htis到底引用的什么,只有仔细分析调用位置才能回答这个问题。
而分析条用位置最重要的就是分析调用栈。
调用栈:就是为了到达当前执行位置所调用的所有的函数。
function baz(){
//当前调用栈是:baz
//因此,当前调用位置是全局作用域
console.log("baz");
bar();//bar的调用位置
}
function bar(){
//当前的调用栈是:baz -> bar
//因此,当前的调用位置是在baz中
console.log("bar");
foo();//foo的调用位置
}
function foo(){
//当前的调用栈是baz -> bar -> foo
//因此,当前的调用位置是在baz中
console.log("foo");
}
baz();//baz的调用位置

绑定规则
思路:通过找到函数的调用位置,然后判断需要应用规则中的哪一条,便可决定this的绑定对象。关于this的绑定规则主要有一下四种:
(1)默认绑定
(2)隐式绑定
(3)显式绑定
(4)new绑定

1.默认绑定
默认绑定的典型类型是:独立函数调用。如下代码:
function foo(){
console.log(this.a);
}
var a = 2;
foo();//2
调用foo()函数的时候,函数应用了默认绑定,this指向全局对象window(这是在非严格模式下,若是在严格模式下会报错),所以this.a被解析成了全局变量a。所以,可在不使用任何修饰的函数引用进行调用,只能使用默认绑定,无法应用其他规则。

2.隐式绑定
隐式绑定的常见形式上在调用位置具有上下文对象,或者说被某个对象拥有或者包含。如下:
function foo(){
console.log(this.a);
}
var obj = {a:2, foo:foo};

obj.foo(); //2
这里的函数是foo()是预先定义好的,然后将其添加为obj对象的引用属性。调用位置使用obj上下文对象来引用函数,因此可以说函数被调用的时候obj对象“拥有”或者“包含”它。无论你怎么称呼这种模式,当foo()被调用时,它的前面确实加上了obj的引用,当函数引用上下文对象时,隐式绑定规则就会把函数调用中的this绑定到这个上下文对象。所以,this.a和obj.a是一样的。

另一个需要注意的点是:对象属性引用链中只有最后一层或者说最顶层会影响调用位置。
function foo(){
console.log(this.a);
}
var obj1 = {a:1, obj2:obj2};
var obj2 = {a:100, foo:foo};
obj1.obj2.foo();//100

隐式丢失
隐式绑定最常见的问题就是隐式丢失 -> 隐式绑定的函数会丢失绑定对象。也就是说它会应用“默认绑定”,而把this绑定到全局对象或者undefined上,取决于是否是严格模式。【*1】
function foo(){
console.log(this.a);
}
var obj = {a:2, foo:foo}

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

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

===========================拓展分界线===========================
*1-备注:js中函数调用的两种方法:
function test(a){
alert("你输入的是:" + a);
}

//方法一:直接调用
test("臣妾做不到啊!");

//方法二:函数赋值给变量
var mytst = test;
mytst("贱人就是矫情");//用变量来调用函数

===========================拓展分界线END===========================

一种更微妙、更常见的并且更出乎意料的情况发生在传入回调函数时:
function foo(){
console.log(this.a);
}
function callBack(fn){
//fn其实引用的是foo
fn();//调用位置
}
var obj = {a:2, foo:foo}
var a = "global";
callBack(obj.foo);//"global"

参数传递其实就是一种隐式赋值,这句话可以用下面的两段代码来详细的讲解:
(1)代码一
var a = 1;
function fn(){
alert(a);
a = 2;
}
fn();
alert(a);
(2)代码二
var a = 1;
function fn(a){
alert(a);
a = 2;
}
fn();
alert(a);

在第一段代码中:
首先,在全局作用域中,先通过变量提升,找到了标识符a和函数fn,a此时有个默认的undefined。然后,在执行阶段,程序先将变量a赋值为1,紧接着执行函数fn(),此时在函数域中,依旧应用变量提升的规则,但是什么都没有找到,紧接着执行函数内的代码alert(a),因为在函数中并没有找到变量a。所以,通过作用域链向上层的父级作用域中查找,我们找到了a,并且此时a的值已经被赋值为1,所以alert(a)的结果是1。下一句:a = 2,注意,a前面没有关键字var ,即:这里的a是全局的,也就说在执行a =2 时,它修改的是全局作用域中a 的值,所以alert(a)时,值自然为2。

在第二段代码中:
同样通过变量提升,找到标识符a和函数fn,a此时的默认值也是undefined。开始执行,a 先被赋值1,然后函数执行,这里与第一段代码的不同处在于,在函数fn中传入了参数a,那么这么做的结果就是:在函数域先运用变量提升的规则的时候,不会像第一段代码中那样,什么都找不到,而相当于定义了一个值为undefined(调用的时候没有传入参数)的变量a,所以执行函数中的alert(a)时,结果为undefined,而不会像第一种情况听过作用域链向上查找。因为本函数中已经找到了a,只不过是以参数的形式传入的。同理代码(a = 2)会修改a的值,即在函数域中,a的值现在为2(可以去尝试在函数中最后面alert一下a的值)。而在函数外执行alert(a),我们得到的结果便是1,因为该句代码是在全局中执行的,即会在全局中去查找变量a,而不会去访问函数域中的a。这也是因为,在JavaSceipt中子作用域可以访问父作用域而反过来却不行的规则。

回到this绑定丢失的话题上,上面讲这么多,其实就是想说明:参数传递其实就是一种隐式赋值,参数传递其实就是一种隐式赋值,参数传递其实就是一种隐式赋值。重要的事情三遍。
按照上面的方式来解析代码:在执行callBack(obj.foo)时,在函数作用域通过变量提升,找到了参数fn,它的默认值是undefined,然后将参数传入,其实相当于(var fn = obj.foo),这就是与前面的将其直接赋值给一个变量对等上了,然后再执行fn(),应用“默认绑定”,此时的this已经不指向obj了,而是指向window(严格模式)。

如果把函数传入内置的函数而不是传入自己声明的函数,会发生什么呢?
function foo(){
console.log(this.a);
}
var objg = {a:2, foo:foo};
var a = "global";

setTimeout(obj.foo, 1000);//"global"

结果还是一样的。
因为javascript环境中内置的setTimeout()函数实现和下面的伪代码类似:
function setTimeout(fn,dalay){
//等待delay秒
fn();//调用位置
}
回调函数丢失this绑定的情况是非常常见的,并且还有一种情况this的行为会出乎我们意料:调用回调函数的函数可能会修改this。由于无法控制回调函数的执行方式,因此就没有办法控制调用位置得到期望的绑定。

那么如何通过固定this来“修复”这个问题呢?
即:如何解决this的丢失问题。

显示绑定
在隐式绑定中,我们必须在一个对象的内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this间接绑定到这个对象上。那么,如果我们不想在每个对象内部包含函数引用,而想在每个对象上强制调用函数,该怎么做呢?

这时就需要call(绑定this,其他参数...)和apply(绑定this,其他参数...) 这两个方法了。这两个方法的第一个参数都是给this准备的,不同之处在于其他参数的形式上,他们两的其他参数对比如下:
call(绑定this,"参数1","参数2","参数3","参数4");
apply(绑定this,["参数1","参数2","参数3","参数4"]);

===========================拓展分界线===========================
注:跑题之apply和call
apply的其他参数是以数组序列形式存在的,它会在执行时将其解析成单个的参数再依次的传递到调用的函数中,这有什么用处呢?加入我们有一个数组:
var arr = [1,2,3,4,5,6];
现在我要找到其中的最大值,当然这里有很多方法了。既然这里讲到apply那么我们就用apply方法来解决这个问题。如果想要找到一组数中最大的一个,有一个简单的方法,使用Math.max(...)。但是,该方法并不能找出一个数组中的最大值,也就是说:
Math.max(1,2,3,4,5); // 可以找到最大值5
Math.max([1,2,3,4,5]); // NAN这就不行了,因为不接受以数组作为参数

我们的做法就是通过:
Math.max.apply(null, [1,2,3,4,5]); //得到数组中的最大值5

===========================拓展分界线END===========================

通过call()和apply()这两种方法我们可以显式的绑定this到指定的对象:
function foo(){
console.log(this.a);
}
var obj = {a: 2}
foo.call(obj);//2

这两个方法是如何工作的呢? 它们的第一个参数是一个obj对象, 它们会把这个对象obj绑定到this,接着在调用函数foo时指定这个this。 因为你可以直接指定this 的绑定对象,也就是obj, 因此我们称之为显式绑定。也就是说:在调用 foo 时强制把它的 this 绑定到 obj 上

但是,显式绑定仍旧无法解决this丢失绑定的问题。

1.硬绑定
显式绑定的一个变种可以解决这个问题。
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

硬绑定是一种非常常用的模式,所以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. 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 绑定
第四条规则,也是最后一条规则,在讲解他之前我们首先要澄清一个非常常见的关于javascript中函数和对象的误解。在传统的面向类的语言中,“构造函数”是类中的一些特殊方法,使用new初始化类是会调用类中的构造函数。通常的形式是这样:
someThinges = new MyClass(...)
javascript中也有个new操作符,但javascript中的new操作符的机制与面向类的语言完全不同。

首先我们重新定义一下JavaScrit中的“构造函数”。
在Javascript中,构造函数只是一些使用new操作符时被调用的函数。它并不会属于某个类,也不会实例化一个类。实际上它甚至都不能说是一种特殊的函数类型,它们只是被new操作符调用的普通函数而已。

举例来说,思考一下Number()作为构造函数时的行为,ES5.1中这样描述它:

Number构造函数
当Number在new表达式中被调用时,它是一个构造函数:它会初始化新建的对象。
所以,包括内置对象函数在内的所有函数都可以用new来调用,这种函数被称为构造函数调用,这有个非常细微的区别:实际上并不存在所为的“构造函数”,只有对于函数的“构造调用”。使用new来调用函数,会自动执行下面的操作:
(1)创建一个全新的对象
(2)这个新对象会被执行[[原型]]连接(之后会细说)
(3)这个新对象会绑定到函数调用的this
(4)如果函数没有返回其他对象,那么new表达式中的函数会自动返回这个对象。

如下代码:
function foo(a){
this.a = a
}
var bar = new foo(2);
console.log(bar) // foo {a: 2}
console.log(bar.a); //2

使用new 来调用foo(...)时,我们会构造一个新的对象,并把它绑定到foo(...)调用中的this上。new是最后一种可以影响函数调用时this绑定行为的方法。我们称之为new绑定。

箭头函数
我们之前介绍的四条规则已经可以包含所有正常是有的函数。但是在ES6中介绍了一种无法使用这些规则的特殊函数类型:箭头函数
箭头函数不是使用function关键字定义的,而是使用“ => ”定义。箭头函数不使用this的四种标准规则,而是根据外层作用域(函数或全局)来决定this。

最终小结:
如果要判断一个运行中函数的this绑定,就需要找到这个函数的直接调用位置。找到后就可以顺序应用下面这四条规则来判断this的绑定对象:
判断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()

另:ES6中的箭头函数不会使用四条标准的绑定规则,而是根据词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的this绑定(无论this绑定到了什么),这其实和ES6之前代码中的self = this 机制一样。