4000 多字学懂弄通 js 中 this 指向问题,顺便手写实现 call、apply 和 bind 👈

560 阅读13分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第6天,点击查看活动详情

全局作用域中

一般情况下,只有函数里面才有 this。但是在全局作用域下,也有 this,在浏览器环境里指向的是 window(GlobalObject),在 Node 环境下指向的是一个空对象 {}

函数中

函数的调用执行需要在执行上下文栈(ECStack)中创建函数执行上下文(FEC),FEC 中有与之关联的 VO(AO)信息和 this 等信息,this 会在调用时动态的指向某个对象,图示如下:
2024-03-23_214830.png

四条绑定规则

因为 this 指向的对象并非是在函数定义时确定而是在执行时动态绑定的,所以在 js 中函数里 this 的指向一直是一个令人迷惑,需要当心注意的存在。然而,this 的指向毕竟不是无源之水,无本之木,而是有规可循的,下面就是 this 绑定的 4 条规则:

1. 默认绑定

像下面的例 1.1 这样,直接调用一个函数,就是默认绑定,this 在非严格模式下指向的为全局对象 window(浏览器环境,下文若无特殊说明,皆同):

// 例 1.1
var day = 'Saturday'
function fn() {
  var day = 'Monday'
  console.log(this.day) // Saturday
}
fn() 

例 1.1 中的函数 fn 既不是通过某个对象(window 除外)比如 obj.fn() 调用,也不是通过 call 或 apply 调用,而是赤裸裸的独立调用,这种情况下 this 绑定的就是全局对象,所以打印 this.day 的时候找到的就是全局作用域中的 Saturday(注意,全局作用域中的 day 是用 var 定义的,如果是用 constlet 定义的,则结果会为 undefined)。有时候,情况比例 1.1 会略显复杂,但只要发现函数最后是独立调用的,那么 this 还是指向着 window,如下面的例 1.2:

// 例 1.2
var day = 'Saturday'
const obj = {
  day: 'Monday',
  fn() {
    var day = 'Tuesday'
    return function () {
      const day = 'Wednesday'
      console.log(this.day)
    }
  }
}
obj.fn()()

obj.fn() 得到的是 fn 执行返回的函数对象,再跟一个 () 也就是执行这个返回的函数,那么该函数依旧是独立执行的,所以函数中的 this 指向全局,打印结果依旧是大家喜爱的 Saturday。 下面再看一种比较少见的写法:

// 例 2.3
var day = 'Saturday'
const obj1 = {
  day: 'Monday',
  fn() {
    console.log(this.day)
  }
}
const obj2 = {
  day: 'Tuesday',
  fn: null
}
;(obj2.fn = obj1.fn)()

执行第 13 行的结果打印的是 Saturday,因为先执行的 obj2.fn = obj1.fn 返回的是 obj1.fn 指向的函数对象,之后跟上 () 调用,是对函数的独立调用,属于默认绑定,所以 this 指向为 window。这里还有个注意点是在 13 行的开头,这个 ; 是必须加的,否则在解析的时候会认为分号后面括号这部分内容和前面 {} 是一个整体,就会报错。

严格模式下

在严格模式下,默认绑定时 this 指向的是 undefined:

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

2. 隐式绑定

如果一个函数,是通过某个对象调用的,那么 this 就会指向该对象,属于隐式绑定。比如下面的例 2.1:

// 例 2.1
var day = 'Saturday'
const obj = {
  day: 'Monday',
  fn() {
    var day = 'Tuesday'
    console.log(this.day) // Monday
  }
}
obj.fn() 

函数 fn 是通过对象 obj 调用的,所以 fn 中的 this,就指向了 obj,打印 this.day 相当于打印 obj.day,所以结果为 Monday。 其实,所谓的默认绑定和隐式绑定可以说是一个道理,比如我们可以把例 2.1 中做一些修改,生成例 2.2:

// 例 2.2
var day = 'Saturday'
const obj = {
  day: 'Monday',
  fn() {
    var day = 'Tuesday'
    console.log(this.day)
  }
}

const foo = obj.fn
foo()

这回不是直接执行 obj.fn(),而是将 obj.fn 先赋值给 foo,也就是让变量 foo 指向了 obj.fn 指向的函数对象,再直接调用 foo() 执行。此时符合默认绑定的情况,foo 中的 this 指向 window。换个角度,亦可看成执行的是 window.foo(),那么根据隐式绑定规则,函数中的 this 指向的应该是调用它的对象,还是 window,所以打印结果就又是令人喜爱的 Saturday 了。

3. 显式绑定

隐式绑定通过对象调用一个函数,这个对象内部必然有个对该函数的引用(比如属性),通过这个引用间接地、隐式地将 this 绑定到了对象上。但有时候,如果对象中不包含对某个函数的引用,我们又想让该对象可以调用这个函数,也就是让函数中的 this 强行指向某个不包含对该函数引用的对象,就得用 js 中所有的函数都拥有的 call、apply 或 bind 方法显示地对函数中的 this 进行绑定(关于它们 3 个方法的区别等细节,本文不做讨论)。下面举个简单的例子:

// 例 3.1
var day = 'Saturday'
function fn() {
  var day = 'Monday'
  console.log(this.day)
}
const obj = {
  day: 'Tuesday'
}
fn.call(obj) // Tuesday
fn.apply(obj) // Tuesday
const foo = fn.bind(obj)
foo() // Tuesday

将 obj 作为参数传给 call 或 apply,那么就是直接调用执行了函数 fn,并且 fn 中的 this 指向的就是 obj。而 bind 则是仅将参数绑定到函数的 this 上,然后返回一个新的函数,并不会直接执行函数。
再来看个稍微复杂的例子:

// 例 3.2
var day = 'Saturday'
const obj = {
  day: 'Monday',
  fn() {
    var day = 'Tuesday'
    return function () {
      console.log(this.day)
    }
  }
}
const obj2 = {
  day: 'Wednesday'
}
obj.fn.call(obj2)()
obj.fn().call(obj2)

第 15 行,先是 obj.fn.call(obj2) 让 obj.fn 这个函数执行,并且将函数中的 this 指向 obj2。但请注意,这是让 obj.fn 的 this 指向 obj2,如果在第 6 行后面打印 this,那么得到结果会是 Wednesday。而 obj.fn.call(obj2) 的执行结果是返回了函数 function () { console.log(this.day) },然后通过 () 直接调用,应该按照默认绑定的规则来判断,this 指向的是 window,所以第 15 行执行的结果为 Saturday。
第 16 行,obj.fn() 执行完后得到函数 function () { console.log(this.day) },通过 call(obj2) 调用执行,并且将 obj2 绑定到了函数内的 this 上,故而执行结果为 Wednesday。

特殊情况

注意,当传给 call、apply 或 bind 的第一个参数为 nullundefined 时,在非严格模式下,this 会指向全局对象 window;而在严格模式下,this 会指向 undefined。

// 非严格模式
var day = 'Saturday'
function fn(day) {
  var day = 'Monday'
  console.log(this)
}
fn.call(undefined)
fn.apply(undefined)
const foo = fn.bind(undefined)
foo()

第 6、7 和 9 行的执行结果如下图,均指向 window: image.png

手写实现 call、apply 和 bind 方法

  • 实现 call

第 1 行直接在 Function.prototype 上添加 myCall 方法,让其成为所有函数的属性。第一个参数为函数的 this 需要绑定的对象,第二个用剩余参数 (Functions Rest Parameters) 接收需要传入函数的参数;

第 2 行的目的是确保 this 要绑定的目标为对象,这样才能在后面通过隐式绑定的方法来绑定 this。如果不传或是传入的为 undefined / null,则让 this 指向 window 对象。这里 new Object() 也可以直接用 Object() 替换;

第 3 行是为防止与 thisArg 原本的属性名重复,故使用 Symbol() 生成独一无二的属性名;

第 7 行的 ...theArgs展开语法 (Spread syntax),虽然和第 1 行的剩余参数写法一样,但可以看成是相反的作用 —— 剩余参数是将一个个参数放入到数组 argArray 中,展开语法是将数组 argArray 中的参数一个个拿出来。

Function.prototype.myCall = function (thisArg, ...argArray) {
  thisArg = thisArg !== undefined && thisArg !== null ? new Object(thisArg) : window
  const symbol = Symbol()
  // 给 thisArg 添加属性,值为调用 myCall 方法的函数本身
  thisArg[symbol] = this
  // 通过隐式绑定让调用 myCall 方法的函数执行,并且 this 指向传入函数的第一个参数 thisArg。
  const result = thisArg[symbol](...argArray)
  // 给 thisArg 添加了原本没有的属性,就需要删除掉
  delete thisArg[symbol]
  return result
}
  • 实现 apply

apply 的实现和 call 非常相似,唯一不同的地方在于在定义 myApply 方法的时候,第二参数接收的直接是数组,需要给个默认值为空数组,防止当普通函数调用 myApply 但是没传任何参数导致在第 6 行使用展开语法的时候报错:

Function.prototype.myApply = function (thisArg, argArray = []) {
  thisArg =
    thisArg !== undefined && thisArg !== null ? new Object(thisArg) : window
  const symbol = Symbol()
  thisArg[symbol] = this
  const result = thisArg[symbol](...argArray)
  delete thisArg[symbol]
  return result
}
  • 实现 bind

bind 是返回一个绑定了指定 this 的新函数,而不会直接执行;且 bind 的传参可以在 bind 里传,也可以在调用返回的函数时传,也可以同时传。比如现有函数 function sum(num1, num2) { return num1 + num2 } ,想传递的参数为 10 和 20,可以 const newSum = sum.bind('Jay',10,20); newSum(),也可以 const newSum = sum.bind('Jay'); newSum(10, 20),还可以 const newSum = sum.bind('Jay', 10); newSum(20)。所以实现起来会与 call 和 apply 稍有不同:

Function.prototype.myBind = function (thisArg, ...argArray) {
  thisArg =
    thisArg !== undefined && thisArg !== null ? new Object(thisArg) : window
  const symbol = Symbol()
  thisArg[symbol] = this
  // 返回的是一个函数
  return function (...args) {
    // 在这个函数内部通过隐式绑定指定 this
    const result = thisArg[symbol](...argArray, ...args)
    delete thisArg[symbol]
    return result
  }
}

4. new 绑定

当函数通过 new 关键字调用时,函数中 this 指向的就是执行 new 后生成的新的实例对象。比如下面的例 4.1:

// 例 4.1
var day = 'Saturday'
function Fn(day) {
  this.day = day
}
const fn1 = new Fn('Monday')
const fn2 = new Fn('Tuesday')
console.log(fn1.day, fn2.day) // Monday Tuesday

当我们 new 构造函数 Fn 的时候,会创建一个新对象,参数 Monday 就被赋值给了新对象的 day 属性。因为 Fn 中没有返回其它对象,所以会默认把创建的新对象返回出去,相当于把 this 返回出去,由 fn1、fn2 接收。所以 fn1 的 day 为 Monday,fn2 的 day 为 Tuesday。

下面再看一个综合了上述四种绑定规则的案例:

var day = 'Saturday'
function Fn(day) {
  this.day = day
  console.log('fn 的 this.day:' + this.day)
  this.obj = {
    day: 'Tuesday',
    foo() {
      var day = 'Wednesday'
      console.log('fn.obj.foo 的 this.day:' + this.day)
      return function () {
        console.log('fn.obj.foo 执行后返回的函数的 this.day:' + this.day)
      }
    }
  }
}
const obj = { day: 'Thursday' }
const fn = new Fn('Monday')
fn.obj.foo.call(obj)()

fn 是 new Fn 得到的,并且传的参数为 Monday,应用 new 绑定规则,所以执行第 18 行代码时会首先输出第 4 行的打印结果为“fn 的 this.day:Monday”。fn.obj.foo.call(obj) 的执行结果,一个是让 fn.obj.foo 这个函数的 this 指向了 obj,应用的是显示绑定规则,所以第 9 行的打印结果为“fn.obj.foo 的 this.day:Thursday”;另一个是返回了函数 function () { console.log(this.day) },之后再加个() 直接执行这个返回的函数,可以看成是独立的函数调用,为默认绑定,所以第 11 行打印的结果为“fn.obj.foo 执行后返回的函数的 this.day:Saturday”。

规则优先级

如果一个函数调用时,情况同时满足上面多条规则时,就需要根据优先级来确定 this 的指向了。上面 4 条规则的优先级如下:

new 绑定 > 显示绑定 > 隐式绑定 > 默认规则

默认绑定的优先级最低这是显而易见的,下面举个例子验证一下隐式绑定和显示绑定的优先级:

var day = 'Saturday'
const obj = {
  day: 'Monday',
  fn() {
    console.log(this.day)
  }
}
const obj2 = { day: 'Tuesday' }
obj.fn.call(obj2)

打印结果为 Tuesday,可见 call 的显示绑定起了作用,说明显示绑定优先级高于隐式绑定。因为 new 和 call 或 apply 都是执行函数,所以他们不能同时使用。下面通过 bind 作为显示绑定的代表来和 new 绑定做对比:

var day = 'Saturday'
function Fn(day) {
  this.day = day
  console.log(this)
}
const obj = { day: 'Wednesday' }
const foo = Fn.bind(obj)
const fn = new foo('Monday')

先在第 7 行将 Fn 的 this 通过 bind 显示绑定为 obj 对象并将返回的函数赋值给 foo,再在第 8 行通过 new 调用 foo 得到 fn 对象。最后浏览器控制台输出结果为“Fn {day: 'Monday'}”,说明 this 指向的是 fn 对象,new 的优先级高于 bind。

箭头函数

箭头函数是不绑定 this 的,所以前面这 4 条绑定规则对箭头函数里的 this 的指向就都不适用了。箭头函数的 this 的指向,与其外层作用域 this 的指向相同。下面举个例子:

var day = 'Saturday'
const obj = {
  day: 'Tuesday',
  fn: () => console.log(this.day)
}
obj.fn() // Saturday

obj.fn 指向的是一个箭头函数,执行箭头函数时其内部的 this 与其上层作用域的 this 指向相同。需要注意的是,这里箭头函数的上层作用域为全局作用域, obj 这个对象的 {},是不构成作用域的,与直接写个 {} 生成的块作用域不同。

再来个综合点的例子:

var day = 'Saturday'
function Fn(day) {
  this.day = day
  this.obj = {
    day: 'Tuesday',
    foo() {
      var day = 'Wednesday'
      return () => {
        console.log(this.day)
      }
    }
  }
}
const obj = { day: 'Friday' }

const fn = new Fn('Monday')
fn.obj.foo.call(obj)()

第 17 行,fn.obj.foo.call(obj) 的执行结果,就是将 fn.obj.foo 这个函数的 this 绑定为了第 14 行定义的 obj 对象,并且返回了一个箭头函数。后面再加个括号执行返回的箭头函数,里面的 this 指向的是外层作用域(fn.obj.foo 这个函数的作用域)的 this,所以结果为 Friday。

内置函数(built-in function)

上面四种规则,适用于对我们自己定义的函数的 this 指向进行分析。但有时候一些函数是如何调用的我们并不清楚,比如函数作为参数传给一个 js 或第三方库的内置函数,像是 setTimeout()arr.forEach()等,我们并不清楚这些内置函数内部是如何调用作为参数传入的函数的。所以下面专门分析这些特别的情况中,this 的指向问题。

setTimeout

执行下面例 5.1 代码,得到打印的结果为 Saturday,可见 setTimeout() 的回调函数里面的 this,无论是否是在严格模式下,默认指向的都是 window 对象,这是因为由 setTimeout() 调用的代码运行在与所在函数完全分离的执行环境上,可能其在内部实现是通过 apply 绑定了 window 执行的我们传入的函数。

// 例 5.1
var day = 'Saturday'
function fn() {
  var day = 'Monday'
  setTimeout(function () {
    console.log(this.day) // Saturday
  }, 0)
}
fn()

注意:例 5.1 中 setTimeout 的回调函数是 function 声明的函数,而非箭头函数,如果是箭头函数那么 this 依然是指向上层作用域中的 this。比如将例 5.1 稍作修改如下:

// 例 5.2
var day = 'Saturday'
function fn() {
  var day = 'Monday'
  setTimeout(() => {
    console.log(this.day) // Sunday
  }, 1000)
}
const obj = { day: 'Sunday' }
fn.call(obj)

例 5.2 中传入 setTimeout 参数为箭头函数,this 指向的就是其上层作用域 fn 的 this,fn 被 call 调用,this 绑定为了 obj,所以打印结果为 Sunday。

事件监听

比如 target.addEventListener('click', function () { ... }),或是 target.onclick = function () { ... } 等,在这些事件监听方法触发后执行的函数中, this 指向的都是触发事件的 target 对象。比如在页面中有个 box:

<div id="box"></div>

我们监听这个 box 元素的点击事件:

box.onclick = function () {
  console.log('onclick', this)
}
box.addEventListener('click', function () {
  console.log('addEventListener', this)
})

两种方式的监听,打印的 this 指向的都是 box 元素对象。

数组方法

我们以 Array.prototype.forEach() 举例说明:

const arr = [1, 2, 3]
arr.forEach(function () {
  console.log(this)
})

得到的打印结果 this 指向的是 window。如果想指定回调函数的 this 指向,可以传入第 2 个参数,比如:

const arr = [1, 2, 3]
arr.forEach(function () {
  console.log(this)
}, 'Jay')

此时 this 指向的就是字符串 Jay。数组的方法能不能通过传入参数指定回调函数的 this,可以查阅文档或是如果使用的代码编辑器,比如 VS Code 就会有如下图提示,可以看到有可选参数 thisArg,则代表可以指定 this:

image.png

当然,这些都是指回调函数不是箭头函数的情况。

感谢.gif 点赞.png