js中的执行上下文、作用域、闭包和this

1,280 阅读14分钟

学习js也有一段时间了,但是每每提到执行上下文、作用域、闭包、变量提升、this等关键词时心中总是有一个模糊的概念,好像知道又好像不知道,因此我想和大家系统的讨论这几个概念。希望能够帮到和我一样还为这几个熟悉而陌生的词感到苦恼的同学!

image.png

1. js的数据类型

这里我不打打算一开始就讨论上面那些概念,每种语言都有内建的数据类型,不同的建立方式也意味着不一样的使用方式。而是从js的数据类型开始一步一步分析,则可以让你摸清楚上面几个概念的来龙去脉。

1.1 js是弱类型、动态的语言

  • 静态语言和动态语言
    • 静态语言:在使用之前就需要确认其变量数据类型的称为静态语言。像C、C++、java等都是静态语言。
    • 动态语言:在运行过程中需要检查数据类型的语言,像js、py等都是动态语言。
  • 弱类型语言和强类型语言
    • 弱类型语言:支持隐式类型转换的语言,像C、js是弱类型语言
    • 强类型语言:不支持隐式类型转换的语言,像python和java便是强类型语言

如果你细心的留意过js语言的一门细节,就会发现js是一门弱类型的动态语言

在js代码会在if判断语句中自动将表达式计算成布尔类型的值,同时在js中声明的变量的定义无需确定它是字符串、数字或者布尔等其他类型,这意味着你可以在一个变量中保存不同类型的数据。值得一提的是这种动态语言的类型再带来极大便利性的同时也会带来一些令人困扰的问题,在vue这门优秀的框架中使用了Flow对js做了静态类型语言检查。

1.2 基本类型和引用类型

上面我们知道了js是一门弱类型的动态语言,那么我们接下来看看js中的数据类型

在js中数据类型分为基本类型和引用类型:

(1)基本类型有:

  • null
  • undefined
  • boolean
  • number
  • string
  • symbol(ES6引入) (2)js的引用类型是从object的子类型,有如下几种:
  • Object
  • Function
  • Array
  • RegExp
  • Date
  • 包装类:String、Number、Boolean
  • Math

2. js的内存模型

js中对不同类型的数据的操作不是相同的,要想理解其中的差异,先得搞清楚js中存储模型。(从极客上拿的图)

image.png

在js执行的过程中,主要有三种类型内存空间,分别是:代码空间栈空间堆空间

2.1 栈空间和堆空间

基本类型的数据类型都存储在栈空间,引用类型的值保存在堆中的。

// 定义四个变量
var num1 = 5
var num2 = num1;
var obj1 = {
    name: '小猪皮皮呆'
}
var obj2 = obj1

// 修改num1和obj1
num1 = 4
obj1.name = '小猪'

// 输出四个变量
console.log(num1) // 4
console.log(num2) // 5
console.log(obj1.name) // 小猪
console.log(obj2.name) // 小猪

上面代码num1和num2的输出我们能够很好的理解,因为在js中基本类型的数据类型都存储在栈空间。如果一个变量向另一个变量赋值基本类型的值,会在变量对象上创建一个新值,然后把该值复制到为新变量分配的位置上。

image.png

那么为什么obj1和obj2的name输出的结果都改变了呢?这是因为在js中引用类型的值保存在堆中的。如果一个变量向另一个变量赋值引用类型的值,同样会在变量对象上创建一个新值,然后把该值复制到为新变量分配的位置上,但与基础类型不同的是,这个值是一个指针,这个指针指向了中的同一个对象,因此在修改其中任何一个对象都是在对同一个对象修改。

image.png

看完上面的内容,相信你对栈和堆已经有了一定的理解,接下来我们来看看js中传递参数的方式。在js中,所有函数的参数都是按值传递的,也就是说把函数外部的值复制给函数内部使用,就像把值从一个变量复制到另一个变量里一样。

这就意味着,基本类型值得传递和引用类型值的传递就如同上述所说的复制过程是一样的。

// 基本类型的传递
function addTen(num){
      num += 10
      return num
}
var count = 20
var result = addTen(count)
alert(count) //20
alert(result) //30

// 引用类型的传递
function setName(obj) {
    obj.name = "小猪皮皮呆"
}
var person = {}
setName(person)
console.log(person.name) // 小猪皮皮呆

在这里有些同学可能会将引用类型传递参数的方式搞错,会发出疑问:访问变量有按值和按引用两种方式,为什么传递参数只有按值传递?

对于上例的基础类型的值的传递可以很容易的理解,但是引用类型的传递在局部中的修改会在全局中反应出来,会有同学误以为引用类型的传递是按参数传递的。但其实真正的过程是这样的:

  • 创建了一个对象,保存倒了person变量中
  • 调用setName函数,person变量传递到setName中
  • person的值复制给了obj,复制的是一个指针,指向了堆中的一个对象
  • 修改了obj
  • person中也体现出来了 从上述的过程中,可以看出来,person这个变量是按值传递的。我们再看个例子来说明这个问题
function setName(obj){
    obj.name = "小猪皮皮呆"
    obj = new Object()
    obj.name = "三元大神"
}
var person = {}
setName(person)
alert(person.name) // 小猪皮皮呆

如果是按引用传递,显示的值应该是“三元大神”,但js中的引用类型的传递也是按值传递的,所以打印出来的是“小猪皮皮呆”。

3. js代码的执行流程

看到这里,肯定很多人要开始骂了,这个人标题党啊,开头说了要理清楚执行上下文、作用域、闭包、变量提升、this这些东西,怎么到现在还只字未提。都已经看到这里了,别着急!本文的思路是自顶向下的,从最外层你熟悉的地方开始讲起,慢慢的渗透到底部的各个概念,将各个知识点串在一起,形成知识体系。

showName() // 小猪
console.log(myName) // undefiend
var myName = "小猪皮皮呆"
function showName() {
    console.log("小猪")
}

上面代码的执行结果相信大家都不意外,这就是我们耳熟能详的变量提升,但是他的内部到底发生了些什么,才会出现这种结果呢?

很多地方给出的解释是js代码在执行的过程中,js引擎会把变量的声明部分和函数的声明提升到代码的开头部分。变量被提升后会设置默认值,也就是undefined。 这种说法没有错,但是我们要更深入的去看看这个所谓的变量提升内部发生了什么。

接下来我们要开始我们便进入了本文的重点部分,js代码的执行流程分为两部分:编译执行

  • 编译:在上述的解释中,js引擎会把变量的声明部分和函数的声明提升到代码的开头部分,这其实并不准确。在第二部分js的内存模型我们看到在js执行的过程中,主要有三种类型内存空间,分别是:代码空间栈空间堆空间。实际上变量和函数声明在代码里的位置不会改变,由一开始编写的代码决定的。接下来在编译阶段后,会形成两部分内容:执行上下文可执行代码。变量和函数声明会被js引擎放入执行上下文中。
  • 执行:在上述一切准备就绪后,js引擎便会一行一行的执行可执行代码

image.png

3.1 执行上下文

看到这里,终于迎来了我们要讨论的第一个重点:什么是执行上下文?

执行上下文的创建分为三种情况:

  • 执行全局代码,编译全局代码,创建全局上下文,且只有一个
  • 调用函数,函数体内代码会被编译,创建函数上下文,函数执行完毕后该函数上下文会被销毁
  • 使用eval函数,很少遇到,在此不讨论。

而在js中,上下文的管理则由调用栈负责,js执行过程中三种内存空间之一的栈空间。我们来看看它是如何负责的:

  1. js编译全局代码,创建全局上下文,将其压入栈底
  2. 全局代码执行console.log,打印出undefined
  3. 为myName变量赋值“小猪皮皮呆”
  4. 调用setName函数,js对其进行编译,创建setName函数的执行上下文
  5. setName函数执行完毕,setName函数的执行上下文弹出栈并销毁
  6. 全局代码执行完毕,弹出栈,代码运行结束

image.png

看到这里我们便可以回答之前的问题了。所谓的变量提升就是js代码执行的过程中,会先将代码进行编译,编译的过程中变量的声明和函数的声明会被放入调用栈中形成上下文调用栈,剩余下的会生成执行代码。这就造成了变量提升的现象。

顺带一提,调用栈的大小有限,如果入栈执行的上下文超过一定数目,js引擎就会报错,这种现象就叫栈溢出,看下面一段代码:

function stackOverFlow (a, b) {
    return stackOverFlow (a, b)
}
console.log(stackOverFlow(1, 2))

image.png

看到这里,相信你已经理解了什么是执行上下文,什么是变量提升。是不是很简单呢?接下来我会带领同学们继续看剩下的几个概念,有了上面的基础,剩下的内容则更好理解。

4. 作用域和作用域链

在上文中我们已经了解了变量提升,由于 js 存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。

var name = "小猪皮皮呆"
function showName(){
    console.log(name);
    if (0) {
        var name = "小猪"
    }
    console.log(name)
}
showName()
// undefined
// undefiend

在我们熟悉调用栈后,在执行到showName时,会生成一个showName()的上下文,里面会将函数内部的name放入变量环境中并赋值undefined,所以第一个console没有打印出“小猪皮皮呆”,第二个打印之前因为if语句里面的语句没有执行,所以打印出的依然是undefined。

(1)作用域

而为什么会存在这种特性还得从作用域说起,js中存在三种作用域,ES6之前只两种作用域:

  • 全局作用域
  • 函数作用域
  • 块级作用域(ES6新增)

(2)作用域链

这段代码很容易让人觉得会打印结果会是“小猪皮皮呆”,这和我们接下来要提到的另一个概念作用域链有关

function bar() {
    console.log(name)
}

function foo() {
    var name = "小猪皮皮呆"
    bar()
}

var name = "小猪"

foo() // 小猪

相信前面的执行上下文部分同学们已经理解了,接下来我们会结合执行上下文来看作用域链

  • 每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。
  • 当一段代码使用了一个变量的时候,js引擎会在当前执行上下文查找该变量,如果没有找到,会继续在outer执行的执行上下文中去寻找。这样一级一级的查找就形成了作用域链

image.png

  • 作用域链的生成由代码决定,和调用无关。所以一开始代码bar编译好了后outer就指向全局上下文,因此打印的不是foo()内部的“小猪皮皮呆”

(3)块级作用域

上面提到了ES5之前只有全局作用域和函数作用域,ES6为了解决变量提升带来的问题,引入了块级作用域。这个大家都很熟悉,但是js如何做到即支持变量提升的特性又支持块级作用域呢?

我们继续从执行上下文的角度解决这个问题

function foo() {
    var a = 1
    let b = 2
    {
        let b = 3
        var c = 4
        let d = 5
        console.log(a)
        console.log(b)
    }
    console.log(b)
    console.log(c)
    console.log(d)
}
foo()
  • 第一步是编译并创建执行上下文
    • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
    • 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。
    • 在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中。

image.png

  • 执行到代码块
    • 代码块内部的let声明存放在了一个新的区域中

image.png

  • 执行console.log(a)

image.png

  • 当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出

image.png

上述形成的新的作用域链便是js对变量提升和块级作用域同时支持的实现。

一个常见的问题:如何解决下面的循环输出问题?

for(var i = 1; i <= 5; i ++){
  setTimeout(function timer(){
    console.log(i)
  }, 0)
}
  • 原因:setTimeout是宏任务,等同步任务执行完毕后i为6,所以会输出五个6
  • 解决办法:使用let,形成块级作用域
for(let i = 1; i <= 5; i ++){
  setTimeout(function timer(){
    console.log(i)
  }, 0)
}

4.1 闭包

在了解了作用域链后再去理解闭包就十分简单了!

  • 什么是闭包? ES5中存在两个作用域:全局作用域、函数作用域,函数作用域会在函数运行结束后自动销毁 作用域链:查找一个变量时会从自身的作用域开始沿着作用域链一直向上查找 闭包:利用了作用域,可以将函数内部的作用域的变量访问到

(1)闭包如何产生:

  • 返回函数 (常见)
const a = 2
function out () {
  let a = 1
  return function b () {
    console.log(a)
  }
}
const b = out()
b() // 1
  • 函数当作参数传递 :当作参数的函数可以访问到函数主体的内部作用域
var a = 1
function bar(fn) {
  var a = 2
  console.log(fn)
}

function baz() {
  console.log(a)
}

bar(baz) // 1
  • 在定时器、事件监听、Ajax请求、跨窗口通信、Web Workers或者任何异步中,只要使用了回调函数,其实就是上面那种情况,将函数当作参数,也就是在使用闭包。
// 定时器
setTimeout(function timeHandler(){
  console.log('111');
}, 100)

// 事件监听
$('#app').click(function(){
  console.log('DOM Listener');
})
  • 立即执行函数:
var a = 2;
(function IIFE(){
  // 输出2
  console.log(a);
})();

IIFE(立即执行函数表达式)创建闭包, 保存了全局作用域window和当前函数的作用域,因此可以全局的变量。

for(var i = 1; i <= 5; i ++){
  (function(j){
      setTimeout(function timer(){
        console.log(j)
      }, 0)
  })(i)
}

(2)应用场景:

  • 柯里化: 函数柯里化、前端经典面试题解密-add(1)(2)(3)(4) == 10到底是个啥?
function add (...args) {
  return args.reduce((a, b) => a + b)
}

function currying(fn) {
  let args = []
  return function _c (...newArgs) {
    if (newArgs.length) {
      args = [...args, ...newArgs]
      return _c
    } else {
      return fn.apply(this, args)
    }
  }
}

let addCurry = currying(add)
let total = addCurry(1)(2)(3, 4)(5, 6 ,7)()
console.log(total) // 28

(3)缺点:全局使用闭包会造成内存泄漏,所以尽量少用

5. this

在上面一小节中我们介绍了bar编译好了后outer就指向全局上下文,因此打印的不是foo()内部的“小猪皮皮呆”,大多数人会产生这样的异或便是将this和作用域链的概念弄混了。

而真实情况是,作用域链这套机制不支持我们直接获得对象内部的变量,而又独立的成立了一套新的机制,绝对不要将两者混为一谈!

var obj = {
    name: "小猪皮皮呆",
    showName: function () {
        console.log(name)
    }
}

var name = "小猪"
obj.showName() // 小猪

上面是一个经典的面试题,输出的结果是“小猪”而不是内部的“小猪皮皮呆”,有了之前对上下文和作用域链的理解,可以很容易的去解释,不在此赘述。

再强调一遍:作用域和this之间没有任何关系!this单独存在于执行上下文中,和执行上下文中的变量环境、词法环境、outer是并行的关系。

那么this要如何使用呢?如果想上述代码输出内部的name,便可以使用this来实现。

var obj = {
    name: "小猪皮皮呆",
    showName: function () {
        console.log(this.name)
    }
}

var name = "小猪"
obj.showName() // 小猪皮皮呆

接下来再对this的指向做一个总结:

  • 默认绑定:在全局执行上下文中,this的指向全局对象。(在浏览器中,this引用 Window 对象)。
  • 隐式绑定:在函数执行上下文中,this 的值取决于该函数是如何被调用的。如果它被一个引用对象调用,那么this会被设置成那个对象,否则this的值被设置为全局对象或者undefined(在严格模式下)
  • 显示绑定:apply、call、bind
  • 箭头函数的this由外层(函数或全局)的作用域来决定
var person = {
    name: "小猪皮皮呆",
    changeName: function () {
        setTimeout(function(){
            this.name = "小猪"
        }, 100)
    }
}

person.changeName()

上述代码想要通过changeName方法修改person内部的name属性,但是该代码存在一些问题,我们便根据上述对this指向的总结来解决这题。

(1) 缓存内部的this

var person = {
    name: "小猪皮皮呆",
    changeName: function () {
        var self = this
        setTimeout(function(){
            self.name = "小猪"
            console.log(person.name)
        }, 100)
    }
}

person.changeName() // 小猪

(2) 使用call、apply或bind显示绑定

var change = function () {
    this.name = "小猪"
}
var person = {
    name: "小猪皮皮呆",
    changeName: function () {
        setTimeout(function(){
            change.call(person)
            console.log(person.name)
        }, 100)
    }
}

person.changeName() // 小猪

(3) 使用箭头函数

var person = {
    name: "小猪皮皮呆",
    changeName: function () {
        setTimeout(() => {
            this.name = "小猪"
            console.log(person.name)
        }, 100)
    }
}

person.changeName() // 小猪

好啦,到此为止文章开头提到的那几个词已经为大家梳理过一遍了,如果觉得还不错的话,给小猪皮皮呆一个👍吧!

参考文献

  • 《js高级程序设计》第三版
  • 极客的专栏
  • 《你不知道的js》
  • 偶像神三元的博客