从执行上下文和作用域谈谈变量提升和闭包的底层原理

233 阅读16分钟

执行上下文与执行上下文栈

首先介绍下执行上下文和执行上下文栈的原理,从而引出变量提升和闭包的底层原理。

这里先简单提下变量提升的原理和含义,具体底层实现请继续往下看。

变量提升与函数提升

  1. 提升的内部原理

    函数在运行的时候,会首先创建执行上下文,然后将执行上下文入栈,然后当此执行上下文处于栈顶时,开始运行执行上下文。

    在创建执行上下文的过程中会做三件事:创建变量对象,创建作用域链,确定 this 指向

    其中创建变量对象的过程中,首先会为 arguments 创建一个属性,值为 arguments,然后会扫码 function 函数声明,创建一个同名属性,值为函数的引用,接着会扫码 var 变量声明,创建一个同名属性,值为 undefined,这就是变量提升。

  2. 变量声明提升

    • 通过var定义(声明)的变量, 在定义语句之前就可以访问到
    • 值: undefined
    • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性
  3. 函数声明提升

    • 通过function声明的函数, 在之前就可以直接调用
    • 值: 函数定义(对象)
    • 如果是通过函数表达式定义一个函数,属于变量提升,不属于函数提升,不可直接调用
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  4. 问题: 变量提升和函数提升是如何产生的?

    • 先有变量提升, 再有函数提升(函数提升优先级高于变量提升,同名的话函数会覆盖变量)

执行上下文

  1. 代码分类(位置)

    • 全局代码
    • 函数代码
  2. 全局执行上下文

    • 在执行全局代码前将window确定为全局执行上下文
    • 对全局数据进行预处理
      • var定义的全局变量==>undefined, 添加为window的属性
      • function声明的全局函数==>赋值(fun), 添加为window的方法
      • this==>赋值(window)
    • 开始执行全局代码
  3. 函数执行上下文

    • 在调用函数, 准备执行函数体之前, 创建对应的函数执行上下文对象
    • 对局部数据进行预处理
      • 形参变量==>赋值(实参)==>添加为执行上下文的属性
      • arguments==>赋值(实参列表), 添加为执行上下文的属性
      • var定义的局部变量==>undefined, 添加为执行上下文的属性
      • function声明的函数 ==>赋值(fun), 添加为执行上下文的方法
      • this==>赋值(调用函数的对象)
    • 开始执行函数体代码

生命周期

执行上下文的生命周期包括三个阶段:创建阶段 → 执行阶段 → 回收阶段

创建阶段

创建阶段即当函数被调用,但未执行任何其内部代码之前

创建阶段做了三件事:

  • 确定 this 的值,也被称为 This Binding(this的值是在执行的时候才能确认)
  • LexicalEnvironment(词法环境) 组件被创建
  • VariableEnvironment(变量环境) 组件被创建
  • 创建作用域链

词法环境

词法环境有两个组成部分:

  • 全局环境:是一个没有外部环境的词法环境,其外部环境引用为null,有一个全局对象,this 的值指向这个全局对象
  • 函数环境:用户在函数中定义的变量被存储在环境记录中,包含了arguments 对象,外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境
GlobalExectionContext = {  // 全局执行上下文
  LexicalEnvironment: {       // 词法环境
    EnvironmentRecord: {     // 环境记录
      Type: "Object",           // 全局环境
      // 标识符绑定在这里 
      outer: <null>           // 对外部环境的引用
  }  
}

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

变量环境

变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性

在 ES6 中,词法环境和变量环境的区别在于前者用于存储函数声明和变量( letconst )绑定,而后者仅用于存储变量( var )绑定

let a = 20;  
const b = 30;  
var c;

function multiply(e, f) {  
 var g = 20;  
 return e * f * g;  
}

c = multiply(20, 30);

执行上下文如下

GlobalExectionContext = {

  ThisBinding: <Global Object>,

  LexicalEnvironment: {  // 词法环境
    EnvironmentRecord: {  
      Type: "Object",  
      // 标识符绑定在这里  
      a: < uninitialized >,  
      b: < uninitialized >,  
      multiply: < func >  
    }  
    outer: <null>  
  },

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

FunctionExectionContext = {  
   
  ThisBinding: <Global Object>,

  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      // 标识符绑定在这里  
      Arguments: {0: 20, 1: 30, length: 2},  
    },  
    outer: <GlobalLexicalEnvironment>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      // 标识符绑定在这里  
      g: undefined  
    },  
    outer: <GlobalLexicalEnvironment>  
  }  
}

letconst定义的变量ab在创建阶段没有被赋值,为< uninitialized >,但var声明的变量从在创建阶段被赋值为undefined,这就是变量提升的实际原因

执行阶段

在这阶段,执行变量赋值、代码执行

如果 Javascript 引擎在源代码中声明的实际位置找不到变量的值,那么将为其分配 undefined

回收阶段

执行上下文出栈等待虚拟机回收执行上下文

执行上下文栈

  1. 在全局代码执行前, JS引擎就会创建一个栈来存储管理所有的执行上下文对象

  2. 在全局执行上下文(window)确定后, 将其添加到栈中(压栈)

  3. 在函数执行上下文创建后, 将其添加到栈中(压栈)

  4. 在当前函数执行完后,将栈顶的对象移除(出栈)

  5. 当所有的代码执行完后, 栈中只剩下window

    如果调用两次bar,则会有5个执行上下文

面试题(妙啊)

/*

  测试题1: 先变量提升,再函数提升

  */

  function a() {}

  var a;

  console.log(typeof a)   //’function‘

  /*

  测试题2: 

   */

  if (!(b in window)) {

    var b = 1;

  }

  console.log(b)  //undefined

  /*

  测试题3: 

   */

  var c = 1

  function c(c) {

    console.log(c)

    var c = 3

  }

  c(2)  //报错!!! 秒啊!!!
  
  //以上代码相当于
  var c 
  function c(c) {
    console.log(c)
    var c = 3
  }
  c=1
  c(2)  
  //先变量提升,再函数提升,再赋值!!!

复习

  • 理解
    • 执行上下文: 由js引擎自动创建的对象, 包含对应作用域中的所有变量属性
    • 执行上下文栈: 用来管理产生的多个执行上下文
  • 分类:
    • 全局: window
    • 函数: 对程序员来说是透明的
  • 生命周期
    • 全局 : 准备执行全局代码前产生, 当页面刷新/关闭页面时死亡
    • 函数 : 调用函数时产生, 函数执行完时死亡
  • 包含哪些属性:
    • 全局 :
      • 用var定义的全局变量 ==>undefined
      • 使用function声明的函数 ===>function
      • this ===>window
    • 函数
      • 用var定义的局部变量 ==>undefined
      • 使用function声明的函数 ===>function
      • this ===> 调用函数的对象, 如果没有指定就是window
      • 形参变量 ===>对应实参值
      • arguments ===>实参列表的伪数组
  • 执行上下文创建和初始化的过程
    • 全局:
      • 在全局代码执行前最先创建一个全局执行上下文(window)
      • 收集一些全局变量, 并初始化
      • 将这些变量设置为window的属性
    • 函数:
      • 在调用函数时, 在执行函数体之前先创建一个函数执行上下文
      • 收集一些局部变量, 并初始化
      • 将这些变量设置为执行上下文的属性

作用域与作用域链

作用域

  1. 理解

    • 就是一块"地盘", 一个代码段所在的区域
    • 它是静态的(相对于上下文对象), 在编写代码时就确定了
    • 这是因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链!

    静态作用域:输出1

    动态作用域:输出2

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar(); //1
  1. 分类
    • 全局作用域
    • 函数作用域
    • 没有块作用域(ES6有了)

image.png

  1. 作用
    • 隔离变量,不同作用域下同名变量不会有冲突
  2. 为什么要有块级作用域
    • 解决变量覆盖问题
    • 解决循环变量变成全局变量

作用域与执行上下文

  1. 区别1
    • 全局作用域之外,每个函数都会创建自己的作用域,作用域在函数定义时就已经确定了。而不是在函数调用时
    • 全局执行上下文环境是在全局作用域确定之后, js代码马上执行之前创建
    • 函数执行上下文环境是在调用函数时, 函数体代码执行之前创建
  2. 区别2
    • 作用域是静态的, 只要函数定义好了就一直存在, 且不会再变化
    • 执行上下文环境是动态的, 调用函数时创建, 函数调用结束时上下文环境就会被释放
  3. 联系
    • 执行上下文环境(对象)是从属于所在的作用域
    • 全局上下文环境==>全局作用域
    • 函数上下文环境==>对应的函数使用域

image.png

作用域链

  1. 理解
    • 多个上下级关系的作用域形成的链, 它的方向是从下向上的(从内到外)
    • 查找变量时就是沿着作用域链来查找的
  2. 查找一个变量的查找规则
    1. 在当前作用域下的执行上下文中查找对应的属性, 如果有直接返回, 否则进入2
    2. 在上一级作用域的执行上下文中查找对应的属性, 如果有直接返回, 否则进入3
    3. 再次执行2的相同操作, 直到全局作用域, 如果还找不到就抛出找不到的异常

image.png

完整执行过程

如下例子:

var scope = "global scope";

function checkscope(){

  var scope2 = 'local scope';

  return scope2;

}

checkscope();

执行过程如下:

1.checkscope 函数被创建,保存作用域链到 内部属性[[scope]]

checkscope.[[scope]] = [

  globalContext.VO

];

2.执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈

ECStack = [

  checkscopeContext,

  globalContext

];

3.checkscope 函数并不立刻执行,开始做准备工作,第一步:复制函数[[scope]]属性创建作用域链

checkscopeContext = {

  Scope: checkscope.[[scope]],

}

4.第二步:用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明

checkscopeContext = {

  AO: {

    arguments: {

      length: 0

    },

    scope2: undefined

  },

  Scope: checkscope.[[scope]],

}

5.第三步:将活动对象压入 checkscope 作用域链顶端

checkscopeContext = {

  AO: {

    arguments: {

      length: 0

    },

    scope2: undefined

  },

  Scope: [AO, [[Scope]]]

}

6.准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值

checkscopeContext = {

  AO: {

    arguments: {

      length: 0

    },

    scope2: 'local scope'

  },

  Scope: [AO, [[Scope]]]

}

7.查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出

ECStack = [

  globalContext

];

总结:

  1. 函数被创建,保存作用域链到 内部属性[[scope]]
  2. 创建 checkscope 函数执行上下文,并压栈
  3. 函数执行前的准备工作:
    • 复制函数[[scope]]属性创建作用域链,
    • 初始化活动对象,加入形参、函数声明、变量声明
    • 将活动对象压入 checkscope 作用域链顶端
  4. 执行函数
  5. 出栈

所以闭包的底层原理就可以知道了

闭包底层原理

从执行上下文的角度来看:

压栈入栈顺序为:全局入栈—fn1入栈—fn1出栈—fn2入栈—fn2出栈—全局出栈

问题:fn1的执行上下文已经被销毁了,为什么fn2还可以访问fn1的变量a??

理由:fn2的作用域链还保存着fn1的活动对象

fn2Context = {
    Scope: [AO, fn1Context.AO, globalContext.VO],
}

详细介绍下闭包

理解闭包

我想实现a++的功能,但是我不想让外部直接操作a(避免外部随意修改a),所以我封装了一个内部函数来实现a++的功能,并把这个内部函数暴露出去,外部可以调用我这个内部函数来实现a++的功能,但是无法直接接触到a

闭包=「函数」+「函数内部能访问到的变量」

  1. 如何产生闭包?

    • 当一个嵌套的内部(子)函数引用了嵌套的外部(父)函数的变量(函数)时, 就产生了闭包
  2. 闭包到底是什么?

    • 使用chrome调试查看
    • 理解一: 闭包是嵌套的内部函数(绝大部分人)
    • 理解二: 包含被引用变量(函数)的对象(极少数人)
    • 注意: 闭包存在于嵌套的内部函数中

  3. 产生闭包的条件?

    • 函数嵌套
    • 内部函数引用了外部函数的数据(变量/函数)
常见的闭包
  1. 将函数作为另一个函数的返回值

  2. 将函数作为实参传递给另一个函数调用

    fn1调用几次,就产生几个闭包。如果没有闭包,a在函数调用后就消失了,没办法累加

    📘Foo调用几次就产生几个闭包

function Foo() {
    var x = 0;
    function Foo1() {
        ++x;
        console.log(x);
    };
    return Foo1;
};
 
var a = new Foo();
a();//1
a();//2
var b = new Foo();
b();//1
  注意:这种情况,闭包的多个实例都是共享同一个 x 变量。
var Foo = (function() {
    var x = 0;
    function Foo() {}
        Foo.prototype.increment = function() {
        ++x;
        console.log(x);
    };
    return Foo;
})();
 
var a = new Foo();
a.increment();//1
a.increment();//2
var b = new Foo();
a.increment();//3
闭包的作用
  1. 使用函数内部的变量在函数执行完后, 仍然存活在内存中(延长了局部变量的生命周期)
  2. 函数外部可以操作(读写)到函数内部的数据(变量/函数)

问题:

  1. 函数执行完后, 函数内部声明的局部变量是否还存在?

    一般不存在,存在于闭包中的变量才可能存在

    如果定义了一个变量来保存,则闭包中的局部变量会存在 var f=fun1(); f();,直到f成为垃圾对象即f=null时,才不存在

    如果直接调用,则不存在了fun1();

  2. 在函数外部能直接访问函数内部的局部变量吗?

    不能,但是可以通过闭包让外部操作局部变量(暴露函数)

闭包的生命周期
  1. 产生: 在嵌套内部函数定义完时就产生了(不是在调用)
  2. 死亡: 在嵌套的内部函数成为垃圾对象时
<script type="text/javascript">
  function fun1() {
    //问题2: 此时闭包产生了吗? --产生了(函数提升,内部函数对象已经创建了)
    var a = 3;
    function fun2() {
      a++;
      console.log(a);
    }
    return fun2;
  }
  //问题1: 此时闭包产生了吗? --产生了
  var f = fun1();
  //问题3: 此时闭包释放了吗? --没有
  f();
  f();
  //问题4: 此时闭包释放回收了吗? --没有
  //问题5: 如何让闭包释放回收呢?
  f=null; //闭包死亡(包含闭包的函数对象成为垃圾对象)
</script>
闭包的应用
  1. 封装私有变量:通过闭包可以创建私有变量,只有内部函数可以访问和修改这些变量,外部无法直接访问。这种方式可以实现数据的封装和隐藏,提高代码的安全性和可维护性。
  2. 延迟执行:通过闭包可以实现延迟执行某个函数,可以在外部函数执行完毕后,仍然可以访问外部函数的变量和参数,实现一些异步操作。
  3. 保存状态:闭包可以保存函数的状态,即使函数执行完毕后,闭包仍然可以访问和修改函数的内部变量和参数。这在一些需要记住上下文状态的场景中非常有用,比如计数器、缓存等。
  4. 高阶函数的应用:闭包在高阶函数中经常被使用,可以用来创建一些特定的函数,比如柯里化函数、偏函数等,实现单例模式,实现缓存
  5. 事件处理程序:在事件处理程序中,闭包可以用来保存事件的状态和上下文信息,以便在事件触发时使用。
  • 定义JS模块

    • 具有特定功能的js文件

    • 将所有的数据和功能都封装在一个函数内部(私有的)

    • 只向外暴露一个包含n个方法的对象或函数

    • 模块的使用者, 只需要通过模块暴露的对象调用方法来实现对应的功能

    • 方法一:

      自定义一个js文件

    • 方法二:更佳

  • 模块化: 封装一些数据以及操作数据的函数, 向外暴露一些行为

  • 函数柯里化

// 假设我们有一个求长方形面积的函数
function getArea(width, height) {
    return width * height
}
// 如果我们碰到的长方形的宽老是10
const area1 = getArea(10, 20)
const area2 = getArea(10, 30)
const area3 = getArea(10, 40)

// 我们可以使用闭包柯里化这个计算面积的函数
function getArea(width) {
    return height => {
        return width * height
    }
}

const getTenWidthArea = getArea(10)
// 之后碰到宽度为10的长方形就可以这样计算面积
const area1 = getTenWidthArea(20)

// 而且如果遇到宽度偶尔变化也可以轻松复用
const getTwentyWidthArea = getArea(20)
  • 循环遍历加监听
  • JS框架(jQuery)大量使用了闭包
闭包的缺点及解决
  1. 缺点

    • 函数执行完后, 函数内的局部变量没有释放, 占用内存时间会变长
    • 容易造成内存泄露
  2. 解决

    • 能不用闭包就不用
    • 及时释放 : f = null; //让内部函数对象成为垃圾对象

面试题(妙啊)

  //面试题1
  var x = 10;
  function fn() {
    console.log(x);    //10   因为作用域在函数定义时就已经确定了,fn的上级是window
  }
  function show(f) {
    var x = 20;
    f();
  }
  show(fn);
  
  //面试题2
  var fn = function () {
    console.log(fn)   //function () {console.log(fn) }
  }
  fn()
  
  var obj = {
    fn2: function () {
      console.log(this.fn2)   //function () {console.log(fn2);console.log(fn2);}
      console.log(fn2)   //报错,找不到fn2
    }
  }
  obj.fn2()

复习

  • 理解:
    • 作用域: 一块代码区域, 在编码时就确定了, 不会再变化
    • 作用域链: 多个嵌套的作用域形成的由内向外的结构, 用于查找变量
  • 分类:
    • 全局
    • 函数
    • js没有块作用域(在ES6之前)
  • 作用
    • 作用域: 隔离变量, 可以在不同作用域定义同名的变量不冲突
    • 作用域链: 查找变量
  • 区别作用域与执行上下文
    • 作用域: 静态的, 编码时就确定了(不是在运行时), 一旦确定就不会变化了
    • 执行上下文: 动态的, 执行代码时动态创建, 当执行结束消失
    • 联系: 执行上下文环境是在对应的作用域中的