为何 "this" in JavaScript 难以理解

371 阅读7分钟

为何 JavaScript this 相比其他传统面向对象语言难以理解

一般而言只有在面向对象的概念中才会有this的身影,this指向的是当前类所创建的实例(对象)。

传统面向对象语言使用this的场景相对固定,如Java,C++等语言this只会出现在类体中。

而JavaScript则可以在函数或类体中使用this,这和该语言的历史有关。而且JavaScript函数可以作为参数传递和返回值返回。

如在Java中你只能将对象作为参数传递(而不是函数本身),并在其函数内部以obj.method()的形式调用。这很明确,更不会出现this丢失的情况

由于灵活的JS性导致this就变得难以捉摸,如下示例:

JS 可以直接创建一个对象(没错这就是单例模式),无需 new 操作,声明的对象可以直接使用外部的 Function,作为对象的 Method


function getName(){
    return this.name
};
const person = {
    name: 'Ton'
    getName:getName
}

// 同样的也可以将对象中的函数赋值给新的变量

const newGetName = person.getName

与上面代码类似却更加具有迷惑性的写法是将函数作为回调函数使用

document.addEventListener('click',obj.fn)
//等价于===
const fn = obj.fn
document.addEventListener('click',fn)

类似这种回到函数你无法确定对方的API(你战友的API,环境内置API,第三方库API)会如何调用你的函数。

由于历史原因JS可以使用函数配合new来创建对象,在面向对象的思想中这个函数变成了类的构造函数。

function Person(){
    this.name = 'Ton'
    this.getName = function(){
        return this.name
    }
}

const person = new Person() // 构造调用

// 但也没有限制你直接函数调用Person

Person()

以及ES6新增的class关键字,不过它的出现可以解决一些问题(特么以后不用再使用function来创建对象了[捂脸])

class Person{
    name = 'Ton'
    getName = function(){
        return this.name
    }
}

如何理解this

以上几个示例展现了JS的灵活性,这也是导致this难以理解的直接原因。同时this本身就具有不确定性,那么代码中还有什么东西是不确定的呢?

参数准确的说是形参

将this看作参数并不罕见,如python的面向对象中就是固定的将self(self理解为this)作为方法的第一个形参

接下来我们将this看作参数。

拷问:下面这段代码中this是什么?

const button = document.getElementById('btn')
button.onclick = function(){
    // who is "this"?
}

害,这么简单这不就是button元素嘛~

恭喜你回答错误...

与参数相同我们只有在调用一个函数时才能确定这个函数中this具体是谁。

  • 你要是只给我一个函数的定义而不说调用,对不起没有人知道参数是什么。
  • 你要是只给我一个函数的定义而不说调用,对不起没有人知道this是什么。

错误的原因需要回到JS的灵活性,在用户正常点击按钮的时候没错this就是button,可在JavaScript可不止如此,比如使用JS来控制调用事件。

button.click() // this === button
const btnClick = button.click 
btnClick() // this === window

好了,既然this就是参数,那我在调用函数的时候手动传给你不就好了嘛!

面对这种困境,JS提供了三个API:call、apply、bind来处理this。这三个API都有一个共同的目标,将this显示的传递给函数。传入之后,this指向将不会改变除了遇到 new(毕竟new才是真正的想要使用this啊!)。

请你在调用函数的时候使用这三个API的其中之一来传入this。好处是更加的清晰易读,帮助你不断的加强this的理解

再来一剂加强针:下面的代码会log出什么?答案将在文末总结中公布。

let length = 10
function fn(){
    console.log(this.length)
}

let obj = {
    length: 5,
    method(fn){
        fn()
        arguments[0]()
    }
}
obj.method(fn,1)

this的四种基本绑定规则

问:在设计API的时候有些函数允许不传入实参,并具有默认的行为,如何完成这种操作?

答:默认参数,API设计的常规操作

this也不例外那谁来处理这个默认参数 - JS引擎

下面的例子展示了this的四种常规绑定规则

默认绑定

function foo(){
    console.log(this)
}

// JS引擎执行:这家伙没传this参数,那些我给你个默认的
foo()  //foo() === foo.call(undefined)

隐式绑定

function foo(){
    console.log(this)
}
const obj = {
    foo:foo
}
// JS引擎执行:这家伙又没传this参数,不过通过他的调用可以判断出obj就是它的this,我给你绑上
obj.foo() //obj.foo() === obj.foo.call(obj) === foo.call(obj)

显示绑定(硬绑定)

function foo(){
    console.log(this)
}
const obj = {
    foo:foo
}
// JS引擎执行:这家伙终于传this了,这次不用我给他擦屁股了
obj.foo.call(obj)
obj.foo.apply(obj)
const objWithThis = obj.foo.bind(obj)
objWithThis()

new绑定

区别于以上的函数调用,这样的函数调用应该叫做构造调用

function foo(a){
    this.a = a;
}
// JS引擎:这家伙用了new关键字,说明他正在执行构造函数调用,我得完成这几步
// 1. 创建一个全新的对象
// 2. 这个新对象会被执行[[prototype]]链接
// 3. 这个对象会被绑定到函数调用的this
// 4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
const bar = new foo(2)

那么这四种绑定的优先级如何: 默认绑定 < 隐式绑定 < 显示绑定 < new绑定。

除了4种常规情况,不代表没有例外(不过多展开,这不是本文的重点)

bind说明

其中Function.prototype.bind,还有类似柯里化的作用。如果我们指向使用它的柯里化功能不想指定this的时候可以这样用:

const fnWithArgs = fn.bind(null,arg1,arg2)
fnWithArgs() 

但是这里并不严谨,如果在非严格模式下nullundefined会被JS引擎视为默认绑定 this === window,如果该函数确实使用this了则有可能会window对象照成污染。

// 声明一个不会对你的程序照成任何副作用的对象,使用null切断对象的原型
const DMZ = Object.create(null) // DMZ(demilitarized zone)
fn.bind(DMZ,arg1,arg2)

箭头函数姗姗来迟

随着JS的发展,最后要稍微讲一下箭头函数()=>,有一定经验的JavaScript程序员下面这段代码或许能唤醒你的记忆。

function foo(){
    var self = this
    setTimeout(function(){
     self.xxx // 使用外部环境的this值
    },1000)
}

// ===

function foo(){
    setTimeout(()=>{
     this.xxx // this由函数定义时的环境确定
    },1000)
}

箭头函数的特性:

  • 自身没有thisthis的值为函数定义时的环境,不可以使用call、apply、bind绑定this
  • 无法使用new操作符创建对象
  • 没有arguments属性

ES6 的 class 和箭头函数配合才能更安全的发挥面向对象的威力,或许这才是它(this)该出现的地方(面向对象)

总结

this本身不难理解,本质在于JS本身的语言特征较为灵活。

this看作参数,参数只有再调用的时候才被确定。如果调用的时候不指定参数,JS引擎会来帮你完成,但这总会有一些意想不到的结果。理解this就是参数是本文的目的。

this的四种绑定规则优先级为:默认绑定 < 隐式绑定 < 显示绑定 < new绑定

答案公布:

let length = 10
function fn(){
    console.log(this.length)
}

let obj = {
    length: 5,
    method(fn){
        fn() // fn() === fn.call(undefined)
        arguments[0]() // arguments[0]()=== arguments[0].call(arguments)  
    }
}
obj.method(fn,1)

λ ? // log1,this指向window,window.length是什么当前页面的iframe的个数
λ 2 // log2,obj.method(fn,1)实际传入的两个参数因此arguments.length === 2

没想到吧看完了文章还是答错了,这里想说的是就算背会了所有的this情况,依然免不了错误。因此从现在开始用箭头函数或call、apply、bind来管控this,不要让JS引擎给你擦屁股。