深入理解JS执行上下文的点点滴滴

2,263 阅读9分钟

前言

对于一名前端开发者来说,深入理解JavaScript程序内部执行机制当然是很有必要的,其中一个关键概念就是JavaScript的执行上下文和执行栈,理解这部分内容也有助于理解作用域、闭包等

本次重点

  • 执行上下文概念、类型、特点
  • 执行上下文的生命周期
  • 关于变量提升
  • this指向问题
  • 执行上下文栈

基本概念:

所谓的JavaScript执行上下文就是当前JS代码代码被解析和执行时所在环境的抽象概念,js代码都是在执行上下文中运行的

一、执行上下文类型

1.全局执行上下文

它的特点有以下几个:

a.它是最基础、默认的全局执行上下文

b.它会创建一个全局对象,并且将this指向全局对象,在浏览器中全局对象是window,在nodejs中全局对象是global

c.一个程序中只有一个

2.函数执行上下文

它的特点有以下几个:

a.有自己的执行上下文

b.可以在一个程序中存在任意数量

c.是函数被执行时创建

3.eval函数执行上下文:

eval函数可以计算某个字符串,并执行其中的js代码,这样就会存在一个安全性问题,在代码字符串未知或者是来自于用户输入源的话,绝对不要使用eval函数

以上就是执行上下文的几种类型和相应的特点,我们可以看下下面这段代码:

里面的三个函数都被执行了,所以是有三个函数执行上下文

// 全局执行上下文
var sayHello = 'Hello'
function someone() {   // 函数执行上下文
  var first = 'Tom', last = 'Ada'
  function getFirstName() {  // 函数执行上下文
    return first
  }
  function getLastName() {  // 函数执行上下文
    return last
  }
  console.log(sayHello + getFirstName() + getLastName())
}
someone()

二、执行上下文的生命周期

执行上下文的生命周期分了三个阶段:

  • 创建阶段
  • 执行阶段
  • 回收阶段
创建阶段

对于函数执行上下文,函数被调用的时候,但是还未执行里面的代码之前,会做三件事情:

  • 创建变量对象:会初始化函数的参数,提升函数声明和变量声明

  • 创建作用域链:作用域链用于标识符解析,看下面代码:

    f3函数被调用的时候,里面的变量num要求被解析的时候,会在当前f3的作用域里查找,如果没找到,就会向上一层作用域中查找,直到在全局作用找到该变量为30

var num = 30;
function f1() {
  function f2() {
    function f3() {
      console.log(num);
    }
    f3();
  }
  f2();
}
f1();
  • 确定this指向:这个情况比较多,会在下文统一介绍

在一个程序执行之前,要先解析代码,会先创建全局执行上下文环境,把需要执行的变量和函数声明都取出来并暂时赋值为undefined,函数也要先声明好待调用,这也是我们下文中会讲到的变量提升,以上几步做完后,开始正式执行程序

执行阶段

执行的变量赋值、函数调用等代码执行

回收阶段

执行上下文出栈,等待虚机垃圾回收执行上下文

三、变量提升

变量提升分为两种:

  • 变量声明提升
  • 函数声明提升

关于变量声明提升,先看以下代码片段:

console.log(a)  // undefined
var a = 5
function test() {
  console.log(a)  // undefined
  var a = 10
}
test()

以上代码中,第1个 a 是在全局执行上下文环境中,由于在全局执行上下文创建的时候,把需要执行的变量和函数声明都取出来并暂时赋值为undefined,所以打印出来的就是undefined

第2个 a 是在test这个函数执行上下文环境中,同上,所以打印出来的就是undefined

var a
console.log(a)  // undefined
a = 5
function test() {
  var a
  console.log(a)  // undefined
  a = 10
}
test()

关于函数声明提升,看以下代码:

console.log(f1) // function f1() {}
function f1() {}
console.log(f2) // undefined
var f2 = function() {} 

打印结果在注释中,由于变量声明和函数声明提升原则可以把代码改成如下:

function f1() {}
console.log(f1) // function f1() {}
var f2;
console.log(f2) // undefined
f2 = function() {} 

f1和f2不一样的地方是:f1是普通函数声明的方式,f2是函数表达式,在f2未被赋值的时候,它就是一个变量,这个时候变量提升,所以打印的f2为undefined

如果一个变量既是函数声明的方式,又是变量声明的方式,代码如下:

我们发现函数声明的优先级是高于变量提升的优先级的

function test(arg){
  console.log(arg);  // function arg(){console.log('hello world') }
  var arg = 'hello'; 
  function arg(){
    console.log('hello world') 
  }
  console.log(arg); // hello 
}
test('hi');

总结:变量提升的几个特点:

  • 如果有形参,先给形参赋值
  • 函数声明的优先级是高于变量提升的优先级的,但可以重新赋值
  • 私有作用域代码从上到下执行

四、确定this指向问题

this指向问题通常会在一些面试题中出现,情况比较多,先了解下它的一些特点:

  • this是执行上下文的一部分
  • 需要在执行时确定
  • 浏览器中 this 指向 window, node中this指向global
对于非严格模式和es5的js程序中,this指向可以分为以下几种情况:

第一种:a()直接调用的方式,this === window

function a() {
  console.log(this.b)  
}
var b = 0
a()

打印出的值为 0

第二种:谁调用了函数,谁就是this

function a() {
  console.log(this)
}
var obj = {a: a}
obj.a()

打印出的值为obj这个对象

第三种:构造函数模式下,this指向当前执行类的实例

function getPersonInfo(name, age) {
  this.name = name
  this.age = age
  console.log(this)
}
var p1 = new getPersonInfo('linda', 13)

打印出来的值是:

getPersonInfo{ name: 'linda', age: 13 }

第四种:call/apply/bind调用函数的方式,this指向第一个参数

function add (b, c) {
  console.log(this)
  return this.a + b + c
}
var obj = {a: 3}
add.call(obj, 5, 7)
add.call(obj, [10, 20])

打印出来的值就是obj的值

对于严格模式的js程序中,this指向对于直接调用的方式有所不同:

严格模式下,函数直接调用的方式中this指向undefined

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

这个时候函数里的this打印出 undefined

对于箭头函数

箭头函数没有自身的this关键字,看外层是否有函数,如果有函数,外层函数的this就是内部箭头函数的this,如果没有,this就是指向window

可以看以下几种情况:

var person = {
  myName: 'linda', 
  age:1, 
  clickPerson: function() { 
  	var show = function() {
  		console.log(`Person name is ${this.myName}, age is ${this.age}`)
  	}
    show()
  }
}
person.clickPerson()

打印结果:Person name is undefined, age is undefined

里面的函数show被调用的时候,是普通函数调用的情况,所以this指向window,而全局函数中没有myName和age,所以打印出来是undefined

可以换成箭头函数:

var person = {
  myName: 'linda', 
  age:1, 
  clickPerson: function() { 
  	var show = () => {
  		console.log(`Person name is ${this.myName}, age is ${this.age}`)
  	}
    show()
  }
}
person.clickPerson()

打印出的结果是:Person name is linda, age is 1

对于箭头函数自身没有this关键字,所以看外层函数,而外层函数中是我们前面说到的第二种情况,this指向person这个对象,所以是有myName和age的值

如果把clickPerson也换成箭头函数:

var person = {
  myName: 'linda', 
  age:1, 
  clickPerson: () => { 
  	var show = () => {
  		console.log(`Person name is ${this.myName}, age is ${this.age}`)
  	}
    show()
  }
}
person.clickPerson()

我们发现打印的结果是:Person name is undefined, age is undefined

由于都是箭头函数,最后找到了全局的window,所以this指向window,而全局函数中没有myName和age,所以打印出来是undefined

再看另外一个例子:

function getPersonInfo(name,age){
  this.myName = name;
  this.age = age;
  this.show = function() {
    console.log(`Person name is ${this.myName}, age is ${this.age}`)
  }
}
getPersonInfo.prototype.friend = function(friends) {
    var array = friends.map(function(friend) {
        return `my friend ${this.myName} age is ${this.age}`
    });
    console.log(array);
}

var person1 = new getPersonInfo("linda",18);
person1.show()
person1.friend(['Ada', 'Tom'])

show()函数调用结果打印:Person name is linda, age is 18

friend()函数调用打印结果:["my friend undefined age is undefined", "my friend undefined age is undefined"]

对于friend函数内部,this指向的是当前的getPersonInfo这个构造函数初始化的实例,但是在内部使用map是一个闭包函数,且内部是普通函数的调用方式,所以内部this是指向了window,可以把里面普通函数调用的方式改成箭头函数的方式即可

function getPersonInfo(name,age){
  this.myName = name;
  this.age = age;
  this.show = function() {
    console.log(`Person name is ${this.myName}, age is ${this.age}`)
  }
}
getPersonInfo.prototype.friend = function(friends) {
    var array = friends.map((friend) => {
        return `my friend ${this.myName} age is ${this.age}`
    });
    console.log(array);
}

var person1 = new getPersonInfo("linda",18);
person1.show()
person1.friend(['Ada', 'Tom'])

这次打印结果就是["my friend linda age is 18", "my friend linda age is 18"]就是我们预想的了

总结:(非严格模式下)可以按照下图规律查找this的指向

五、执行上下文栈

js创建了执行上下文栈来管理执行上下文,我们通过如下一段代码和进栈出栈顺序图来理解执行上下文栈

var name = 'Tom';
function father() {
  var sonName = 'Anda';
  function son() {
    console.log('son name is ', sonName)
  }
  console.log('father name is ', name)
  son();
}
father();

过程:

1.全局执行上下文进栈

2.调用函数father,father函数执行上下文进栈

3.father函数内部代码执行,son函数被执行,son函数执行上下文进栈

4.son函数执行完毕,son函数的执行上下文出栈

5.father函数执行完毕,father函数的执行上下文出栈

6.浏览器关闭时,全局执行上下文出栈

执行上下文栈特点:

  • 先创建全局执行上下文,并压入栈顶
  • 函数执行时创建函数执行上下文,再压入栈顶
  • 函数执行完函数的执行上下文出栈,等待垃圾回收
  • JS执行引擎总是访问栈顶的执行上下文
  • js代码是单线程的,代码是排队执行
  • 全局执行上下文在浏览器关闭时出栈
参考资料:

揭秘JavaScript中“神秘”的this关键字

全面了解JS作用域

js执行上下文栈/作用域链

深入理解js执行上下文

深入理解JavaScript执行上下文和执行栈