面试官:说说闭包吧。我:那还要从作用域说起
前言
面试中经常会问及闭包,以及在实际开发过程中我们也接触过很多闭包,为了更好的解闭包、使用闭包,全面的了解闭包的过往今来还是很有必要的。
为了更全面的分析闭包,我们在上一章学习了执行上下文的相关知识,有了一定的知识储备后,再来看本篇的作用域和闭包,或许能给你带来不一样的思考。
本文为面试专题闭包相关之作用域与闭包。
作用域
简单来说,作用域
指程序中定义变量的区域,它决定了当前执行代码对变量的访问权限。
JavaScript 中大部分情况下,只有两种作用域类型🤓:
- 全局作用域:全局作用域为程序的最外层作用域,一直存在。
- 函数作用域:函数作用域只有函数被定义时才会创建,包含在父级函数作用域 / 全局作用域内。
/* 全局作用域 开始 */
var a = 1;
function fn () { /* fn 函数作用域 开始 */
var a = 2;
console.log(a);
} /* fn 函数作用域 结束 */
fn(); // => 2
console.log(a); // => 1
/* 全局作用域 结束 */
作用域链
从刚才的用列中我们发现,函数是能够从自己内部作用域中找到变量a
的;对于在函数内部没有找到变量的情况是怎样的?
/* 全局作用域 开始 */
var a = 1
var b = 2
function fn(){ /* fn 函数作用域 开始 */
var c = 3
console.log(a + b + c) // 6
} /* fn 函数作用域 结束 */
/* 全局作用域 结束 */
我们可以发现的是,函数在内部未找到变量 a
、b
时,它会从所在的上级作用域查找。
这看起来有点像集合的包含概念,本地作用域 => 父级作用域 => 全局作用域
找到目标直接返回,否则会一直向上查找,而这就是所谓的作用域链。
👀注:函数参数是在函数作用域中的
function fn(a){ console.log(a) }
这里的a
是属于函数fn
的作用域中的
块级作用域
简单来说,花括号内 {...}
的区域就是块级作用域区域。
👀注:JavaScript 不是原生支持块级作用域的
ES6标准之后let
、const
创建的为块级作用域
测试代码:
if (true) {
var a = 1;
}
console.log(a); // 1
if (true) {
let a = 1;
}
console.log(a); // ReferenceError
词法作用域
在了解了作用域和作用域链之后,我们继续分析动态作用域和静态作用域。
先看个用例,然后简单思考下,JavaScript使用的是哪种?
var value = 1;
function foo() {
console.log(value);
}
function bar() {
var value = 2;
foo();
}
bar();
// 结果是 ???
先说结果,打印值是 1
再说结论,JavaScript是采用词法作用域的,与之相对的是动态作用域(bash
是动态的)。
- 词法作用域,也被称为 “静态作用域”,意味着函数被定义的时候,它的作用域就已经确定了,和拿到哪里执行没有关系。
- 这就意味着函数的执行依赖于函数定义的时候所产生(而不是函数调用的时候产生的)的变量作用域。
核心就是: 函数的作用域在函数定义的时候就决定了。
为了去实现这种词法作用域,JavaScript函数对象的内部状态不仅包含函数逻辑的代码,除此之外还包含当前作用域链的引用。函数对象可以通过这个作用域链相互关联起来,如此,函数体内部的变量都可以保存在函数的作用域内,这在计算机的文献中被称之为闭包。
严格意义上所有的函数都是闭包😮。
需要注意的是:我们常常所说的闭包指的是让外部函数访问到内部的变量,也就是说,按照一般的做法,是使内部函数返回一个函数,然后操作其中的变量。这样做的话一是可以读取函数内部的变量,二是可以让这些变量的值始终保存在内存中。
闭包
在前面我们说到了,函数是可以向外查找父作用域的变量,而不能向内查找子函数作用域内的变量,这是不允许的。我们调整下代码:
/* 全局作用域 开始 */
var a = 1
var b = 2
function fn(){ /* fn 函数作用域 开始 */
var c = 3
console.log(c + d)
function foo() { /* foo 函数作用域 开始 */
var d = 4
} /* foo 函数作用域 结束 */
} /* fn 函数作用域 结束 */
/* 全局作用域 结束 */
如果你尝试了,控制台会输出 Uncaught ReferenceError: d is not defined
这样一个错误。
现在我们再来看一下什么是闭包?
闭包是指有权访问另一个函数作用域中变量的函数,创建闭包的最常见的方式就是在一个函数内创建另一个函数,创建的函数可以访问到当前函数的局部变量。
也可以这样理解,闭包就是函数内部定义的函数,被返回了出去并在外部调用。
这样看,闭包的这种变体是不是就相当于,可以从外部获取一个函数内部的变量信息
👀注意:这里是一个类比。
不过也正是因为闭包的这种特性,才极大的丰富了开发人员的编码方式。
闭包有两个常用的用途:
- 创建私有变量:闭包使我们在函数外部能够访问到函数内部的变量,使用闭包,可以在外部调用闭包函数,从而在外部访问到函数内部的变量
- 保留变量对象在内存中:使已经运行结束的函数上下文中的变量对象继续留在内存中,因为闭包函数保留了这个变量对象的引用,所以这个变量对象不会被回收。
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 这就形成了一个闭包
认识到指针的作用,foo()
的执行结果 bar
函数被返回赋值给了 baz
,这时虽然 baz
在作用域之外,但是指针还是指向的 bar
,因此哪怕它位于 foo
作用域之外,它还是能够获取到 foo
的内部变量。
垃圾回收机制
先了解下GC是什么?GC是Garbage Collection的缩写,意为垃圾回收。
与C
语言和C++
不同,因为没有GC,需要程序员手动管理内存;Java
、JavaScript
等高级语言是带有GC的,
JavaScript
的GC是工作在V8引擎内部的,不需要用户手动管理。
一般常用的GC算法有:标记清除、引用计数法。因为引用计数算法的循环引用问题的存在,现代浏览器中主要使用的是标记清除算法。
标记清除算法是先把所有活动对象做上标记,在清除阶段的时候回收没有打上标记的对象。
GC标记类似一个定时任务,它会在下次执行的时候回收不再被标记(不再使用)的内存。
内存泄露
是指当一块内存不再被应用程序使用的时候,由于某种原因,这块内存没有返还给操作系统或者内存池的现象。内存泄漏可能会导致应用程序卡顿或者崩溃。
回想下,在上一章中我们学习的执行上下文的销毁阶段是怎样的(创建阶段、执行阶段、销毁阶段)?
- 执行上下文是以执行栈的形式进行管理的,遵循后进先出(LIFO) ,全局作用域会一直存在栈的底部
- 引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。
也就是说,原本 foo
函数执行完之后,就应该弹出 foo
的执行栈,并销毁 foo
的执行上下文的。但是在闭包场景中, foo
内部的 bar
函数使用了 foo
作用域中的变量,形成了一个作用域链,导致 foo
函数不能释放。
再看下这个闭包:
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 这就形成了一个闭包
导致内存泄漏的行为操作:
- 全局变量的无意创建
- 闭包使用过度导致内存占用无法释放的情况
- DOM 的事件绑定(移除 DOM 元素前如果忘记了注销掉其中绑定的事件方法,也会造成内存泄露)
解决方案:
- 使用严格模式
- 避免过度使用闭包。
- 关注 DOM 生命周期,在销毁阶段记得解绑相关事件(或者可以使用事件委托的手段统一处理事件,减少由于事件绑定带来的额外内存开销)
总结
- JavaScript 语言层面只原生支持两种作用域类型:全局作用域 和 函数作用域
- JavaScript不是原生支持块级作用域的,ES6标准之后
let
、const
创建的为块级作用域 - JavaScript是采用词法作用域的,函数的作用域在函数定义的时候就决定了,和拿到哪里执行没关系
- 闭包的本质是利用了作用域的机制,来达到外部作用域访问内部作用域的目的
- 过度使用闭包容易导致内存泄漏
交流
面试相关的文章及代码demo,后续打算在这个仓库(JS-banana/interview: 面试不完全指北 (github.com))进行维护,欢迎✨star,提建议,一起进步~
资料
转载自:https://juejin.cn/post/7348647534393131062