《You Dont Know JS上卷》(六,七) ---this和this指向

83 阅读8分钟

从这一篇开始进入上卷的第二部分 this和对象原型

关于this

这一小节作者先阐述了使用this的原因: 简洁.之后点明了常见的误解: this不指向自身也不指向其所在作用域

首先先聊聊为什么使用this:

不使用this的代码:
function speak(context){
    return context.name
}
speak(obj)
使用this的代码:
function speak(){
    return this.name
}
speak.call(obj)

可以看出在使用this的情况下,不用显示的传递参数也可以实现相同的效果,这就是this简洁的地方,这个特点在对象和原型中起到很重要的作用.

ok,现在我们知道了为什么使用this,接下来消除两点对this指向常见的误解

误解1: this指向自身

function fn(){
    this.num++ //如果this指向自身,那么会使fn上的num属性++
};
fn.num = 0;
fn()
console.log(fn.num); // 0
​
如果fn中的this指向fn自身,那么应该输出1,这里输出0说明this并不是指向fn,也就是说this并不指向自身

误解2: this指向它的作用域.

function foo(){
    let a = 2;
    cosnole.log(this.a)
}
fn() // undefined
​
如果this指向函数的词法作用域,那么应该输出函数作用域中a的值,但此时输出为undefined,可见this并不指向函数作用域,事实上this在任何情况下都不指向函数的词法作用域,作用域"对象"无法通过代码访问,它存在引擎的内部.

那么this到底指向什么?实际上this指向在函数调用时才发生的绑定,它指向什么完全取决于函数在哪里调用

当函数被调用时,会创建执行上下文,这个上下文包含函数在哪里调用,函数的调用方式,传参等信息,this就是这个上下文中的一个属性,会在函数执行的时候用到.

判断this的指向

了解了为什么使用this,排除了常见的误解,接下来就是如何判断this的指向,简单来说可以按照下面的流程:

  1. 查找调用位置
  2. 确定属于四种调用规则的哪一种
  3. 确定this指向

查找调用位置

根据函数的调用栈找到运行时函数的调用位置: 就在当前正在执行函数的前一个调用中

例子:
function main(){
    first()
}
function first(){
    second()
}
function second(){
    console.log('second')
}
main()
​
上述代码的执行过程为: 
1.创建全局上下文,并入栈,然后执行全局代码,此时调用栈为: [全局上下文]
2.遇到main(),创建main执行上下文,并入栈,然后执行main函数中的代码,此时调用栈为: [全局上下文 => main]
3.执行main的时候,遇到first(),创建first执行上下文,并入栈,然后执行first函数的代码,此时调用栈为: [全局上下文 => main => first]
4.执行first的时候,遇到second(),创建second执行上下文,并入栈,然后执行second函数的代码,此时调用栈为: [全局上下文 => main => first => second]
5.之后执行完毕就是出栈并销毁,与判断this指向无关,这里就不展开了
​
结合调用栈那么上述代码中的函数调用位置也一目了然了:
main函数执行时其前一个调用为全局,所以main函数的调用位置就是全局作用域
first函数执行时其前一个调用为main,所以first函数的调用位置就是main
second函数执行时其前一个调用调用为,first,所以second函数的调用位置为first

四种调用规则

找到了函数的调用位置,接下来便是根据下面的四种规则:默认绑定,显式绑定,隐式绑定和new绑定,去判断应用哪一种

默认绑定

我理解默认绑定就是: 直接调用,或者说不知道是谁调用.此时函数的this指向全局对象,严格模式下指向undefined,可以把这条规则看作是兜底规则,当无法使用其他调用规则的时候的默认规则

例子:
function main(){
    foo()
}
function foo(){
    console.log(this.a)
}
​
var a = 1;
main() //输出1

上述foo的调用方式就是直接调用,应用默认绑定,其this指向为全局对象,由于var声明的变量都会成为全局对象的属性,因此foo输出1.

对于严格模式只有函数运行在严格模式下默认绑定的this才是undefined,函数调用在严格模式下默认绑定的this依然是全局对象,下面用例子来证明这一点:

调用在严格模式下:
function main(){
    "use strict"
    foo()
}
function foo(){
    console.log(this.a)
}
​
var a = 1;
main() //输出1
​
运行在严格模式下:
function main(){
    foo()
}
function foo(){
    "use strict" // 或者全局开启严格模式
    console.log(this.a)
}
​
var a = 1;
main() // Uncaught TypeError: Cannot read properties of undefined (reading 'a')

隐式绑定

被某个对象调用,也就是函数的调用位置前有上下文对象此时this指向这个对象

function foo(){
    console.log(this.a)
}
const obj = {
    a: 2,
    foo: foo
}
obj.foo() // 2
虽然obj的foo属性只是foo函数的引用,但在其调用位置前面确实加了obj的引用,但函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象上

特别需要注意的是隐式绑定情况下函数的调用位置前是有上下文对象的,否则就是直接调用(应用隐式绑定)了,来看两个例子:

function foo(){
    console.log(this.a)
}
const obj = {
    a: 2,
    foo:foo
}
const bar = obj.foo;
var a = 3
bar() // 输出3而不是2,由于bar是foo的引用,这里使用bar()和使用foo()是一样的
​
函数的真正调用位置为全局作用域,因为bar本身就是foo的引用因此 bar()等价于foo()
​
再看一个例子:
function foo(){
    console.log(this.a)
}
function doFoo(fn){
    fn()
}
const obj = {
    a: 2,
    foo:foo
}
var a = 3;
doFoo(obj.foo) //输出3 foo的调用位置为doFoo函数内,其调用位置前没有上下文对象,也就是直接调用,使用默认绑定规则,this指向全局对象

上述的显现叫做隐式丢失,其中最常见的地方就是在回调函数中:

setTimeout(fn,1000)
与下方伪代码类似:
function setTimeout(fn,delay){
    delay //等待
    fn() //函数的调用s位置
}

显式绑定

通过bind,call,apply直接将函数的this绑定到对象上,如果指定的是原始值,会将原始值转换为对象形式new String(),new Number()..

function foo(){
    console.log(this.a)
}
const obj = {
    a:2
}
foo.call(obj)

其中bind有所不同,了解这个方法的同学应该知道,bind绑定后无法再绑定其他的对象,这种方式称为硬绑定,它可以解决隐式绑定规则中提到的隐式丢失问题

function fn(){
    console.log(this.a)
}
​
const obj = {
    a: 1
}
setTimeout(fn.bind(obj),1000)  // 输出: 1 this成功绑定在obj上

bind出现之前,硬绑定的实现方式是使用一个包装函数包装需要硬绑定的函数:

实现方式大致是这样:
function bind(self,fn){
    return function (){
        return fn.call(self)
    }
}

需要注意的是:显式绑定时传入nullundefined时,函数的this为默认绑定

new绑定

使用new调用函数的时候,函数的this指向新创建的对象

在传统面向对象语言中构造函数是类的方法,使用new操作符实例化类的时候,会执行类的构造函数,在JS中new的机制完全不同,JS中构造函数就是被new调用的函数,它不属于某个类,也不会实例化一个类,实际上他就是一个普通函数,只是被new操作符调用了而已

因此JS中的所有函数都可以通过new调用,这种函数调用被称为构造函数调用,为什么要说这些呢?主要是为了搞明白: js中并不存在所谓的"构造函数",只有对函数的构造调用.

使用new调用函数的时候会自动执行下面的操作:

  1. 创建一个空对象
  2. 将this指向这个空对象
  3. 将空对象的对象原型指向函数的原型对象
  4. 如果函数没有返回其他对象,自动返回新创建的对象

在这个过程中new操作符将函数的this指向新创建的对象

规则优先级

从上到下依次是:

  1. new绑定
  2. 显式绑定
  3. 隐式绑定
  4. 默认绑定

证明:

显式 vs 隐式
function foo(){
    console.log(this.a)
}
const obj1 = {
    foo:foo
}
const obj2 = {
    a: 2
}
obj1.foo.call(obj2) // 2 foo实际执行时this指向obj2,因此显示大于隐式
​
隐式 vs new
function foo(a){
    this.a = a
}
const obj = {
    foo:foo
}
const bar = new obj.foo(2)
console.log(bar.a) //2
console.log(obj.a) //undefined  // 显然new的优先级要比隐式绑定的高
​
显式 vs new
function foo(a){
    this.a = a
}
const obj = {}
const bar = foo.bind(obj)
const obj2 = new bar(2)
console.log(obj2.a) // 2
console.log(obj.a) // undefined   // new 大于显式绑定

整体判断流程

  1. 确定函数调用位置
  2. 函数是否是new调用? 是! this指向创建的空对象
  3. 函数是否是显式调用? 是! this指向绑定的对象
  4. 函数是否是隐式调用? 是! this指向包含的上下文对象
  5. 都不是的话,使用默认绑定,非严格模式下指向全局对象,严格模式下指向undefined

箭头函数的this

箭头函数不适用上述四种调用规则来判断this,其this指向固定为外部作用域的this,且不可以修改.和bind很像同样可以确保函数的this指向指定位置.