重学 JavaScript[3] this

this 是在函数运行时进行绑定的,它的上下文取决于函数调用时的各种条件

this 的指向与函数书写位置无关,而与调用位置有关

全局的 this

浏览器环境

在浏览器环境下,this 指向Window对象:

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

fn()

这两段代码会得到同样的结果:Window对象,也就是全局对象globalThis

Node 环境

在 Node 环境下,全局对象是global,但直接打印 this 会得到一个{},实际上指向的是module.exports对象

// ...
console.log(this === module.exports)

// true
function fn() {
    console.log(this === global)
}

fn()

// true

通过函数打印出的 this,指向global对象

绑定规则

默认绑定

上一篇中提到过使用callapply等来绑定函数以改变 this 指向

现在,我们不使用任何“绑定”的行为

this.num = 100

function fn() {
    console.log(this.num)
}

fn()

// 100

不执行任何绑定行为时,this 会绑定到当前对象(在段代码里是全局对象)

请注意:如果使用严格模式,全局对象将无法使用默认绑定:

this.num = 100

function fn() {
    'use strict'
    console.log(this.num)
}

fn()

// TypeError报错

隐式绑定

考虑以下代码:

function fn() {
    console.log(this.num)
}

this.num = 200

let obj = {
    num: 100,
    fn: fn,
}

fn()
obj.fn()

// 200
// 100

函数 fn 被单独写出来(好像不属于 obj 对象),在对象内被引用为一个属性,当通过obj.fn()调用时,函数的 this 会根据隐式规则绑定到引用函数的上下文对象,也就是obj

如果有多个对象直接引用,形成了对象属性引用链时,只有“最接近”调用的一层会影响其调用位置:

function fn() {
    console.log(this.num)
}

let child = {
    num: 100,
    fn: fn,
}

let father = {
    num: 200,
    child: child,
}

father.child.fn()

// 100

丢失

在某些时候,隐式绑定会丢失,这时候自动执行默认绑定规则,考虑以下代码:

function fn() {
    console.log(this.a)
}

function doFn(f) {
    f()
}

this.a = 'global'

let obj = {
    a: 'obj',
    fn: fn,
}

doFn(obj.fn)

// global

可以理解为:以传参方式传入的函数,其 this 也要根据实际调用情况来判定(不过往往你不知道类似doFn这样的函数的实现时要尤其小心,它很有可能改变调用时的 this)

这一点如果放在实际应用里就很好理解,调用内置的setTimeout函数:

function fn() {
    console.log(this.a)
}

this.a = 'global'

let obj = {
    a: 'obj',
    fn: fn,
}

setTimeout(obj.fn, 100)

// global

显式绑定

在上一篇我们已经介绍过显式绑定的方法

在调用call等函数时,需要传进一个thisArg参数作为绑定的对象,通常我们会传递一个类似上面提到的obj这样的复杂对象,但如果传递一个字符串、数字,或者布尔类型呢?

尝试以下代码:

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

fn.call('hello')

在浏览器环境运行,你会在控制台得到一个String类型的对象,而非'hello'字符串本身

再传入一个数字试试:

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

fn.call('hello')

也会得到类似的结果

打印出的对象形式说明:在这个过程中会对传入的原始值(基本数据类型)装箱,等价于:new String('hello')new Number(100)

new 绑定

提到 new,不得不想起上一篇中介绍的“构造器函数”,事实上 JavaScript 并不存在什么“构造器函数”,任何函数都可以被直接执行,也可以在调用前面加上new关键字(这样的调用方式被称为“构造器调用”)

比如这个函数:

function Person() {
    this.name = '王昭君'
}

let person1 = new Person()
let person2 = Person()

console.log(person1)
console.log(person2)

第一次打印会得到一个{name: '王昭君'}的对象
第二次打印会得到undefined,这样不奇怪,因为 Person 函数什么也没返回

当一个函数被“构造器调用时”,会执行这些操作:

  • 创建一个全新的对象
  • 对这个新对象进行“原型”连接
  • 这个新对象会绑定到函数调用的 this
  • 如果函数没有返回其他对象,那么 new 表达式会自动返回这个新对象

我们主要关心第三步操作,至于其他步会在以后提到

执行构造器调用时,将函数的 this 绑定到新对象,称之为 new 绑定

绑定优先级

四种绑定优先级的关系:

默认 < 隐式 < 显式 < new

在判断时遵循优先级从大到小的顺序,如果发现有较大优先级存在,则根据较大优先级规则判断 this 指向

例外

如果你尝试了显式绑定中给出的例子,会发现如果传入的参数是null或者undefined,代码会打印出全局对象(在浏览器环境下是Window对象,在 node 环境是global对象)

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

fn.call('hello')

实际上这里应用的是默认绑定规则,不过正常情况下都不会去传入这两个看起来就“不正常”的值作为thisArgs

在函数柯里化(后面的文章也许会提到)操作中,调用bind时需要传入一个参数作为“占位”,如果这时候传入了这两个“不正常”的值,就有可能造成修改全局对象等不可预计的后果

箭头函数

在上一篇中我们提到了函数的几种书写方式

其中箭头函数并不是由function关键字来定义的,而是使用了 lambda 表达式来定义

箭头函数并不遵循 this 的四种绑定规则,而是根据外层作用域来决定 this

考虑以下代码:

let obj = {
    name: 'obj',
    getName1: () => {
        console.log(this.name)
    },
    getName2() {
        this.getName1()
    },
    getName3() {
        console.log(this.name)
    },
}

this.name = 'global'

obj.getName1()
obj.getName2()
obj.getName3()

// global
// global
// obj

箭头函数在 this 绑定上也带来了很多优势,比如下面这段 React 代码:

class App extends React.Component {
    constructor() {
        this.state = {start: false}
    }

    handleClick() {
        this.setState({start: true})
    }

    render() {
        return <Button onClick={ this.handleClick.bind(this) }>开始<Button/>
    }
}

为确保事件处理函数在调用时总能得到正确的 this,在书写时要手动绑定 this

React 官网提供的示例是:

<Button onClick={() => this.handleClick()}>开始<button />

如果使用箭头函数,可以省去这一过程:

class App extends React.Component {
    constructor() {
        this.state = {start: false}
    }

    handleClick = () => {
        this.setState({start: true})
    }

    render() {
        return <Button onClick={ this.handleClick }>开始<Button/>
    }
}

手写 XX

在面试中经常会考察 this 绑定相关的知识,通常会要求手写callapplybind等函数,这些内容我打算在单独的一篇文章中总结

前端八股文之手写系列