备战26春招,彻底搞懂前端八股之作用域:知识点讲解+面试示例回答
在 JavaScript 中,作用域是一个非常基础、非常核心,但又让很多同学容易混淆的知识点。面试时大家往往说不清楚「作用域在哪里生效」「作用域链怎么查找」「var、let、const 的区别」「闭包为什么能访问外部变量」这些关键点。
本文将带你彻底理解作用域、作用域链、声明提升、闭包等相关概念,并附带面试中一口气说清楚、让面试官满意的示范回答。
知识点讲解
作用域
在 JS 中,作用域规定了变量可以被访问的范围。
一个变量只能在它的作用域内被访问,如果当前不在该变量的作用域内,就访问不到。
例如:
function func() {
const a = 10;
}
console.log(a); // 报错:a 不在此作用域
函数内部声明的变量,只能在当前函数中访问。
作用域嵌套与作用域链
当一个函数或代码块嵌套在另一层内部时,就产生了作用域的“嵌套关系”。
当 JS 引擎要查找某个变量时:
- 先在当前作用域查找
- 找不到就去外层作用域
- 再找不到就继续向外层找,一直找到全局作用域
- 全局还找不到就报错
例如:
const x = 1;
function outer() {
const y = 2;
function inner() {
const z = 3;
console.log(x, y, z);
}
inner();
}
outer();
查找顺序:
inner → outer → global → 找到为止
这条从内到外逐级查找的路径,就是作用域链。
遮蔽效应(变量遮蔽)
如果内层作用域定义了与外层作用域相同的变量名,那么内层会把外层“遮住”。
let a = 1;
function test() {
let a = 2;
console.log(a); // 2(内层覆盖外层)
}
test();
作用域链查找时遇到最近的变量就会停止。
全局作用域中用 var 会自动挂到 window 上
var a = 1; console.log(window.a); // 1 let b = 2; console.log(window.b); // undefined
只有 var 和 function 声明 会被挂载到全局对象。
作用域只和函数的“声明位置”有关,与调用位置无关
var a = 1;
function foo() {
var a = 2
console.log(a);
}
function bar() {
var a = 3;
foo(); // 输出 2
}
bar();
当我们在 bar 内部调用 foo 时,foo 内部查找 a 时,仍然是沿着 foo 内部 -> 全局作用域 这样的作用域链进行查找,不会找到 bar 内部的 a,即使 foo 是在 bar 内部调用的。这是因为,作用域只和函数的“声明位置”有关,与调用位置无关
作用域的意义之一:私有变量
把变量放在函数内部,就形成私有变量,外部无法直接访问。
function counter() {
let count = 0;
return function () {
count++;
return count;
};
}
const add = counter();
console.log(add()); // 1
console.log(add()); // 2
console.log(count); // 报错,因为count 不在当前作用域内
私有变量可以防止全局变量污染,也避免意外修改。
作用域的意义之二:避免变量冲突
function f1() {
const a = 10;
console.log(a) // 10
}
function f2() {
const a = 20;
console.log(a) // 20
}
两个函数中可以访问各自的 a,互相不会冲突、覆盖。
如果都写全局:
var a = 10; var a = 20; console.log(a); // 20,被覆盖
此时第二个 a 会覆盖第一个 a
JS 中三种作用域
JS中,有全局作用域、函数作用域、块级作用域。
在全局下使用 var 、let、const 声明变量,这个变量的作用域是全局
在函数内使用 var 声明变量,这个变量的作用域是当前函数内部
在 for、if 这些由大括号包裹的代码块内使用 let、const 声明变量,这个变量的作用域是当前块内部
假设我们在 for 循环中使用 var 声明一个 i 变量,那么这个变量的作用域是全局,而不是 for 内部。这个行为是有风险的。
可以看以下的代码:
if (true) {
let x = 1; // 块级作用域
}
console.log(x); // 报错
注意:
for (var i = 0; i < 3; i++) {}
console.log(i); // 3(var 没有块级作用域)
声明提升
函数声明和变量声明,会提升到当前作用域的顶端。在JS中,var a = 2 这样的代码,会被引擎看作两个步骤,一个是 var a 这个声明步骤,一个是 a = 2 这个赋值步骤。只有 var a 这个声明过程会被提升,而 a=2这个赋值过程不会被提升。如果同时有函数提升和变量提升,则函数提升优先于变量提升去执行。提升有什么用呢?假设我们在第五行声明一个函数,而在第三行去执行这个函数,是可以正常执行的,而不会报错;假设我们在第六行 var a = 2,在第二行访问 a,此时会得到 undefined,而不会报错。
console.log(a); // undefined var a = 2;
引擎内部实际处理成:
var a; console.log(a); // undefined a = 2;
函数提升优先于变量提升,<font style="color:rgb(10, 10, 10);">且不会被变量提升覆盖</font>:
console.log(a); // function a()
function a() {}
var a = 2;
let / const 存在暂时性死区
还有一个要注意的点,let、const 声明的变量,会存在暂时性死区,暂时性死区的意思是,假设我们在 第七行 let b = 3,在第一行访问 b,此时会报错
console.log(b); // 报错 let b = 3;
闭包(Closures)
还有一个跟作用域有关的概念,叫做闭包。
当内层函数 访问了外层作用域中的变量,就形成了闭包。
闭闭包的作用有两个,首先是它让我们可以把这个变量作为外部函数的私有化变量,让内层函数只能按照我们规定的方式访问;另一个作用是,可以延长这个变量的生命周期。本来这个变量会随着外层函数的执行完成而被垃圾回收机制回收,但是现在由于它被内层函数访问,所以只要内层函数还存在,那么这个变量就不会被回收。
可以看下面这个例子:
function create() {
let value = 0;
return function () {
return ++value;
};
}
const add = create();
add(); // 1
add(); // 2
因为内层函数引用了 value,所以外层执行结束后 value 仍然保留。
闭包在JS中有很多具体的应用场景,比如函数柯里化、防抖截流,甚至于 react 的 hooks,都用到了闭包的原理。
但是由于变量不会被及时回收,闭包也有一个隐患,就是可能会导致内存泄漏。所以我们需要在使用完毕之后,即使地把变量赋值为 null,以便垃圾回收机制回收
以上就是跟作用域相关的全部概念了。接下来我们来看下面试中应当如何回答,以提升竞争力:
面试回答示例
面试官问:你如何理解作用域和作用域链?
示范回答:
作用域决定了变量能够被访问的范围。在 JavaScript 中主要有三种作用域:全局作用域、函数作用域和块级作用域。 var 声明的变量是函数作用域,而 let 和 const 声明的变量是块级作用域。
当一个作用域嵌套在另一个作用域内部时,就形成了作用域嵌套。当我们访问一个变量时,JS 引擎会先在当前作用域查找,如果找不到,就向外层作用域查找,直到找到该变量或到达全局作用域为止。这就是作用域链
如果内层作用域声明了与外层同名的变量,会产生遮蔽效应,也就是内层变量会覆盖外层变量。
作用域的一个重要意义是变量隔离和避免变量冲突;另一个意义是实现变量私有化,例如把变量写在函数内部,避免被全局访问到。
var 和函数声明会产生声明提升,但是变量提升的话只有 声明过程会被提升,赋值过程不会被提升。这意味着在函数声明之前、变量声明之前,我们是能访问到他们的。但是 let / const 存在暂时性死区,在声明之前不能访问。
跟作用域有关的另一个概念是闭包。当一个函数访问了外部函数作用域中的变量时,就产生了闭包。闭包会让外层变量不会立即被销毁,所以可以利用闭包来实现变量的私有化和持久化,例如函数柯里化、节流防抖、React Hooks 内部逻辑都基于闭包。不过闭包有一个隐患,它可能导致内存泄漏,所以我们在开发中需要注意在适当时机释放。
前端新一水八股系列,每日讲解一道面试高频八股题,涵盖CSS、JS、Vue、React、工程化、性能优化、场景题等