JavaScript核心原理三部曲:《 闭包原理深度解析:带你看懂闭包》

592 阅读6分钟

前置知识

声明提升

  • 引擎会先编译代码,在执行(简单理解v8引擎不按代码的顺序执行代码)

块级作用域

let,const 结合{}

词法作用域

一个函数的词法作用域就是函数声明的位置,outer代表了词法作用域,outer指向的是外层作用域

作用域链

  • js 引擎在查找变量时,会从当前作用域查找,如果没有,就会向上一级作用域查找,直到找到为止或则直到全局作用域为止,

  • 作用域链下一级是谁,而不是单纯的由出栈顺序决定的,是由outer指针决定的(outer在每个变量环境都存在,在v8编译的时候就已经加入),outer代表了函数的词法作用域,outer的位置在函数声明的位置 。

词法环境

词法环境是被专门创建来存储 let const声明的变量,let const用来解决var 不科学的执行现象,微观来看也是一个栈结构

调用栈

  • js 引擎追踪函数的一个机制,管理一份代码的执行关系

  • 调用栈不能设计的太大,否则,js 引擎在查找上下文时会花费大量时间

面试官爱问的:可以不可以把栈设计的非常大?

fuction a(){
  function b(){
    fuction c(){
      ...
    }
  }
  b()
}
a()

假设嵌套很多个函数... 栈大小设计合适,使得程序猿写代码必须简洁

来看下面这段代码:

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);
}

QQ20250605-112051.png

第一步,全局编译,执行代码 ,进入函数fn()。第一步创建函数执行上下文对象,没有形参只寻找变量名,a:undefined,会进入变量环境,b:undefined,进入到词法环境(let声明),到了{}的是时候,小伙伴是不是有些懵呢,这里涉及前面讲到的块级作用域,注意只有let+{}声明的是块级作用域,不是let声明的不是块级哟,像这里的 var c=4就不是块级作用域的所以全局查找变量的时候会找到c:undefined放在变量环境,d:undefined,没有函数声明,直接到执行阶段。这时执行5行b:3,c:4,d:5 ,打印a的时候,块级作用域里找不到,会向外部作用域找,找到a:1,第7行d:5,第8行打印a:1 ,第九行打印b:3,之后将块作用域退出词法环境销毁,执行第11行打印b:2,词法环境,栈空。执行第13行打印,此时发现没有d的声明报错,因为d声明在之前的词法作用域销毁的时候一起销毁。

QQ20250605-125157.png

作用域链

function bar(){
  console.log(myname);
}
function foo(){
  var myname="pp"
  bar()
}
var myname='ww'
foo()

QQ20250605-140656.png 这里需要注意的是函数的词法作用域位置是由函数声明决定的而不是函数调用bar()函数执行的时候会通过outer指向外层作用域找到全局作用域的var myname:ww,而不是从栈从上往下,所以这里输出的是ww,而不是pp

面试官爱问的:

var arr = [];
for (var i = 0; i <= 5; i++) {
  arr.push(function () {
    console.log(i);
    
  });
}

for (var j = 0; j < arr.length; j++) {
  arr[j]();
}

屏幕截图 2025-06-05 143919.png

开始预编译的时候,for循环在数组里面放进去的是五个函数体并且没有触发,for循环到5的时候,还满足条件,运行完之后,i到了6,结束for循环,结束预编译,到执行阶段,下面的for循环调用数组里的函数,这时,触发函数,函数里i的取值为预编译结束后的全局i的值,值为6,所以循环输出五个6,你懂了吗? 那你知道怎么解决吗,可以用let,这样let和{}形成块级作用域,在词法环境中形成五个i值,每次循环一个i值,这样,在函数被调用的时候就会依次打印1,2,3,4,5

屏幕截图 2025-06-05 195520.png

闭包

-根据作用域链的查找规则,内部函数一定有权力访问外部函数的变量。另外,一个函数执行完毕后它执行的上 下文一定会被销毁。那么当函数 A 内部声明 了一个函数 B,而函数 B 被拿到 A 的外部执行时。为保证以上两个规则正常执行,A 函数执行完毕后会将 B 需要访问的变量保存在一个集合中,并留在调用栈 当中,这个集合就是闭包。

具体到底是什么,我们来看这段代码:

function foo() {
  var myname = "pp";
  var age = 18;

  return function bar() {
    console.log(myname);
  };
}
var baz = foo();
baz();

image.png

QQ20250605-212108.png 首先我们知道当foo函数执行完毕的时候,它在栈的内存会被销毁,对吧,那么,当return的时候,foo 函数算不算是执行完毕呢,是算的,这个时候我们在外面调用foo函数接收它返回的函数体,执行打印,这个时候麻烦了,myname的变量都被销毁了,我怎么知道它是什么,可我们还知道一条铁律,内部函数作用域是一定可以访问外部函数作用域的,你会说这不冲突了吗,是的,正是冲突了,才引进了闭包,同时满足两个条件又不冲突,解决方式就是上面提到的,每个执行上下文对象都会有一个outer,outer会指向它的外部作用域,而它的外部作用域,即foo()函数被销毁时,会创建一个集合,来保存bar()函数需要的变量,当foo()函数销毁,原本bar()指向foo()的outer,会自动的指向这个集合访问到需要的变量,这就是闭包

还是这段代码请通过闭包解决

var arr = [];
for (var i = 0; i <= 5; i++) {
  arr.push(function () {
    console.log(i);
    
  });
}

for (var j = 0; j < arr.length; j++) {
  arr[j]();
}

那怎么运用闭包解决呢,解决思路:现在有函数执行上下文对象,它的outer指向全局上下文对象,没有其他执行上下文对象,想要闭包,我们就需要两个函数执行上下文对象,那我们就创建一个新的函数执行上下文对象在原来的函数执行上下文对象的外面,这里的旧函数就被拿到fn外面使用了,并且当新的函数执行上下文对象销毁的时候,因为旧函数的outer会自动的由指向新函数转而指向保存变量而创建的集合,这样当旧函数执行上下文对象被调用执行时,就会从已被销毁的新函数执行上下文对象的集合找到需要的变量。

代码如下:

var arr = [];
for (let i = 1; i <= 5; i++) {
  function fn(j) {
    arr.push(function () {//相当于拿到fn外面
      console.log(j);
    });
  }
  fn(i);
}

for (var j = 0; j < arr.length; j++) {
  arr[j]();
}

QQ20250605-223123.png 循环的每次,旧函数(这里的匿名函数)都会有一个outer指向一个集合来保存每次的变量

  • 缺点:内存泄漏