liangbus / blogging

Blog to go
10 stars 0 forks source link

JavaScript 中 this 详解 #22

Open liangbus opened 4 years ago

liangbus commented 4 years ago

最近又在重读《你不知道的 JS》,发现好多知识点都有点忘记了,也有一些原来没有理解透彻的,因此就写下来,加深一下理解

对于初学者来说 this,可真的是让人头大,其语义也有一定的歧义,在不同地方,可能会有不同含义,甚至是相同地方也有可能在不同时机下会变得不一样,实在是难以琢磨,当初的我也是对此倍感费解

我们在网上看得最多的对 this 的描述是,this 指向函数执行时上下文,而箭头函数则指向声明时的上下文,那么,我们来看下例子

function foo() {
  var a = 2
  this.bar()
}
function bar() {
  console.log(this.a)
}
foo() // undefined

可见即使 bar 执行在 foo 的作用域内,但仍然无法访问到 a. 这段代码试图通过 this 来联通 foo() 和 bar() 的词法作用域,这是不可能实现的

还有一些说法会说 this 指向函数自身,那么再来看下

num = 0
function foo(num) {
  console.log(`foo was invoked ${num} times`);
  this.count++
}
foo.count = 0
foo(++num)
foo(++num)
foo(++num)
foo(++num) // foo was invoked 4 times
console.log(`foo.count = ${foo.count}`) // 0

所以函数内的 this 并非指向自身,而在函数内部使用的 this.count,实际上是在 this 所指向的作用域创建了一个值为 NaN 的变量,此处 this 指向 window

上述是对 this 的一些错误的理解,我们知道每个函数的 this 是在函数调用时被绑定的,完全取决于函数的调用位置

调用位置

调用位置是指函数被调用的位置,而非声明位置(箭头函数除外,后面再讨论箭头函数)

分析调用栈

function baz() {
  console.log('当前处于 baz 中 ', this)
  bar()
}
function bar() {
  console.log('当前处于 bar 中 ', this)
  foo()
}
function foo() {
  console.log('当前处于 foo 中 ', this)
}
baz();
// 当前处于 baz 中  Window {parent: global, postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, …}
console_runner-a7d19dfb8db35c27bf343618f838527f62153df43b74154074f4a8ccb026cd27.js:1 
// 当前处于 bar 中  Window {parent: global, postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, …}
console_runner-a7d19dfb8db35c27bf343618f838527f62153df43b74154074f4a8ccb026cd27.js:1 
// 当前处于 foo 中  Window {parent: global, postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, …}

可以看出,三个函数的 this 都是指向 window,也就是 baz 被调用的位置 这里也可以通过在控制台断点查看其函数内部的 scope,其 this 一直是指向 window,这里函数体内调用外部的其他函数,与 this.bar(), this.foo() 无异(非严格)。 image

绑定规则

默认绑定

从上面的例子我们可以看得知,在函数体内使用 this 访问对象,然后在全局环境中调用它,可以访问到全局环境下的同名属性,这里就是应用了 this 的默认绑定,但是如果使用了严格模式,则不能对全局对象用于默认绑定(声明时,非调用时)

var a = 10
function foo() {
  "use strict"
  console.log('a >>> ', this.a)
}
foo() // Uncaught TypeError: Cannot read property 'a' of undefined

隐式绑定

再来看一段更常见的代码

function foo() {
  console.log('this.a >>> ', this.a)
}
var o = {
  a: 101,
  fn: foo
}
o.fn() // this.a >>>  101

这里 o.fn 实际上保存是 foo 函数的引用地址,也就是说,o.fn 和 foo 是同一个东西,这里可以说函数被调用时,o 对象“拥有”或者“包含” foo 函数的引用

当 foo 被调用时(o.fn()),它的前面增加了对 o 对象的引用。当函数引用有上下文对象时,隐式绑定规则会把函数中的 this 绑定到这个上下文件对象

function foo() {
  console.log('this === o', this === o)
}
var o = {
  a: 101,
  fn: foo
}
o.fn() // this === o true

如果对象属性的引用是有链式引用,则以上一层或者说最后一层的调用位置为准:

function foo() {
  console.log('this.a >>> ', this.a)
}
var o1 = {
  a: 101,
  fn: foo
}
var o2 = {
  a: 1001,
  context: o1
}
o2.context.fn() // 101

此处引申出另一个问题,平常被隐式绑定的函数,有些场景下会丢失绑定的对象,也就是说它会应用默认绑定,如下:

var a = 2020
function foo() {
  console.log('this.a >>> ', this.a)
}
var o = {
  a: 2019,
  fn: foo
}
var doFoo = o.fn
doFoo()

还有一种更微秒的情况,是发生在函数作为参数传递,以回调函数执行时

var a = 2020 // global variable
function foo() {
  console.log('this.a >>> ', this.a)
}
function doFoo(fn) {
  fn()
}
var o = {
  a: 2019,
  fn: foo
}
doFoo(o.fn) // this.a >>> 2020

其实最重要还是记住,其传参的值,也是引用地址,真正决定 this 的,还是调用的位置

显式绑定

我们常见的 call 和 apply 就是显示绑定的例子,通过这两个方法,我们可以指定函数调用时 this 的指向,简单示例如下,call 和 apply 的区别这里不展开说明了

var a = 2020
function foo() {
  console.log('this.a >>> ', this.a)
}
var o = {
  a: 2019,
  fn: foo
}
foo.call(o)

另外一种显示绑定的方法是我们常见的 bind,由 ES5 提出 Function.prototype.bind,它会创建一个新的函数,并且指定其 this 为传入的参数上,简单的实现为

function _bind(fn, ctx){
   return function() {
     return fn.apply(ctx, arguments)
   }
}

更详细的模拟 bind 实现参见此处

new 绑定

使用 new 调用函数时,会执行如下操作:

  1. 创建(构造)一个全新的对象
  2. 这个新对象会被执行 [[prototype]] 连接(继承相关)
  3. 这个新对象会绑定到函数调用的 this.
  4. 如果函数返回值为非 object 类型,那么 new 表达式中的函数调用就会自动返回这个全新的对象

示例:

function foo(a) {
  this.a = a
}
var bar = new foo(123)
console.log('bar.a >>> ', bar) // 123

箭头函数

以上的四种绑定规则仅适用于正常的函数,ES6 中新增了一种特殊的函数声明方式:箭头函数

箭头函数不使用 this 的四条标准规则,而是根据外层(函数或者全局)作用域来决定 this

示例:

function foo(a) {
  return a => {
    // this 继承自 foo
    console.log(this.a)
  }
}
var o1 = {
  a: 2020
}
var o2 = {
  a: 2019
}
var bar = foo.call(o1)
bar.call(o2) // 2020

解析:foo() 内部创建的箭头函数会捕获调用 foo() 时的 this。由于 foo() 的 this 绑定到了 o1,所以箭头函数的 this 也绑定到了 o1,然后将其引用赋值给 bar,箭头函数的 this 无法被修改。

总结: 如果要判断一个运行中的函数 this 的绑定,就需要找到这个函数的直接调用位置,找到之后就可以按顺序应用以下规则来判断 this 绑定的对象(箭头函数除外)

  1. 由 new 调用?绑定到新建的对象
  2. 由 call 或者 apply 或者 bind 调用,绑定到指定的对象
  3. 由上下文对象调用?绑定到对应的调用上下文对象
  4. 默认:在严格模式下绑定到 undefined,否则绑定到全局对象