亲宝软件园·资讯

展开

详解JavaScript中的this(阅读《你不知道的JavaScript》笔记)

RYZZ 人气:0

《你不知道的JavaScript(上卷)》第二部分

 

第一章:关于this

this关键字是JavaScript中最复杂的机制之一,这是一个很特别的关键字,被自动定义在所在函数的作用域内,下面给出一个例子,

function identify(){

return this.name.toUpperCase();

}

function speak(){

var greeting="hello, i am "+identify.call(this);

console.log(greeting);

}

var me={ name:"jack" };

var you={ name:"bob" };

console.log(identify.call(me)); //JACK

console.log(identify.call(you)); //BOB

speak.call(me); //hello, i am JACK

speak.call(you); //hello, i am BOB

如果不使用this,就需要给identify和speak函数显式的传入一个对象,如,

function identify(ctx){

return ctx.name.toUpperCase();

}

function speak(ctx){

var greeting="hello, i am "+identify(ctx);

console.log(greeting);

}

var me={ name:"jack" };

speak(me); //hello, i am JACK

显然,this提供了一种更为优雅的方式来隐式“传递”一个对象引用,因此可以将API设计得更加简洁并且易于复用。

 

误解1:this指向自身

人们很容易把this理解成指向函数自身的指针,这从this本身单词的意义上来说确实是这样,但是为什么需要从函数内部引入自己呢,或许最为合理的解释是支持递归或者写一个在第一次被调用后自己解除绑定的事件处理器。

JS不太熟悉的新手会这样认为,既然JS中函数是对象,那就可以在调用函数时存储状态(属性的值),这确实可行,有些时候也确实需要这样,但是学到更后面,你会发现除了函数对象还有很多更合适存储状态的地方,考虑下面代码,

function foo(num){

console.log("foo: "+num);

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

0

可以看出foo.count输出0,原因是:执行foo.count=0时,确实向函数对象foo添加了属性count,但是函数内部代码的this.count中的this并不是指向这个函数对象,深入的研究,你会发现这段代码无意中在全局作用域中创建了count变量,它的值是NaN,NaN是undefined+1表达式的结果,再来思考下面两个函数,

function foo(num){

foo.count++; //因为是具名函数,所以可以引用本身的函数对象,实现对count计数

}

(function(){

//匿名函数由于没有函数名,无法指向(调用)自己

})();

在以前可以使用arguments.callee来引用当前正在运行的函数对象,这也是匿名函数调用本身的唯一方法,但是这项技术已经在ES6中被废弃了,更好的用法是改用具名函数。

 

误解2:this的作用域

把this误解为指向函数的作用域的指针,需要明确指出,this在任何情况下都不指向函数的词法作用域,如下代码,

function foo(){

var a=2;

this.bar();

}

function bar(){

console.log(this.a); //undefined

}

foo();

这段代码的本意是想在foo下调用bar,使得bar就像定义在foo内一样,这里误把this理解为指向当前词法作用域的指针,让函数bar试图去访问定义在foo函数内的a,用this联通foo和bar的词法作用域,这是绝对错误的,绝对不能把this和词法作用域混合而谈。

 

this是在运行时被绑定,而不是在编写时,它的上下文对象(指向的对象)取决于函数调用时的各种条件,this的绑定和函数声明的位置没有任何关系,只取决于函数被调用的方式。

当一个函数被调用时,会创建一个活动记录,这个记录会包含函数在哪被调用(调用栈)、函数的调用方式、传入的参数等信息,this就是这个记录的一个属性,会在函数执行的过程中用到。

 

在学习this之前,必须要明确这两点:1.this不指向函数本身 2.this也不指向函数的词法作用域

this是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪被调用!

 

第二章:this全面解析

首先来理解一个概念:调用位置——就是函数在代码中被调用的的位置(注意,不是声明位置),只有仔细分析调用位置才能明白这个this到底指向谁,看起来很简单,但是某些编程模式会隐藏真正的调用位置,最重要的是分析调用栈(为了到达当前正在执行的函数,需要调用的所有前驱函数),我们关心的调用位置就在当前正在执行的函数的前一个函数中,如下代码,

function baz(){

//当前调用栈是:baz

//因此当前调用位置是全局作用域

console.log("baz");

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

}

function bar(){

//当前调用栈是:bar<-baz

//因此当前调用位置在baz函数中

console.log("bar");

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

}

function foo(){

/https://img.qb5200.com/download-x/debugger;

//当前调用栈是:foo<-bar<-baz

//因此当前调用位置在bar函数中

console.log("foo");

}

baz(); //<--baz的调用位置,也就是全局作用域

调用栈可以在Chrome开发者工具中的Sources选项卡看到,先在代码中插入断点(也可以在函数的第一行写上debugger关键词),然后刷新页面(重新执行代码),这时可以在右侧的【Call Stack】窗口中看到程序运行到当前断点时的调用栈了。

 

我们再来看看在函数的执行过程中调用位置如何决定this的绑定对象,一共有4种绑定规则,下面一一分析,

第一种:默认绑定,独立的函数调用是最常用的函数调用类型,如下,

function foo(){

console.log(this.a);

}

var a=2;

foo(); //2

首先需要知道这个知识点:在全局作用域声明的变量就是全局对象(在浏览器中是window)的一个属性。

所以可以看出,上面的foo被调用时,其内部的this被绑定(指向)到了window,由于foo是直接使用不带任何修饰的函数引用进行调用的,因此只能是默认绑定。

另外需要注意,在严格模式下,不能把全局对象绑定到this,那么此时的this将是undefined,如下,

function foo(){

"use strict"

console.log(this.a);

}

var a=2;

foo(); //TypeError: Cannot read property 'a' of undefined

这里还有一个微妙的细节,虽然this的绑定规则完全取决于函数调用的位置,但是只有foo运行在非严格模式下时默认绑定才会绑定到全局对象,如下,

function foo(){

console.log(this.a);

}

var a=2;

(function(){

"use strict"

foo(); //2

})();

 

第二种:隐式绑定,当函数调用的位置存在上下文对象,或者说被这个上下文对象包含(不严谨),如下,

function foo(){

console.log(this.a);

}

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

obj.foo(); //2

这里很明显,foo被当作属性添加到了obj对象中,也就是说被obj对象所包含(不严谨),之所以不严谨是因为:不论这个函数是在obj内部声明的,还是引入到obj内部的,这个函数严格来说都不属于obj对象,也绝对不能误认为foo函数在obj内部,foo函数属于全局作用域,obj中的foo就是一个指针,指向这个函数,即引用。

当函数引用时存在上下文对象时,隐式绑定就会把函数调用中的this绑定到这个上下文对象,因此foo中的this.a等同于obj.a。

对象属性引用链只有最后一层在调用位置起作用,即,obj1.obj2.obj3.foo();,那么this只可能绑定obj3。

隐式丢失,最常见的this绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把this绑定到window或undefined上,如下,

function foo(){

console.log(this.a);

}

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

var bar=obj.foo; //函数foo的别名

var a="oops, this binded to window";

bar(); //"oops..."

虽然bar和obj.foo都指向同个函数,但是,bar调用时是不带任何修饰的,因此应用了默认绑定,再比如,

function foo(){

console.log(this.a);

}

function doFoo(f){

f();

}

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

var a="oops, this binded to window";

doFoo(obj.foo); //"oops..."

传递参数是一种隐式赋值,所以上述代码也发生了隐式丢失,

把函数传入内置函数和传入自定义函数是相同的,如下,

function foo(){

console.log(this.a);

}

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

var a="oops, this binded to window";

setTimeout(obj.foo, 1000);

其实setTimeout内部代码可以用如下伪代码表示,

function setTimeout(fn, time){

//until time

f(); //调用位置,可见就是默认绑定

}

可见回调函数丢失this绑定是很常见的,但是有些情况会出乎我们的意料:调用回调函数的函数可能会修改this绑定的值,例如很多JS库的事件处理器通常会把回调函数的this强制绑定到触发事件的DOM元素上,很遗憾,这些工具通常无法选择是否开启这个行为,但是大多数情况下这些强制绑定并无大碍,反而更加方便。

 

第三种:显式绑定,使得this指向你规定的一个对象,就好像这个对象包含了这个函数

在JS中所有的函数有一些有用的特性(这和它们的[[__proto__]]有关)可以用来解决这个问题,具体来说,可以使用函数的call或apply方法。

它们的第一个参数都是一个对象,是给this准备的,在调用时将this绑定到这个对象上,由于可以手动指明要this绑定哪个对象,所以称为显式绑定,如下,

function foo(){

console.log(this.a);

}

var obj={a:2};

foo.call(obj); //2

通过foo.call,我们可以在调用foo时强制把它的this绑定到我们传入的对象上,但是如果你传入的是基本数据类型(字符串、布尔值、数字、undefined或甚至是null),那么这个原始值会转为它的对象形式,即装箱,如new String()、new Number(),如果是undefined或null,那么this自动绑定到window。

在严格模式下,这些基本数据类型不会被装箱,这时的this就是这个基本类型的值。

可惜,显式绑定还是无法解决之前的隐式丢失问题,如下,

function foo(){

console.log(this);

}

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

var a="oops, this binded to window";

setTimeout(obj.foo.call(obj), 1000);

控制台输出的this就是obj,但是我们的延迟函数并没有被执行!这是因为obj.foo.call(obj)这个函数调用返回值的是undefined,而setTimeout只接受函数类型的值。

 

显式绑定的变种——硬绑定,如下代码,

function foo(){

console.log(this.a);

}

var obj={a:2};

var bar=function(){

foo.call(obj);

}

bar(); //2

setTimeout(bar, 100); //2

bar.call(window); //2 传入的这个window只是被bar的this绑定,而不是foo的this

首先创建了函数bar,在它的内部手动调用了foo.call(obj),这样就强制把foo的this绑定到了obj,不论之后如何调用函数bar,它的this永远都是obj,硬绑定典型的案例就是创建一个包裹函数,负责接收参数并返回值,如下,

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

上面的bar是个函数(bar引用了一个匿名函数),arguments用于存储这个函数接受的所有参数,这个函数返回foo执行的结果,foo的this被显式绑定为obj,foo的参数something是arguments的第一个值。

另一种方式用法是创建一个可以重复使用的辅助函数,如下,

function foo(something){

console.log(this.a,something);

return this.a+something;

}

var obj={a:2};

function mybind(f,obj){

function baz(){

return f.apply(obj,arguments);

}

return baz;

}

var bar=mybind(foo,obj);

var b=bar(1); //1 2

console.log(b); //3

由于硬绑定是非常实用的模式,所以ES5提供了内置的方法Function.prototype.bind(返回一个函数的副本,这个函数不论被如何调用,它的this都绑定到指定的对象,但有例外,参见之后的new绑定),使用方法如下,

function foo(something){

console.log(this.a,something);

return this.a+something;

}

var obj={a:2};

var bar=foo.bind(obj); //bar是foo函数的副本,bar无论在哪被调用时,也无论如何被调用,它的this永远指向传入的对象,这里就是obj

var b=bar(1); //1 2

console.log(b); //3

 

另外,第三方库中的很多函数,以及JS语言和宿主(主要就是浏览器)的内置函数,都提供了一个可选的参数,通常称为上下文对象,用于确保你的回调函数使用指定的this,比如在ES3就存在的forEach方法,

function foo(elem){

console.log(elem,this.data);

}

var obj={data:"so cool!"};

var bar=[1,3];

bar.forEach(foo,obj);

输出:

1 "so cool!"

3 "so cool!"

 

第四种:new绑定,this指向构造函数新创建的对象

在传统的OOP语言中,构造函数是类中的一个特殊方法,当实例化一个对象时(通常是new),会被自动调用,JS也有一个new操作符,然而这个new的机制和传统的OOP语言完全不同,在JS中的构造函数只是那些使用new操作符时被调用的函数,它们不属于某个类,也不会实例化出一个对象,实际上它们都不算是特殊的函数,只是被new操作符调用的普通函数而已。

当Number函数作为构造函数时的行为在ES5.1这样定义:

当Number在new表达式中被调用时,它是一个构造函数,用于初始化新创建的对象。

所有的函数都可以用new来调用,实际上,不存在所谓的构造函数,只是对函数的构造调用,使用new来调用函数时,会自动执行下面的操作:

  1. 创建(即构造)一个全新的对象
  2. 这个新对象会被执行[[__proto__]]连接
  3. 这个新对象会被绑定到函数调用的this(这个构造函数的this就是这个新对象)
  4. 如果函数没有返回其他对象(如果返回的是非对象类型,那么会忽略这次返回),那么new表达式中的函数调用会自动返回这个新对象

如下代码,

function foo(){

this.name="jack";

}

var bar=new foo();

console.log(bar.name); //jack

如下,自定义对象代替默认返回的新对象,

function foo(){

this.name="jack";

return {name:"bob"};

}

var bar=new foo();

console.log(bar.name); //bob

返回的是基本数据类型(包括null和undefined,非严格和严格模式均相同),

function foo(){

this.name="jack";

return "bob";

}

var bar=new foo();

console.log(bar.name); //jack

我们来模拟传统的OOP,

function foo(name,age){

this.name=name;

this.age=age;

}

var bar=new foo("jack",20);

console.log(bar.name+" "+bar.age); //jack 20

当使用new来调用foo时,会构造一个全新的对象,并把它绑定到foo的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.apply(obj1); //2

显然,显式比隐式高,现在比较new绑定和隐式的优先级,

function foo(sth){

this.a=sth;

}

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

var bar=new obj1.foo(4);

console.log(obj1.a); //2

console.log(bar.a); //4

其中的new obj1.foo(4)就是同时使用了隐式绑定和new绑定,前面提到了new会在函数中创建一个新对象,必然不会使用任何其他绑定的对象,也就比隐式绑定优先级高了,那么显式绑定和new绑定谁高呢,由于new和call或apply无法同时使用,所以也无法使用诸如new foo.call(obj1)这样的方式(报错:TypeError: foo.call is not a constructor),但是我们可以使用硬绑定来测试,

浏览器实现硬绑定的内置函数Function.prototype.bind方法会创建一个新的函数,这个函数会忽略它当前的this绑定,并把提供的对象用于this绑定,再回忆一下new绑定,new绑定会在函数内新建一个对象,如果函数没有返回一个有效的对象,就返回这个新建的对象,这看起来,它两的优先级不相上下,给出如下代码,

function foo(sth){

this.a=sth;

}

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是bind方法返回的一个函数,这个函数在运行时会把this强制绑定到obj1上,然后对bar函数使用new运算,从接下来的一行可以看出,obj1.a的值依旧是2,没有改变,而baz(也就是bar作为构造函数返回的对象)的a的值是3,说明new还是将this绑定到了新建对象,这里可以看出new绑定高于bind绑定(硬绑定),再来看看我们之前自己编写的bind函数,

function foo(sth){

this.a=sth;

}

function bind(f,o){

return function(){

f.apply(o,arguments);

};

}

var obj1={};

var bar=bind(foo,obj1);

bar(2);

console.log(obj1.a); //2

var baz=new bar(3);

console.log(obj1.a); //3

console.log(baz.a); //undefined

解释:bind函数同样返回了一个函数,这个返回的函数用于调用foo函数,在调用foo时使用apply显式绑定this为obj1,之后,bar被new操作符运算,new的结果会返回一个构造bar函数时生成的对象,这个对象在上述代码就是baz,虽然baz被创建了,但是没有任何属性,这也就意味着这里的new没有改变this绑定的对象,为什么呢?很简单,new的是bar函数,而bar函数就是:

function (){

foo.apply(obj1,arguments);

}

没错,你的new是在操作这个函数啊,在这个函数里面this绑定的是新对象,而且还把bar收到的参数还传给了另一个函数,这个函数使用apply又调用了另一个函数(调用栈+1),而在那个函数里面发生的才是硬绑定,

实际上,ES5中内置的bind函数实现很复杂,本书的P93~P94给出了一段polyfill代码(polyfill主要为了让旧的浏览器也能用上新标准的语法,比如ES6有很多新特性,但是旧的浏览器不支持ES6标准的话就无法使用这些特性,那么可以引入一个polyfill库,这些库使用了旧的浏览器支持的标准,比如ES5,用ES5的代码实现或部分实现了ES6的新特性)。

这段polyfill代码会检测硬绑定是否通过new调用,是的话就会使用new创建的新对象来绑定this,实现和浏览器内置硬绑定bind方法相同的工作方式。

 

综上,new绑定 > 显式绑定(硬绑定) > 隐式绑定 > 默认绑定。

 

判断this基本顺序:

  1. 函数是否在被new调用(new绑定),那么this绑定的是新建对象
  2. 函数是否通过call、apply调用(显式绑定,其中call和apply的区别仅在于call接受参数列表,而apply接受一个表示参数列表的数组),那么this绑定的是方法指定的对象
  3. 函数是否在某个上下文对象中被调用(隐式绑定),那么this绑定的是那个上下文对象
  4. 如果都不是,那么就是默认绑定,在严格模式下this绑定的是undefined,在非严格模式下this绑定的是window(浏览器的全局对象)

 

绑定例外:

凡事总有例外,这里的绑定规则也是,在某些场景下,你认为应当绑定到其他对象,而实际上应用的却是默认绑定,

例外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,b);

}

//把数组展开成参数

foo.apply(null,[1,2]); //1 2

//使用bind对函数柯里化

var bar=foo.bind(null,2);

bar(3); //2 3

apply和bind都需要传入第一个参数作为this绑定的目标,即使不关心this,你依旧需要传入一个占位符。

在ES6中有...操作符可以代替apply来展开一个数组,例如,

function foo(a,b,c,d){

console.log(a,b,c,d);

}

foo(100, ...[1,2,3]); //100 1 2 3

但是ES6依旧没有提供有关柯里化的语法,因此还需要使用bind。

然而使用null来忽略this的绑定也可能会导致一些后果,如果某个函数确实使用了this,比如第三方库的一些函数,那么默认绑定规则会把this绑定到全局对象(浏览器中是window),这将导致毁灭性的破坏,一种更安全的做法是传入一个没有副作用的对象,这个对象就像沙箱一样,对人对它的操作都不会产生副作用,例如,

function foo(a,b){

console.log(a,b);

}

var φ=Object.create(null);

foo.apply(φ,[2,3]); //2 3

var bar=foo.bind(φ,2);

bar(3); //2 3

我们这里使用数学中空集的符号φ来表示这个安全沙箱对象,其中Object.create(null)创建的对象比{}还要空,它连__proto__属性都没有,而{}有个__proto__属性指向Object。

 

例外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的值就是它的左运算符所表示的值,而p.foo实际上是个指针,指向foo,因此,这里的调用位置是foo()而不是p.foo()或o.foo(),所以应用默认绑定。

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

 

ES6中引入了新的函数定法方式——箭头函数(Lambda表达式),语法格式如下,

(参数列表)=>{ ...(函数体) }

可以看出箭头函数本质是函数表达式(即匿名函数),并不是函数声明,所以没有名称,如果需要定义其名称,可以赋值给另一个变量,如,

var foo=(x,y)=>{ ... };

箭头函数不使用this的四种绑定规则,而是根据外层作用域(父作用域)的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

foo内部创建的箭头函数会捕获调用foo时的this,由于foo被显式绑定到obj1,那么箭头函数bar的this也会绑定到obj1,箭头函数的绑定无法被修改(new也不行)。

箭头函数最常用于回调函数中,例如事件处理器或定时器,如下,

function foo(){

setTimeout(()=>{

//这里的this同样继承自foo

console.log(this.a);

} ,1000);

}

var obj1={a:2};

foo.call(obj1); //2

箭头函数可以像bind一样确保函数的this被绑定到指定的对象上,此外,其重要性还体现在它用于更常见的词法作用域取代了传统的this机制,实际上,在ES6之前,我们就已经在使用一种几乎和箭头函数完全一样的模式,如下,

function foo(){

var self=this; //通过词法作用域捕获this

setTimeout(function(){

console.log(self.a);

} ,1000);

}

var obj1={a:2};

foo.call(obj1); //2

即便是self=this这样的机制,还是bind方法,还是ES6的箭头函数,从本质上来说,它们都想代替的是this机制,这是因为传统的this机制绑定过于繁琐,容易造成代码潜在的陷阱。

 

小结:

要判断一个运行中的函数内部的this指的是谁,只需要找到这个函数的直接调用位置(通过调试器的调用栈,或者是肉眼寻找),再根据四条规则来判断this具体绑定的对象,另外,ES6新加的箭头函数不受传统的this绑定机制影响,箭头函数只会继承来自父级作用域的this。

 

 

加载全部内容

相关教程
猜你喜欢
用户评论