JS面试重点“闭包”,面试官想知道的都在这
前言
JavaScript 是一种非常面向函数的语言,但函数总归绕不开传参和返回值这两个问题,而它们的本质就是变量。如果不理清楚此时它们所指的变量都是谁,就无法理清函数的层级关系,对JS源码的理解也就难上加难。
所以我们不得不去理解 JavaScript 运行机制,逐步打通作用域、调用栈、词法环境以及闭包等概念。
对于初学者或者准备面试的开发者来说,这些概念往往显得晦涩难懂,需要花费大量时间和精力去理解。然而,一旦掌握了它们的本质原理,不仅能够应付各种面试问题,更能够在实际开发中运用自如,写出更加优雅和健壮的代码。
为此,本文将以"深入浅出"的方式,循序渐进地解释这些重要概念,并最终聚焦于闭包这一备受关注的高级话题。我们将从整体上把握这些概念的核心原理,再深入探究其内部机制,提供代码案例解析,力求让读者在短时间内对这些关键知识点有全面而深入的理解。
让我们一起开启 JavaScript 内部原理的探索之旅吧!
变量声明方式与其作用域
前言提到,JS的关键是函数,函数的关键是变量,那么理解万物本源变量就是我们的第一步。这里将从变量的三种声明方式的辨析入手,带你理解关于变量的那些事。
问题引入
我们知道,在现在的JavaScript中,有三种声明变量的方式:
var
let
const
其中const
是专门用来声明只读变量(也就是我们熟知的常量)的,除此之外const
与let
几乎没有差别。
那么var
和let
又有啥区别呢?你也许不知道,这里面其实牵扯到一个历史遗留问题!这里先带你了解老旧的var
。在这个过程当中,你便会理清他们的区别,并掌握变量的作用域。
let
和 const
是 ES2015(ES6) 新增加的两个 JavaScript 关键字。在此之前,Javascript中只有var
才能声明变量。
但在如今,在现代JavaScript中一般不再会使用它,但你又不得不了解他——因为你的同事可能会写/面试官可能会问。
那么为啥要抛弃var
呢?因为在下面三个案例中,var
都明显暴露出了它不安全的问题。
案例一:变量作用域问题
一天小明写了一段代码,在中途用
var
声明了一个变量i
用在后面的代码中。但是!突然之间他要给函数做debug,但错误地在这个变量声明之前给它来了个
console.log(i)
。console.log(i); var i = 10; console.log(i);
这个问题在开发中其实非常常见,但在Java、C/C++等语言中,别说编译器会给你个报错——这个变量没声明,哪怕开发工具都会飘红警告让你悬崖勒马。
但在JavaScript中,你这样写毫无毛病,开发工具不会警告,编译器也不认为这是个错误。你运行这段代码,编译器还给你呈上了运行结果:命
undefined
10
这时没有报错可未必就是个好事!因为它给出的还是错误的结果,况且没有报错的辅助,若在庞大的代码中发现这个错误那可就麻烦了。
let
和 const
虽然也有声明提升机制,但它们在提升时不会初始化为 undefined
,而是保持在暂时性死区(Temporal Dead Zone,TDZ)中,调用也会报错:ReferenceError: Cannot access 'b' before initialization
,这样就一定程度上保证了编程安全啦!
案例二:变量重复声明问题
小明又在写代码
(bug)了,这次他在写函数时遇到了两个非常相似的量(一个叫总价,一个叫总计),这时他用“见名思义”法不小心给两个量都命名为了sum
。var sum = 666; var sum = 999;
又是个令人窒息,但又不得不承认非常容易遇见的错误。只不过换在其它语言,开发工具就开始飘红警告了 (严师出高徒),但JavaScript它还是这么温柔,照样运行不误……
这时候我都不敢想小明要多久才能发现,最后总价和总计为什么不对了。
let
和 const
就规避了这个问题,它们都不允许重复声明,不然就报错,开发工具也会飘红哦!
案例三:全局污染问题
这次不迫害小明了,我们举一个生活中的例子。
在学校里,重名的现象并不少见也无法避免,但为了方便老师管理,一般都会把名字相同的同学排在不同的班级。这样3班的张三和4班的张三就可以区分了。
写代码也是一样,变量名就那么多,如果写个超级庞大的函数,好用的名字也就那么几个,所以有时候也会迫不得已地使用相同的变量名。那我们只能确定变量的管辖范围,来精确不同位置用的到底是哪个变量。
比如,你敢说你没有在一个函数里写过两个
for(let i = ...; i < n; i++)
嘛?
所以在其他高级语言中,你一定听说过代码块和全局变量这两个概念。
一般情况下,一个花括号{...}
就是一个代码块,变量只能在代码块中访问,一出去就被回收了。哪怕是几个代码块嵌套也可以这么解读:
{
let a = 999;
{
let b = 888;
{
let c = 666;
console.log(c); // 输出:666
}
console.log(a); // 输出:999
console.log(b); // 输出:888
console.log(c); // ReferenceError: c is not defined
}
}
我们也称这种变量的作用范围叫块级作用域。它的存在把我们变量的命名空间打理的井井有条,很少出现变量打架的问题。遇到必须得有一个可以随处访问的变量时,才会使用全局变量来满足业务需求,轻微污染命名空间。这就是为什么非必要不用全局变量了。
但在ES6 之前,JavaScript根本不存在块级作用域这个概念,var
声明的变量只有两种作用域:
- 全局变量:在函数外用
var
声明的变量,哪里都能用; - 函数内的局部变量:函数内用
var
声明的变量,函数范围内任何位置都能用。
所以,这种同样的代码换用var
声明变量,就截然不同了:
{
var a = 999;
{
var b = 888;
{
var a = 666;
console.log(a); // 输出:666
}
console.log(a); // 输出:666
}
}
第2行的变量a
就被第6行的变量a
给污染了,这可真的是解放前啊……
但 let
和 const
声明的变量只在其命令所在的代码块内有效。是不是安全多了?
小结
let
与 const
的区别仅在于一个是声明“可变变量”、一个是声明“只读变量”,其它并无差别。
var
与 let/const
有两个主要的区别:
var
声明的变量没有块级作用域,要么全局可见,要么整个函数内可见。var
变量存在变量提升(Hosting)机制,使得在函数开头就被声明并初始化为undefined
。但let/const
虽然也有类似机制,但使用上和没有该机制一致。
这些差异使
var
在大多数情况下都比let
更糟糕。块级作用域是这么好的一个东西。这就是let
在几年前就被写入到标准中的原因,并且现在(与const
一起)已经成为了声明变量的主要方式。
调用栈
聊完变量,想必你已经对“作用域”有了一定的了解。那么函数的嵌套、递归都是怎么处理、整合这些作用域的呢?这里不得不提到JS的调用栈。
在揭秘JS的预编译过程中,有提到“执行上下文”这一概念,每一个函数的“执行上下文”就像它的身份证一样,记录了它运行有关的所有信息。而调用栈就是存储并管理这一个个执行上下文的数据结构。
另外,调用栈既然叫这个名字,就意味着他也遵顼着栈这一数据结构先进后出的原则。每有一个函数开始执行时,就意味着有一个执行上下文进入调用栈。当函数执行完成,它的执行上下文就从栈顶弹出,释放内存空间。
案例解析
let value = 666;
function test(count) {
console.log("now:" + count);
// 递归终止条件
if (count <= 0) {
return;
}
// 递归调用自身,层级减一
test(count - 1);
}
// 调用递归函数
test(3);
其调用栈的简单示意图为(图中“堆栈”意为“调用栈”):
它的执行过程大体可以解释为:
- 程序开始执行时,首先把整个文件(最大的函数)的全局执行上下文(即图中1号)压入调用栈。
- 程序运行到第16行调用
test(3)
,将这个函数的执行上下文(图中2号)压入调用栈。 - 函数调用到
test(count-1)
,即test(2)
,将新函数的执行上下文(图中3号)压入调用栈。 - ...(重复第3步)
- 最后在
count=0
时,函数调用到return
语句,图中5号函数执行完成,从栈顶出栈。 - 图中4号函数最后一行语句
test(count-1)
执行完毕,函数运行结束,从栈顶出栈。 - 3号、2号函数重复第6步依次出栈。
- 程序运行结束,全局执行上下文出栈。
词法环境
上文我们理解了作用域与调用栈,并回顾了执行上下文。但为了方便理解,前文一直都隐藏了一个概念——“词法环境”。
之前我们提到,执行上下文会存储函数运行过程中的所需要的东西,其中也包含所需的变量。这些并无问题,但实际上变量并非直接挂载在执行上下文中的,而是有一个“词法环境”代为管理。
关于词法环境
“词法环境”是一个规范对象(specification object),它只存在于“理论”层面帮助我们理解工作机制。我们无法在代码中获取该对象并直接对其进行操作。
(如果你学过初高中物理,可以理解为“磁感线”的引入,只是帮助我们理解理论的工具,本身并不存在)
“词法环境”由两部分组成:
- 环境记录(Environment Record):装在这个词法环境对象内的属性(即变量)。
- 外部词法环境:像指针一样,指向它的外层环境是什么地方,俄罗斯套娃也要知道它套在哪里。
而词法环境的重点,就在于这个外部词法环境,正是它一直帮我们维护者代码块的层级关系,帮我们捋清楚什么地方使用的是哪个变量。
在当前代码块中找不到需要访问的变量时,V8引擎就会顺着这个外部词法环境一层层找,直到最外层——浏览器层。而这个“顺藤摸瓜”的过程就称作为作用域链。
案例解析
let value = 0;
function inner() {
console.log(value);
}
function outer() {
let value = 1;
inner();
}
outer(); // 0
上面这段代码的输出结果是多少?
如果按照调用栈的思路来说,你应该会这么认为:
但输出结果实际上是 0
,且这个读取顺序实际上和调用栈无关。函数运行时该到哪个区块找变量并不是由调用栈决定的,而是词法环境决定的。
- 函数运行到需要调用变量的位置时,首先在当前代码块的词法环境找这个变量。
- 如果没有找到,则根据词法环境所指向的外部词法环境去下一个词法环境找,一直到最外层(也就是浏览器层)——最外层的外部词法环境指向的是
null
。
那么为什么inner()
的外部词法环境是全局而非outer()
?inner()
不是在outer()
内部调用的吗?
这是因为所有的函数在“诞生”时都会记住创建它们的词法环境并不会再修改,这与函数被在哪儿调用无关。inner()
是在全局环境下声明创建的,那它的外部词法环境就指向全局。所以这段代码应当是这样解释的:
让我们一步一步分析:
- 首先,我们在全局作用域中声明了变量
value
并初始化为0
。 - 然后我们定义了两个函数
inner()
和outer()
。
当我们调用 outer()
函数时,发生了以下过程:
outer()
函数被调用,创建了outer()
函数的执行上下文。- 在
outer()
函数内部,我们声明了一个局部变量value
并赋值为1
。 - 接着,我们调用
inner()
函数。 - 这时,
inner()
函数被调用,创建了inner()
函数的执行上下文。 - 在
inner()
函数内部,它首先在自己的词法环境中查找value
变量,但是没有找到。 - 于是,
inner()
函数沿着它的词法环境的外部引用,向上查找value
变量。 - 它找到了全局作用域中的
value
变量,并打印出其值0
。
通过这个例子,你便可理解词法环境对函数变量调用地影响。
词法环境的技术细节
-
[[Environment]]
属性:- 每个 JavaScript 函数对象都有一个内部的
[[Environment]]
属性。 [[Environment]]
属性记录了函数被创建时所处的词法环境。
- 每个 JavaScript 函数对象都有一个内部的
-
词法环境:
- 词法环境是一个用于存储标识符(变量、函数名等)及其对应值的结构。
- 每个函数调用都会创建一个新的函数词法环境,其中包含了函数内部定义的标识符及其值。
-
作用域链:
- 当访问一个标识符时,V8 引擎会沿着作用域链向上查找,直到找到该标识符为止。
- 作用域链由当前函数的词法环境以及该函数被创建时的外部词法环境(即
[[Environment]]
属性)组成。
闭包
闭包(console)指的是一种特殊的函数,它能够"记住"它被创建时所处的环境。具体来说,闭包是一个内部函数,它可以访问外部函数中的变量,即使外部函数已经执行完毕了。
这么一说,感觉还有点抽象,但有句话是不是非常眼熟——"记住"它被创建时所处的环境,这不就跟刚刚介绍的词法环境非常像吗?
因为有词法环境这一特性,所以在 JavaScript 中所有函数都是天生闭包的(只有一个例外—— "new Function" 语法 )。
那接下来我们再用例子理解闭包的具体形式是怎样的:
function counterFactory() {
let count = 0;
return function() {
return ++count;
}
}
const counter = counterFactory();
console.log(counter()); // 输出 1
console.log(counter()); // 输出 2
console.log(counter()); // 输出 3
在这个例子中,counterFactory()
函数返回了一个内部函数function(){return count++;}
并赋值给了只读变量counter
。
这个内部函数就是一个闭包,因为它是在counterFactory()
中被创建的,它的词法环境指向的就是counterFactory()
,故能够访问 counterFactory()
函数中的 count
变量,即使 counterFactory()
已经执行完毕了。
这样,我们就可以通过调用 counter()
函数来获取递增的计数值。这就是闭包的一个典型用途——在函数外部"记住"某些状态。
闭包的用途
-
实现私有变量和方法: 可以用闭包在函数内部定义变量和方法,并且只允许外部通过特定的接口访问它们。这样可以实现数据封装和信息隐藏。
function createCounter() { let count = 0; // 私有变量 return { increment: function() { // 公共方法 count++; }, decrement: function() { // 公共方法 count--; }, getCount: function() { // 公共方法 return count; } }; } const counter = createCounter(); counter.increment(); counter.increment(); console.log(counter.getCount()); // 输出 2
在这个例子中,
createCounter()
函数返回了一个包含了三个方法的对象。只有这些方法能够访问createCounter()
函数中的私有变量count
,外部代码无法直接访问count
。这就是闭包实现数据封装和信息隐藏的一种方式。 -
事件处理和回调函数: 当我们需要在事件处理函数或回调函数中访问外部变量时,闭包就派上用场了。
function addClickHandler(element, data) { element.addEventListener('click', function() { console.log(data); // 可以访问 data 参数 }); } const myElement = document.getElementById('my-element'); addClickHandler(myElement, 'some data');
在这个例子中,
addClickHandler()
函数接受一个 DOM 元素和一些数据作为参数,并为元素添加一个点击事件处理函数。这个事件处理函数是一个闭包,它能够访问
addClickHandler()
函数的data
参数——即使在事件触发时addClickHandler()
函数已经执行完毕了。这种闭包的应用在事件处理和异步回调中非常常见。
-
函数柯里化: 闭包可以用于实现函数柯里化,即部分应用一个函数,返回一个新函数,新函数可以记住原函数的参数。
function multiply(a, b) { return a * b; } function createMultiplier(a) { return function(b) { return multiply(a, b); } } const double = createMultiplier(2); console.log(double(5)); // 输出 10 console.log(double(10)); // 输出 20
在这个例子中,
createMultiplier()
函数返回了一个新函数function(b) { return multiply(a, b); }
,这个新函数记住了createMultiplier()
函数的a
参数,并将整个函数体赋值给了只读变量double
。这就是函数柯里化的实现方式。
double
所指向的函数记住了a
参数为2
,所以每次调用它时,只需要提供b
参数就可以计算出结果。这种方式可用于创建更具可重用性的函数。
面试中对“闭包”的解释
在面试时通常会被问到“什么是闭包?”,一个满分的答案应当包括下面的内容:
-
什么是闭包?
- 闭包(console) 是一个内部函数,它可以访问外部函数中的变量,即使外部函数已经执行完毕了。
-
为什么JS中的所有函数都具有闭包的特性?
- 因为 JavaScript 有词法作用域这一特性,所以每个函数都能访问其定义时所在的词法环境。这意味着,即使函数是在定义它的词法环境之外的地方执行,它仍然能够访问其定义时的变量。
-
JS实现闭包的技术细节
- 当一个函数被创建时,它的
[[Environment]]
属性被设置为当前的词法环境。 - 即使该函数是在定义它的词法环境之外的地方执行,它仍然能够通过作用域链访问到它被创建时所在的词法环境。
- 当一个函数被创建时,它的
-
闭包的作用
- 实现数据封装;
- 实现函数柯里化;
- 事件处理和回调函数;
- ...
参考资料
转载自:https://juejin.cn/post/7374325853867573284