前言
作用域是JS中一个很基础但是很重要的概念,面试中也经常出现,本文会详细深入的讲解这个概念及其他相关的概念,包括声明提升,块级作用域,暂时性死区,作用域链及作用域链延长等问题。
作用域
作用域 (scope) 可以被理解为是标识符(变量)在程序中的可见性范围。作用域是按照具体规则维护标识符的可见性,以确定当前执行的代码对这些标识符的访问权限。作用域是在具体的作用域规则之下确定的。
作用域类型
-
动态作用域:动态作用域是在代码运行时确定的,关注函数从何处调用。javascript 并不具有动态作用域,但是this机制某种程度上很像动态作用域。
-
静态作用域:静态作用域在函数定义时决定了,关注函数在何处声明。静态作用域又叫词法作用域,JS就是静态作用域。
let x = 10 function f () { return x } function g () { let x = 20 return f() } console.log(g()) // 10上述代码中,函数
f返回的x是外层定义的x,也就是10,我们调用g的时候,虽然g里面也有个变量x,但是在这里我们并没有用它,用的是f里面的x。也就是说我们调用一个函数时,如果这个函数的变量没有在函数中定义,就去定义该函数的地方查找,这种查找关系在我们代码写出来的时候其实就确定了,所以叫静态作用域。
作用域种类
在JavaScript中,ES2015 / ES6出现之前,作用域分为函数作用域和全局作用域。后来作用域概念不断演进,ES6 中增加了通过 let 和 const 声明变量的块级作用域,使得 JavaScript 中的作用域内涵更加丰富。
全局作用域
全局作用域的生命周期存在于整个程序内,能被程序中任何函数或者方法访问,在js中默认是可以被修改的。一旦你声明了一个全局变量,那么你在任何地方都可以使用它,包括函数内部。事实上,JavaScript默认拥有一个全局对象window,声明一个全局变量,就是为window对象的同名属性赋值。
示例1:
var a = 'bar'
function foo () {
console.log(a) // "bar"
}
console.log(window.a) // "bar"
foo()
执行以上 foo 函数时,函数在自身函数作用域内并未找到b变量,但是它会继续向外扩大查找范围,于是便在全局作用域中找到了变量 b,并输出 bar
函数作用域
函数作用域内,对外是封闭的,从外层的作用域无法直接访问函数内部的作用域。
示例2:将示例1稍加改动,在代码执行时,变量 a 在 foo 函数的局部作用域内,因此可以在函数体内访问。
function foo () {
var a = 'bar'
console.log(a) // "bar"
}
foo()
块级作用域
块级作用域就是指变量在指定的代码块里面才能访问,也就是一对{}中可以访问,在外面无法访问。为了区分var,块级作用域使用let和const声明变量。
示例3:
function foo () {
let b = 1 // 作用域就是整个f函数
if(true) {
var a = 2 // 作用域就是整个f函数
let b = 2 // 作用域是if这个代码块对应的块级作用域
}
console.log(a) // 2
console.log(b) // 1
}
foo()
作用域链
在 JavaScript 中,执行某个函数时,如果遇见变量且需要读取其值,就会“就近”先在函数内部查找该变量的声明或赋值情况。如果在函数内无法找到该变量,就要跳出函数作用域,到更上层作用域中查找。这里的“更上层作用域”可能也是一个函数作用域。更上层作用域也可以顺着作用域范围向外扩散,一直到全局作用域。
var a = 1
function bar () {
var b = 2
function foo () {
console.log(a) // 1
console.log(b) // 2
}
foo()
}
bar()
我们看到,变量作用域查找是一个扩散的过程,就像各个环节扣的链条,逐次递进,这就是“作用域链”的由来。
所以,访问变量时,如果当前作用域没有,会一级一级往上找,一直到全局作用域,这就是作用域链。
作用域链延长
大部分情况下,作用域链有多长主要看它当前嵌套的层数,但是有些语句可以在作用域链的前端临时增加一个变量对象,这个变量对象在代码执行完后移除,这就是作用域延长了。
能够导致作用域延长的语句有两种:try...catch的catch块和with语句。
try...catch
这其实是我们一直在用的一个特殊情况:
let x = 1
try {
x = x + y
} catch(e) {
console.log(e)
}
上述代码try里面我们用到了一个没有申明的变量y,所以会报错,然后走到catch,catch会往作用域链最前面添加一个变量e,这是当前的错误对象,我们可以通过这个变量来访问到错误对象,这其实就相当于作用域链延长了。这个变量e会在catch块执行完后被销毁。
with
with语句可以操作作用域链,可以手动将某个对象添加到作用域链最前面,查找变量时,优先去这个对象查找,with块执行完后,作用域链会恢复到正常状态。
function f(obj, x) {
with(obj) {
console.log(x) // 1
}
console.log(x) // 2
}
f({x: 1}, 2)
上述代码,with里面输出的x优先去obj找,相当于手动在作用域链最前面添加了obj这个对象,所以输出的x是1。with外面还是正常的作用域链,所以输出的x仍然是2。
注意:with语句里面的作用域链要执行时才能确定,引擎没办法优化,所以严格模式下是禁止使用with的。
声明提升
块级作用域的出现增加了一定的复杂度,带来了新的概念,比如暂时性死区。说到暂时性死区,还需要从“变量提升”说起。
变量提升
变量声明的提升是以变量所处的第一层词法作用域为“单位”的,即全局作用域中声明的变量会提升至全局最顶层,函数内声明的变量只会提升至该函数作用域最顶层。
来看下面的例子:
function foo () {
// var bar
console.log(bar) // undefined
var bar = 3
}
foo()
执行以上代码会输出 undefined, 原因是变量 bar 在函数内进行了提升。也就是说在 console.log 执行之前,执行力一个类似 var bar 的操作。
但是在 let 对 bar 进行声明的时候,则会报错 Uncaught ReferenceError: Cannot access 'bar' before initialization,如下所示:
function foo () {
console.log(bar)
let bar = 3
}
foo()
我们知道,使用 let和const 声明变量的时候会针对这个变量形成一个封闭的块级作用域,在这个块级作用域中,如果声明变量前访问该变量,就会报错,否则可以正常获取变量值。示例如下:
function foo () {
let bar = 3
console.log(bar)
}
foo()
以上代码将正常输出3。因此,在相应的花括号形成的作用域存在一个“死区”,起始于函数开头,终止与相关变量声明语句的所在行。在这个范围内无法访问使用 let 或 const 声明的变量。这个“死区”的专业名称为暂时性死区(temporal dead zone, TDZ)。
经常看到有文章说: 用let和const申明的变量不会提升。其实这种说法是不准确的,比如下面代码:
var bar = 2
function foo () {
console.log(bar) // ReferenceError
let bar = 3
}
foo()
如果let申明的 bar 变量不会提升,那我们在他前面console应该拿到外层var定义的bar才对。但是现在却报错了,说明执行器在function{}这个块里面其实是提前知道了下面有一个 let 申明了 bar。
所以说变量完全不提升是不准确的。只是 let 和 const 所在的块级作用域变量提升后的行为跟var不一样,var是读到一个undefined,而块级作用域的提升行为是会制造一个暂时性死区。
函数提升
有了上面变量提升的说明,函数提升理解起来就比较容易了,但较之变量提升,函数的提升还是有区别的。即:函数提升只会提升函数声明,而不会提升函数表达式。
来看下面的例子:
console.log(foo1) // [Function: foo1]
foo1() // foo1
console.log(foo2) // undefined
foo2() // TypeError: foo2 is not a function
function foo1 () {
console.log("foo1")
}
var foo2 = function () {
console.log("foo2")
} // foo2在这里是一个函数表达式且不会被提升
以上代码中,函数 foo1 是一个函数声明,在执行前会预解析,所以会提升至全局作用域,输出函数本身。但是foo2 是一个表达式,是在执行阶段才进行的赋值操作,所以不能预解析。
暂时性死区
暂时性死区的现象就是在块级顶部到变量正式申明这块区域去访问这个变量的话,直接报错,这个是ES6规范规定的。
ECMAScript2015规范中,在一个不规范的*“注意”*中清晰的解释了let/const声明提升和TDZ语义:
13.2.1 Let 和 Const声明
注意:let和const声明定义的变量作用在当前执行上下文的词法环境中**。变量在他们的词法环境被初始化的时候被创建**,但是在变量的词法绑定被执行前他们不能被以任何形式被访问。以带有初始化器的词法绑定形式定义的变量,在词法绑定被执行的时候用他的初始化器的赋值表达式的计算结果来赋值,而不是在变量被创建的时候赋值。如果一个let声明的词法绑定没有初始化器,那么这个变量在初始化绑定被执行的时候会被用
undefined赋值。
深入理解
-
变量在当他们的词法环境被初始化的时候创建
这意味着不管控制流何时进入新的作用域(例如:module, function或者块作用域), 所有属于给定的作用域的let/const绑定都会在任何代码执行之前被初始化 —— 换句话说,let/const声明被提升了!
-
但是不能被以任何形式访问直到变量的词法绑定被执行
这就是暂时性死区。一个给定的let/const声明绑定不能被以任何形式访问(读/写)直到控制流执行了声明语句 —— 这个跟提升无关,但是跟声明实际在代码中的位置有关。通过例子能简单的解释:
console.log(x) let x = 42 -
如果一个let声明的词法绑定没有初始化器,那么这个变量在初始化绑定被执行的时候会被用
undefined赋值这句话的代表着:
let x等价于let x = undefined同样的,在控制流执行初始化器(或者“隐式” = undefined 的初始化器)之前试图以任何方式访问x都将导致ReferenceError, 当控制流已经执行了声明后访问则是正常的——在上面两个例子中,在let x声明之后读取x变量都会返回undefined。
特殊情况
对于以上的函数中的暂时性死区,有几种比较“极端”的情况:
-
声明等于赋值
let x = x这是因为 let/const 变量只有在他的初始化器被完全执行后才算作已经完成初始化。也就是说,在赋值的右边表达式被执行并且他的结果被赋值给所声明的变量后(才算做已经完成初始化)。
在这种情况下,右边的表达式尝试去读取x变量,但x的初始化器还没有被完全执行。实际上这个时候我们正在执行——所以这个时刻x仍然未始化,而试图去读取他的值将会导致一个TDZ的ReferenceError。
-
访问父作用域变量
let a = f(); const b = 2; function f() { return b; }第一行,
f()调用导致了控制流跳转去执行f方法,他将尝试去读取b常量,在运行时的这个时候,他(b)还没有被初始化(在TDZ描述的范围内),因此这将会抛出一个ReferenceError。如你所见,TDZ语义也适用于访问父作用域的变量。 -
函数的参数默认值
function foo(arg1 = arg2, arg2) { console.log(arg1, arg2) } foo(undefined, 'b')上面这个例子看起来可能有点让人困惑,但是他实际上也是一个TDZ反例 —— 因为默认参数在给定函数的父作用域和内部作用域之间的中间作用域被执行。
a和b参数被绑定在这个(中间)作用域并且从左到右被初始化,因此当a的初始化器试图读取b的时候,由当前作用域(中间作用域)b绑定解决的b标识符在这个时候尚未初始化,这时由于TDZ语义抛出了一个ReferenceError。
不允许重复声明
块级作用域在同一个块中是不允许重复申明的,比如:
var a = 1
let a = 2
这个会直接报错Uncaught SyntaxError: Identifier 'a' has already been declared。
但是如果你都用var申明就不会报错:
var a = 1
var a = 2
在循环语句中的应用
下面这种问题我们也经常遇到,在一个循环中调用异步函数,期望是每次调用都拿到对应的循环变量,但是最终拿到的却是最后的循环变量:
for(var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i)
})
}
上述代码我们期望的是输出0,1,2,但是最终输出的却是三个3,这是因为setTimeout是异步代码,会在下次事件循环执行,而i++却是同步代码,而全部执行完,等到setTimeout执行时,i++已经执行完了,此时i已经是3了。以前为了解决这个问题,我们一般采用自执行函数:
for(var i = 0; i < 3; i++) {
(function(i) {
setTimeout(() => {
console.log(i)
})
})(i)
}
现在有了let我们直接将var改成let就可以了:
for(let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i)
})
}
这种写法也适用于for...in和for...of循环:
let obj = {
x: 1,
y: 2,
z: 3
}
for(let k in obj){
setTimeout(() => {
console.log(obj[k])
})
}
那能不能使用const来申明循环变量呢?对于for(const i = 0; i < 3; i++)来说,const i = 0是没问题的,但是i++肯定就报错了,所以这个循环会运行一次,然后就报错了。对于for...in和for...of循环,使用const声明是没问题的。
let obj = {
x: 1,
y: 2,
z: 3
}
for(const k in obj){
setTimeout(() => {
console.log(obj[k])
})
}
不影响全局对象
在最外层(全局作用域)使用var申明变量,该变量会成为全局对象的属性,如果全局对象刚好有同名属性,就会被覆盖。
var JSON = 'json'
console.log(window.JSON) // JSON被覆盖了,输出'json'
而使用let申明变量则没有这个问题:
let JSON = 'json'
console.log(window.JSON) // JSON没有被覆盖,还是之前那个对象
上面这么多点其实都是let和const对以前的var进行的改进,如果我们的开发环境支持ES6,我们就应该使用let和const,而不是var。
使用建议优先级:const > let > var
- 首先const声明常量的好处,一是阅读代码的人立刻会意识到不应该修改这个值,二是防止了无意间修改变量值所导致的错误,另外其实js编译器也对const进行了优化,可以提高代码的执行效率;
- 另外let声明的变量没有预编译和变量升级的问题,先声明再使用其实更为规范,而let本身是一个块级作用域,很多时候我们在写代码的时候都希望变量在某个代码块内生效,也更为方便。
小结
最后,总结一下var 和 let、const的区别:
var变量会进行申明提前,在赋值前可以访问到这个变量,值是undefined。- 块级作用域也有“变量提升”,但是行为跟
var不一样,块级作用域里面的“变量提升”会形成“暂时性死区”,在申明前访问会直接报错。 - var 定义的变量可以反复去定义,let 和 const 不可以
- var 定义的变量在循环过程中无法保存,let 和 const 可以
- const 不能在 for 循环中定义,对于
for...in和for...of循环是没问题的 - var声明的变量会挂载到 window 全局对象上,let 和 const 不会
面试题
1. 作用域问题
let x = 1
function A (y) {
let x = 2
function B (z) {
console.log(x + y + z)
}
return B
}
let C = A(2)
C(3)
答案:7,在 B 里找 z 是3,找 y 没找到,去父级找,是2,x 找是2
2. 声明提升问题
console.log(a) // function a { alert(10) } 最后的声明为函数声明, 因此a此时为函数体
a() // 10 执行 a 函数,输出10
var a = 3 // 3 赋给 a
function a() {
alert(10)
}
console.log(a) // 3
a = 6 // 6赋给a,不是一个函数,故下方执行throw error
a() // throw error
参考文章
最后
文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你,欢迎点赞和关注。
想阅读更多优质文章、可我的微信公众号【阳姐讲前端】,每天推送高质量文章,我们一起交流成长。