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

学习js也有一段时间了,可是往往提到执行上下文、做用域、闭包、变量提高、this等关键词时心中老是有一个模糊的概念,好像知道又好像不知道,所以我想和你们系统的讨论这几个概念。但愿可以帮到和我同样还为这几个熟悉而陌生的词感到苦恼的同窗!javascript

image.png

1. js的数据类型

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

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

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

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

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

1.2 基本类型和引用类型

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

在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中存储模型。(从极客上拿的图)markdown

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》
  • 偶像神三元的博客
相关文章
相关标签/搜索