窥探 JavaScript 中的变量作用域
讲解 JavaScript 作用域的文章也有很多了,这里我想聊聊一些不一样的东西。会的读者可以复习,不会的同学可以了解。
在 JavaScript 中,我们可以通过
var
、let
来声明变量,也可以通过const
来定义常量。但是在 JavaScript 中,变量的作用域一直都很复杂。
目录
- 1、热身:ES3 的作用域
- 2、ES6 的作用域
- 3、浏览器的全局作用域
- 4、模块作用域
1、热身:ES3 的作用域
在 JavaScript ES3 中,我们只能通过 var
来声明变量,变量在声明时会有变量提升(hoisting),即在后面声明的变量可以被提前访问,而值默认为 undefined
。
在 ES3 中,最外层的作用域称为全局作用域。如果你在全局作用域下声明变量,这些变量都会被添加到一个全局对象 globalThis
上,成为它的一个属性。
这个 globalThis
在不同环境下指代不同的目标,比如在 Node.JS 中,globalThis
就是 global
;在浏览器下,globalThis
就是 window
,并且也可以通过 self
来访问。
除了全局作用域以外,ES3 还有三种局部作用域:
- 函数作用域
在 ES3 中的函数中,使用
var
来声明变量,所有变量都会提升至函数开头,并且只能在当前函数块内部访问。var a = '🍐'; (function() { console.log(a, b); // 🍐, undefined var b = '🍐'; console.log(b); // 🍐 })(); console.log(a); // 🍐 try { console.log(b) } catch(e) { console.error(e.message) } // b is not defined
- catch 作用域
在
try { ... } catch(e) { ... } finally { ... }
语句中,变量e
仅在catch
块中可以访问。try { try { console.log(err) } catch(e) { console.error(e.message) } // err is not defined throw Error('🍐'); } catch(err) { console.log(err); // Error: 🍐 } finally { try { console.log(err) } catch(e) { console.error(e.message) } // err is not defined }
- with 作用域
虽然
with
已经不推荐使用了,并且在严格模式(use strict)中已经不可以使用了,但是with
确实会创造一个局部作用域环境。在with (obj) {}
语句中,JavaScript 会为 obj 上所有的属性都创建一个局部变量,所有这些变量都只可以在with
块中访问。try { console.log(sin) } catch(e) { console.error(e.message) } // sin is not defined with(Math) { console.log(sin); // function sin() { [native code] } } try { console.log(sin) } catch(e) { console.error(e.message) } // sin is not defined
2、ES6 的作用域
在 ES6 中引入了两个新的变量/常量定义方法:let
和 const
。由 let
和 const
声明/定义的变量/常量没有提升,并且仅在当前块中有效,也就是说,他们是块级作用域。
当你尝试在声明变量/常量之前访问它时,它会提示你“不能在初始化之前访问”,而不是“变量未定义”。这种现象被叫做“临时死区”,而不是“变量提升”。
if (true) {
try { console.log(a); } catch(e) { console.error(e.message) } // Cannot access 'a' before initialization
try { console.log(b); } catch(e) { console.error(e.message) } // Cannot access 'b' before initialization
const a = '🍐';
let b = '🍐';
console.log(a, b); // 🍐 🍐
}
try { console.log(a); } catch(e) { console.error(e.message) } // a is not defined
try { console.log(b); } catch(e) { console.error(e.message) } // b is not defined
这里 const
和 let
声明/定义的变量/常量仅在 if
块中可以访问。
甚至,你可以直接写一个块:
{
const a = '🍐';
let b = '🍐';
}
那么,如果混合 var
和 const
,会发生什么?
(function() {
var a = '🍐';
{
var b = '🍐';
const a = '🍐🍐';
const b = '🍐'; // Uncaught SyntaxError: Identifier 'b' has already been declared
}
})();
我们可以看到,即便 a
已经声明/定义,在独立的块中也可以使用 const
来覆盖,重新定义。在块中 a
的值是 🍐🍐
,但是离开块后,a
的值还是 🍐
。
但是,如果在这个独立的块中,使用 var
声明了一个变量 b
,虽说 b
会提升至 function
层,但是,在语法解释阶段 const b
就会失败,因为在同一个块中已经声明了 b
。
3、浏览器的全局作用域
在浏览器中,HTML 允许我们使用 <script>
包裹 JavaScript 代码,并且在同一个 HTML 文档中可以放置多个 <script>
标签。
考虑这段代码:
<script>
var a = '🍐';
let b = '🍐';
const c = '🍐';
</script>
<script>
console.log(a);
console.log(b);
console.log(c);
console.log(self.a);
console.log(self.b);
console.log(self.c);
</script>
有两个 <script>
标签,第一个里面声明/定义了三个变量/常量,第二个里面花式访问这些变量/常量,会发生什么?答案是:
🍐
🍐
🍐
🍐
undefined
undefined
结果前 4 个输出了🍐,而后两个输出了 undefined
。
在前面说过,如果你在全局作用域声明了变量,它会被自动添加到全局对象上去。
但是这仅仅是针对 ES3 来说的。
首先,a
、b
、c
都在全局作用域下,第二个 <script>
也是在全局作用域下的,所以是可以直接访问三个变量/常量的。
但是在 ES6 中,let
和 const
即便是在全局作用域下声明/定义,也不会将其添加到全局对象上去,所以如果在第二个标签中去通过 self
访问是不存在的。
如果访问不存在的变量,会抛出异常;但是仅仅是访问不存在的属性就没关系,因此后两个返回 undefined
。
4、模块作用域
考虑这段代码:
<script type="module">
var a = '🍐';
let b = '🍐';
const c = '🍐';
</script>
<script type="module">
console.group('A');
try { console.log(a) } catch(e) { console.error(e.message) }
try { console.log(b) } catch(e) { console.error(e.message) }
try { console.log(c) } catch(e) { console.error(e.message) }
try { console.log(d) } catch(e) { console.error(e.message) }
try { console.log(e) } catch(e) { console.error(e.message) }
try { console.log(f) } catch(e) { console.error(e.message) }
console.groupEnd();
</script>
<script defer>
console.group('B');
try { console.log(a) } catch(e) { console.error(e.message) }
try { console.log(b) } catch(e) { console.error(e.message) }
try { console.log(c) } catch(e) { console.error(e.message) }
try { console.log(d) } catch(e) { console.error(e.message) }
try { console.log(e) } catch(e) { console.error(e.message) }
try { console.log(f) } catch(e) { console.error(e.message) }
console.groupEnd();
</script>
<script>
var d = '🍐';
let e = '🍐';
const f = '🍐';
</script>
首先,一个 <script>
标签,拥有 type="module"
属性,里面声明定义了几个变量/常量;然后,跟着一个 <script>
标签,同样拥有 type="module"
属性,里面尝试访问并打印 a
、b
、c
、d
、e
、f
六个变量/常量;然后又是一个 <script>
标签,内容与第二个几乎一样,除了 group 的内容,没有 type="module"
属性,却多了 defer
属性;最后还是一个 <script>
标签,除了没有 type="module"
属性外,内容与第一个完全一样。
运行结果会怎样?答案是:
┏ B
┣ a is not defined
┣ b is not defined
┣ c is not defined
┣ d is not defined
┣ e is not defined
┗ f is not defined
┏ A
┣ a is not defined
┣ b is not defined
┣ c is not defined
┣ 🍐
┣ 🍐
┗ 🍐
猜对了吗?
这里涉及到四个知识点:受到 defer
作用的代码块会被延迟到最后执行;type="module"
的 <script>
默认包含 defer
行为,并且里面定义的变量/常量作用域都是仅影响局部的;对于 inline 内联的 <script>
而言,defer
属性会被忽略。
先看第一个代码块,type="module"
,因此代码被延迟执行。
然后是第二个代码块,同样是 type="module"
,代码被延迟执行。
再看第三个代码块,由于这是个内联的脚本(内容直接在标签内给出而不是通过 src
属性指定),因此 defer
属性被忽略,这个脚本还是以正常顺序执行。执行这个代码块会先创建一个 console group,输出一个 B
,然后开始依次访问所有变量/常量。但是,看看上面两个代码块,都被延迟执行了,因此此时所有变量都未定义。
然后是第四个代码块,一个普通的 <script>
标签,声明定义了 d
、e
、f
三个变量/常量。
再之后,被延迟的代码块开始依次执行,先是第一个代码块,声明定义了 a
、b
、c
三个变量/常量,但是由于它是一个 module,因此所有变量/常量仅对自身 module 可见,对外部均不可访问。
最后,是被延迟的第二个代码块,执行这个代码块会先创建一个 console group,输出一个 A
,然后开始依次访问所有变量/常量。其中 a
、b
、c
处于其他 module 中,因此无法访问,而 d
、e
、f
均已声明/定义,因此可以正常访问。
注意,这里不是说 let
和 const
发生了提升,而仅仅是受到 defer
效果而使得执行顺序发生了改变。
转载自:https://juejin.cn/post/6844903982012301320