在之前的一篇转载文中,介绍了javascript中的this,但是经过这么多年之后,我始终觉得,对this的解释太过复杂和神秘,似乎this是不可把握的,像是玄学。但是,真的需要这么费劲去理解JavaScript中的this吗?我想,并不必。
在工作多年以后,再未遇到this带来的问题。除了对this本身的理解比较自然之外,我写代码的方式也起到了很大的帮助。this出现的位置,由于代码的简洁明了,反而让this出现bug的可能性降低。但,对于没有花时间去理解this的同学而言,或许读完这篇文章可以更快的理解和把握this的指向。
this的指向只有一种情况
对于网上五花八门的举例而言,对我来说,它们全部都是一种情况:this指向function的调用者。this指向谁并非在定义function时决定,而是在function被运行时决定的。而由谁来调用function,则其内部的this指向调用者。
this指向所在作用域函数被运行时的直接调用者。
this究竟指向谁和它的定义时真的没有一点关系,我们来看一个代码:
const prototypes = { song: 'Hello!', sing() { alert(this.song) }, } const sing = prototypes.sing sing()
这个例子是为了说明,你在写代码时,写下this的那一刻,绝对不能去看this上有什么,因为this上到底有什么是不可预测的,只有当this所在的函数被运行时,this上到底有什么才是可知的。这也是为什么this难的原因,因为不可预测性。
需要强调的是,“作用域函数”是指用function声明的函数(包括匿名函数),箭头函数会在下文讨论。因为在某些情况下,这个调用者很难被人察觉,开发者会认错调用者。我们来举个例子,让你很难察觉调用者究竟是谁。
function invoke() {
function createName() {
return this.familyName + ' ' + this.customName
}
this.name = createName()
this.say = function() {
alert(this.name)
}
}
const person = {
familyName: 'Adrew',
customName: 'Karm',
invoke,
}
person.invoke()
person.say()
调用之后,程序能够正常运行,但是结果却非预期。这是因为createName函数中的this指向了一个我们不知道的对象,而非person这个对象。问题的根源在于在这个程序运行过程中,createName的调用者并非person,具体是谁我们暂且还不知道。既然createName的调用者并非person,那么它所创建的作用域中的this,在其运行时指向的自然就不是person了。那么此时createName的调用者究竟是谁?
我们重新去观察createName的运行过程,它是在person.invoke()的时候被定义,且立即就执行的,但我们前面说过,函数定义时并不决定this的指向,只有运行时决定了this的指向。因此,我们要去看函数运行时它的调用者。createName的运行时调用者并不存在。这听上去有些荒唐,但事实如此。
在JavaScript中,如果函数的调用者不存在,那么它的调用者将会是window(或其他环境中的全局变量),所以,最终,createName中this指向window。
我们怎么证明这件事呢?我们来写这样一段代码:
(function () { function invoke() { function createName() { console.log(this.familyName + ' ' + this.customName) return this.familyName + ' ' + this.customName } this.name = createName() this.say = function() { console.log(this.name) } } const person = { familyName: 'Adrew', customName: 'Karm', invoke, } person.invoke() person.say() console.log(this.familyName + ' ' + this.customName) }).call({ familyName: 'Jimy', customName: 'Hardsam', })
你会发现,createName外层函数的this指向了call中传入的对象,但是createName中的this丝毫不受影响,仍然指向window。怎么修复这个问题呢?我们其实可以采取一个比较笨的办法:
this.createName = createName this.name = this.createName() delete this.createName // 等效于 this.name = createName.call(this)
在实际开发中,我们要注意另外一个事实,当我们在函数中声明了'use strict'时,这种指向window的行为会被改变,this将没有指向,也就是undefined,但是需要注意的是'use strict'必须声明于当前作用域:
(function () { 'use strict' function invoke() { function createName() { console.log(this.familyName + ' ' + this.customName) return this.familyName + ' ' + this.customName } this.name = createName() this.say = function() { console.log(this.name) } } const person = { familyName: 'Adrew', customName: 'Karm', invoke, } person.invoke() person.say() console.log(this.familyName + ' ' + this.customName) }).call({ familyName: 'Jimy', customName: 'Hardsam', })
上面这段代码运行时会报错,因为声明'use strict'只之后this指向了undefined。为了使代码更容易理解,我们应该声明'use strict',因为没有调用者的this指向undefined才更符合逻辑。
没有调用者的函数中this指向了undefined。
为了让上述表示更精确,我们应该习惯于在每一个文档中声明'use strict',因为'use strict'具有继承性,在同一作用域内声明一次'use strict',那么在该作用域内声明的所有函数都继承其特性。所以,大部分开发者会直接在js文件的最顶端,或者一个模块的最开始声明'use strict'。
控制调用者
我们已经知道call, apply, bind这三个函数的方法可以修改一个函数内this的指向。其中,call和apply是规定一次性运行时的调用者,例如fn.call(some)即规定了在这次运行时,将fn的调用者规定为some,相当于some.fn()。因此,对于fn内的this,它的调用者就是some。而bind的作用是返回一个新函数,该新函数的调用者绑定为某个对象,而且该绑定是永久的,无法被改变的,例如const fn2 = fn.bind(some),那任何时候运行fn2(),它的调用者都是some,即使你用call、apply去修改调用者,也是无效的。
隐含的调用者
从理论上讲,一个被声明的且没有调用者的函数内的this指向undefined。但是在浏览器(包括例如nodejs这样的环境)中执行某些特殊动作时,这些特殊动作会临时控制传入参数的函数的调用者,最为常见的就是setTimeout,它接收一个函数作为参数,并且异步执行这个函数,在异步执行时,它会通过call的方式临时将参数函数的调用者切换为window。除了setTimeout,DOM的事件回调函数在运行时,会将调用者切换为当前DOM节点。总之,这种隐含的调用者在标准文档中都有说明。
要主动控制这些传入参数的调用者,需要你在编写代码时采用不同的策略。一般有两种方式,一种是使用bind将被传函数的调用者绑定。另一种是使用箭头函数。
箭头函数的调用者?
和通过function声明的函数不同,箭头函数的性质并非作用域函数,在其他语言里称为Lambda,是一种表达式函数。在ES6之后,js拥有了新的作用域范围,即“块级作用域”。而Lambda函数所创建的作用域是块级作用域表达式,因此,它不具备调用者,其内的this指向的是它所在的作用域函数的调用者。例如:
function invoke() {
const createName = () => this.familyName + ' ' + this.customName
this.name = createName()
}
person.invoke()
上面代码中,createName虽然是一个函数,但它是一个表达式函数,并不具备作用域函数的能力,其内部的this的所有者是外层invoke这个函数,因此,当invoke被person调用时,this指向了person。
在异步代码中,即时作用域函数被调用执行完毕,但是this的指针本保留下来。我们看一个例子:
function invoke() { setTimeout(() => this.name = 'tomy', 500) // 以前的写法 // var that = this // setTimeout(function() { // that.name = 'tomy' // }, 500) } person.invoke()
在invoke被person调用运行时,this的指针就指向了person,在setTimeout中的this保留了该指向,因此,无论这个this在何时何处被使用,只要invoke运行时this明确了自己的指向,就不会改变。
面对这样的疑问,你还会遇到这样的问题:
function invoke() { const createName = () => this.familyName + ' ' + this.customName return createName } const person = { familyName: 'Adrew', customName: 'Karm', invoke, } const createName = person.invoke() const some = { familyName: 'Ximen', customName: 'Sam', createName, } const name = some.createName() console.log(name)
你会发现,some.createName()的结果并没有得到some的正确结果,这是因为invoke的调用者是person,invoke内的this只会指向person,而不会指向任何人。(需要强调,上文中,多次提到函数内的this,是指该this所在的直接作用域是该函数,而不是函数内函数中的this。)
调用栈
栈是一种后进先出的结构,对于js中函数的执行而言,在一个函数中调用另外一个函数,就会形成调用栈。调用栈中,只有顶层的被调用函数在执行,底下的函数全部处于等待执行状态,当顶部函数执行完毕,被弹出栈,才会回到下面一层的调用函数中继续执行。于是,在这个调用栈中,this的指向也开始会变得扑朔迷离。但实际上,每一个函数在执行时它都有自己的调用者(包括undefined和this),在一层又一层的调用栈中,你需要想象当前这一层调用的主人是谁,你只要确认当前执行的函数的调用者是谁,那么该函数中的this指向谁就变得清晰可见。
2019-04-01 2923 javascript, this