浅谈this指向的前世今生

154 阅读10分钟

最近在回头看js函数相关的一些知识,发现,有几个核心的知识贯穿始终,可以毫不夸张的说,这些核心的点如果没有掌握,对js的深入了解准在巨大的障碍。this的指向就是其中的一个点。

即便前端介绍this的文章遍地开花,但是我依旧想把我了解到的认为较为准确的理解记录下来,供大家参考。

一、变量的作用域

js声明变量,使用var关键字。

1、全局变量和局部变量

一个变量的作用域就是定义这个变量的区域。在非函数体外的变量,称为全局变量,拥有全局作用域,在js代码的任何地方都是有定义的(可以访问,修改)。函数体内声明的变量,包含函数参数,称为局部变量,拥有局部作用域,只在函数体内有定义。

2、二者之间的关系

局部变量优先级高于同名的全局变量,会对同名的全局变量进行遮盖(遮盖性)

var name = 'hello'
function sayHello() {
  var name = 'hi'
  return name
}
var res = sayHello()
console.log(res) // hi
var name = 'hello'
// 声明同名局部变量
function sayHello(name = 'say hi') {
  // name = 'say hi' // 效果等同作为参数声明
  return name
}
var res = sayHello()
console.log(res) // say hi

3、局部变量声明要使用var

全局变量的声明可以忽略var,局部变量(非函数参数情况下)如果不使用,否则在同名的情况下会视为对全局变量的修改

var name = 'hello'
function sayHello(name) {
  name = 'say hi'
  return name
}
var res = sayHello()
console.log(res) // say hi
console.log(name) // hello
var name = 'hello'
function sayHello() {
  name = 'say hi'
  return name
}
var res = sayHello()
console.log(res) // say hi
console.log(name) // say hi

js要求使用var声明变量,但是声明全局变量和局部变量的函数参数可以忽略var,而全局变量在js所有地方都有定义,即可以访问、修改。因此即便在函数体内,只要不是声明的情况下,对同名的全局变量操作都视为修改。

二、函数作用域和声明提前

1、没有块级作用域,只有函数作用域

在类c的某些语言中,花括号内有自己的作用域,花括号之外的区域对于区域内的声明的变量不可以见,这就是块级作用域。js中没有块级作用域的说法,取而代之的是函数作用域。

函数作用域,明确了变量在声明它们的函数体以内以及这个函数体嵌套的任意函数体内都是有定义的。

function test(o) {
  var i = 0;
  if (typeof o == "object") {
    var j = 0;
    for (var k = 0; k < 10; k++) {
      console.log(k);
    }
    console.log(k);
  }
  console.log(j);
}
test(null)
// 因为没有块级作用域的概念,所以不存在if for之外不能访问它们{}内声明的变量的情况

但是es6提供了const、let关键字来声明变量,这种情况下声明的变量是存在块级作用域的,具体情况不在本文的讨论范畴中。

function test(o) {
  let i = 0;
  if (typeof o == "object") {
    let j = 0;
    for (let k = 0; k < 10; k++) {
      console.log(k);
    }
    console.log(k); // 这里会报错,因为k超出了for循环的作用域
  }
  console.log(j); // 这里会报错,因为j超出了if语句的作用域
}
test(null)

2、声明提前

为什么会有声明提前这种骚操作?为什么要提前?重述一遍函数作用域的定义:函数作用域,明确了变量在声明它们的函数体以内以及这个函数体嵌套的任意函数体内始终有定义的。这就意味着变量在声明之前就可以访问了,为了实现这一点,函数体内所有的变量声明会在预编译阶段提前到函数体顶部,但不会赋值,只有在执行阶段代码变量才会被赋值。

var name = 'hello'
function test(){
  console.log(name) // undefined
  var name = 'local'
  console.log(name) // local
}
// 编译后执行
var name = 'hello'
function test(){
  var name
  console.log(name) // undefined
  name = 'local'
  console.log(name) // local
}

三、作用域链

1、作为属性的变量

当你声明一个全局变量,实际上是定义了一个全局对象的一个属性。声明全局变量使用var和不使用var的区别:

var notDel = 'can not delete' // 创建一个不可删除的属性
canDel = 'can delet' // 创建一个可以删除的属性

delete notDel
delete canDel

console.log(this.notDel) // can not delete
console.log(this.canDel) // undefined

2、作用域链

js使用this来引用全局对象,但是没有办法引用局部变量作为属性的对象(变量对象),这个对象对我们是不可见的内部实现。

局部变量是在函数作用域内声明的,js使用链表来存储该作用域内的变量对象。

每一个函数作用域内都有一个与之对应的作用域链。作用域链定义了在该作用域中的变量对象。

当需要解析某个局部变量x的时候,会在作用域链中查找第一个对象是否有x的属性,有则直接使用,否则会查找下一个对象。如果所有的对象都找不到属性x,说明作用域链中不存在x,则抛出异常引用错误的异常。

那针对不同的作用域内,对应的作用域链是怎么样的呢?

  • 全局作用域:

对应的作用域链中只有一个对象,全局对象

  • 函数作用域:

fn函数的作用域链

fn对应的作用域链有两个对象,一个是定义函数参数和局部变量的对象,一个是全局对象

var name = 'hello'
function fn (age){
  var yourAge = age
  var yourName = name
  console.log(yourAge)
  console.log(yourName)
}
fn()
// 编译执行
var name
name = 'hello'
function fn(){
  var age
  var yourAge
  var yourName
  yourAge = age // 在变量对象中找到age,其值为 undefined
  yourName = name // 在全局对象中找到name,其值为 hello
  console.log(yourAge) // 在变量对象中找到yourAge,其值为 undefined
  console.log(yourName) //在变量对象中找到yourName,其值为 hello
}
fn() // 函数没有返回值,其值为 undefined

fn2函数作用域链

fn对应的作用域链有三个对象,一个是定义函数参数和局部变量的对象,一个是定义上一层函数参数和局部变量的对象,一个是全局对象

var name = 'hello'
function fn (age){
  var yourAge = age
  var yourName = name
  function fn2(){
    var hisName = yourName
  }
  fn2()
}
fn()
// 编译执行
var name
name = 'hello'
function fn (){
  var age
  var yourAge
  var yourName
  yourAge = age // 变量对象2中找到 age
  yourName = name // 从全局变量中找到 name
  function fn2(){
    var hisName
    hisName = yourName // 从变量对象2中找到 yourName
  }
  fn2()
}
fn()

3、作用域链的出现

当定义一个函数时,实际保存了一个作用域链。当调用这个函数时,会创建一个新的对象,局部变量作为这个新对象的属性,并将这个新的对象存在作用域链(链表)中。

四、this的指向

上面的知识得出:this在全局作用域中,是全局对象的引用。

但是在函数作用域中,其对应的作用域链存在多个对象,this指向谁?而对于this指向的讨论也正仅限在函数中这个范围。

根据实践,如果用一句话来总结this的指向,那就是:this的指向跟具体的调用有关。

下面看看都有什么样的调用形式

1、作为函数调用,this指向不是全局对象(非严格模式)就是undefined(严格模式)

function fn(){
  console.log(this)
}
fn()
const foo = {
  bar: 10,
  fn: function() {
    console.log(this)
    console.log(this.bar)
  }
}
var fn1 = foo.fn
fn1()
// 输出
// window
// undefined

fn,fn1以函数的形式调用,this指向全局对象,全局对象是window/global;在严格模式下,this指向undefined

2、作为方法调用,this指向该调用该方法的对象

下面例子,fn作为foo对象方法被调用,this指向foo对象

const foo = {
  bar: 10,
  fn: function() {
    console.log(this)
    console.log(this.bar)
  }
}
foo.fn()
// 输出
// { bar: 10, fn: f }
// 10

下面例子,this所在的位置是o1对象的fn函数中,下面三种变形调用

const o1 = {
  name: 'o1',
  fn: function(){
    return this.name
  }
}
const o2 = {
  name: 'o2',
  fn: function(){
    return o1.fn()
  }
}
const o3 = {
  name: 'o3',
  fn: function(){
    var fn = o1.fn // 此时,fn是一个函数的引用,函数体是o1.fn
    return fn() // fn作为函数调用
  }
}
console.log(o1.fn()) // o1
console.log(o2.fn()) // o1
console.log(o3.fn()) // undefined

o1.fn()执行后,fn作为o1对象的方法被调用,this指向o1;

o2.fn()后,最后执行o1.fn(),fn作为o1对象的方法被调用,this指向o1;

o3.fn()执行后,this所在的函数,由引用fn作为函数调用,this指向全局对象,这里是window;

如果希望console.log(o2.fn())语句输出o2,应该:

this所在的函数作为某个o2对象的方法被调用

const o1 = {
  name: 'o1',
  fn: function(){
    return this.text
  }
}
const o2 = {
  name: 'o2',
  fn: o1.fn
}
o2.fn()

3、箭头函数的this 继承外层作用域的的this

const foo = {
  fn: function(){
    setTimeout(function(){
      console.log(this)
    })
  }
}
foo.fn()

this所在位置是setTimeout的匿名函数中,那什么时候匿名函数才会被执行呢,那就是setTimeout函数被执行的时候,setTimeout函数是window的进程函数,由window调用,因此this指向window。

箭头函数的一个重要特性是它不会创建自己的 this ,而是继承外层作用域的 this。因而,window.setTimeout并不会产生箭头函数所在的this,而是继承fn所在的this,而fn所在的this的产生是执行fn作为foo对象的方法被调用,因此,箭头函数的this指向了foo对象

const foo ={
    name:'a',
    fn: function(){
        console.log(this.name,'fn函数的this')
        setTimeout(()=>{
            console.log(this.name, '箭头函数的this') // a
        })
    }
}
foo.fn()

4、通过call/apply/bind方式显式调用函数,函数体内的this指向指定参数的对象上

const foo = {
  name:'a',
  fn: function(){
    setTimeout(function(){
      console.log(this)
    }.apply(foo))
  }
}
foo.fn()
const foo = {
  name:'a',
  fn: function(){
    setTimeout(function(){
      console.log(this)
    }.call(foo))
  }
}
foo.fn()
const foo = {
  name:'a',
  fn: function(){
    setTimeout(function(){
      console.log(this)
    }.bind(foo)())
  }
}
foo.fn()

5、使用new方法调用构造函数,构造函数内的this会指向新创建的对象

当你使用 new 关键字调用一个构造函数时,JavaScript 会执行以下步骤:

  1. 创建一个新对象:这个新对象会继承构造函数的原型。
  2. this 绑定到新对象:在构造函数内部,this 会被绑定到这个新创建的对象。
  3. 执行构造函数的代码:构造函数中的代码会被执行,通常会对 this 进行一些属性赋值。
  4. 返回新对象:构造函数会隐式返回这个新对象,除非构造函数显式返回一个不同的对象。
function Person(name) {
  this.name = name;
  this.sayHello = function() {
    console.log(`Hello, my name is ${this.name}`);
  };
}

const alice = new Person('Alice');

console.log(alice.name); // 输出: Alice
alice.sayHello(); // 输出: Hello, my name is Alice
function Foo(){
  this.name = 'a'
  const o = {}
  return o
}
const instance = new Foo()
console.log(instance.name) // undefined
function Foo(){
  this.name = 'a'
  return 'hello'
}
const instance = new Foo()
console.log(instance.name) // 'a'

五、this的多次变更

1、bind修改后得到的函数作为构造函数

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

当bar作为构造函数使用,原来的this指向obj1则被解除

var bar = new.bar(1)
console.log(baz.a) // 1

2、箭头函数的this是继承来的,无法被修改

function foo() {
  return a => console.log(this.a) // this继承foo函数内的this
}
const obj1 = {
  a: 2
}
const obj2 = {
  a: 3
}
const bar = foo.call(obj1) // foo内的this指向修改成obj1,箭头函数也会继承这个修改
console.log(bar.call(obj2)) // 2
// const a = 123 // const、let声明的变量不会挂载到window,箭头函数打印输出undefined
var a = 123
const foo = () => a => {
  console.log(this.a) // 123 foo本身就是一个箭头函数,因此内部this继承window
}
const obj1 = {a: 2}
const obj2 = {a: 3}
const bar = foo.call(obj1) // 无法修改只能继承
console.log(bar.call(obj2)) // 无法修改只能继承