JS从在变量提升这个特性,从而导致了很多与直觉不符的代码,这是JS的一个重要设计缺陷。
ES6通过引入let
和const
关键字来避开这种设计缺陷,但是因为JS需要保持向下兼容(毕竟还存在古老版本的浏览器有人使用),所以变量提升整个特性依旧还存在。
一、作用域(scope)
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。可以理解为作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。
在es6之前JS只有全局作用域和函数作用域;es6之后引入了块级作用域(因为JS的设计之初,并没有想到JS会这么火,所以很多设计都是按照最简单的方式来设计的,所以有挺多的bug,但是又一直存在)
- 全局作用域:全局作用域中的变量、函数在代码中的任何地方都可以访问,其生命周期伴随着页面的生命周期
js
var globalVar = 'I am a global variable';
function exampleFunction( ) {
console.log(globalVar); // 可以访问全局变量
}
exampleFunction(); // 调用全局函数
- 函数作用域:在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部能被访问,函数执行结束之后,函数内部定义的变量会被销毁。
js
function exampleFunction() {
var localVar = 'I am a local variable';
function innerFunction() {
console.log('I am an inner function');
}
innerFunction(); // 可以在函数内部调用内部函数
}
exampleFunction();
// 下面的代码将会报错,因为 localVar 和 innerFunction 在函数外部是不可访问的
console.log(localVar);
innerFunction();
在 JavaScript 中,内部函数(在外部函数内声明的函数)不会在外部函数执行完毕后被销毁。相反,内部函数会形成一个闭包,它可以访问外部函数的变量和参数,即使外部函数已经执行完毕。
js
function outerFunction() {
var outerVar = 'I am an outer variable';
function innerFunction() {
console.log(outerVar); // 内部函数可以访问外部函数的变量
}
return innerFunction; // 返回内部函数
}
var closure = outerFunction(); // 调用外部函数,得到内部函数的引用
closure(); // 调用内部函数
innerFunction
是在 outerFunction
内部声明的。当 outerFunction
被调用时,它返回了 innerFunction
的引用,并且将这个引用赋给了 closure
。即使 outerFunction
执行完毕,closure
仍然可以访问 outerVar
,因为它形成了一个闭包,保留了对外部函数作用域的引用。因此,内部函数的生命周期不仅仅限于外部函数的执行过程,而是由内部函数形成的闭包来决定。只有当没有对内部函数的引用时,它的内存才会被垃圾回收。
- 块级作用域:块级作用域就是一对花括号包括的一段代码。
引入块级作用域的关键词有 let
和 const
,它们允许变量在块级作用域中声明,而不是在函数作用域或全局作用域中。
使用 let
或 const
声明的变量在块级作用域内定义,它们在块级作用域外是不可访问的。这与使用 var
声明的变量在函数作用域中的行为不同,var
的作用域是函数作用域,而不是块级作用域。
js
function exampleFunction() {
if (true) {
let blockScopedVar = 'I am a block-scoped variable';
const constantVar = 'I am a constant variable';
console.log(blockScopedVar); // 在块级作用域内可以访问
console.log(constantVar); // 在块级作用域内可以访问
}
// 下面的代码将会报错,因为在块级作用域外无法访问 blockScopedVar 和 constantVar
// console.log(blockScopedVar);
// console.log(constantVar);
}
exampleFunction();
二、变量提升所带来的问题
1.变量容易在不被察觉的情况下被覆盖掉
js
var x = 10;
function exampleFunction() {
console.log(x); // 输出 undefined
var x = 5;
console.log(x); // 输出 5
}
exampleFunction();
在调用栈中有两个x变量,先使用函数执行上下文中的变量。
2.本应销毁的变量没有被销毁
js
function foo() {
for (var i = 0; i < 5; i++) {
console.log("inner i ===>", i);
// inner i ===> 0
// inner i ===> 1
// inner i ===> 2
// inner i ===> 3
// inner i ===> 4
}
console.log("outer i ===>", i); // outer i ===> 5
}
在其他语言中for
循环结束之后i
变量就已经被销毁,但是在js代码中i
没有被销毁,所以最终打印出来是5
3.ES6解决变量提升带来缺陷的方法
为了解决变量提升带来的问题,ES6引入了let
和const
关键字,从而使JS也有了块级作用域
js
let x = 5;
const y = 6;
x = 7;
y = 8; // Uncaught TypeError: Assignment to constant variable.
使用let
关键字声明的变量的值可以改变,使用const
关键字声明的变量的值是不可以改变的。
- 引入块级作用域的例子(使用 let 或 const):
js
function blockScopeExample() {
if (true) {
let blockScopedVar = 'I am a block-scoped variable';
const blockScopedConst = 'I am a block-scoped constant';
console.log(blockScopedVar); // 在块级作用域内可以访问
console.log(blockScopedConst); // 在块级作用域内可以访问
}
// 下面的代码将会报错,因为在块级作用域外无法访问 blockScopedVar 和 blockScopedConst
// console.log(blockScopedVar);
// console.log(blockScopedConst);
}
- 没有引入块级作用域的例子(使用 var):
js
function noBlockScopeExample() {
if (true) {
var notBlockScopedVar = 'I am not block-scoped';
console.log(notBlockScopedVar); // 在块级作用域内也可以访问,这是因为使用 var 声明的变量没有块级作用域
}
console.log(notBlockScopedVar); // 在块级作用域外也可以访问
}
在块级作用域中声明的变量不影响块外面的变量。
4.JavaScript如何支持块级作用域
js
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
console.log(a); // 1
console.log(b); // 3
}
console.log(b); // 2
console.log(c); // 4
console.log(d); // VM336:13 Uncaught ReferenceError: d is not defined
}
代码的执行流程
第一步编译并创建执行上下文
可以看出来函数内部通过var
声明的变量在编译阶段全都存放到变量环境中。
通过let
声明的变量会被存放在词法环境中。
在词法环境内部维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量(通过let
或const
声明的变量)压到栈顶,当作用域执行完成之后,该作用域的信息会从栈顶弹出。
代码执行过程中查找变量的顺序:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到则直接返回给JS引擎,如果没有找到继续在变量环境中查找。
三、ES6之前的版本如何实现块级作用域
为了兼容ES5以及之前的版本,但是编写代码时使用到ES6的新语法,通过babel
将ES6代码转换成ES5的形式。
在ES6之前的JavaScript版本中,由于缺乏 let
和 const
关键字,没有直接的方法来声明块级作用域的变量。在旧版本中,通常使用函数作用域来模拟块级作用域。通过使用立即执行的匿名函数表达式(Immediately Invoked Function Expression,IIFE),可以创建一个新的函数作用域,从而限制变量的作用范围。
js
(function () {
// 在这里声明的变量仅在该IIFE的作用域内可见
var blockScopedVar = 'I am a block-scoped variable';
console.log(blockScopedVar); // 在块级作用域内可以访问
})();
// 下面的代码将会报错,因为在块级作用域外无法访问 blockScopedVar
// console.log(blockScopedVar);
Babel 主要通过两个插件来实现:
1. babel-plugin-transform-block-scoping:
这个插件主要用于将 let
和 const
关键字转换为适用于旧版 JavaScript(ES5)的代码。它将块级作用域转换为函数作用域,使用函数作用域的方式来模拟块级作用域。
原始代码:
js
{
let x = 10;
console.log(x);
}
// 在这里无法访问 x,因为它在块级作用域内
console.log(typeof x);
经过Babel转换后:
js
"use strict";
var _loop = function _loop() {
var x = 10;
console.log(x);
};
{
_loop();
}
// 在这里无法访问 x,因为它在块级作用域内
console.log(typeof x);
在这个例子中,Babel
将原始的块级作用域内的let声明转换为一个函数 _loop
,然后在原始的块级作用域位置调用这个函数。这样就实现了通过函数作用域模拟块级作用域的效果。在实际项目中,开发者无需手动进行这种转换,Babel
会根据配置自动处理这些转换。
2. babel-plugin-transform-strict-mode:
在旧版 JavaScript 中,let
和 const
关键字并不是唯一引入块级作用域的方法。使用严格模式(strict mode) 也可以引入块级作用域。这个插件用于将使用了 let
和 const
的代码转换为使用严格模式的等效代码。
原始代码:
js
{
let x = 10;
console.log(x);
}
// 在这里无法访问 x,因为它在块级作用域内
console.log(typeof x);
经过Babel转换后:
js
"use strict";
{
var _x = 10;
console.log(_x);
}
// 在这里依然无法访问 x,因为它被转换成了 _x,并且在块级作用域外部不可见
console.log(typeof x);