什么是this陷阱?
看一段代码:
var name = "global" var obj = { name: "LIYI", getName: function () { function fn () { return this.name // 期望返回 LIYI } return fn() } } obj.getName() // 实际返回 global
在这段代码中,第6行,我们期待返回“LIYI”,因为我们认为此时this等于obj,但事实并非如此,最终返回的是“global”,是我们在obj外面定义的name变量。为什么会这样?
我们知道,JS是一门动态语言,JS中的this是一个运行时动态对象,在程序没有运行的时候谁也不知道代码中的this具体指向哪里,只有运行以后,JS宿主的上下文执行环境才会给this赋予一个具体的对象,this指向哪里,与它在编写时所处的代码位置有关,但也取决于运行时它是在什么位置被调用的。 很多程序员依赖静态编译型语言的直观经验判断,或者依据this所处的代码位置进行简单判断,难免会产生错误,仿佛掉入了陷阱,这便是this陷阱说法的由来。
如何避免this陷阱?
怎么避免掉入这样的this陷阱呢?
在JavaScript中,箭头函数看上去是匿名函数的一种简写,但实际上,箭头函数和匿名函数有个明显的区别:箭头函数内部的 this 关键字所指向的对象 是 由 箭头函数所在的父作用域决定的,并不是箭头函数本身,这是箭头函数除了格式简洁之外最大的用途之一。
对于以下代码:
1. const obj = { 2. name: "LY", 3. run: function (n) { 4. const fn = function (n) { 5. // 这里的 this 指向全局对象window或GameGlobal,并不指向obj 6. return `${this.name}${n}` 7. } 8. return fn 9. } 10. } 11. obj.run()(1) // Output:1
示例A
在上述代码的第6行中,我们想让this.name取到第2行的属性值,然而实际情况是,this 并不能如愿以偿地取到 name 的值。
this在这个示例中具体是指什么,取决于所在的宿主环境,在浏览器中是window,在小游戏中是GameGlobal。由于全局对象上并不存在一个名为name的值,因此this.name未定义,所以最终输出为1(this.name输出为空,1来自参数)。
注意:第6行中的this为什么返回全局对象?在JS中,普通函数中的this是一个特殊的动态变量,它在函数定义的时候是确定不了的,只有函数执行的时候才能确定。关于如何确定this,下面有详细的规则讲解。
如果我们想取到obj.name,怎么办?在没有箭头函数之前,可以这样修改:
const obj = { name: "LY", run: function (n) { const that = this const fn = function (n) { // 这里的 that 指向 obj return `${that.name}${n}` } return fn } } obj.run()(2) // Output:LY2
示例B
在上面的代码中,第4行声明一个临时常量that,使其等于this,这时第9行返回的fn实际上是一个裹挟了临时常量that的闭包。that等于obj,第7行that.name等于LY,所以最终输出LY2。
在有了箭头函数以后,可以这样改写:
const obj = { name: "LY", run: function (n) { const fn = n => { // 这里的 this 指向 obj return `${this.name}${n}` } return fn } } obj.run()(3) // Output:LY3
示例C
与上一个示例相比,上述代码没有声明临时常量that,且第4行的fn是用箭头函数声明的。
为什么在这里的箭头函数可以帮助我们找到obj这个对象呢?
箭头函数没有自己的this,箭头函数中的this指向运行时父级作用域下的this对象。我们可以将箭头函数看作是一个lambda表达式,一个表达式是可以视为没有自己的作用域的(但其实箭头函数有花括号,它是有自己的作用域的),箭头函数内部函数体中的this就是它所在作用域中的this。
在上面的代码中,第6行中的this,其实是第3行至第9行run函数所在的作用域——这个作用域里的this,和上一示例(示例B)中第4行中的that是等同的,是同一个对象引用。
示例C和示例A中都使用了this,但示例C中的this是从run函数所在的作用域绑定的,示例A中的this却是从全局作用域绑定的,所以示例C中的this可以找到obj对象,而示例A中的this不可以。
简单的三条判定规则
考察代码中的this具体指向哪个对象,我们要看三个问题,而这其中又涉及三条规则:
❑ 一、 是不是顶级函数? 这要看函数是不是全局作用域下的顶级函数,如果是,this等于全局对象;
❑ 二、 是不是箭头函数? 这要看是不是箭头函数,如果是,将箭头函数看成lamda表达式,以其父函数重新作为考察对象,回到第1条规则继续;
❑ 三、 有没有执行者? 如果不是箭头函数,看被执行的函数有没有执行者,如果有,this等于执行者;如果没有执行者,this等于全局对象。
为了更好地理解这三条规则,下面用一张示意图来展示这三条判断规则,如图5-6所示。
图5-6 this关键字判定规则图
14个小练习
针对上述判定规则,我们再看一些具体示例,这是一套关于this对象判定的思维体操,共有14个小练习,它们基于一个示例演绎,几乎涵盖了所有变化情况,你可以根据自己的情况,选择练习全部或其中几个。
示例1:
function foo(){ this.name = "LY1" return () => { console.log("name", this.name) } } foo()() // Output:name LY1 console.log(name) // Output:LY1
在上面的代码中,第4行想通过this.name取到第2行赋值的LY1,这里取到了,但是我们要小心,因为此时this指向全局对象,this.name随时可能被其他代码污染,这个写法是不安全的。这个示例代码的初衷,可能是想将name限定在foo函数之内,但这是行不通的。第8行打印name,仍然有值,这个值便是在第2行写入的。
为什么第4行的this指向全局对象?
我们应用上面的三条规则判定一下,判定过程如下:
this所在的函数不是顶级函数,是箭头函数,向上跃升一个作用域,相当于取foo函数下的this,foo函数是一个处在全局作用域下的顶级函数,所以this等于全局对象。
注意:示例1及以下各示例,都可以在chrome浏览器的Console面板中执行,在这个环境中全局对象是window。注意每次执行后要换一个Tab页面或刷新Tab页面,避免受上一次测试代码的污染。
既然示例1不行,我们换个写法,在foo函数内创建一个内部对象,在这个对象上声明属性name,下面来看示例2:
function foo(){ const country = { name: "LY2" } country.bar = () => { console.log("name", this.name) } return country } foo().bar() // Output:name undefined
运行后发现,很遗憾,第6行的this并不有指向country,而是指向了全局对象,为什么?
按判断规则,如果this所在的函数不是顶级函数,而是箭头函数,则向上跃升一个作用域,相当于取foo函数下的this,foo函数是一个处在全局作用域下的顶级函数,所以this等于全局对象。
可能有读者会想,country是函数作用域下的对象,如果将它变成一个全局对象,情况会不会改变?我们可以通过示例3试一下:
const country = { name: "LY3" } function foo() { country.bar = () => { console.log("name", this.name) } return country } foo().bar() // Output:name undefined
现在country已经是一个foo函数外部的全局对象了,但仍然没有用,第6行中的this仍然指向全局对象,为什么?
按判断规则,如果this所在的函数不是顶级函数,而是箭头函数,则向上跃升一个作用域,而 foo函数是一个处在全局作用域下的顶级函数,所以this等于全局对象。
可能有读者会想,我不仅将country提升全局作用域,还将包含this的箭头函数也提升,又会怎样?来看示例4:
const country = { name: "LY4" } country.bar = () => { console.log("name", this.name) } function foo() { return country } foo().bar() // Output:name undefined country.bar() // Output:name undefined
其中第10行、第11行,无论是通过foo()返回的country调用,还是通过全局常量country直接调用bar函数,结果都是一样的,第5行中的this仍然指向全局对象,为什么?
按规则,如果this所在的函数是顶级函数,那么this等于全局对象。
如果我们不用箭头函数,将this所在的函数改为普通函数呢?来看示例5:
function foo() { const country = { name: "LY5" } country.bar = function () { console.log("name", this.name) } return country } foo().bar() // Output:name LY5
从打印结果看,第6行中的this指向第2行声明的对象country,this.name成功取到了值LY5,为什么?
按规则,如果this所在的函数不是顶级函数,而是普通函数,且它有执行者,其执行者是第10行第一步调用foo()返回的country,那么this对象等于country。
示例5的第5行是通过赋值的方法声明了普通函数,如果将函数直接写在对象的键值对属性里又会怎样?来看示例6:
function foo() { const country = { name: "LY6", bar: function () { console.log("name", this.name) } } return country } foo().bar() // Output:name LY6
与上一个示例类似,只是bar函数声明的方式不同,测试结果是一样的。
这一次我们不让foo函数返回对象,让它返回一个函数,来看示例7:
function foo() { const country = { name: "LY7", bar: function () { console.log("name", this.name) } } return country.bar } foo()() // Output:name undefined
这里第5行中的this.name取不到LY7了,this指向全局对象,为什么?
按规则,如果this所在的函数不是顶级函数,而是普通函数,但它没有执行者,且第10行第一步调用foo()返回的是函数bar,不是一个执行者,那么this等于全局对象。
再来看示例8,这次我们将foo也放在一个对象里面:
const obj = { name: "LY8-1", foo: function () { const country = { name: "LY8-2", bar: function () { console.log("name", this.name) } } return country } } obj.foo().bar() // Output:name LY8-2
这一次,第7行中的this.name指向第5行定义的name,this指向country,为什么?
按规则,如果this所在的函数不是顶级函数,而是普通函数,且它有执行者,其执行者是第10行第一步调用obj.foo()返回的country,那么this对象等于country。
我们将this关键字所在的普通函数改为箭头函数试一下,具体如示例9所示:
const obj = { name: "LY9-1", foo: function () { const country = { name: "LY9-2", bar: () => { console.log("name", this.name) } } return country } } obj.foo().bar() // Output:name LY9-1
这次第7行中的this指向了第1行的obj,而非第4行的country,为什么?
按规则,如果this所在的函数不是顶级函数,而是箭头函数,向上跃升一个作用域,相当于取函数foo作用域下的this,以函数foo的拥有者obj为执行者,函数foo是普通函数,它有执行者obj,那么this对象等于obj。
上一示例函数foo返回的是一个对象,我们让它返回一个函数再试一下,具体如示例10所示:
const obj = { name: "LY10-1", foo: function () { const country = { name: "LY10-2", bar: () => { console.log("name", this.name) } } return country.bar } } obj.foo()() // Output:name LY10-1
在这个示例中,我们很期望第7行中的this.name返回第5行写下的LY10-2,但事实上它返回了第2行写下的LY10-1,为什么?
按规则,this所在的函数不是顶级函数,是箭头函数,向上跃升一个作用域,相当于取函数foo作用域下的this,以函数foo的拥有者obj作为执行者,函数foo是普通函数,它有执行者obj,所以this对象等于obj。判断路径与上一示例是一样的。
该示例与示例7有点像,第一步调用同样是返回一个函数,而不是一个对象。为什么示例7中的this指向全局对象,这次示例中的this就等于obj了呢?根本原因在于,本示例判断时发生了作用域跃升,在父级作用域中找到了执行者。
假设作用域不跃升,我们再看一个示例,具体如示例11所示:
const obj = { name: "LY11-1", foo: function () { const country = { name: "LY11-2", bar: function () { console.log("name", this.name) } } return country.bar } } obj.foo()() // Output:name undefined
这个示例与示例10很像,只是第6行声明函数的方式不同,上一个示例bar函数是箭头函数,本示例中是普通函数。
如何判断呢?
按规则,this所在的函数不是顶级函数,是普通函数,且第13行第一步调用obj.foo()返回的是一个函数,它没有执行者,所以this等于全局对象。
以上示例都没有涉及类,接下来看一个在一个对象上调用其方法的示例,具体如示例12所示:
class User { name = "LY12" foo() { console.log("name", this.name) } } const u = new User() u.foo() // Output:name LY12 const f = u.foo f() // TypeError: Cannot read properties of undefined (reading 'name')
第8行输出name LY12,说明第4行中的this指向了User类的实例。第10行报错了,错误大意是“类型错误:无法读取未定义的属性name”,说明此时第4行中的this又不指向User类的实例了。
注意:为什么第10行调用f()会报错,却不会打印name undefined呢?这是因为类是ES6语法,在class内部,默认开启了JS的use strict,即开启了严格模式。在严格模式下,未定义的属性不能访问,否则报错。
从以上12个示例的练习可以看出,同样一份类代码,调用方法不一样,this的指向就不同,这也从侧面说明了this纯粹是一个动态关键字,它具体指向谁,完全取决于运行时。
那么,现在我们再想一下,为什么第10行调用f(),取不到正确的this呢?
按规则,this所在的函数不是顶级函数,是普通函数(第3行的foo函数只是简写,它并不是箭头函数),第9行返回的f是一个函数,而不是一个对象,它没有执行者,所以this等于全局对象。
再想一下,为什么第8行调用u.foo(),this又取到了正确的对象了呢?
按规则,如果this所在的函数不是顶级函数,而是普通函数,第9行调用u.foo()时,foo有执行者,执行者即u,那么this等于类User的实例。
也不一定返回一个函数,就取不到正确的t
his对象,看示例13:
class User { name = "LY13" foo() { return () => { console.log("name", this.name) } } } const u = new User() const f = u.foo() f() // Output:name LY13
这个示例是在示例12的基础上修改的,在foo函数内,使用箭头函数将代码包裹了一下。第10行仍是返回了一个函数,不是对象,但this所在的函数是箭头函数,发生作用域跃升了,相当于取函数foo作用域下的this对象,foo的拥有者(User的实例u)是执行者,所以this指向了类User的实例u。
JS的这个动态关键字this,很容易将程序员搞得晕头转向,一不小心也很容易写出有Bug的代码,大多数时候程序员在使用this的时候,都会经过本地测试,在发现苗头不对时,马上修改。不过有一个简单的方法可以避免在普通函数中使用this关键字时产生错误,这个方法就是使用Function.bind或Function.call。
bind允许开发者在运行时动态改变代码执行上下文环境中的this,call则是既改变又执行,来看示例14:
class User { name = "LY14" foo() { console.log("name", this.name) } } const u = new User() u.foo() // Output:name LY14 const f = u.foo f.call(u) // Output:name LY14 f.bind(u)() // Output:name LY14
这个示例是从示例12修改过来的,类代码没有修改,只是修改了调用方式。第10行使用call将u绑定为函数的执行者,并执行函数,this等于实例u;第11行,先用bind绑定u为函数的执行者,再执行,this等于实例u。
注意:本节在讲解this关键字时,没有特意区分函数和方法。一般情况下,属于某个对象的是方法,不属于任何对象的是函数,但从根本上讲它们都是Function,要么是普通函数,要么是箭头函数。
小结
以上就是在普通函数和箭头函数中判定this关键字所指的具体内容,如仍有疑问,对照示意图多分析几遍示例代码就明白了。判断规则记住三句话就可以了:
❑ 是不是顶级函数?
❑ 是不是箭头函数?
❑ 有没有执行者?
最后留一道思考题给你:箭头函数中的this指向哪里?如果是面试官问你这个问题,你会如何回答呢?(思考与练习5-5,附录有参考答案)
以上内容摘自机工出版的《微信小游戏开发》,李艺著,该书已在京东上架,内容为适合网络发表有少量修改。