# JavaScript 执行原理、闭包、垃圾回收、立即执行函数

TIP

本章节,我们将学习 JavaScript 执行原理相关的内容。首先,我们要知道 JavaScript 是一门解释性语言,也就是边解析(编译),边执行。

你可以理解为一段 JS 代码在正式执行需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段。

image-20221016201347158

我们把 JavaScript 引擎在执行前的编译过程,程为JS 的预编译

在 JS 的编译阶段,会做以下三件事 ?

1、语法检查

  • JS 引擎会检查你的代码有没有什么低级的语法错误,以消除一些歧义。
  • 如果有语法错误,则不会往下执行,直接抛出“语法错误”。如以下代码:
var a = 1;
console.log(a);
var b = 3// b后面的; 分号是中文状态下的
// 代码并不会执行,打印出1,因为在预编译阶段有语法错误,所以直接抛出了误法错误

2、创建执行上下文 (Execution context)

  • 执行上下文是 JavaScript 执行一段代码时所处的运行环境
  • 关于执行上下文的相关细节,我们待会在下面详细讲解,这也是本章的重点。

3、生成可执行代码

JavaScript 引擎并不认识我们写的 JS 代码,所以需要将 JS 代码转换为计算机能读懂的机器码(二进制文件)

# 一、什么是执行上下文

TIP

  • 执行上下文是 JavaScript 执行一段代码时所处的运行环境

MDN 官网中提到:作用域是当前的执行上下文

参考地址:https://developer.mozilla.org/zh-CN/docs/Glossary/Scope (opens new window)

  • 接下来我们就来明确下,哪些情况下代码才算是 “一段” 代码,才会在执行前进行预编译过程,并创建执行上下文环境。

主要有以下三种情况

image-20221016204140956

# 1、全局执行上下文

TIP

  • 当 JavaScript 执行全局作用域中的代码时,会编译全局代码并创建全局执行上下文
  • 整个页面的生命周期内,全局执行上下文只有一份。
  • 只有当整个页面关闭后,全局执行上下文才会被销毁。即页面没有关闭前,这些变量对应的数据都保存在内存中。
// 代码在执行前,会预编译,并会创建全局执行上下文
// 以下代码在页面没有关闭前,是不会被销毁的,即当前数据还保存在内存中
var a = 1;
var b = 2;
function sum(a, b) {
  console.log(a + b);
}

# 2、函数执行上下文

TIP

  • 当调用一个函数时,函数体内的代码会被编译,并创建函数执行上下文
  • 一般情况下,函数执行结束之后,创建的函数执行上下文就会被销毁。
var a = 1;
var b = 2;
function sum(a, b) {
  var c = a;
  var d = b;
  console.log(c + d);
}
sum(2, 3); // 调用函数

// 代码执行前,会预编译,并创建全局执行上下文,然后从上往下执行代码
// 执行到sum(2,3)时,他调用函数,调用函数时会对函数体内代码预编译,同时创建函数执行上下文
// 执行函数体中代码,执行完后,函数执行上下文就会被销毁,即函数体内的变量c和d不再占据内存空间

以上代码创建了 全局执行上下文和 函数执行上下文

# 3、eval 执行上下文

TIP

  • 在严格模式下,当使用 eval 函数时,eval 的代码会被编译,并创建eval 执行上下文
  • 考虑安全性能问题,现在 eval 被禁用

eval() 函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在整个程序中这个位置的代码。

eval 通常被用来执行动态创建的代码,但是安全风险过高,如果传过来的是一段 JS 木马呢 ?

function foo() {
  eval("var a=1;var b=2;");
  console.log(a + b);
}
foo(); // 在控制台输出 3

在严格模式下,eval()在运行时会有自己的执行上下文

function foo() {
  "use strict";
  eval("var a=1;var b=2;");
  console.log(a + b); // 直接抛出错误 a is not defined
}
foo();

# 4、执行上下文分类

TIP

通过上面的学习,我们知道执行上下文主要分类以下三种:

  • 全局执行上下文
  • 函数执行上下文
  • eval 执行上下文

image-20221016205234225

详细解读

  • 全局执行上下文: 当 JavaScript 执行全局作用域中的代码时,会编译全局代码并创建全局执行上下文
  • 函数执行上下文: 当调用一个函数时,函数体内的代码会被编译,并创建函数执行上下文
  • eval 执行上下文: 当使用 eval 函数时,eval 的代码会被编译,并创建 eval 执行上下文

在深入学习执行上下文之前,我们还需要学习一个重要的知识:

执行上下文栈 学习这个知识将有助于我们理解 JavaScript 引擎背后的工作原理,以及对于我们理解执行上下文也有很大帮助。

# 二、执行上下文栈

TIP

首先我们要理解,什么是 栈 LIFO ?在算法那一章我们学习过栈这种数据结构,这里我们回顾下。

# 1、什么是栈

TIP

  • 是一种先进后出的数据结构,要弄明白什么是栈,我们先举一个生活中的例子来帮助大家理解
  • 假如你现在有一个长长的圆筒,圆筒的一端是封闭的,另一端是开口,现在往圆筒底部放气球,那先放的是不是在圆筒的底部,后放的是不是在靠近圆筒的位置。

如下图:

stack

详细解读:

我们现在要从圆筒中取出气球,那我们是不是得先取离圆筒出口最近的一个,即取球时的顺序正好和放的时候的顺序是反的。

我们把圆筒比喻从栈,那放气球的过程叫入栈,拿气球的过程叫出栈;圆筒的底部称为栈底,圆筒出口的第一个气球位置叫栈顶

栈 LIFO : 是一种先进后出的一种数据结构。 插入一般称为入栈(PUSH),删除则称为出栈(POP)

# 2、什么是执行上下文栈(调用栈)

TIP

  • 我们知道, 函数里面可以嵌套函数, 不同的函数调用又会形成不同的执行上下文环境
  • 这些不同的执行上下文环境,我们统一放进一个中来管理。

我们把这种用来管理执行上下文的栈,称为执行上下文栈,又称调用栈

  • 栈底为全局执行上下文, 每当有一次函数调用, 形成的函数执行上下文就会被 push 进栈顶,即压栈
  • 函数执行完, 该函数所对应的函数上下文将会被 pop 出上下文栈,即出栈

我们用下面这个代码来演示,整个执行上下文栈的压栈和出栈过程

var a = 1;
function fn1() {
  console.log("fn1");
  function fn2() {
    console.log("fn2");
  }
  fn2();
}
fn1();

image-20221016211555201

进栈

  • 当页面打开,就会创建全局执行上下文,并将其压入执行上下文栈底。
  • 然后执行全局上下文中代码,遇到fn1()调用,则会创建 fn1 的函数执行上下文, 压入执行上下文栈
  • 然后执行 fn1 中代码,遇到fn2()调用,创建 fn2 的函数执行上下文,压入执行上下文栈。
  • 接着执行 fn2 中的代码

image-20221016212312883

出栈

  • fn2 执行完毕后, 对应的执行上下文从执行上下文栈中 pop 出
  • 此时 fn1 也被执行完,对应的执行上下文也从上下文栈中 pop 出
  • 全局上下文要在浏览器关闭后才会被销毁

通过调试工具,来查看整个的压栈和出栈过程

image-20221003200438404

# 3、栈溢出

TIP

  • 执行上下文栈是用来管理执行上下文的数据结构,不过要注意的是 执行上下文栈是有大小的
  • 当入栈的执行上下文超过一定的数目,栈对应的内存空间被占满后,JavaScript 引擎就会报错,我们把这种错误叫做栈溢出

递归代码,很容易出现栈溢出情况,如下代码

var i = 0;
function a() {
  i++;
}
a();

image-20221016214415771

注:

不同的浏览为栈分配的内存空间大小是不—样的。
所以我们在使用递归时,要特别注意这一点,确保递归压栈时不会造成栈溢出。

修改上面代码,就不会出现栈溢出

let i = 0;
function a() {
  i++;
  if (i < 13921) a();
}
a();

# 三、 执行上下文组成

TIP

执行上下文中包含了:

  • 变量环境
  • 词法环境
  • 外部环境
  • this

共四个部分

image-20221003183701550

详细解读

  • 变量环境: 其实就是我们之前提到的变量和函数提升,在代码执行前变量和函数会被提升到当前作用域的最前面。
  • 外部环境: 其实就是我们之前了解的作用域链,他记录了当前作用域及他的外层作用域之间的关系,我们查找变量在当前作用域中找(当前变量环境中找)找不到再到外部外境(沿着作用域链去查找)
  • this: 就是我们前面讲的 this,函数上下文对象,谁把函数当成方法来调用,this 就指向谁
  • 词法环境: ES6 中需要学习到的 let 和 const 声明的变量,是保存在词法环境中。

# 1、变量环境

TIP

  • 在变量环境中存在一个变量环境对象(viriable Environment),叫变量对象 。英文全称 variable Object 简称 VO
  • 在这个执行上下文中,所有由var 声明的变量函数等都存在于这个 “变量对象” 上。
  • JavaScript 代码不能直接访问该 “变量对象” ,但是可以直接访问该对象的成员。

接下来我们分别来学习以下三中执行上下文中的变量对象

image-20221016215522508

# 1.1、全局执行上下文中 - 变量对象

TIP

  • 全局上下文是最外层的上下文,全局执行上下文中的变量对象就是window对象
  • 因此全局变量和全局函数都会成为 window 对象的属性方法
  • Node环境中,全局执行环境是global对象
  • 在全局上下文中, this 指向 windw 对象

注意: JavaScript 中没法直接访问到 "变量对象" ,除全局上下文的变量对象 window

var a = 1;
function fn() {
  console.log(2);
}
console.log(this === window); // true 全局上下文中,this指向window
console.log(window.a); // 1  通过window对象的属性a可以访问到变量a
window.fn(); // 2 通过window对象的方法fn可以访问到fn函数

# 1.2、函数上下文中 - 变量对象

TIP

  • 在函数执行上下文中,变量对象常常被称为“活动对象(Activation Object)简称 AO” ,因为变量对象是在进入函数执行上下文时被创建的(被激活)的。
  • 刚开始,活动对象上只有 arguments 这一个属性,其后函数中的变量、函数、参数都被保存在这个 活动对象(AO) 上,成为了这个活动对象的 属性方法

我们直接访问函数中的变量,参数,函数,arguments,本质就是在访问 "活动对象" 上的属性。

function sum(c) {
  var a = 2;
  var b = 3;
  console.log(arguments);
  console.log(a, b, c);
}
sum(1);

// 当前会创建全局执行上下文,然后执行到sum(1)时,会创建函数执行上下文
// 函数执行上下文中会创建一个活动对象,其内变量a,b和参数c,arguments都是活动对象的属性,包括 this

image-20221016222358187

在函数执行上下文中,this 对象保存在活动对象上的 this 属性上。

# 1.3、eval 执行上下文 - 变量对象

TIP

  • 创建 eval 函数是为了将字符串转换为可执行的 JavaScript 代码。虽然看起来很强大,但不建议使用这个功能,因为我们无法控制它的权限。
  • eval 函数的使用可能会使您的应用程序或服务受到注入攻击。 eval 函数接收到的字符串可能是恶意字符串,可以完全破坏您的数据库或应用程序。
  • 这就是为什么不推荐使用 eval 函数的原因,也就不做介绍。

# 2、变量对象的创建过程

TIP

通过前面的学习我们知道,变量对象(活动对象,简称 AO) 是在 JS 预编译的过程中被创建的。

接下来我们来了解下变量对象的整个创建过程

# 2.1、变量对象创建的整个过程

TIP

我们以下面代码中的函数执行上下文中的变量对象为例,来看整个变量对象的创建过程

function fn(a) {
  var b = 2; // 声明局部变量,并赋值2
  function num() {} // 声明局部函数
  var c = function () {}; // 声明局部变量,并赋值为匿名函数
  b = 3; // 修改变量b的值为3
}
fn(1); // 调用函数,并传入实参 1

第一步:创建空的变量对象

全局执行上下文中代码执行到fn(1)时,函数被调用,就会创建函数执行上下文,同时创建空的变量对象

// 1、创建 AO 变量对象
var AO = {}; // 这里 AO 指代被创建出来的变量对象

第二步:初始化变量对象的第一个属性 arguments

// 1、创建 AO 变量对象
AO = {
  // 2、创建arguments属性,其属性值为Arguments对象
  arguments: {
    0: 1, // 实参
    length: 1, // 实参个数
    // ..... 其它属性省略
  },
};

第三步:处理形参与实参

函数的所有形参的名称和实参对应组成的一个变量对象的属性被创建。如果没有实参,属性值设为 undefined。

// 1、创建 AO 变量对象
AO = {
    // 2、创建arguments属性,其属性值为Arguments对象
    arguments: {
        0: 1, // 实参
        length: 1, // 实参个数
        // ..... 其它属性省略
    }// 3、 寻找函数形参a,作为变量对象的属性,同时赋值为1
    a:1,
}

第四步:处理函数体内的函数声明

TIP

  • 函数体内的函数声明的名称和对应值组成一个变量对象的属性被创建。
  • 如果变量对象已经存在相同名称的属性,则会完全替换这个属性。

重点提示:变量与函数提升

  • 上面这个过程,就是我们之前提到变量与函数提升
  • 函数声明提升的优先级是高于变量提升,本质就是在变量对象初始化属性时,同名的方法会替换掉同名的属性。
  • 如果是同名的函数,则以后面写在后面的为主。
// 1、创建 AO 变量对象
AO = {
  // 2、创建arguments属性,其属性值为Arguments对象
  arguments: {
    0: 1, // 实参
    length: 1, // 实参个数
    // ..... 其它属性省略
  },
  // 3、寻找函数形参a,作为变量对象的属性,同时赋值为1
  a: 1,
  // 4、寻找函数声明 function num(){ },将num为变量对象属性,值为函数本身
  num: function () {},
};

第五步:处理函数体内的变量声明

TIP

  • 变量声明的名称和对应值(undefined)组成的一个变量对象的属性被创建。
  • 如果变量名称与已经声明的形参函数名相同,则变量声明不会覆盖已经存在的这类属性。

重点提示:变量与函数提升

  • 变量对象的创建过程,就是我们之前提到的变量的提升,变量提升提升的是变量,并不会提升值,所以创建出来的属性,默认值是 undefined
  • 同时同名的变量不会覆盖同名函数,同名的变量和变量本质覆盖与不覆盖没有区别。
// 1、创建 AO 变量对象
AO = {
  // 2、创建arguments属性,其属性值为Arguments对象
  arguments: {
    0: 1, // 实参
    length: 1, // 实参个数
    //..... 其它属性省略
  },
  // 3、寻找函数形参a,作为变量对象的属性,同时赋值为1
  a: 1,
  // 4、寻找函数声明 function num(){ },将num为变量对象属性,值为函数本身
  num: function () {},
  // 5、寻找var声明的变量,将变量b作为变量对象的属性,值为undefined
  b: undefined,
  c: undefined, // 6 寻找var声明的变量,将变量b作为变量对象的属性,值为undefined
};

通过控制台,查看整个变量对象的创建过程

image-20221003205808364

# 2.2、代码执行阶段

TIP

代码进过预编译处理之后,就会开始从上往下来执行代码,执行代码时,就可能会修改变量对象的值。

function fn(a) {
  var b = 2; // 声明局部变量,并赋值2
  function num() {} // 声明局部函数
  var c = function () {}; // 声明局部变量,并赋值为匿名函数
  b = 3; // 修改变量b的值为3
}
fn(1); // 调用函数,并传入实参 1
  • 第一步:修改变量对象上属性 b 的值为 2
AO = {
  arguments: {
    0: 1,
    length: 1,
    // ..... 其它属性省略
  },
  a: 1,
  num: function num() {},
  b: 2, // 1、修改属性b的值
  c: undefined,
};
  • 第二步:修改变量对象属性 c 的值
AO = {
  arguments: {
    0: 1,
    length: 1,
    // ..... 其它属性省略
  },
  a: 1,
  num: function num() {},
  b: 2, // 1、修改属性b的值
  c: function () {},
};
  • 第三步:再次修改变量对象属性 b 的值
AO = {
  arguments: {
    0: 1,
    length: 1,
    // ..... 其它属性省略
  },
  a: 1,
  num: function num() {},
  b: 3, // 1、修改属性b的值
  c: function () {},
};

通过控制台,查看整个fn()函数执行完时,整个变量对象上的属性值

image-20221003205908484

# 3、外部环境(outer)

TIP

  • 其实,在 JS 中,每个函数都存在一个隐式属性[[scopes]], 这个属性用来保存当前函数的外部执行上下文中的变量对象身上的一些属性, 由于在数据结构上是链式的, 也被称为作用域链。
  • 只有当内部执行上下文中引用了外部执行上下文中的变量(AO 对象上的属性或方法)时,其外部执行上下文中变量对象的属性值才会被记录在个隐式属性[[scopes]]中,除全局执行上下文中的变量对象 window 外。
var a = 1;
function fn1() {
  var b = 2;
  var c = 3;
  var d = 4;
  function fn2() {
    console.log(b);
  }
  fn2();
}
fn1();

image-20221024172438524

重点提示:作用域链

  • 外部环境本质就是我们之前提到的作用域链,外部环境中记录了外部执行上下文中变量对象身上的一些属性和方法。
  • 当我们在变量查找时,如果当前执行上下文的变量对象上找不到,则会去当前执行上下文的外部上下文的 “变量对象”其是实闭包对象)上去查找。
  • 如果找到就用,找不到就会一直找到全局执行上下文的变量对象 window 身上。还找不到,就会报错。

# 3.1、变量查找过程

我们通过下面这个代码来分析,整个变量的查找过程

var a = 1;
function fn1() {
  var b = 2;
  var c = 3;
  var d = 4;
  function fn2() {
    var e = 5;
    console.log(e + b + a);
  }
  fn2();
}
fn1();

第一步:创建全局执行上下文

image-20221016230245841

第二步:执行全局上下文中的代码

TIP

  • 从上往下执行代码,首先变量 a 赋值为 1
  • 同时遇到fn1(),调用fn1()函数,创建函数执行上下文,并压入执行上下文栈

image-20221016233504445

第三步:执行 fn1 函数上下文中的代码

TIP

  • 首先给变量赋值 b=2c=3d=4fn1 = function(){....}
  • 同时遇到fn2(),调用fn2()函数,创建函数执行上下文,并压入执行上下文栈

image-20221016233557565

第四步:执行 fn2 函数执行上下文中的代码

TIP

  • 首先给变量 e 赋值,e=5
  • 然后执行 console.log(e+b+a);代码
  • 首先在当前作用域(执行上下文)中的词法环境中找变量 e,没有找到,再到变量环境的变量对象上找 e,找到e=5,并使用
  • 然后在当前作用域中的词法环境中去找变量 b,没有找到,则到变量环境中的变量对象上找变量 b,没有找到,则沿着外部环境去其外层的作用域中去查找。
  • 在外层作用域中查找时,也是先到词法环境中找,找不到再到变量环境中找,再找不到就再到其外部环境中去找,一层一层找,找到就用,找不到一直找到全局作用域中,还没找到,就报错。

变量 e,b,a 的查找流程图如下

image-20221016235829339

第五步:fn2 执行完,开始出栈

image-20221017000327368

第六步:fn1 执行完,开始出栈

image-20221017000404813

全局执行上下文要等整个页面关闭后才会被销毁,才会弹栈。

# 3.2、控制台演示

TIP

通过控制台,查看整个执行上下文创建,压栈,变量查找,弹栈的整个过程

image-20221017000055945

详细解读

  • 只有内部函数引用了外部函数中的部分变量,部分变量才会被保存在函数的[[scopes]]属性中
  • 内部函数在变量查找时,在自己作用域中找,找不到再到[[scopes]]属性中一层一层向下找
  • [[scopes]]属性中,本质记录的是全局作用域的变量对象 window 和每一次内部形成的闭包对象
var a = 1;
function fn1() {
  var b = 2;
  var c = 3;
  var d = 4;
  function fn2() {
    var e = 5;
    console.log(e + b + a);
    function fn3() {
      console.log(e, c);
    }
    fn3();
  }
  fn2();
}
fn1();

image-20221017001612732

关于闭包,在接下来就会讲到,往下看

# 4、词法环境

TIP

ES6 中利用 let 和 const 声明的变量,会放在词法环境中。
在变量查找时,首先会在词法环境中去查找,如果找不到,再到变量环境中查找。

var a = 1;
let b = 2;
const c = 3;
console.log(a + b + c);

image-20221017002500564

# 5、this 函数上下文

TIP

  • 在函数中,其内部this指向把函数当成方法调用的上下文对象
  • 这个之前详细讲过,在此略过不讲。大家可以参考下面的表格自己复习下
函数的调用方式 this 指向
对象.函数() 对象
函数() window
IIFE 立即执行函数 window
数组[下标]() 数组
call(对象,arg1,arg2) 对象
apply(对象,array) 对象
bind(对象,arg1,arg2) 对象
定时器中的回调函数 window
DOM 事件处理函数 添加事件监听的元素
new 函数() 对象的实例

# 四、闭包

TIP

至于 什么是闭包 ? 我们暂且先放下,我们通过两个案例来理解,什么是闭包,什么情况下会形成闭包。

# 1、形成闭包的条件

TIP

  • 内部函数访问外部函数的变量时,其内部就会形成闭包。
  • 但这种方式,并不能保持闭包,因为函数执行完就被销毁了,其闭包对象也被销毁。

下面这段代码,就会形成闭包

function fn() {
  var a = 1;
  var b = 2;
  function fn2() {
    // 内部函数访问了外部函数中的变量,这里候就形成了闭包,
    console.log(a);
  }
  fn2();
}
fn();

image-20221003222528230

闭包形成过程

  • 当调用fn()函数时,其内部的fn2函数引用了fn函数中的变量a,这时fn函数就会形成闭包。
  • 他内部创建了一个新对象,把内部函数用到的变量a和对应的值成为了这个新对象的属性和值,这个新对象就是我们说的闭包Closure

闭包带来的便利-方便变量查找

  • fn2()函数调用时,就会访问变量a,他首先会在自己作用域(执行上下文)中找,找不到
  • 然后就在闭包对象中去查找,找到中了变量a。最终在控制台输入 1
  • 假设没有闭包对象,那他要去外层作用域中找,外层作用域中如果有 100 个就变量,那要从 100 个变量中找到一个方便 ,还是把用到的那一个存好,直接拿来用方便呢?肯定是后者。

闭包销毁

  • 最后fn2执行完就销毁,其对应的闭包也就随着销毁
  • 所以这种情况下会形成闭包,但闭包不能被保持。所以我们很多时候讨论的闭包并不是这种情况。

但内部函数能快速访问到外部函数作用域中的变量,本质就是因为形成了闭包。

# 2、形成闭包的条件

TIP

  • 内部函数使用了外部函数的变量,同时被返回到了外部函数的外面,这时就会形成闭包
  • 主要表现在于,在外部执行被返回的函数时,可以访问他在定义时所处环境中的变量
  • 这种情况才是真正意义上的形成了闭包,因为闭包被保持下来,供后期使用
function fn() {
  var a = 1;
  var b = 2;
  function fn2() {
    console.log(a);
  }
  return fn2;
}
var fn3 = fn(); // 被赋值
fn3();

image-20221026181215538

闭包形成过程

  • 当代码执行到fn3=fn()时,fn()被调用了,因为fn2函数引用了fn函数中的变量a,这时fn函数就会形成闭包
  • 他内部创建了一个新对象,把内部函数用到的变量a 和对应的值成为了这个新对象的属性和值,这个新对象就是我们说的闭包(Closure

闭包是如何保持的

  • 然后 fn2 函数的隐式属性[[Scopes]]数组中,多了一个新的对象,这个对象指向上面 fn 创建出来的闭包。
  • 然后fn2被当成返回值,返回给到了变量fn3
  • fn()函数执行完,就被销毁了,但是他创建的闭包并没有销毁,一直存在内存中
  • 因为fn3在何时调用,调用多少次这个说不定,只要 fn3 被调用,就会执行 fn2 中的代码,就会访问变量 a,所以 fn 函数形成的闭包并不会随着 fn 函数的销毁而被销毁,而是一直存在于内存中。

闭包带来的便 利-函数体外可以访问函数内部的变量

  • 只要我们执行fn3,就相当于执行fn2中的代码,就会访问变量a,他在当前fn2的作用域中找不到,就会去他的隐式属性[[Scopes]]即作用链中去查找,因为之间有闭包存在,所以他会先在闭包中找。找到了a,就打印出来。

# 3、总结闭包形成的两种情况

情况一:

当内部函数访问了外部函数的变量时,就会形成闭包,但这种情况下闭包不能保持,内部函数执行完,闭包就销毁了。

情况二

内部函数使用了外部函数的变量,同时被返回到了外部函数的外面,这时就会形成闭包。这种情况下闭包能被保持,一直在保存在内存中。被返回到外部的函数,不管何时执行,执行多少次,都可以访问到他在定义时所在作用域中的变量。

我们通常说说的闭包,指的是第二种情况下形成的闭包,因为第一种情况没有办法保持。

接下来我们来看下闭包的定义

# 4、什么是闭包

TIP

  • 闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment词法环境)的引用的组合。
  • 换而言之,闭包让开发者可以从内部函数访问外部函数的作用域
  • 在 JavaScript 中,闭包会随着函数的创建而被同时创建。

以上定义来自 MDN 官方文档 (opens new window)

image-20211224231948096

闭包

可以理解为是函数的一种性质,他能记住他在声明时所处的环境状态。
那么不管后来函数在什么第方被调用,他都能访问他在定义时所处作用域中的变量。

# 5、闭包与作用域链的关系

TIP

  • 每个函数身上都有一个[[Scopes]]属性, 这个属性用来保存当前函数所有用到的闭包对象和全局执行上下文对象。
  • 当内部函数引用了外部函数中的变量时,就会形成闭包,这个闭包对象中保存了内部函数引用外部函数中的那些变量。 当前函数身上的[[Scopes]]属性中,保持了对这个闭包对象的引用。
  • 作用域链查找,本质就是先在当前作用域中找,如果找不到,就会去函数的[[Scopes]]属性中保存的闭包对象和全局对象中去找。

# 6、闭包的用途

TIP

闭包有两大特性:记忆性、模拟私有变量

  • 记忆性: 当闭包产生时,函数所处环境的状态会始终保持在内存中,不会在外层函数调用后被自动清除,这就是闭包的记忆性。
  • 模拟私有变量: 我们可以把一些不需要的全局变量封装成 ”私有变量“。

# 6.1、闭包记忆性案例

TIP

创建提问检测函数 checkTemp(n),可以检查体温 n 是否正常,函数会被返回布尔值,体温正常会返回true,体温不正常会返回false

但,不同的小区有不同的体温检测标准:

比如:A 小区体温合格线是 37.1 ℃,而 B 小区体温合格线是37.5 ℃,应该怎样编程呢 ?

// 闭包的记忆性(同时这样的函数也叫:高阶函数或偏函数,未来会学习)
function createCheckTemp(standardTemp) {
  function checkTemp(n) {
    if (n <= standardTemp) {
      console.log("你的体温正常");
    } else {
      console.log("你的体温偏高");
    }
  }
  return checkTemp;
}

// 创建一个checkTemp函数,它以37.3度为标准线
var checkTemp_A = createCheckTemp(37.3);
// 再创建一个checkTemp函数,它以37.0度为标准线
var checkTemp_B = createCheckTemp(37.0);

checkTemp_A(37.1); // 你的体温正常
checkTemp_A(37.8); // 你的体温偏高

checkTemp_B(36.5); // 你的体温正常
checkTemp_B(37.1); // 你的体温偏高

闭包记忆性案例:查询你的工资是否高于本城市的平均工资

function checkSalary(salary) {
  function check(mySalary) {
    if (mySalary >= salary) {
      alert("你的工资高于平均工资");
    } else {
      alert("你的工资代于平均工资");
    }
  }
  return check;
}
var citySalary = checkSalary(10000);
citySalary(8000);
var citySalary2 = checkSalary(7000);
citySalary2(8000);

# 6.2、模拟私有变量

题目:

请定义一个变量 a,要求是能保证这个 a 只能被进行指定操作(如:加 1、乘 2),而不能进行其他操作,应该怎么编程呢 ?

在 Java、C++等语言中,有私有属性的概念,但是 JavaScript 中只能用闭包来模拟。

// 封装一个函数,这个函数的功能就是私有化变量
function fun() {
  // 定义一个局部变量 a
  var a = 0;

  return {
    getA: function () {
      return a;
    },
    add: function () {
      a++;
    },
    pow: function () {
      a *= 2;
    },
  };
}

var obj = fun();
// 如果想在fun函数外边使用变量a,唯一的方法就是调用getA()方法
console.log(obj.getA()); // 0
// 如果需要变量+1只能调用add方法
obj.add();
obj.add();
obj.add();
console.log(obj.getA()); // 3
obj.pow();
obj.pow();
obj.pow();
console.log(obj.getA()); // 24

# 五、垃圾回收 GC

TIP

深入浅出垃圾回收,JavaScript 中垃圾回收的策略,闭包与内存管理等。

# 1、什么是垃圾回收(Garbage Collection)?

TIP

  • 在现实生活中,所谓的垃圾,就是指用过了,不会再用的东西,就可以当成垃圾被处理掉。
  • 在 JS 中,所谓的垃圾,你可以理解为那些不会再被使用的数据,就会被当成垃圾回收掉
  • JavaScript 中 JS 引擎会自动回收不再使用的变量,释放其所占的内存,开发人员不需要手动的做垃圾回收的处理。

但最艰难的任务是找到那些变量将不会再使用,释放其“占用的内存”

我们来看下面几个例子,分析下,其中的变量否会被当成垃圾回收掉

  • 下面代码执行完后,其变量 a 和 obj 还会占用内存空间吗?
function fn() {
  var a = 1;
  console.log(a);
  var obj = {
    name: "张三",
    age: 23,
  };
  console.log(obj);
}
fn(); // 执行函数

// 上面fn()函数执完后,变量中的a 和obj就会被销毁掉,不会再占用内存的空间了。
// 当然垃圾回收,并不会立刻马上就回收,他可以马上,也可以会等一会儿,但时间不会太久
// 函数执行完,里面的数据都不会再被其它对象引用,也就会当成垃圾被处理掉
  • 下面代码执行完后,其变量 a 和 obj 还会占用内存吗 ?
function fn() {
  var a = 1;
  var obj = {
    name: "张三",
    age: 23,
  };
  window.a = a;
  window.obj = obj;
}
fn(); // 执行函数
// fn()函数执行后,变量a和obj被赋值给了window对象的属性,也就是全局对象window保持了对变量a和obj的引用。说不定什么时候我们就可以需要用到window.a和window.obj
// 所以这种情况下 变量a被销毁,但是window.a上的a属性和obj中的引用对象并不会被销毁
// 但这里的obj和window.a并不是垃圾,因为我们在后面还需要用到他。
// 以上变量占用内存,是符合用户预期的
  • 下面代码执行完后,a 和 b 还会占用内存吗 ?
function fn() {
  var a = 1;
  var b = 2;
  function sum() {
    console.log(a + b);
  }
  return sum;
}
var s = fn(); // 调用函数
s(); // 调用函数

// 上面代码执行完后,a,b并不会被销毁会,因为形成了闭包,我们不知道什么进候,我们还会调用s();
// 如果我们把变量a,b销毁了的话,那我们后面如果要调用s(),那不就会报错吗?
// 这种情况下闭包就会造成变量不能被销毁,一直占用内存。那这算是内存泄露吗?
// 这种情况,我们是有意想要形成闭包,人为的希望局部变量a和b能一直保存在内存中,所以不能算内存泄露。

总结:

  • 如果某些数据我们未来还有可能会用到,那么他一直占用内存是符合用户期望的,并不能算垃圾,所以也不能当成垃圾回收掉。
  • 只有那些被执行完,未来不可能再用到的数据,就是垃圾,就可以当成垃圾被回收掉。

那 JS 是如何判断那些数据未来永远都不可能用到呢 ?然后把他当成垃圾回收掉呢 ?

# 2、JS 中垃圾回收的两种策略

TIP

垃圾回收主要有两种策略,标记清理引用计数

引用计数

  • 引用计数其实是一种比较老的垃圾回收策略
  • 引用计数就是追踪被引用的次数。
  • 声明变量并给它赋一个引用类型值时,这个值的引用数为 1,如果同一个值又被赋给另一个变量,那引用数+1
  • 如果保存该值引用的变量被其它值覆盖了,则引用数减 1
  • 当引用计数为 0 时,表示这个值不再用到,垃圾收集器就会回收他所占用的内存。
var a = [1, 2, 3]; // [1,2,3]的引用计数为1
var b = a; // 变量b也引用了这个数组,所以[1,2,3]的引用数为2
var a = null; // [1,2,3]的引用被切断,引用数-1,所以[1,2,3]的引用数为1
// 如果只是到这里,那[1,2,3]不所占的内存不会被回收
var b = null; // [1,2,3] 的引用被切断,引用数-1,所 [1,2,3]的引用数为0
// 到这里,垃圾收集器在下一次清理内存时,就会把[1,2,3]所占的内存清理掉

引用计数有一个很大的坑,就是循环引用时,会造成内存永远无法释放。

function fn() {
  var obj1 = {};
  var obj2 = {};
  obj1.a = obj2;
  obj2.a = obj1;
}
fn();
// 这种情况下,fn函数执行完后,其内部的obj1和obj2已经没有用了,可以被回收了。
// 但引用计数统计到他们引用数>0,则 obj1和obj2就没有办法被清理了,因为引用数永远不可能为0

image-20221026193122906

fn 执行上下文在代码执行完后,就出栈,意味着 obj1 与 obj2 被销毁,不会再有指向堆内存中的引用

但是堆内存中的数据,自己指向自己是有引用的,所以永远都不会被销毁

标记清理

  • 这个算法假定设置一个叫做根(root)的对象(在 JavaScript 里,根是全局对象 window)
  • 垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……
  • 从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。
  • 那些无法从根对象查询到的对象都将被清除

如果用标记清理的方式来处理垃圾回收,则就不会出现上面循环引用的问题,造成垃圾不能回收了

因为函数调用之后,两个对象 obj1 和 obj2 都无法从全局对象出发获取。因些,他们将会被垃圾回收器回收掉。

从 2012 年起,所有现代浏览器都使用了标记 - 清除垃圾回收算法

# 3、手动标记垃圾

TIP

  • 通过上面两种垃圾加收的策略,我们知道,在全局作用域中的变量永远都可以从全局对象上获取到。所以永远不会自动回收。
  • 所以我们在写代码时,尽量要避免不要把一些变量设置为全局变量,如果实在要设为全局变量,那我们使用完后不再需要,那我们就需要手动将其标注为垃圾,让垃圾回收器回收掉。
  • 手动标记垃圾的方式,本质就是切断引用,常用的方式就是把变量的值重新赋值为 null
var obj = {
  name: "张三",
  age: 23,
};
// 标记为垃圾,垃圾回收器就会自动回收掉内存中的 { name:'张三',age:23} 这个对象占用的空间
obj = null;

# 4、总结

TIP

所谓的垃圾可以理解为,非用户预期的内占存用,那么这些占用内存的数据就可以理解为垃圾,应该回收掉。如果是用户预期的内存占用,那都不能算是垃圾。

如果有些变量我们不再需要,而垃圾回收器无法识别,那我们就可以手动将其标记为垃圾。即把变量的值起来 null

# 5、闭包与内存管理

TIP

我们经常听说闭包会造成内存泄露,所谓内存泄漏是指程序中已动态分配的内存由于某种原因未释放或无法释放

那闭包会造成内存泄露吗 ? 我们来看下这段代码:

// 设置当地的一个参考分数线,然后输入你的分数,查看是否超过分数线
function compare(score1) {
  return function (score) {
    if (score1 > score) return "分数线过底不达标";
    return "恭喜,分数线超过一本";
  };
}
// 北京1本录取分数线
var fn = compare(530);
// 小明的分数是540
console.log(fn(540));
// 小线分数
console.log(fn(480));

代码解读

  • 上面代码中的 score1 变量在页面没有关闭前,永远都不会被销毁
  • 因为内部函数作为返回值被返回,同时内部函数引用了变量 score1,所以就形成了闭包。闭包对象中包含了属性 score1,
  • 但是,我们使用闭包,本质也是为了用他的这个特性,希望局部变量能被保存在内存中,不要销毁。如果从这个角度来看,闭包并不能说会造成内存泄露。

本质上闭包是有意的将变量保存在内存中,是用户预期的内存占用,所以不能算是内存泄露。

如果因为不小心误用了闭包,而造成某些数据一直占用内存而不能被回收,那就可以理解为因为误用闭包而造成的内存泄露。因为闭包中的数据,肯定是不能被垃圾加收的。

总结:

不能滥用闭包,否则会造成网页的性能问题,严重时可能会导致内存泄漏

关于如何检测内存泄露和在实际中那些情况会存在内存泄露,在后续的课程中再讲。

# 6、区分内存泄露和内存溢出

TIP

  • 内存泄露: 是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。
  • 内存溢出: 是指程序在申请内存时,没有足够的内存空间供其使用,内存不足。

# 六、IIFE 立即执行函数

TIP

接下来我们学习一种特殊的方式来调用函数,那就是 IIFE 立即执行函数

# 1、什么是 IIFE 立即执行函数?

TIP

  • IIFE (Immediately Invoked Function Expression)(立即调用的函数表达式
  • 是一种特殊的 JavaScript 函数写法,一旦被定义,就立即被调用

语法:

声明一个匿名函数,也就是没有名字的函数,然后用()把匿名函数转为 “函数表达式”,然后再调用

// 写法一
(function () {
  // 函数体语句
})();

// 写法二
(function () {
  // 函数体语句
})();
(function () {
  console.log("立即执行函数");
})();

(function () {
  console.log("立即执行函数");
})();

温馨提示:

我们之前说,直接用 function 声明的函数称为函数声明,那这里为什么称为函数表达式呢 ?

是因为()括的功能,他将函数变为了表达式,然后()括号后面的()括号,表示执行函数

// 以下是错误写法
// 函数不能直接加圆括号被调用
function(){
    // 函数体语句
}();

# 2、形成 IIFE 的其它方法

TIP

  • 除了用()包裹函数声明,将函数声明转为“函数表达式”之外
  • 我们还可以在函数声明前添加 - 或 +号,来将函数声明转为“函数表达式”,然后再调用
(function () {
  // 函数体语句
})();

+(function () {
  // 函数体语句
})();

-(function () {
  // 函数体语句
})();
+(function () {
  console.log("我被调用直接执行");
})();

-(function () {
  console.log("我被调用直接执行");
})();

# 3、IIFE 的作用 - 为变量赋值

TIP

  • 当我们给变量赋值时,其值需要一些较为复杂的计算才能得到,这时候就可以用立即执行函数来实现
  • 使用 IIFE 显得语法更紧凑
// 获取一个随机颜色
var color = (function () {
  var arr = ["red", "pink", "skyblue", "khaki"];
  var i = (Math.random() * arr.length) >> 0;
  return arr[i];
})();
console.log(color);

以上写法,函数不会被其它对象引用,也不能在其它地方被执行。

如果你的某个函数只是为了一次求值,其它地方也不会再使用他,则可以用 IIFE 来实现。

var age = 22;
var sex = "男";
// 获取身份 定义IIFE立即执行函数来实现
var status = (function () {
  if (age < 18) {
    return "小朋友";
  } else {
    if (sex == "男") {
      return "先生";
    } else {
      return "女士";
    }
  }
})();

console.log(title); // 先生

# 4、IIFE 的作用 - 将全局变量变为局部变量

TIP

在很多情况下,我们希望将全局变量转为局部变量保存起来。

我们来看下面几个情况

var arr = [];
for (var i = 0; i <= 5; i++) {
  arr.push(function () {
    console.log(i);
  });
}
arr[0](); // 6
arr[1](); // 6
arr[2](); // 6
arr[3](); // 6
arr[4](); // 6
var arr = [];
for (var i = 0; i <= 5; i++) {
  // IIFE  本质是,在每一次循环,形成了一次闭包
  (function (i) {
    arr.push(function () {
      console.log(i);
    });
  })(i);
}
arr[0](); //0
arr[1](); //1
arr[2](); //2
arr[3](); //3
arr[4](); //4

# 5、arguments.callee

TIP

arguments 对象身上有一个 callee 属性,是指向 arguments 对象所在函数的指针。

通过arguments.callee能获取到 arguments 对象所在的函数。arguments.callee已经被弃用,不应该再使用了,这里只当了解。

function sum() {
  console.log(arguments.callee);
}
sum();

// 求一个数的阶乘
function factorial(n) {
  if (n == 1) return 1;
  return n * arguments.callee(n - 1);
}
console.log(factorial(5));

arguments.callee 的作用

当我们需要在一个匿名函数内部,调用这个函数自身时,他就非常有用了

// 输入5的阶乘
var n = (function (n) {
  if (n == 1) return 1;
  return n * arguments.callee(n - 1);
})(5);
console.log(n);

通常在递归调用匿名函数时,就可以用argument.callee来找到匿名函数

上次更新时间: 6/8/2023, 9:23:17 PM

大厂最新技术学习分享群

大厂最新技术学习分享群

微信扫一扫进群,获取资料

X