块级作用域:var缺陷以及为什么要引入let和const|浏览器篇
# 块级作用域:var缺陷以及为什么要引入let和const
30 秒速记
var的核心缺陷是只有全局作用域和函数作用域,代码块无法限制变量的可见范围与生命周期。- 函数执行上下文创建时,
var声明会被提前登记并初始化为undefined,因此声明前也能访问。 - 变量提升可能让代码块中的同名变量遮蔽外层变量,即使声明所在分支根本没有执行。
- 循环结束后,使用
var声明的计数器仍属于整个函数,在函数退出前不会因代码块结束而消失。 ES6引入块级作用域以及let、const,用于缩小变量作用范围;但为了兼容旧代码,var的提升规则仍然保留。
var 的问题是没有块级作用域,声明会提升到当前函数或全局作用域,容易产生超出直觉的可见性。 它在执行前就被登记并初始化为 undefined,所以声明前可以读取,块内变量也可能泄漏到块外。let、const 把绑定限制在代码块内,并通过暂时性死区阻止声明前访问。需要注意,const 只禁止重新赋值,并不代表对象内容不可修改;旧代码中的 var 规则则因兼容性继续保留。
在前面《07 | 变量提升:JavaScript代码是按顺序执行的吗?》这篇文章中,我们已经讲解了JavaScript中变量提升的相关内容,正是由于JavaScript存在变量提升这种特性,从而导致了很多与直觉不符的代码,这也是JavaScript的一个重要设计缺陷
虽然ECMAScript6(以下简称ES6)已经通过引入块级作用域并配合let、const关键字,来避开了这种设计缺陷,但是由于JavaScript需要保持向下兼容,所以变量提升在相当长一段时间内还会继续存在。这也加大了你理解概念的难度,因为既要理解新的机制,又要理解变量提升这套机制,关键这两套机制还是同时运行在“一套”系统中的。
但如果抛开JavaScript的底层去理解这些,那么你大概率会很难深入理解其概念。俗话说,“断病要断因,治病要治根”,所以为了便于你更好地理解和学习,今天我们这篇文章会先“探病因”——分析为什么在JavaScript中会存在变量提升,以及变量提升所带来的问题;然后再来“开药方”——介绍如何通过块级作用域并配合let和const关键字来修复这种缺陷
原理拆解: var 声明会被登记到当前函数的变量环境中;若不在函数内,则属于脚本对应的全局作用域。if、for 和普通花括号不会为它建立新的绑定。因此,声明位置虽然写在块内,读取范围却可能覆盖整个函数。let、const 则创建块级绑定,并从进入作用域到声明执行前处于暂时性死区;这使声明前误读直接抛错,而不是悄悄得到 undefined。const 约束的是绑定不可重新赋值,并不意味着对象内容不可修改。
最小验证: 输入同名的三种声明,验证声明前访问、块外可见性以及 const 的约束对象。
function inspect() {
console.log(a); // undefined
var a = 1;
if (true) {
var leaked = 2;
let scoped = 3;
const config = { enabled: false };
config.enabled = true;
console.log(scoped, config.enabled); // 3 true
}
console.log(leaked); // 2
try {
console.log(scoped);
} catch (error) {
console.log(error.name); // ReferenceError
}
}
inspect();
关键差异不在赋值语句是否提前执行:var a 只有声明被提前登记,赋值仍在原位置发生,所以首次输出是 undefined。scoped 离开 if 后无法解析,而修改 config.enabled 合法,因为绑定仍指向同一个对象。
边界与排查: let、const 同样会在词法环境创建阶段建立绑定,只是初始化前禁止访问,因此把它们简单描述成“完全不提升”并不严谨。迁移旧代码时应检查声明前读取、块外引用和重复声明;将 var 机械替换为 let 可能把原来的隐式 undefined 变成 ReferenceError,这通常暴露了控制流缺陷,但也要求测试覆盖相关分支。
面试官追问
追问 1筛选页在 if (false) 内写了 var region = "cn",函数开头读取同名全局 region 却得到 undefined,分支明明没执行为何仍会遮蔽?
函数执行上下文创建时,块内的 var region 已被登记为当前函数的局部绑定并初始化为 undefined。if 是否进入只决定赋值是否发生,不决定该绑定是否存在;普通代码块不会为 var 提供块级隔离。
追问 2列表页用 var i 遍历数百条数据,循环结束后其他逻辑继续读取 i,代码评审是否应直接替换成 let?
应先确认循环外读取是意外泄漏还是既有数据传递方式;若计数器只服务于循环,改用 let 能把绑定限制在循环范围。若后续明确依赖最终值,机械替换会造成 ReferenceError,应先用单独变量显式保存结果。
追问 3迁移脚本把函数内所有 var 批量改成 let,测试发现原先声明前打印 undefined 的分支开始报错,这算重构缺陷还是合理暴露?
这是声明规则变化导致的可预期行为:let 绑定在初始化前处于暂时性死区,提前读取会抛出 ReferenceError。它通常暴露了不清晰的初始化顺序,但迁移仍需修正控制流并覆盖相关分支,不能把异常简单视为无害变化。
追问 4线上只有某个条件分支出现配置丢失,你会如何排查块内同名 var、块级 let 与全局配置之间的关系?
先在函数范围搜索同名声明,再于函数入口、分支入口和赋值点查看当前作用域中的绑定值。块内 var 会提升到函数环境并遮蔽全局,块内 let 只在对应块解析;若仍不吻合,还要检查嵌套函数、模块边界和其他遮蔽层级。
追问 5团队规定新代码默认使用 const,有人反对说配置对象需要修改属性,因此只能用 let,你如何裁决?
若变量始终指向同一个对象,即使需要修改对象属性也可以使用 const,它限制的是绑定重新赋值而非对象内容变化。只有确实需要让变量改指向其他值时才使用 let;const 本身不提供深层不可变保证。
追问 6面试者说 let 和 const“完全不会提升”,但又能解释块外不可见,你会怎样追问其模型是否完整?
应让他区分创建绑定与完成初始化:let、const 会建立块级绑定,但声明执行前禁止访问,因此不能简单描述为完全不提升。块外不可见来自词法作用域边界,块内声明前报错来自暂时性死区,两者不是同一个机制。
# 作用域(scope)
30 秒速记
- 作用域决定变量和函数能在什么位置被访问,也约束其存活范围。
- 按原文所述,在
ES6之前,JavaScript主要只有全局作用域和函数作用域,不具备通用的块级作用域。 - 全局绑定可在程序各处访问,并随页面存续;函数内部绑定通常只在函数中可见,并在函数执行结束后结束其生命周期。
- 块级作用域以
{}对应的代码块为边界,可出现在条件、循环或独立代码块中;块内声明不应泄漏到块外。 - 旧式
var声明按函数统一处理:编译阶段进入当前执行上下文的变量环境,因此声明位置之前及所在代码块之外仍可能访问到它。
作用域决定变量和函数在哪里可见,也影响它们的生命周期。 ES6 之前,JavaScript 主要有全局作用域和函数作用域:全局绑定随页面存续,函数内绑定通常只在函数中可访问。块级作用域以 {} 为边界,条件、循环和独立代码块都可以形成自己的范围。var 不受普通代码块限制,因此块内声明仍可能在整个函数中被访问,这也是变量提升容易违背直觉的根源。
为什么JavaScript中会存在变量提升这个特性,而其他语言似乎都没有这个特性呢?要讲清楚这个问题,我们就得先从作用域讲起
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期
在ES6之前,ES的作用域只有两种:全局作用域和函数作用域。
- 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
在ES6之前,JavaScript只支持这两种作用域,相较而言,其他语言则都普遍支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个{}都可以被看作是一个块级作用域。
为了更好地理解块级作用域,你可以参考下面的一些示例代码:
//if块
if(1){}
//while块
while(1){}
//函数块
function foo(){
//for循环块
for(let i = 0; i<100; i++){}
//单独一个块
{}
简单来讲,如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。你可以看下面这段C代码:
char* myname = "极客时间";
void showName() {
printf("%s \n",myname);
if(0){
char* myname = "极客邦";
}
}
int main(){
showName();
return 0;
}
上面这段C代码执行后,最终打印出来的是上面全局变量myname的值,之所以这样,是因为C语言是支持块级作用域的,所以if块里面定义的变量是不能被if块外面的语句访问到的。
和Java、C/C++不同,ES6之前是不支持块级作用域的,因为当初设计这门语言的时候,并没有想到JavaScript会火起来,所以只是按照最简单的方式来设计。没有了块级作用域,再把作用域内部的变量统一提升无疑是最快速、最简单的设计,不过这也直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,这也就是JavaScript中的变量提升。
