一、前置基础:先搞懂「调用栈」与「作用域链」
1.调用栈
下面通过各个示例来逐步了解调用栈的规则
示例一:
function fn()
{
fn();
}
fn();
简单描述一下这个示例: 我们定义了一个fn()函数体,其内部唯一的操作是递归调用自身,然后在函数定义后立即执行了该函数。
这段代码的编译执行过程: 根据先编译后执行的原理,首先在栈里先创建全局执行上下文对象,可见全局执行上下文对象只有fn()这一函数体,编译完成以后就是执行,执行fn(),这个时候栈里现在得创建一个建函数的执行上下文对象,但是fn()这一函数里没有变量声明、没有形参实参、没有函数体声明。所以直接执行,而执行语句是调用fn(),所以又是在栈里创建fn()函数的执行上下文对象,以此后推,所以只要遇到fn()就会在栈里面创建一个函数执行上下文对象。(可以说这段代码就是永不休止的)
知识点: 栈(Stack)在计算机系统中是有 固定大小限制 的,其大小取决于具体的环境(如操作系统、编程语言、编译器等)。
结果:
该结果显示栈溢出
原因: 因为栈是有固定大小限制的,而这段代码是无止境的添加fn()的函数执行上下文对象,所以最后会导致栈溢出。(类比:好比一个水龙头无止境向一个有一定容量的桶子里滴入水,最后一定会导致桶子里的水溢出)
思考问题
上面代码比较极端,因为是死循环,一定是会栈溢出,下面我们来个可能不会溢出的代码
function runStack(n){
if(n===0){
return 100;
}
return runStack(n-2);
}
runStack(50000);
这段代码仍然溢出,但是我们把runStack(500),它就不会显示溢出,这就说明只要栈足够大,面对很多次函数自我调用仍然不会栈溢出。
提出问题:栈的容量是不是越大好?
栈不能设计太大,否则js引擎在查找上下文会花费大量时间,执行效率太低,会导致 内存浪费、性能下降、系统不稳定 等问题,本质是用 “空间换深度”,但这种交换往往得不偿失。现代软件开发更倾向于通过 算法优化 和 堆内存管理 替代依赖大栈的设计。(类比:我们裤子口袋的深度为什么不设到脚跟位置呢?当我们取最底下的东西是不是不太方便? 执行在当下上下文对象中找不到自己需要的变量,讲个极端的只能在全局上下文对象中找到,那就得经过很多层的上下文对象,会大大增加寻找效率)
示例二:
var a=2;
function add(){
var b=3;
return a+b;
}
var c=add()
console.log(c)
简单描述一下这个示例: 这段代码定义了一个全局变量 a 和一个函数 add,然后调用该函数并将结果存储在变量 c 中,最后打印输出结果。
这段代码的编译执行过程:
简单描述过程: 先编译后执行,先创建全局执行上下文,里面有变量环境和词法环境(这两个概念后续有),先是a的变量声明,再是函数add()的声明,这就编译完毕了,接下来执行代码,先将a赋值为 2,然后执行add(),这个时候在栈里创建函数上下文对象,编译函数体里的内容,依据前面内容,最后给b赋值为10,最后返回 a+b的值(12)。这个时候函数体add()就执行完毕,那么重点来了! add()的函数上下文对象就会从栈里移除掉。
结果:
所以我们通过上述描述到底要学到什么呢?
了解了编译执行的规则:先编译,再执行 编译全局 》执行全局 》编译函数 》执行函数
重要知识点: 一个函数执行完后它的执行上下文会被销毁。
示例三:
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执行上下文来讲
该执行上下文分为了两部分,变量环境和词法环境
变量环境与词法环境的区别
| 对比项 | 变量环境(Variable Environment) | 词法环境(Lexical Environment) |
|---|---|---|
| 存储内容 | var 声明的变量和函数声明 | let/const 声明的变量 |
| 作用域类型 | 函数作用域 | 块级作用域({} 内有效) |
| 变量提升 | 变量提升至作用域顶部,初始值为 undefined | 存在 TDZ,声明前访问报错 |
| 重复声明 | 允许重复声明(后声明覆盖前声明) | 不允许重复声明(SyntaxError) |
| 闭包实现 | 依赖外部词法环境的引用 | 直接存储闭包捕获的变量 |
根据上述知识点接下来对foo()执行上下文进行分析,解释在代码注释中;
function foo(){
var a = 1; //var定义将a放在变量环境中
let b = 2;//let定义将b放在词法环境中
{
let b = 3;//因为{}使用了let所以形成了块作用域,在词法环境独自创建一个空间存储,与块外面的变量b区别,b、d在一个块作用域中。
var c = 4;//var定义将c放在变量环境中
let d = 5;
console.log(a);//如图所示由于a在词法环境没有找到,就到变量环境中寻找,所以a=1
console.log(b);//b在当前块作用域就寻找到了所以b=3,不会继续寻找下去。
}//至此块的作用域就执行完毕了,这个块作用域就要出栈
console.log(b);//由于前面块作用域被销毁,所以在当前作用域中b=2
console.log(c);//c可以在变量环境中找到
console.log(d);//因为块作用域被销毁所以没有定义d,这时候程序报错
}
这个示例是要告诉我们什么呢
1. 词法环境相当于一个栈,{}里有let、const则单独形成了块级作用域
2. 在执行上下文对象时,寻找变量顺序是先在词法环境中自上而下去寻找,如果没有,才会到变量环境中寻找
3. 在词法环境中应注意当块作用域执行完毕会将其中的内容全部销毁(相当于出栈)
最重要的一点:一个函数执行完后它的执行上下文会被销毁
2.作用域链
下面通过各个示例来逐步了解调用栈的规则
示例一:
function bar() {
console.log(myname);
}
function foo() {
var myname = '典典';
bar();
}
var myname = '傅总';
foo();
试着去猜猜这段代码打印的是什么
根据画栈图去理解
错误理解: 我们一般会觉得bar的上层作用域在foo()中
引入知识点: 作用域链的下一级是谁,是由outer指针指向的,而outer指针,指向外层作用域就是定义函数的地方。(如该代码:bar()和foo()都是在全局作用域定义的,所以他们的上层作用域就是全局作用域)
正确理解: bar的上层作用域是在全局作用域中
二、终于来到了重头戏“闭包”
什么是闭包?
根据作用域链的查找规则,内部函数一定有权力访问外部函数的变量。另外,一个函数执行完后它的执行上下文会被销毁。那么当函数A内部声明一个函数B,而函数B被拿到函数A外部执行时,为了保证以上两个规则正常执行,A函数在执行完毕后会将B需要访问的变量保存在一个集合当中,并留在调用栈当中,这个集合就是闭包。
示例一:
function foo(){
var myname = '刘洋'
var age = 18
return function bar(){
console.log(myname)
}
}
var baz = foo()
baz()
栈分析:
冲突体现: 在执行baz()时,由于foo()已经执行完毕了所以foo()的执行上下文就已经出栈了,而baz要去寻找myname的值,它在自己的作用域找不到,所以要到它的上层作用域寻找,而它的上层作用域是foo(),但是它已经被销毁了,与“作用域链的查找规则,内部函数一定有权力访问外部函数的变量”相矛盾。
闭包的出现 闭包的出现就是为了解决上诉的矛盾,一个函数执行完后它的执行上下文会被销毁。那么当函数A内部声明一个函数B,而函数B被拿到函数A外部执行时,为了保证以上两个规则正常执行,A函数在执行完毕后会将B需要访问的变量保存在一个集合当中,并留在调用栈当中,这个集合就是闭包。(可以将闭包比喻成背包)单独开辟空间去存储有用的资源。
使用闭包的优缺点
| 维度 | 优点 | 缺点 |
|---|---|---|
| 内存管理 | 可封装私有变量,避免全局污染(如模块模式) | 延长变量生命周期,可能导致内存泄漏(如未释放的事件监听器闭包) |
| 性能 | 适用于需要持久化状态的场景(如计数器、缓存) | 作用域链查找开销大,高频调用时性能下降明显 |
| 代码灵活性 | 实现函数私有状态(如闭包计数器)、函数柯里化、事件绑定保留上下文 | 多层嵌套闭包导致作用域链复杂,变量来源不直观,调试困难 |
| 模块化 | 实现单例模式、模块封装(如立即执行函数表达式) | 过度使用闭包会导致代码晦涩,维护成本增加 |
| 数据安全 | 保护变量不被外部访问和修改(私有变量) | 闭包持有外层变量引用,可能意外暴露敏感数据 |
三、总结
-
调用栈:管理函数调用的上下文,有固定大小限制,递归过深会导致栈溢出。
-
作用域链:由函数定义时的词法环境决定,通过
[[OuterEnvironment]]指针形成层级链。 -
闭包:通过保留外部函数的变量环境,实现私有状态的持久化,但需注意内存泄漏和性能问题。
建议:合理使用闭包,优先通过迭代替代递归,避免滥用导致的内存和性能隐患。