JS 基础
在 JavaScript 中,作用域、作用域链以及闭包是理解函数执行环境及其变量访问机制的关键概念。这些概念不仅对于编写高效且易于维护的代码至关重要,也是深入掌握 JavaScript 语言特性的基础。
- 作用域:作用域定义了如何查找变量的位置,即确定当前执行上下文中变量和其他资源的作用范围。JavaScript 中的作用域主要分为全局作用域和局部作用域(或称函数作用域)。全局作用域指的是在整个脚本或程序中都可以访问到的变量;而局部作用域则仅限于特定函数内部使用,当该函数执行完毕后,其内部声明的变量就会被销毁。ES6 之后还引入了块级作用域,通过 let 和 const 关键字可以在花括号 {} 内创建一个新的作用域。
- 作用域链:每当一个函数被执行时,都会创建一个新的执行上下文,并形成一条作用域链。这条链由多个对象组成,每个对象都对应着一个变量对象,它包含了当前执行上下文中所有可用的标识符(如变量名)。作用域链从当前执行上下文开始,一直向上延伸至全局执行上下文。这意味着如果在一个函数内部尝试访问某个变量,首先会在自己的作用域中寻找,若找不到,则会沿着作用域链逐级向上搜索直到全局作用域。这种机制允许子函数能够访问父函数甚至更外层函数中的变量。
- 闭包:闭包是一种特殊的对象,它结合了一个函数以及创建该函数时所在的作用域。简单来说,闭包使得函数即使在其外部环境中也可以"记住"并访问自己创建时所在环境中的变量。这是通过将函数与其词法环境一起保存来实现的。闭包的强大之处在于它们可以用来封装私有数据、延迟计算表达式的结果等,从而增强代码的安全性和灵活性。但需要注意的是,不当使用闭包可能会导致内存泄漏等问题,因为被封闭的数据不会被垃圾回收器自动清理。
什么是作用域: 变量与函数的可访问范围,控制着变量与函数的可见性和生命周期。我们有时把作用域称作为运行期上下文:当函数执行时会创建一个称为执行期的上下文的内部对象,一个执行期上下文定义了一个函数执行时的环境,函数每次执行时对应的上下文都是独一无二的,所以多次调用一个函数会创建多个执行期上下文,当函数执行完毕,它产生的执行期上下文会被销毁。
比如:
js
function foo(){
var a=1 console.log(a);//1
}
foo()
console.log(a);//ReferenceError: a is not defined
foo 函数执行的时候会打印出 a 的值 1,而在函数外打印的 a 会报错,原因在于最外层------全局作用域没有定义 a 变量
js
var a = 1;
function foo() {
console.log(a); //1
}
foo();
在输出 a 的时候,自己函数内部没有找到变量 a,那么就在外层的全局中查找,找到了就停止查找并输出了。
所以,通俗的讲,作用域就是查找变量的地方 。在某函数中找到该变量,就可以说在该函数作用域 中找到了该变量;在全局中找到该变量,就可以说在全局作用域中找到了该变量。
壹. 作用域类型
1. 全局作用域
在代码中任何地方都能访问到的对象拥有全局作用域,一般有以下几种情况:
- 最外层函数和在最外层函数外面定义的变量
js
var a = 1; //全局变量
function foo() {
var b = 2; //局部变量
console.log(a); //输出1 全局变量
console.log(b); //输出2 局部变量
}
foo();
console.log(a); //输出1 全局变量
console.log(foo); //输出 [Function: foo]
console.log(b); //ReferenceError: b is not defined
- 所有末定义直接赋值的变量自动声明为拥有全局作用域
js
function foo() {
var a = 2;
b = 3; //全局变量
console.log(a); //2
}
foo();
console.log(b); //输出3
console.log(a); //ReferenceError: a is not defined
变量 b 拥有全局作用域,而变量 a 在函数外无法访问
- 所有 window 对象的属性拥有全局作用域,一般情况下,window 对象的内置属性都拥有全局作用域,例如 window.name、window.location、window.top 等。
2. 函数作用域
在函数内部定义的变量,拥有函数作用域。
js
var a = 1; //a 全局变量
function foo() {
var b = 2; //b 局部变量
console.log(b);
}
function bar(s) {
console.log(s); //形参s 局部变量
}
foo(); //输出2
bar(a); //输出1
console.log(b); //ReferenceError: b is not defined
console.log(s); //ReferenceError: b is not defined
3. 块级作用域
使用 let
或 const
声明的变量,如果被一个大括号{}括住,那么这个大括号括住的变量就形成了一个块级作用域。所声明的变量在指定块的作用域外无法被访问。
js
if (true) {
let a = 1;
console.log(a); //输出1
}
console.log(a); //ReferenceError: a is not defined
块级作用中定义的变量只在当前块中生效 ,这和函数作用域类似。 注意:
- 内存作用域是能够访问外层作用域的,反之则不可以
- 函数调用时会执行上下文,创建一个对象
AO:{}
(表示函数作用域对象) - 每个函数都会有自己的函数作用域属性
[[scope]]
。这个隐式属性:只供给引擎访问的属性,其中存储了执行期上下文的集合
贰. 作用域链(Scope Chain)
指的是作用查找的线路,即 [[scope]]
中所存储的执行期上下文对象的集合 ,这个集合呈链式连接 ,我们把这种链式链接叫做作用域链。 通俗的讲,当所需要的变量在所在的作用域中查找不到的时候,它会一层一层向上查找,直到找到全局作用域还没有找到的时候,就会放弃查找。这种一层一层的关系,就是作用域链 。 e.g. :
js
var a = 1;
function fn() {
var a = 2;
function fun() {
console.log(a); //2
}
fun();
}
fn();
输出 a 时由于 fun 函数内没有定义变量 a,所以往上一层查找变量 a,结果在上一层的 fn 函数内找到了变量 a,输出 a 的值。
它们之间的关系示意图如下:

js
function a() {
function b() {
var b = 2;
}
var a = 1;
b();
console.log(a);
}
var glob = 100;
var d = 5;
a();
-
全局作用域
- 在代码执行前,首先会有两个全局变量被声明并初始化:
- glob = 100 (全局变量)
- d = 5 (全局变量)
- 然后,调用 a() 函数。
-
函数
a()
执行- 在执行 a() 函数时,进入 a() 的局部作用域:
- 局部变量: var a = 1; 声明并初始化了 a ,注意到这是局部作用域内的 a ,它和全局的 glob 不同。
- 声明函数: function b() 在 a() 内部定义,这个函数也属于 a() 的局部作用域。
- 接着,调用了 b() 函数。
-
函数
b()
执行- 在执行 b() 函数时,它的局部作用域中有一个变量 b 被声明并赋值为 2 。但是,由于 b 是 b() 函数的局部变量,它只存在于 b() 函数的作用域内,在 b() 执行完毕后,这个 b 变量会被销毁。因此, b 函数内的 b 变量与 a() 函数内的 a 变量以及全局的 glob 无关。
-
回到
a()
函数- b() 执行完后,返回到 a() 中。
- console.log(a) 会打印 a ,但这里的 a 是 a() 函数内部的局部变量( var a =1 )。它与全局的 glob 变量没有关系。
- 因此, console.log(a) 输出的是 1
-
全局作用域
- 函数 a() 执行完毕后,控制权返回到全局作用域。
- 全局作用域的 glob 和 d 变量没有被 a() 函数影响,所以它们的值依然是 glob = 100 和 d = 5 。
叁. 预编译
预编译概述 一般来说,编译的步骤分为以下三部分:
-
词法分析(词法单元)
-
语法解析(抽象语法树)
-
代码生成
注意:JS 编译发生在代码执行之前
声明提升:
- 在编译时将变量的声明,提升到当前作用域的顶端
- 函数声明整体提升
e.g.
js
foo();
function foo() {
console.log(a); //undefined
var a = 1;
}
var b = 2;
//foo()函数执行前进行编译,编译时相当于如下
foo();
b = 2;
在 JavaScript 程序执行前,会先创建全局执行上下文(Global Execution Context),这个过程又分为两大阶段:创建阶段 (又称变量环境/词法环境的初始化)和执行阶段。
- 创建阶段
-
全局对象创建:为全局作用域创建一个全局对象,并生成全局执行上下文。
-
词法环境和变量环境的建立:
- 函数声明:例如 function foo(){...} 。在创建阶段,JavaScript 引擎会把整个函数体作为一个对象存储,并将标识符 foo 和函数对象的引用存储到全局环境中。这样,调用 foo() 时就可以直接找到该函数。
- 变量声明(var 声明):var 属于变量声明表达式(Variable Declaration Expression)。在创建阶段,所有通过 var 声明的变量都会在当前的环境记录中被创建,并初始化为 undefined 。这一过程称为"变量提升"(Hoisting)。
- 在全局上下文中,会为 var b 创建一个标识符 b ,初始值为 undefined (赋值操作 b = 2 会在执行阶段完成)。
-
注意:在同一作用域内,如果有函数声明和变量声明同名,函数声明的优先级高于 var 声明。并且后续的 var 声明不会覆盖已经存在的绑定(只影响赋值部分)。
- 进入函数 foo 的执行上下文
当执行 foo() 时,会创建一个新的执行上下文,这个上下文同样有创建和执行两个阶段:
-
在 创建阶段 内:
- 函数内部的变量声明: var a 被处理,创建一个绑定(内部环境记录中添加标识符 a ),初始值为 undefined 。这就意味着即使代码中 var a = 1 在 console.log(a) 之后写,变量 a 已经存在,但其值目前还是 undefined 。
- 函数内部的其他声明:如果有其他函数声明、参数等,同样会被处理,但本例中只有 vara 。
js
// 全局执行上下文(创建阶段):
var b; // 创建变量 b, 初始值 undefined
function foo() {
// 创建函数 foo,函数体整体存储
var a; // 在 foo 的上下文中创建变量 a, 初始值 undefined
console.log(a); // 此时 a 为 undefined
a = 1; // 赋值操作
}
// 执行阶段(全局):
foo(); // 调用 foo() 时进入 foo 的执行上下文
b = 2; // 全局变量 b 赋值为 2
肆. 闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包。即使函数是在当前词法作用域之外执行。 闭包:在一个作用域中可以访问另一个函数内部的局部变量的函数
闭包的形成
js
function foo() {
var a = 1;
return function () {
console.log(a);
};
}
var bar = foo();
bar();
foo() 函数的执行结果返回给 bar ,而此时由于变量 a 还在使用,因而没有被销毁,然后执行 bar() 函数。这样,我们就能在外部作用域访问到函数内部作用域的变量。这个就是闭包。
闭包的形成条件:
- 函数嵌套
- 内部函数引用外部函数的局部变量
闭包的作用
- 可以读取函数内部的变量
- 可以使变量的值长期保存在内存中,生命周期比较长。
- 可用来实现 JS 模块( JQuery 库等)
JS
模块是具有特定功能的 JS
文件,将所有的数据和功能都封装在一个函数内部(私有的),只向外暴露一个包含多个方法的对象或函数,模块的使用者,只需要通过模块暴露的对象调用方法来实现对应的功能。
js
(function () {
var a = 1;
function test() {
return a;
}
window.module = { a, test }; // 向外暴露
})();
js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./1.js"></script>
<title>Document</title>
</head>
<body>
<script>
console.log(module.a); // 1 console.log(module.test()); // 1
</script>
</body>
</html>
闭包的特性
- 每个函数都是闭包,函数能够记住自己定义时所处的作用域,函数走到了哪,定义时的作用域就到了哪。
- 内存泄漏 (内存泄漏就是一个对象在你不需要它的时候仍然存在。所以不能滥用闭包。当我们使用完闭包后,应该将引用变量置为 null 。)
js
function outer() {
var num = 0;
return function add() {
num++;
console.log(num);
};
}
var func1 = outer();
func1(); // 1
func1(); // 2 [没有被释放,一直被占用]
var func2 = outer();
func2(); // 1 [重新引用函数时,闭包是新的]
func2(); // 2
- 闭包的应用
实现点击第几个 button 就输出几
js
<button>1</button> <button>2</button>
js
let buttons = document.getElementsByTagName("button");
const resultDiv = document.getElementById("result");
var i = 0;
for (var i = 0; i < buttons.length; i++) {
console.log(i);
i = 1;
i = 2;
i = 3;
i = 4;
i = 5;
buttons[i].onclick = function () {
console.log(i + 1);
resultDiv.textContent = i + 1;
};
}
在这段代码中,使用 var 声明的变量 i 在整个函数作用域内共享同一个引用。当 for 循环结束时,i 的值已经变成了 5,而 onclick 的回调函数是一个闭包,它引用的是同一个 i 变量。因此,当任何按钮被点击时,i 的值都是 5,所以 resultDiv.textContent 显示的是 i + 1,也就是 6 我们可以用两种方式解决这个问题
let
声明: 将上述代码中的var
改为let
- 闭包
js
(function () {
for (var i = 0; i < buttons.length; i++) {
// 使用闭包保存当前循环的索引值
buttons[i].onclick = (function (index) {
return function () {
// 在点击时显示按钮对应的数字(索引+1)
resultDiv.textContent = index + 1;
console.log(index + 1);
};
})(i);
}
})();