变量作用域
变量作用域与提升
JavaScript 中的作用域主要分为全局作用域(Global Scope)与局部作用域(Local Scope)两大类。在 ES6 之前,JavaScript 中只存在着函数作用域;而在 ES6 中,JavaScript 引入了 let、const 等变量声明关键字与块级作用域,在不同作用域下变量与函数的提升表现也是不一致的。在 JavaScript 中,所有绑定的声明会在控制流到达它们出现的作用域时被初始化;这里的作用域其实就是所谓的执行上下文(Execution Context),每个执行上下文分为内存分配(Memory Creation Phase)与执行(Execution)这两个阶段。在执行上下文的内存分配阶段会进行变量创建,即开始进入了变量的生命周期;变量的生命周期包含了声明(Declaration phase)、初始化(Initialization phase)与赋值(Assignment phase)过程这三个过程。
传统的 const 关键字声明的变量允许在声明之前使用,此时该变量被赋值为 undefined;而函数作用域中声明的函数同样可以在声明前使用,其函数体也被提升到了头部。这种特性表现也就是所谓的提升(Hoisting);虽然在 ES6 中以 let 与 const 关键字声明的变量同样会在作用域头部被初始化,不过这些变量仅允许在实际声明之后使用。在作用域头部与变量实际声明处之间的区域就称为所谓的暂时死域(Temporal Dead Zone),TDZ 能够避免传统的提升引发的潜在问题。另一方面,由于 ES6 引入了块级作用域,在块级作用域中声明的函数会被提升到该作用域头部,即允许在实际声明前使用;而在部分实现中该函数同时被提升到了所处函数作用域的头部,不过此时被赋值为 undefined。
作用域
作用域(Scope)即代码执行过程中的变量、函数或者对象的可访问区域,作用域决定了变量或者其他资源的可见性;计算机安全中一条基本原则即是用户只应该访问他们需要的资源,而作用域就是在编程中遵循该原则来保证代码的安全性。除此之外,作用域还能够帮助我们提升代码性能、追踪错误并且修复它们。JavaScript 中的作用域主要分为全局作用域(Global Scope )与局部作用域(Local Scope )两大类,在 ES5 中定义在函数内的变量即是属于某个局部作用域,而定义在函数外的变量即是属于全局作用域。
全局作用域
当我们在浏览器控制台或者 Node.js 交互终端中开始编写 JavaScript 时,即进入了所谓的全局作用域:
// the scope is by default global
const name = "Hammad";
定义在全局作用域中的变量能够被任意的其他作用域中访问:
const name = "Hammad";
console.log(name); // logs 'Hammad'
function logName() {
console.log(name); // 'name' is accessible here and everywhere else
}
logName(); // logs 'Hammad'
函数作用域
定义在某个函数内的变量即从属于当前函数作用域,在每次函数调用中都会创建出新的上下文;换言之,我们可以在不同的函数中定义同名变量,这些变量会被绑定到各自的函数作用域中:
// Global Scope
function someFunction() {
// Local Scope #1
function someOtherFunction() {
// Local Scope #2
}
}
// Global Scope
function anotherFunction() {
// Local Scope #3
}
// Global Scope
函数作用域的缺陷在于粒度过大,在使用闭包或者其他特性时导致异常的变量传递:
const callbacks = [];
// 这里的 i 被提升到了当前函数作用域头部
for (const i = 0; i <= 2; i++) {
callbacks[i] = function () {
return i * 2;
};
}
console.log(callbacks[0]()); //6
console.log(callbacks[1]()); //6
console.log(callbacks[2]()); //6
块级作用域
类似于 if、switch 条件选择或者 for、while 这样的循环体即是所谓的块级作用域;在 ES5 中,要实现块级作用域,即需要在原来的函数作用域上包裹一层,即在需要限制变量提升的地方手动设置一个变量来替代原来的全局变量,譬如:
const callbacks = [];
for (const i = 0; i <= 2; i++) {
(function (i) {
// 这里的 i 仅归属于该函数作用域
callbacks[i] = function () {
return i * 2;
};
})(i);
}
callbacks[0]() === 0;
callbacks[1]() === 2;
callbacks[2]() === 4;
而在 ES6 中,可以直接利用 let
关键字达成这一点:
let callbacks = [];
for (let i = 0; i <= 2; i++) {
// 这里的 i 属于当前块作用域
callbacks[i] = function () {
return i * 2;
};
}
callbacks[0]() === 0;
callbacks[1]() === 2;
callbacks[2]() === 4;
词法作用域
词法作用域是 JavaScript 闭包特性的重要保证,笔者在基于 JSX 的动态数据绑定一文中也介绍了如何利用词法作用域的特性来实现动态数据绑定。一般来说,在编程语言里我们常见的变量作用域就是词法作用域与动态作用域(Dynamic Scope ),绝大部分的编程语言都是使用的词法作用域。词法作用域注重的是所谓的 Write-Time,即编程时的上下文,而动态作用域以及常见的 this 的用法,都是 Run-Time,即运行时上下文。词法作用域关注的是函数在何处被定义,而动态作用域关注的是函数在何处被调用。JavaScript 是典型的词法作用域的语言,即一个符号参照到语境中符号名字出现的地方,局部变量缺省有着词法作用域。此二者的对比可以参考如下这个例子:
function foo() {
console.log(a); // 2 in Lexical Scope,But 3 in Dynamic Scope
}
function bar() {
const a = 3;
foo();
}
const a = 2;
bar();