js之执行上下文与作用域

    变量或者函数的上下文决定了他们可以访问哪些数据,以及它们的行为,所以执行上下文(Execution Context)在JavaScript中很重要。

一、执行上下文的分类

1.1 全局执行上下文

    全局上下文是最外层的上下文。根据ECMAScript的宿主环境不同,表示全局上下文的对象也不一样。在浏览器环境下全局上下文是window对象,在node环境中全局上下文是global,由于它和形成的宿主环境有关,所以在任何环境中全局上下有且只有一个。本文主要讨论浏览器环境,我们可以通过this来找到它。
image.png

    所有通过var定义的全局变量和函数都会成为window对象的属性和方法。但是使用let或者是const声明的顶级声明不会定义在全局上下文中通过作用域链解析上效果是一样的。window上面本来就有大量内置的方法和属性,我们可以在全局中任何位置使用。上下文在其代码都执行完后会被销毁,释放内存空间,全局上下文在关闭网页或退出浏览器会销毁。
image.png

1.2 函数执行上下文

    每个函数调用都有自己的上下文,当代码执行进入到函数时,函数执行上下文会被推到执行环境栈(ECStack)中,在函数执行完后,函数执行上下文出栈。需要注意的是函数执行上下文可以有多个,每一个函数执行都会创建一个函数执行上下文,同一个函数被多次调用都会为该函数创建一个新的函数执行上下文

1.3 eval函数执行上下文

    运行在eval函数中的代码也能获得自己的上下文,但是在实际的开发中eval函数用的不多,所以在此不做讨论。

1.4 代码运行

    浏览器想要代码执行就要提供一个供代码执行的环境。我们把这个环境叫做执行环境栈ECStack。浏览器还会把内置的一些属性和方法放到一个单独的堆内存中,这个堆内存叫做全局对象(Global Object) 简称GO,供以后我们调用。浏览器会让window指向GO,所以在浏览器端window代表的就是全局对象。

    浏览器环境提供后,我们开始让代码执行,代码执行都有自己的执行上下文,而每一个执行上下文都有一个与之关联的变量对象VO(variable object)这个上下文中定义的所有变量和函数都存在于这个对象上。不同的执行上下文中变量对象也不一样。

    在全局上下文中,变量对象就是全局对象,在浏览器环境下就是window。而在函数执行上下文中这个变量对象我们用AO活动对象(activation object)来表示,这是因为变量对象VO它是在引擎上实现的,在js环境中不能直接访问,只有当函数被调用变量对象被激活,成为活动对象AO时,我们才能访问到其中的属性和方法,活动对象AO它其实就是变量对象VO,只不过是处于不同的状态看是否被激活,可以看成是VO的一个分支。

    JavaScript代码在浏览器端运行,形成的全局上下文进入执行环境栈ECStack中,这种过程称为“进栈”,因为执行环境栈也是栈结构具有栈结构先进后出的特点,在全局执行上下文中会创建一些全局变量,这些全局变量还有全局变量存放的值放在变量对象VO(G)中。 
如下所示,代码自上而下执行:

  • 先创建一个值。在创建时,如果是基本数据类型值,它可以直接存在栈内存中,如果是引用数据类型值要重新开辟一个堆内存,把内容存入,最后把这个堆内存16进制地址放入栈内存中,供变量关联使用。
  • 然后创建相应的变量
  • 最后把值和变量进行关联(所有的指针赋值都是指针关联指向)
var a = 12;
var b = a;
b = 13;
console.log(a);
var n = {
    name: 'davina'
}
var m = n;
m.name = 'lisa';
console.log(n.name);
复制代码
var a = {n:12};
var b = a;
a.x = a = {n:13};
console.log(a.x);
console.log(a);
复制代码

image.png

    当发生函数调用时,新形成的函数执行上下文会进入栈顶。ECStack会执行栈顶的函数,全局上下文被压入栈底。当函数执行完后,会执行出栈操作,执行环境栈的控制权向下移动,如下所示:

var arr = [12,20];
function fn1(){
   console.log('*****fn1 start********');
   fn2();
   console.log('*****fn1 end***********')
}
function fn2(){
    console.log('*****fn2*************');
}
fn1();
复制代码

上面代码执行分为以下几步:

  • 浏览器提供一个供代码执行的环境ECStack;
  • 代码自上而下运行,生成全局上下文,并把全局上下文推入到ECStack中;
  • 当fn1函数调用时,js引擎为其创建一个函数执行上下文(fn1 func Execution Context),并把它放在ECStack顶部;
  • 在处理fn1函数时,发现里面有fn2函数的调用,这时js引擎会为fn2函数创建一个新的执行上下文(fn2 func Execution Context),并把它推ECStack顶部;
  • 当fn2函数执行完后,它的执行上下文(fn2 func Execution Context)会从当前栈中弹出,出栈,上下文控制权下移,移动至fn1函数执行上下文
  • fn1函数执行完后,它的执行上下文也会从ECStack中弹出,上下文控制权下移,称动至全局执行上下文。
  • 当所有代码都执行完后,js引擎会把全局执行上下文也从ECStack中移除。

image.png

    上下文代码在执行时,会创建变量对象的作用域链(scope chain)。这个作用域链它决定了各级上下文中的代码在访问变量和函数时的顺序。当查找变量时,首先从当前上下文中的变量对象查找,如果没有就会向上查找父级作用域中的变量对象,最终找到全局上下文的变量对象,如果全局中也没有,则会报错。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

    那作用域链和上下文又有什么关系呢?在函数执行上下文中,作用域链它是一种查找机制,如果要找到函数里面的某个变量,要通过作用域对应的执行上下文环境获取变量相应的值,作用域是静态的,而执行上下文是动态的。而且就算在同一作用域下,对同一个函数的不同调用会产生不同的函数私有执行上下文,继而也会产生不同的值,作用域中变量的值是在执行的过程中确定的,而作用域([[scope]]) 是在创建函数时就确定了的,它是当前函数所在的上下文。

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

    在了解执行上下文生命周期之前,我们先要知道什么是变量提升,什么是函数声明提升?

2.1 变量提升

    在当前上下文中,js代码在执行之前,浏览器会把当前上下文中所有带”var/function”关键字的内容进行提前的声明和定义。解析到它们对应的上下文位置(这是词法解析的一个环节,词法解析发生在代码执行之前)这种预处理机制叫做变量提升,变量提升的意义在于创建变量前使用这个变量不报错,它也可以称之为“预解析”。从中我们可以看出变量声明有以下步骤:

  • 声明(declare):var a / function fn
  • 定义(defined : 给对应的变量进行赋值

在变量提升阶段,带var的只声明没有定义,而带function是声明和定义都已完成。

    变量提升只发生在当前上下文中,开始加载页面时只对全局上下文进行变量提升,这时函数堆空间存储的都是字符串而已。浏览器很懒,做过的事情并不会重复执行,也就是说当代码执行遇到创建函数这部分代码时,它会直接跳过(在提升阶段已经完成了函数的赋值操作)。

    函数执行上下文中,带var的在变量提升阶段声明为私有变量,它与外界没有任何关系。

console.log(g, h); // undefined
var g = 12,
    h = 12;
function fn() {
    console.log(g, h); //undefined
    var g = h = 13;
    console.log(g, h); // 13 13 
}
fn();
console.log(g, h); // 12 12 
复制代码

    一般来说我们创建函数的方式有两种,一种是通过函数声明function fn(){},或者是函数表达式var fn=function fn(){}。通过下面的代码我们可以看出通过函数声明创建的fn1在函数定义之前提前使用可以得到结果,而通过函数表达式创建的fn2调用时报错,这是因为遇到var fn2 = function(){}它是将var fn2提升到函数体的最前方,这时fn2的值是undefined,所以执行失败。而对于函数声明的fn1来说,它是整个的提升,所以是可以执行的。

    当我们遇到函数名和变量名同名且都被提升时,函数声明优先级高,变量声明会被覆盖,但是它是可以重新赋值。

function fn() {
    // fn1(); //fn1
    fn2(); //Uncaught TypeError: fn2 is not a function
    function fn1() {
        console.log('fn1');
    }
    var fn2 = function fn2() {
        console.log('fn2');
    }
}
fn();
复制代码

    不管是全局上下文,还是函数上下文它都有创建和执行两个不同的阶段,下面来分别进行说明。

2.2 生命周期

2.2.1 创建阶段

    在上下文创建阶段主要步骤有以下三点:生成变量对象,建立作用域链,确定this指向。

    在浏览器器环境下全局执行上下文中this指向window,变量对象就是全局对象(window)。

在函数执行上下文中,主要经过以下几步:

  • 创建变量对象:
    • 创建arguments对象,给变量对象添加形参名称和值
    • 扫描函数,进行变量提升和函数声明提升。
  • 创建作用域链:作用域链是在变量对象之后创建,它本身包含变量对象。作用域链它主要用于变量解析。当要解析变量时,首先从变量所在的作用域进行查找,如果所在作用域没有,则沿着作用域链,查找父级作用域中是否存在该变量,如果没有紧接着向上进行查找直到找到全局作用域为止。
  • 确认this的指向:this指向会包含很多种情况。在全局执行上下文中this总是指向全局对象,如浏览器环境下this指向window。而在函数执行上下文中this的指向取决于函数的调用方式,如果是被一个对象调用那么this指向这个对象,否则一般this是指向window或者在严格模式下,this指向undefined。

2.2.2 执行阶段

    执行阶段js引擎开始完成对变量的赋值函数执行等操作,如果有函数调用就生成一个新的函数私有执行上下文并把它推入到执行环境栈(ECStack)中并进行解析。

var num1 = 12;
function fn() {
    console.log(num1); //undefined
    var num1 = 13;
    console.log(num1); //13
}
fn();
console.log(num1);//12
复制代码

上面代码在解析时会经过如下步骤:

  • 浏览器生成一个供代码执行的环境ECStack;
  • 执行全局代码,首先生成一个全局上下文,进入到ECStack中执行;
  • 全局上下文初始化。在浏览器器环境下,this指向window,变量环境也是window,进行变量提升和函数声明提升,这时函数堆空间存储都是字符串,还有一些内置的属性。带var的只是声明,而带function的声明和定义都进行了。创建函数时所在的上下文即是函数的作用域[[scope]]。
  • 代码自上而下执行,给变量赋值。当遇到函数fn时,浏览器很懒在函数声明提升时已经创建且定义了函数堆此时它会直接跳过;
  • 函数执行。函数执行的目的是让之前存储在堆空间中的代码字符串执行,代码执行必须有一个自己的执行环境,形成函数fn自己的私有上下文。
  • 形成函数上下文后它会进到执行环境栈中,新形成的函数上下文放到栈顶,把全局执行上下文下压。当它执行完后会出栈
  • 在函数执行上下文中,它也有创建阶段和执行阶段,创建阶段要经历如下几个步骤:
    • 初始化作用域链[[scope chain]],作用域链一边是函数自己所在的上下文,一边是函数的作用域[[scope]];
    • 初始化this:this的值在执行时才能确定;
    • 初始化arguments实参集合,创建活动对象;
    • 形参赋值:形参变量也是放在函数上下文中的私有变量对象AO中;
    • 变量提升
    • 执行阶段
  • 函数执行上下文执行完后,会出栈,把执行控制权下移,一直到全局执行上下文代码都执行完,全局出栈。

image.png

2.2.3 回收阶段

    每一个函数执行上下文和全局执行上下文都会有一个垃圾回收,释放内存空间的过程,这个过程根据js代码所处环境不一样,被回收的时机也不尽相同。

2.3 词法环境和变量环境

    在es5版本中以词法环境组件( LexicalEnvironment component) 和变量环境组件( VariableEnvironment component) 替代了变量对象VO和活动对象AO。所以执行上下文的创建阶段有以下三件事:确定this指向、创建词法环境、创建变量环境

2.3.1 词法环境

    词法环境(Lexical Environment)它是一种规范类型,是基于ECMAScript代码的词法嵌套结构来定义标识符与特定变量和函数的关联关系。词法环境由环境记录(environment record)和可能为空引用(null)的外部词法环境组成。

    词法环境其实就是一个包含标识符变量映射的结构。标识符表示的是变量/函数的名称,变量是对实际对象【包括函数类型对象】或原始值的引用。

GlobalExectionContext = {  // 全局执行上下文
  LexicalEnvironment: {         // 词法环境
    EnvironmentRecord: {       // 环境记录
      Type: "Object",           // 全局环境
      // 标识符绑定在这里 
      outer: <null>           // 对外部环境的引用
  }  
}


FunctionExectionContext = { // 函数执行上下文
  LexicalEnvironment: {     // 词法环境
    EnvironmentRecord: {    // 环境记录
      Type: "Declarative",      // 函数环境
      // 标识符绑定在这里      // 对外部环境的引用
      outer: <Global or outer function environment reference>  
  }  
}
复制代码

从上可以看出词法环境有两种类型:

  • 1、全局环境:它在全局执行上下文中,是一个没有外部环境的词法环境,全局环境的外部环境引用为null,因为它本身就是最外层的环境。它拥有一个全局对象及其关联的方法和属性以及任何用户自定义的全局变量。
  • 2、函数环境:它包含了用户在函数中定义的属性和方法,并且还包含一个arguments对象。函数词法环境的外部环境引入可以是全局也可以是其它函数的环境。

词法环境还有两个组件:

  • 1、环境记录器(EnvironmentRecord):存储变量和函数声明的实际位置。环境记录也有两种类型:
    • 1、在全局环境中使用对象环境记录器,用来定义出现在全局上下文中的变量和函数的关系。
    • 2、在函数环境中使用声明环境记录器,用来存储变量函数,参数。
  • 2、对外部环境引用(outer):它可以访问其外部词法环境。
    • 1、创建全局上下文的词法环境使用对象环境记录器,outer值为null
    • 2、创建函数私有上下文使用声明式环境记录器 outer值是全局对象,或者是为父级词法环境

2.3.2 变量环境

    其实变量环境也是词法环境,它身上具有上面词法环境的所有属性。

    在es6中,词法环境和变量环境区别点在于词法环境用于存储用let和const关键字绑定的函数声明和变量,变量环境仅仅是用于存储用var声明的变量。

    使用let,const和var声明全局变量时,let或者const声明的全局变量它会绑定到Script对象上而不是Window对象上,而使用var声明的全局变量会绑定到Window对象。而使用let,const或者是var声明的局部变量,它们会绑定到Local对象上。Script对象,Window对象,Local对象它们三者是平行关系。

let num1 = 10;
const num2 = 20;
var num3 = 30;
function fn(g) {
    console.log(num1);
    console.log(g);
    let num1 = 40;
    console.log(num1);
    console.log(g);
}
fn(20);
console.log(num1);
复制代码

当执行到fn(20)时,函数执行上下文开始被创建:

GlobalExectionContext = {
    ThisBinding: <Global Object>, //this的指向
    LexicalEnvironment: {  // 词法环境
        EnvironmentRecord: {   //环境记录
            Type: "Object",   
            // 标识符绑定在这里  
            num1: < uninitialized >,  
            num2: < uninitialized >,  
            fn: < func >  
        }  
        outer: <null>   //对外部环境的引用
    },

    VariableEnvironment: {  //变量环境
        EnvironmentRecord: {  // 环境记录
            Type: "Object",   
            // 标识符绑定在这里  
            num3: undefined,  
        }  
        outer: <null>  
    }  
}

FunctionExectionContext = {  
    ThisBinding: <Global Object>,
    LexicalEnvironment: {  // 词法环境
        EnvironmentRecord: {  //环境记录 
            Type: "Declarative",  
            // 标识符绑定在这里  
            num1:<uninitialized>,
            Arguments: {0: 20, length: 1},  
        },  
        outer: <GlobalLexicalEnvironment>  
    },

    VariableEnvironment: { // 变量环境 
        EnvironmentRecord: {  
            Type: "Declarative",  
            // 标识符绑定在这里   
        },  
        outer: <GlobalLexicalEnvironment>  
    }  
}
复制代码

    我们可以注意到在全局执行上下文(GlobalExectionContext)的词法环境(LexicalEnvironment)中保存着用let和const声明的num1和num2,并且它们的值是没有初始化(uninitialized),没有任何与之关联的值,而变量环境(VariableEnvironment)中保存着用var声明的num3,它的值是undefined。在创建阶段,代码会被所在环境从上到下进行扫描并且解析变量和函数声明,函数声明存储在环境中,而变量会被设置成undefined(在var的情况下)或者是uninitialized没有初始化(let/const情况下)。

从上可以看出执行步骤如下:

  • 首先创建全局上下文的词法环境
    • 先创建对象环境记录器(将let,const声明的变量及函数声明和引用放置其中)
    • 创建对外部环境的引用outer(null)
  • 接着创建全局上下文的变量环境,
    • 将var声明的变量存放其中
    • 然后创建相应的对象环境记录器和对外部环境的引用outer
  • 确定this指向(浏览器下this就是window)
  • 函数调用时:创建函数私有上下文,推入执行环境栈中执行
    • 创建函数上下文的词法环境
      • 首先是声明式环境记录器,将用let,const声明的变量放置其中,此时它们处于未初始化的状态(uninitialized)
      • 词法环境中还有一个arguments对象
      • 最后是它的外部环境引用outer(类似作用域链)
    • 创建函数上下文的变量环境
      • 用var声明的变量放置其中
      • 其次是对外部环境的引用
    • 确定this指向
    • 代码执行进栈执行
  • 上下文执行完后进行回收
let num1 = 10;
const num2 = 20;
var num3 = 30;
function fn(g) {
    // console.log(num1);//Cannot access 'num1' before initialization
    let num1 = 40;
    console.log(num1) //40
}
fn(20);
console.log(num1);//10
复制代码

image.png

三、闭包

3.1 定义

不同人站在不同角度对闭包有不同理解,分别有以下几种比较权威的解释:

函数可以记住并访问所有的词法作用域时,就产生了闭包。即使函数是在当前作用域外执行。——《你不知道的JavaScript(上卷)》

闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。 ——《JavaScript高级程序设计(四)》

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。 ——MDN

在我看来,浏览器加载页面时会形成一个供代码执行的环境——执行环境栈,当函数进栈执行时会形成一个私有上下文——函数执行上下文,这个上下文会保护里面的私有变量不受外界干扰,并且如果当前上下文中的某些内容被上下文以外的内容所占用,当前上下文是不会出栈释放的,这样可以保存里面的变量和值。所以闭包可以看成是是一种机制。函数执行产生的私有上下文可以保护里面的私有变量不受外界干扰,防止全局变量污染并且把私有变量相应的值保存起来供上下文调取使用这种机制称之为闭包。 而不仅仅是那些没有被销毁的上下文才是闭包。

示例:

var x = 10;
function fn() {
    var x = 30;
    let y = 20;
    return function () {
        z = x + y;
        console.log(z);
    };
}
let f = fn()
f();
复制代码

image.png

一般函数中的代码执行完后浏览器会自动将私有上下文出栈释放,但是如果当前上下文中某个和它相关的内容被当前上下文以外的地方占用了,那么这个私有上下文不能出栈释放,这样私有上下文中的私有变量和值也被保存起来了。

在示例代码中我们可以看到fn函数中的私有变量x它和外界全局上下文中的x并不冲突,互不影响。而且它里面的x,y被f函数所占用,所以函数fn所在的上下文是不会立即出栈释放,这样就会把上下文中的一些信息保存起来,供上下文以外的f函数使用了。

3.2 闭包的应用

闭包应用的很广泛,一般常用应用有两种

  • 1、将一个函数作为另一个函数的返回值
function fn1() {
    var a = 2;
    function fn2() {
        a++;
        console.log(a);
    }
    return fn2;
}
var f = fn1(); // 执行外部的fn1函数,返回的是fn2函数
f(); // 执行fn2
复制代码
  • 2、将函数作为实参传递给另一个函数调用
function fn(v, t) {
    setTimeout(function () {
        alert(a)
    }, t)
}
fn(100, 1000);
复制代码

3.4 优点与不足

闭包的优点在于它的保护和保存机制,同样闭包的不足也来自于它的保护和保存,因为它会产生大量不被销毁的上下文,这样会导致内存消耗过大,影响页面的整体性能,所以建议在十分必要时使用,虽然v8引擎它努力回收被闭包困住的内存,但是它的回收还是有限,所以要谨慎使用闭包。

3.4 其它

如果我们需要将闭包中的值暴露到外面那应该怎么做呢?有以下方法:

  • 基于window.xxx = xxx 暴露到全局

我们可以看到虽然这种方法可以实现要求,但是如果我们将每一个方法都暴露到window上,也有可能导致全局方法之间的冲突甚至于是修改了全局上原本的方法。

(
    function fn() {
        function fn1() {
            console.log('fn1')
        }
        function fn2() {
            //...
        }
        function fn3() {
            //...
        }
        window.fn1 = fn1;
        window.fn2 = fn2;
        window.fn3 = fn3;
    }
)();
fn1();
复制代码
  • utils基于return,把需要公用的方法暴露出去
var utils = (function fn() {
    function fn1() {
        console.log('fn1')
    }
    function fn2() {
        //  do something
    }
    // 把需要供外面访问的变量和方法,赋值给一个对象,最后返回
    return {
        fn1: fn1,
        fn2: fn2
    }; //=>return AAAFFF000;
})();
console.log(utils);
utils.fn1();
复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享