JavaScript中的this陷阱的最全收集并整理(没有之一)
当有人问起你JavaScript有什么特点的时候,你可能立马就想到了单线程、事件驱动、面向对象等一堆词语,但是如果真的让你解释一下这些概念,可能真解释不清楚。有句话这么说:如果你不能向一个6岁小孩解释清楚一个东西,那么你自己也不懂这个东西。这句话或许有点夸张,但是极其有道理。个人觉得,如果需要掌握一门语言,掌握它的API只是学了皮毛,理解这门语言的精髓才是重点。提及JavaScript的精髓,this、闭包、作用域链、函数是当之无愧的。这门语言正式因为这几个东西而变得魅力无穷。
博客的标题是《JavaScript中的this陷阱的最全收集--没有之一》,很显然这篇博客阐述的是this。相信做过JavaScript开发的人都遇到过不少this的陷阱,我自己本身也遇到过不少坑,但是如果非要给出一个系统的总结的话,还没有足够的底蕴。非常幸运的是,今天早上起来看《HackerNews》的时候,恰巧看到了一篇有关于JavaScriptthis的解析:allthis。于是,本着学习和共享的精神,决定将它翻译成中文。翻译的目的绝对不是为了当大自然的搬运工,在这个过程中会完全弄明白别人的著作,加深认识,同时将好东西分享给别人,才能让更多的学习者站在巨人的肩膀上前进。按照我自己的习惯,会翻译的过程中加上一些自己解释(引用部分),毕竟中西方人的思考方式是有差异的。当然文章标题所述的最全也不是吹的,文章非常长。
JavaScript来自一门健全的语言,所以你可能觉得JavaScript中的this和其他面向对象的语言如java的this一样,是指存储在实例属性中的值。事实并非如此,在JavaScript中,最好把this当成哈利波特中的博格特的背包,有着深不可测的魔力。
下面的部分是我希望我的同事在使用JavaScript的this的时候应当知道的。内容很多,是我学习好几年总结出来的。
JavaScript中很多时候会用到this,下面详细介绍每一种情况。在这里我想首先介绍一下宿主环境这个概念。一门语言在运行的时候,需要一个环境,叫做宿主环境。对于JavaScript,宿主环境最常见的是web浏览器,浏览器提供了一个JavaScript运行的环境,这个环境里面,需要提供一些接口,好让JavaScript引擎能够和宿主环境对接。JavaScript引擎才是真正执行JavaScript代码的地方,常见的引擎有V8(目前最快JavaScript引擎、Google生产)、JavaScriptcore。JavaScript引擎主要做了下面几件事情:
- 一套与宿主环境相联系的规则;
- JavaScript引擎内核(基本语法规范、逻辑、命令和算法);
- 一组内置对象和API;
- 其他约定。
但是环境不是唯一的,也就是JavaScript不仅仅能够在浏览器里面跑,也能在其他提供了宿主环境的程序里面跑,最常见的就是nodejs。同样作为一个宿主环境,nodejs也有自己的JavaScript引擎--V8。根据官方的定义:
Node.jsisaplatformbuiltonChrome'sJavaScriptruntimeforeasilybuildingfast,scalablenetworkapplications
globalthis
在浏览器里,在全局范围内,this等价于window对象。
<scripttype="text/javascript"> console.log(this===window);//true </script>
在浏览器里,在全局范围内,用var声明一个变量和给this或者window添加属性是等价的。
<scripttype="text/javascript"> varfoo="bar"; console.log(this.foo);//logs"bar" console.log(window.foo);//logs"bar" </script>
如果你在声明一个变量的时候没有使用var或者let(ECMAScript6),你就是在给全局的this添加或者改变属性值。
<scripttype="text/javascript"> foo="bar"; functiontestThis(){ foo="foo"; } console.log(this.foo);//logs"bar" testThis(); console.log(this.foo);//logs"foo" </script>
在node环境里,如果使用REPL(Read-Eval-PrintLoop,简称REPL:读取-求值-输出,是一个简单的,交互式的编程环境)来执行程序,this并不是最高级的命名空间,最高级的是global.
>this {ArrayBuffer:[Function:ArrayBuffer], Int8Array:{[Function:Int8Array]BYTES_PER_ELEMENT:1}, Uint8Array:{[Function:Uint8Array]BYTES_PER_ELEMENT:1}, ... >global===this true
在node环境里,如果执行一个js脚本,在全局范围内,this以一个空对象开始作为最高级的命名空间,这个时候,它和global不是等价的。
test.js脚本内容:
console.log(this); console.log(this===global);
REPL运行脚本:
$nodetest.js {} false
在node环境里,在全局范围内,如果你用REPL执行一个脚本文件,用var声明一个变量并不会和在浏览器里面一样将这个变量添加给this。
test.js: varfoo="bar"; console.log(this.foo); $nodetest.js undefined
但是如果你不是用REPL执行脚本文件,而是直接执行代码,结果和在浏览器里面是一样的(神坑)
>varfoo="bar"; >this.foo bar >global.foo bar
在node环境里,用REPL运行脚本文件的时候,如果在声明变量的时候没有使用var或者let,这个变量会自动添加到global对象,但是不会自动添加给this对象。如果是直接执行代码,则会同时添加给global和this
test.js foo="bar"; console.log(this.foo); console.log(global.foo); $nodetest.js undefined bar
上面的八种情况可能大家已经绕晕了,总结起来就是:在浏览器里面this是老大,它等价于window对象,如果你声明一些全局变量(不管在任何地方),这些变量都会作为this的属性。在node里面,有两种执行JavaScript代码的方式,一种是直接执行写好的JavaScript文件,另外一种是直接在里面执行一行行代码。对于直接运行一行行JavaScript代码的方式,global才是老大,this和它是等价的。在这种情况下,和浏览器比较相似,也就是声明一些全局变量会自动添加给老大global,顺带也会添加给this。但是在node里面直接脚本文件就不一样了,你声明的全局变量不会自动添加到this,但是会添加到global对象。所以相同点是,在全局范围内,全局变量终究是属于老大的。
functionthis
无论是在浏览器环境还是node环境,除了在DOM事件处理程序里或者给出了thisArg(接下来会讲到)外,如果不是用new调用,在函数里面使用this都是指代全局范围的this。
<scripttype="text/javascript"> foo="bar"; functiontestThis(){ this.foo="foo"; } console.log(this.foo);//logs"bar" testThis(); console.log(this.foo);//logs"foo" </script>
test.js foo="bar"; functiontestThis(){ this.foo="foo"; } console.log(global.foo); testThis(); console.log(global.foo); $nodetest.js bar foo
除非你使用严格模式,这时候this就会变成undefined。
<scripttype="text/javascript"> foo="bar"; functiontestThis(){ "usestrict"; this.foo="foo"; } console.log(this.foo);//logs"bar" testThis();//UncaughtTypeError:Cannotsetproperty'foo'ofundefined </script>
如果你在调用函数的时候在前面使用了new,this就会变成一个新的值,和global的this脱离干系。
<scripttype="text/javascript"> foo="bar"; functiontestThis(){ this.foo="foo"; } console.log(this.foo);//logs"bar" newtestThis(); console.log(this.foo);//logs"bar" console.log(newtestThis().foo);//logs"foo" </script>
我更喜欢把新的值称作一个实例。
函数里面的this其实相对比较好理解,如果我们在一个函数里面使用this,需要注意的就是我们调用函数的方式,如果是正常的方式调用函数,this指代全局的this,如果我们加一个new,这个函数就变成了一个构造函数,我们就创建了一个实例,this指代这个实例,这个和其他面向对象的语言很像。另外,写JavaScript很常做的一件事就是绑定事件处理程序,也就是诸如button.addEventListener(‘click',fn,false)之类的,如果在fn里面需要使用this,this指代事件处理程序对应的对象,也就是button。
prototypethis
你创建的每一个函数都是函数对象。它们会自动获得一个特殊的属性prototype,你可以给这个属性赋值。当你用new的方式调用一个函数的时候,你就能通过this访问你给prototype赋的值了。
functionThing(){ console.log(this.foo); } Thing.prototype.foo="bar"; varthing=newThing();//logs"bar" console.log(thing.foo);//logs"bar"
当你使用new为你的函数创建多个实例的时候,这些实例会共享你给prototype设定的值。对于下面的例子,当你调用this.foo的时候,都会返回相同的值,除非你在某个实例里面重写了自己的this.foo
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ console.log(this.foo); } Thing.prototype.setFoo=function(newFoo){ this.foo=newFoo; } varthing1=newThing(); varthing2=newThing(); thing1.logFoo();//logs"bar" thing2.logFoo();//logs"bar" thing1.setFoo("foo"); thing1.logFoo();//logs"foo"; thing2.logFoo();//logs"bar"; thing2.foo="foobar"; thing1.logFoo();//logs"foo"; thing2.logFoo();//logs"foobar";
实例里面的this是一个特殊的对象。你可以把this想成一种获取prototype的值的一种方式。当你在一个实例里面直接给this添加属性的时候,会隐藏prototype中与之同名的属性。如果你想访问prototype中的这个属性值而不是你自己设定的属性值,你可以通过在实例里面删除你自己添加的属性的方式来实现。
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ console.log(this.foo); } Thing.prototype.setFoo=function(newFoo){ this.foo=newFoo; } Thing.prototype.deleteFoo=function(){ deletethis.foo; } varthing=newThing(); thing.setFoo("foo"); thing.logFoo();//logs"foo"; thing.deleteFoo(); thing.logFoo();//logs"bar"; thing.foo="foobar"; thing.logFoo();//logs"foobar"; deletething.foo; thing.logFoo();//logs"bar";
或者你也能直接通过引用函数对象的prototype来获得你需要的值。
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ console.log(this.foo,Thing.prototype.foo); } varthing=newThing(); thing.foo="foo"; thing.logFoo();//logs"foobar";
通过一个函数创建的实例会共享这个函数的prototype属性的值,如果你给这个函数的prototype赋值一个Array,那么所有的实例都会共享这个Array,除非你在实例里面重写了这个Array,这种情况下,函数的prototype的Array就会被隐藏掉。
functionThing(){ } Thing.prototype.things=[]; varthing1=newThing(); varthing2=newThing(); thing1.things.push("foo"); console.log(thing2.things);//logs["foo"]
给一个函数的prototype赋值一个Array通常是一个错误的做法。如果你想每一个实例有他们专属的Array,你应该在函数里面创建而不是在prototype里面创建。
functionThing(){ this.things=[]; } varthing1=newThing(); varthing2=newThing(); thing1.things.push("foo"); console.log(thing1.things);//logs["foo"] console.log(thing2.things);//logs[]
实际上你可以通过把多个函数的prototype链接起来的从而形成一个原型链,因此this就会魔法般地沿着这条原型链往上查找直到找你你需要引用的值。
functionThing1(){ } Thing1.prototype.foo="bar"; functionThing2(){ } Thing2.prototype=newThing1(); varthing=newThing2(); console.log(thing.foo);//logs"bar"
一些人利用原型链的特性来在JavaScript模仿经典的面向对象的继承方式。任何给用于构建原型链的函数的this的赋值的语句都会隐藏原型链上游的相同的属性。
functionThing1(){ } Thing1.prototype.foo="bar"; functionThing2(){ this.foo="foo"; } Thing2.prototype=newThing1(); functionThing3(){ } Thing3.prototype=newThing2(); varthing=newThing3(); console.log(thing.foo);//logs"foo"
我喜欢把被赋值给prototype的函数叫做方法。在上面的例子中,我已经使用过方法了,如logFoo。这些方法有着相同的prototype,即创建这些实力的原始函数。我通常把这些原始函数叫做构造函数。在prototype里面定义的方法里面使用this会影响到当前实例的原型链的上游的this。这意味着你直接给this赋值的时候,隐藏了原型链上游的相同的属性值。这个实例的任何方法都会使用这个最新的值而不是原型里面定义的这个相同的值。
functionThing1(){ } Thing1.prototype.foo="bar"; Thing1.prototype.logFoo=function(){ console.log(this.foo); } functionThing2(){ this.foo="foo"; } Thing2.prototype=newThing1(); varthing=newThing2(); thing.logFoo();//logs"foo";
在JavaScript里面你可以嵌套函数,也就是你可以在函数里面定义函数。嵌套函数可以通过闭包捕获父函数的变量,但是这个函数没有继承this
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ varinfo="attemptingtologthis.foo:"; functiondoIt(){ console.log(info,this.foo); } doIt(); } varthing=newThing(); thing.logFoo();//logs"attemptingtologthis.foo:undefined"
在doIt里面的this是global对象或者在严格模式下面是undefined。这是造成很多不熟悉JavaScript的人深陷this陷阱的根源。在这种情况下事情变得非常糟糕,就像你把一个实例的方法当作一个值,把这个值当作函数参数传递给另外一个函数但是却不把这个实例传递给这个函数一样。在这种情况下,一个方法里面的环境变成了全局范围,或者在严格模式下面的undefined。
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ console.log(this.foo); } functiondoIt(method){ method(); } varthing=newThing(); thing.logFoo();//logs"bar" doIt(thing.logFoo);//logsundefined
一些人喜欢先把this捕获到一个变量里面,通常这个变量叫做self,来避免上面这种情况的发生。
博主非常喜欢用这种方式
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ varself=this; varinfo="attemptingtologthis.foo:"; functiondoIt(){ console.log(info,self.foo); } doIt(); } varthing=newThing(); thing.logFoo();//logs"attemptingtologthis.foo:bar"
但是当你需要把一个方法作为一个值传递给一个函数的时候并不管用。
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ varself=this; functiondoIt(){ console.log(self.foo); } doIt(); } functiondoItIndirectly(method){ method(); } varthing=newThing(); thing.logFoo();//logs"bar" doItIndirectly(thing.logFoo);//logsundefined
你可以通过bind将实例和方法一切传递给函数来解决这个问题,bind是一个函数定义在所有函数和方法的函数对象上面
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ console.log(this.foo); } functiondoIt(method){ method(); } varthing=newThing(); doIt(thing.logFoo.bind(thing));//logsbar
你同样可以使用apply和call来在新的上下文中调用方法或函数。
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ functiondoIt(){ console.log(this.foo); } doIt.apply(this); } functiondoItIndirectly(method){ method(); } varthing=newThing(); doItIndirectly(thing.logFoo.bind(thing));//logsbar
你可以用bind来代替任何一个函数或者方法的this,即便它没有赋值给实例的初始prototype。
functionThing(){ } Thing.prototype.foo="bar"; functionlogFoo(aStr){ console.log(aStr,this.foo); } varthing=newThing(); logFoo.bind(thing)("usingbind");//logs"usingbindbar" logFoo.apply(thing,["usingapply"]);//logs"usingapplybar" logFoo.call(thing,"usingcall");//logs"usingcallbar" logFoo("usingnothing");//logs"usingnothingundefined"
你应该避免在构造函数里面返回任何东西,因为这可能代替本来应该返回的实例。
functionThing(){ return{}; } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ console.log(this.foo); } varthing=newThing(); thing.logFoo();//UncaughtTypeError:undefinedisnotafunction
奇怪的是,如果你在构造函数里面返回了一个原始值,上面所述的情况并不会发生并且返回语句被忽略了。最好不要在你将通过new调用的构造函数里面返回任何类型的数据,即便你知道自己正在做什么。如果你想创建一个工厂模式,通过一个函数来创建一个实例,这个时候不要使用new来调用函数。当然这个建议是可选的。
你可以通过使用Object.create来避免使用new,这样同样能够创建一个实例。
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ console.log(this.foo); } varthing=Object.create(Thing.prototype); thing.logFoo();//logs"bar"
在这种情况下并不会调用构造函数
functionThing(){ this.foo="foo"; } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ console.log(this.foo); } varthing=Object.create(Thing.prototype); thing.logFoo();//logs"bar"
因为Object.create不会调用构造函数的特性在你继承模式下你想通过原型链重写构造函数的时候非常有用。
functionThing1(){ this.foo="foo"; } Thing1.prototype.foo="bar"; functionThing2(){ this.logFoo();//logs"bar" Thing1.apply(this); this.logFoo();//logs"foo" } Thing2.prototype=Object.create(Thing1.prototype); Thing2.prototype.logFoo=function(){ console.log(this.foo); } varthing=newThing2();
objectthis
在一个对象的一个函数里,你可以通过this来引用这个对象的其他属性。这个用new来新建一个实例是不一样的。
varobj={ foo:"bar", logFoo:function(){ console.log(this.foo); } }; obj.logFoo();//logs"bar"
注意,没有使用new,没有使用Object.create,也没有使用函数调用创建一个对象。你也可以将对象当作一个实例将函数绑定到上面。
varobj={ foo:"bar" }; functionlogFoo(){ console.log(this.foo); } logFoo.apply(obj);//logs"bar"
当你用这种方式使用this的时候,并不会越出当前的对象。只有有相同直接父元素的属性才能通过this共享变量
varobj={ foo:"bar", deeper:{ logFoo:function(){ console.log(this.foo); } } }; obj.deeper.logFoo();//logsundefined
你可以直接通过对象引用你需要的属性
varobj={ foo:"bar", deeper:{ logFoo:function(){ console.log(obj.foo); } } }; obj.deeper.logFoo();//logs"bar"
DOMeventthis
在一个HTMLDOM事件处理程序里面,this始终指向这个处理程序被所绑定到的HTMLDOM节点
functionListener(){ document.getElementById("foo").addEventListener("click", this.handleClick); } Listener.prototype.handleClick=function(event){ console.log(this);//logs"<divid="foo"></div>" } varlistener=newListener(); document.getElementById("foo").click();
除非你自己通过bind切换了上下文
functionListener(){ document.getElementById("foo").addEventListener("click", this.handleClick.bind(this)); } Listener.prototype.handleClick=function(event){ console.log(this);//logsListener{handleClick:function} } varlistener=newListener(); document.getElementById("foo").click();
HTMLthis
在HTML节点的属性里面,你可以放置JavaScript代码,this指向了这个元素
<divid="foo"onclick="console.log(this);"></div> <scripttype="text/javascript"> document.getElementById("foo").click();//logs<divid="foo"... </script>
overridethis
你不能重写this,因为它是保留字。
functiontest(){ varthis={};//UncaughtSyntaxError:Unexpectedtokenthis }
evalthis
你可以通过eval来访问this
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ eval("console.log(this.foo)");//logs"bar" } varthing=newThing(); thing.logFoo();
这会造成一个安全问题,除非不用eval,没有其他方式来避免这个问题。
在通过Function来创建一个函数的时候,同样能够访问this
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=newFunction("console.log(this.foo);"); varthing=newThing(); thing.logFoo();//logs"bar"
withthis
你可以通过with来将this添加到当前的执行环境,并且读写this的属性的时候不需要通过this
functionThing(){ } Thing.prototype.foo="bar"; Thing.prototype.logFoo=function(){ with(this){ console.log(foo); foo="foo"; } } varthing=newThing(); thing.logFoo();//logs"bar" console.log(thing.foo);//logs"foo"
许多人认为这样使用是不好的因为with本身就饱受争议。
jQuerythis
和HTMLDOM元素节点的事件处理程序一样,在许多情况下JQuery的this都指向HTML元素节点。这在事件处理程序和一些方便的方法中都是管用的,比如$.each
<divclass="foobar1"></div> <divclass="foobar2"></div> <scripttype="text/javascript"> $(".foo").each(function(){ console.log(this);//logs<divclass="foo... }); $(".foo").on("click",function(){ console.log(this);//logs<divclass="foo... }); $(".foo").each(function(){ this.click(); }); </script>
thisArgthis
如果你用过underscore.js或者lo-dash你可能知道许多类库的方法可以通过一个叫做thisArg的函数参数来传递实例,这个函数参数会作为this的上下文。举个例子,这适用于_.each。原生的JavaScript在ECMAScript5的时候也允许函数传递一个thisArg参数了,比如forEach。事实上,之前阐述的bind,apply和call的使用已经给你创造了传递thisArg参数给函数的机会。这个参数将this绑定为你所传递的对象。
functionThing(type){ this.type=type; } Thing.prototype.log=function(thing){ console.log(this.type,thing); } Thing.prototype.logThings=function(arr){ arr.forEach(this.log,this);//logs"fruitapples..." _.each(arr,this.log,this);//logs"fruitapples..." } varthing=newThing("fruit"); thing.logThings(["apples","oranges","strawberries","bananas"]);
这使得代码变得更加简介,因为避免了一大堆bind语句、函数嵌套和this暂存的使用。