这篇文章主要探讨和学习如何在ES6中处理变量和作用域。
通过let和const决定块作用域
let
和const
创建的变量只在块作用域中有效。它们只存于包含它们的块中。下面演示的代码,通过let
在if
语句块中声明一个tmp
变量。这个变量仅在if
语句中有效。
function func() {
if (true) {
let tmp = 123;
console.log(tmp); // => 123
}
console.log(tmp); // => ReferenceError: tmp is not defined
}
相比之下,var
声明的变量作用域的范围是函数范围内的:
function func() {
console.log(tmp); // => undefined
if (true) {
var tmp = 123;
console.log(tmp); // => 123
}
console.log(tmp); // => 123
}
console.log(tmp); // => ReferenceError: tmp is not defined
块作用域意味着你可在有函数内有变量的阴影。
function func() {
let foo = 5;
console.log(foo); // => 5
if (true) {
let foo = 10;
console.log(foo); // => 10
}
console.log(foo); // => 5
}
const创建不可变的变量
由let
创建的变量是可变的:
let foo = 'abc';
foo = 'def';
console.log(foo); // => def
由const
创建的是变量是一个常量,这个变量是不可变的:
const foo = 'abc';
foo = 'def';
console.log(foo); // => TypeError: Assignment to constant variable.
请注意,如果一个常量指的是一个对象,那么const
并不影响常量本身的值是否是可变的,因为它总是指向那个对象,但是对象本身仍然是可以被改变的。
const obj = {};
obj.prop = 123;
console.log(obj.prop); // => 123
console.log(obj); // => {prop: 123}
obj = {} // => TypeError: Assignment to constant variable.
如果你想让obj
真正成为一个常量,你必须冻结它的值:
const obj = Object.freeze({});
obj.prop = 123;
也就是说,如果const
定义的常指向的是一个对象。这个时候,它实际上指向的是当前对象的地址。这个地址是在栈里面的,而这个真实的对象是在堆栈里面的。所以,我们使用const
定义这个对象后,是可以改变对象的内容的。但是这个地址是不可以改变的。意思也就是不可以给这个对象重新赋值,比如const obj= {}, obj = {}
,即使是这样,obj
好像什么也没有改变,但还是错误的。然而在普通模式下,并没有报错,而obj.name = 'abc'
这是完全可以的。这跟JavaScript存储引用对象的值的方式有密切的关系。
const obj = Object.freeze({});
const newObj = {};
obj.name = 'w3cplus';
newObj.name = 'damo';
使用Babel把上面ES6的代码编译成ES5代码:
'use strict';
var obj = Object.freeze({});
var newObj = {};
obj.name = 'w3cplus';
newObj.name = 'damo';
事实上不管我们是以const
声明freeze object或者是不freeze
,Babel都会把它转换成var
。但我们把编译后代码执行之后,会报错TypeError: Cannot add property name, object is not extensible
循环体内的const
一旦创建了const
变量,就不能更改它。但这并不意味着你不能在作用域内重新给它新的值。例如:
function logArgs(...args) {
for (let [index, elem] of args.entries()) {
const message = index + '.' + elem;
console.log(message);
}
}
logArgs('Hello', 'everyone');
// => 0.Hello
// => 1.everyone
上面的示例演示了循环体通过const
声明的变量message
,还是取出不同的值。
什么时候使用let,什么时候使用const
如果你想改变变量的原始值,则不能使用const
:
const foo = 1;
foo++; // => TypeError: Assignment to constant variable.
然而,你可以使用const
变量来引用一些可变的东西:
const bar = [];
bar.pus('abc'); // => bar是可变的
我还在考虑最好的样式是什么,但我现在使用的是像前面的示例,用的是let
,因为bar
指的是可变的东西。如果确定使用const
来表示的变量,那么这个变量和其值都是不可变的:
const EMPTY_ARRAY = Object.freeze([]);
时间死区(TDZ)
由let
或者const
声明的变量都有一个所谓的时间死区(TDZ):当进入它的作用域时,它不能被访问,直到执行到达声明为止。
让我们先来看看var
变量的生命周期,它没有时间死区:
- 当进入
var
变量的作用域(比如在一个函数内)时,就会为它创建存储空间(所谓的绑定)。通过将变量设置为undefined
,可以立即初始化该变量。 - 当范围内的执行到达声明时,变量被设置为初始化器指定的值(如果有赋值)。如果没有,变量的值仍然是
undefined
通过let
声明的变量有时间死区,这意味着它们的生命周期是这样的:
- 当进入
let
变量的作用域(比如一个块内)时,就会为它创建存储空间(所谓的绑定)。但这个变量仍然是一个未初始化的变量 - 获取或设置未初始化会导致引用错误
ReferenceError
- 当范围内的执行到达声明时,变量被设置为初始化器指定的值(如果有赋值)。如果没有,变量的值将被设置为
undefined
const
声明变量的工作方式类似于let
声明变量,但它闪必须有一个初始化器(即,立即设置为一个值),不能更改。
在TDZ中,如果有一个变量,则抛出一个异常:
if (true) { // 进入新的作用域,TDZ开始
// 创建`tmp`的未初始化绑定
tmp = 'abc'; // ReferenceError: tmp is not defined
console.log(tmp); // => ReferenceError: tmp is not defined
let tmp; // TDZ结束,`tmp`初始化为`undefined`
console.log(tmp); // => undefined
tmp = 123;
console.log(tmp); // => 123
}
下面的示例演示了死区实际上是时间(基于时间)而不是空间(基于位置):
if (true) { // 进入新的作用域, TDZ开始
const func = function () {
console.log(myVar); // => 3
}
// 这里我们在TDZ中
// 访问`myVar`导至一个参考错误
let myVar = 3; // TDZ 结束
func(); // TDZ之外
}
typeof 和 TDZ
一个变量在时间死区是无法访问的,这意味着你甚至不能使用typeof
来判断它的类型:
if (true) {
console.log(typeof tmp); // => ReferenceError: tmp is not defined
let tmp = 'abc';
}
我并不认为这在实践中是一个问题,因为你不能有条件地将let
声明的变量添加到作用域中。与此相反,你可以使用var
像这样做来声明变量,比如分配一个window
属性将创建一个全局变量:
if (typeof myVar === 'undefined') {
// `myVar` 没有退出 => 创建它
window.myVar = 'abc';
}
循环头里的let
在循环中,如果你将使用let
声明一个变量,则为每次迭代获得一个新的绑定。允许你在for
、for-in
和for-of
中这样做:
let arr = [];
for (let i = 0; i < 3; i++){
arr.push(() => i);
}
console.log(arr.map(x => x())); // => [0, 1, 2]
上面的代码转换成ES5:
var arr = [];
var _loop = function _loop(i) {
arr.push(function () {
return i;
});
};
for (var i = 0; i < 3; i++) {
_loop(i);
}
console.log(arr.map(function (x) {
return x();
}));
相反,var
声明会导致整个循环的单个绑定(const
声明的工作原理相同):
let arr = [];
for (var i = 0; i < 3; i++) {
arr.push(() => i);
}
console.log(arr.map(x => x())); // => [3, 3, 3]
这个编译出来的ES5代码:
var arr = [];
for (var i = 0; i < 3; i++) {
arr.push(function () {
return i;
});
}
console.log(arr.map(function (x) {
return x();
}));
首先,为每次迭代获得一个新的绑定看起来很奇怪,但是当你使用循环来创建函数(比如事件处理的回调时),它就显得非常有用。
参数
参数 vs 局部变量
如果你使用let
声明一个和参数同名的变量,则会得到一个静态(加载时)错误:
function func(arg) {
let arg; // => SyntaxError: Identifier 'arg' has already been declared
}
在块中做同样的事情:
function func(arg) {
if (true) {
let arg; // => undefined
}
}
与此相反,var
声明一个与参数相同的变量什么都不做,就像在同一范围内重新声明一个var
变量,什么也不做:
function func(arg) {
var arg; // => undefined
}
function func(arg) {
if (true) {
var arg; // => undefined
}
}
参数默认值和TDZ
如果参数具有默认值,那么它们就被视为一个序列的let
语句,并受到TDZ的影响:
// ES6
// `y`在声明后访问`x`
function foo(x = 1, y = x) {
return [x, y];
}
foo(); // => [1, 1]
// ES5
function foo() {
var x = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1;
var y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : x;
return [x, y];
}
foo(); // => [1, 1]
// ES6
// `x`试图在TDZ中访问`y`
function bar(x = y, y = 2) {
return [x, y];
}
bar(); // => ReferenceError: y is not defined
// ES5
function bar() {
var x = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : y;
var y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 2;
return [x, y];
}
bar(); // => [undefined, 2]
参数默认值不在作用域主体内
参数默认值的范围与主体的范围是分开的(前者包围后者)。这意味着,定义“inside”参数默认值的方法或函数没有看到主体的局部变量:
// ES6
let foo = 'outer';
function bar (func = x => foo) {
let foo = 'inner';
console.log(func()); // => outer
}
bar();
全局对象
JavaScript的全局对象(Web浏览器中指的是window
,Node.js中指的是global
),与其说是一个特性,还不如说是一个bug,特别是在性能方面。这就是为什么ES6引入了一个区别(Distinction),而不会让人感到有任何奇怪的原因。
- 全局对象的所有属性都是全局变量。在全局作用域内,使用
var
或Function
声明的属性都是一个全局变量 - 但现在使用
let
、const
和Class
在全局作用域下声明的变量是全局变量但不是全局对象
函数声明和类声明
函数声明:
- 像
let
一样是一个块作用域 - 像
var
在全局作用域下创建全局对象 - 被提升:独立于其声明的函数作用域内,它总是在作用域的开始创建
下面的代码演示了函数声明的提升:
{ // Enter a new scope
console.log(foo());
function foo() {
return 'hello';
}
}
类声明:
- 是一个块作用域
- 不能创建全局对象
- 不能提升
类没有提升可能会令人惊讶,因为在引擎下,它们会创建函数。这种行为的基本原理是,它们是通过表达式定义来扩展子句的值,而这些表达式必须在适当的时候执行。
{ // Enter a new scope
const identity = x => x;
// Here we are in the temporal dead zone of `MyClass`
let inst = new MyClass(); // ReferenceError
// Note the expression in the `extends` clause
class MyClass extends identity(Object) {
}
}
模拟块作用域
在JavaScript中开发人员期望变量被限定在特定的块中(比如for
、if
),但是在使用var
声明的变量,则作用域是靠近最近的父函数。
首先,让我们来看看这是如何出错的。
var avatar = 'Ang';
var element = 'Air';
var elements = ['Air', 'Earth', 'Fire', 'Water'];
for (var i = 0; i < elements.length; i++) {
var element = elements[i];
console.log(avatar + ' has mastered ' + element);
}
console.log(avatar + "'s primary element is " + element);
使用块作用域语言的开发人员可能不会看到上面的代码有任何问题,并且期望Ang's primary element is Air
替代实际的结果。
一旦你意识到这个问题,这个问题很容易就可以避免。在块内避免变量声明往往可以避免任何混淆。
但是假设我们真的想用JavaScript来使用块作用域。我们可能会这样做:
var avatar = "Ang"; var element = "Air";
var elements = [
"Air",
"Earth",
"Fire",
"Water"
];
for (var i = 0; i < elements.length; i++) {
(function() {
var element = elements[i];
console.log(avatar + " has mastered " + element);
})();
}
console.log(avatar + "'s primary element is " + element);
这个解决方案使用一个IIFE来模拟块作用域。由于函数是JavaScript的作用域(Scoping)机制,因此我们定义并立即在每个循环中调用一个新函数,因此近似于块作用域的行为。
而在ES6中,在循环体中使用let
来声明变量,就不会有前面所说的问题存在了。
扩展阅读
- Variables and scoping in ECMAScript 6
- Emulating Block Scope in JavaScript
- Using ECMAScript 6 today
- Destructuring and parameter handling in ECMAScript 6
特别声明:上述内容主要来源于Variables and scoping in ECMAScript 6和Emulating Block Scope in JavaScript两篇文章。
如需转载,烦请注明出处:https://www.w3cplus.com/javascript/es6-scoping.html