我们在JavaScript起源一篇中提到过,var
声明变量的作用域问题是一个经典的JavaScript声明提升问题。在我们讨论JavaScript声明提升之前,需要讨论清楚JavaScript作用域及作用域链的概念。
作用域
作用域是高级编程语言中通用的概念。作用域可以看作当前执行上下文,值和表达式在执行上下文范围进行绑定以提供访问性,这个绑定我们称为变量绑定(name binding)。在作用域之外,值和表达式的绑定失效,对外部不可见。下面代码展示了作用域的隔离效果。
函数作用域外不可见:
function exampleFunction() {
var x = '函数内定义'; // x 只能在 exampleFunction 函数中使用
console.log(x); // 输出 "函数内定义"
}
exampleFunction();
console.log(x); // 运行报错,x is not defined
函数作用域间不可见:
function exampleFunction1() {
const y = '函数内定义y-[exampleFunction1]'; // x 只能在 exampleFunction 函数中使用
console.log(y); // 输出 "函数内定义y-[exampleFunction1]",exampleFunction2 中的 y 不会造成影响
}
function exampleFunction2() {
const y = '函数内定义y-[exampleFunction2]'; // x 只能在 exampleFunction 函数中使用
console.log(y); // 输出 "函数内定义y-[exampleFunction2]",exampleFunction1 中的 y 不会造成影响
}
exampleFunction1();
exampleFunction2();
根据值和表达式可见范围的确定方式,作用域分为静态作用域和动态作用域。作用域在代码逻辑定义时(编译时)就确定的属于静态作用域,作用域在代码逻辑运行时(运行时)确定的属于动态作用域。动态作用域在运行前难以确定,复杂度较高,所以大部分高级编程语言(诸如C, C++, Python, Java,C#)采用的是静态作用域。JavaScript采用的也是静态作用域。静态作用域又称为词法作用域。大部分时候,作用域堆叠成层次结构,子作用域可以访问父作用域,反过来则不行。 请看以下JavaScript代码。
var word = 'Hi';
function sayHi() {
return word;
}
function greeting() {
var word = 'Hello';
return sayHi();
}
console.log(greeting()); // 输出'Hi'
上述代码运行结果输出Hi
,sayHi()
中取到的word
是函数外层定义的,它的值是Hi
,即使sayHi()
的调用是在greeting()
内部并且greeting()
内部也有个word
定义。方法的变量值可以在函数定义的作用域中查找,但是不会在方法调用的作用域中查找,这种查找关系在我们写代码的时候就可以清晰理解,这就是静态作用域工作方式。动态作用域的工作方式却不同,请看同样的代码逻辑在Perl语言中的运行方式,如下。
$word = 'Hi';
sub sayHi
{
return $word;
}
sub greeting
{
local $word = 'Hello';
return sayHi();
}
print greeting(); # 输出'Hello'
上述代码运行结果输出Hello
,sayHi()
中取到的word
是函数调用的作用域中定义的,它的值是Hello
。即方法中变量的作用域是沿着调用的作用域查找的,一个函数可能在多个地方调用,变量的值可能都不一样,这种运行时才能确定作用域的方式就是动态作用域。
JavaScript 作用域
在JavaScript中,作用域主要有全局作用域、模块作用域、函数作用域、块级作用域四种,其中,块级作用域属于额外的作用域。
- 全局作用域:脚本模式下运行所有代码的默认作用域。直接运行在<script>标签下的代码,或者浏览器端单独的一个js文件中的代码,都运行在全局作用域下。全局作用域在页面创建时打开,在页面关闭时销毁。在浏览器端中,在全局作用域中有一个全局对象 window(代表的是一个浏览器的窗口,由浏览器创建),可以直接使用。在Node中,有一个全局对象global。
- 模块作用域:模块模式中运行代码的作用域。在Node中,每一个js文件都是一个单独的作用域,Node的模块化会将每一个文件中的代码运行在一个单独的作用域中。每个js文件是独立的作用域不共享,所以Node中每一个文件中的变量都是私有的。
- 函数作用域:由函数创建的作用域。调用函数时创建函数作用域,函数执行完毕之后,函数作用域销毁;每调用一次函数就会创建一个新的函数作用域,它们之间是相互独立的。
- 块级作用域:用一对花括号(一个代码块)创建出来的作用域。这类代码块包括
while
,if
,try...catch
,switch
,for...in
,for...of
,甚至匿名{/*StatementList*/}
。代码块运行完毕后,作用域同样会销毁。块级作用域是ES6扩展的概念,在变量绑定的时候,只支持let
,const
,class
声明的变量或类。使用var
声明的变量不会绑定到块级作用域上。
下面是一些作用域的示例代码。
var globalScope = 'global scope';
function checkScope1(){
console.log(globalScope); // 输出 "global scope"
}
checkScope1();
function checkScope2() {
var functionScope = 'function scope';
console.log(functionScope); // 输出 "function scope"
}
checkScope2();
{
var variableInBlock = 'variable in block';
let blockScope = 'block scope';
console.log(blockScope); // 输出 "block scope"
}
console.log(variableInBlock); // 输出"variable in block"
console.log(blockScope); // 报错 blockScope is not defined
这些代码演示了程序是如何访问不同作用域中变量。题外话,由于JavaScript的松散性,在非严格模式下,浏览器端的JavaScript运行时,没有使用任何声明的参数会被绑定在全局对象window
上,对于使用var
重复声明已经使用var
声明的变量,会静默失败,不会报错。而使用let
,const
,class
重复声明,或者使用var
重复已经被let
,const
,class
声明的变量,JavaScript程序会报错。
JavaScript 作用域链
上面我们说过,大部分时候作用域堆叠成层次结构,子作用域可以访问父作用域,反过来则不行。这便是JavaScript作用域的查找机制。具体来说,当程序访问一个变量的时候,首先会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域。如果在全局作用域里仍然找不到该变量,它就会在全局范围内隐式声明该变量(非严格模式下)或是直接报错(严格模式下)。请看以下代码示例。
var scope = '[global] scope';
var globalScope = 'global scope';
function outerFunc() {
var scope = '[outer function] scope';
var outerScope = 'outer scope';
function innerFunc() {
var scope = '[inner function] scope';
var innerScope = 'inner scope';
console.log(scope); // 输出 "[inner function] scope"
console.log(innerScope); // 输出 "inner scope"
console.log(outerScope); // 输出 "outer scope"
console.log(globalScope); // 输出 "global scope"
}
console.log(scope); // 输出 "[outer function] scope"
console.log(outerScope); // 输出 "outer scope"
console.log(globalScope); // 输出 "global scope"
innerFunc();
}
console.log(scope); // 输出 "[global] scope"
console.log(globalScope); // 输出 "global scope"
outerFunc();
在innerFunc()
中,访问scope
时,首先找到的是定义在innerFunc()
中的scope
,所以得到"[inner function] scope",同时,innerFunc()
中可以访问到自身作用域中的innerScope
,父作用域中的outerScope
和全局作用域中的globalScope
。在outerFunc()
中,父作用域访问不了子作用域,outerFunc()
访问不了innerFunc()
中的scope
,但是在当前作用域中访问到scope
,得到"[outer function] scope",同时outerFunc()
中可以访问到自身作用域中的outerScope
和全局作用域中的globalScope
。在全局作用域中,可以访问到全局作用域中的scope
和globalScope
。观察scope
变量的访问可以看到,程序沿着作用域链向上查找一个变量的同时,在找到第一个声明的时候会命中并停止,这个效果称为遮蔽效应。
JavaScript程序访问变量时沿着作用域链向上查找的机制,以及函数作为一等公民可以作为返回值的设定,使得在JavaScript中能够使用一种强大的编程技巧,就是闭包,这个后面我们单开一篇进行讨论。
JavaScript 声明提升
理解了作用域的概念,我们就可以探讨JavaScript声明提升的问题了。
JavaScript 提升是指解释器在执行代码之前,将函数、变量、类或导入的声明移动到其作用域的顶部的过程。提升不是 ECMAScript 规范中规范定义的术语。规范确实将一组声明定义为可提升的声明,但这只包括function
、function*
、async function
以及async function*
声明。提升通常也被认为是 var 声明的一个特性,尽管方式不同。用通俗的话来说,以下任何行为都可以被视为提升:
- 能够在声明变量之前在其作用域中使用该变量的值。(“值提升”)
- 能够在声明变量之前在其作用域中引用该变量而不抛出 ReferenceError,但值始终是 undefined。(“声明提升”)
- 变量的声明导致在声明行之前的作用域中行为发生变化。
- 声明的副作用在评估包含该声明的其余代码之前产生。
function
、function*
、async function
以及async function*
四种函数声明的提升表现为第1种行为;var
声明的提升表现为第2种行为;let
、const
和class
声明(也称为词法声明)的提升表现为第3种行为;import
声明的提升表现为第1和第4种行为。
函数声明提升
函数声明提升也会提升到当前作用域(包括块级作用域)最前面,同时提升的,还有函数签名和函数体定义(函数值)的绑定。请看如下代码。
console.log(sayHi); // 输出函数体定义 ƒ sayHi() { ... }
sayHi(); // 执行sayHi(),运行结果是输出 "Hi"
function sayHi() {
console.log('Hi');
}
四种函数声明具备同样的提升效果。并且对于重复的函数声明,最后的(新的)函数声明会覆盖之前的(旧的)函数声明,请看下面代码。
sayHi(); // 执行sayHi(), 运行结果输出 "Hello"
function sayHi() {
console.log('Hi');
}
function sayHi() {
console.log('Hello');
}
var
声明提升
ES6之前没有声明提升的概念,但是var
声明的表现是声明提升的。不管var
变量声明是写在哪里,最后都会被提到当前作用域(非块级作用域)的顶端,并使用undefined
进行初始化。请看如下代码。
console.log(x); // 输出 undefined
var x = 1;
console.log(x); // 输出 1
console.log(y); // 报错:y is not defined。对比项,因为没有任何地方声明 y,所以报错
上述代码等价于下面代码。
var x;
console.log(x); // 输出 undefined
x = 1;
console.log(x); // 输出 1
console.log(y); // 报错:y is not defined。对比项,因为没有任何地方声明 y,所以报错
另,函数声明提升的优先级是高于var
变量声明的,请看如下代码。
console.log(sayHi); // 输出函数体定义 ƒ sayHi() { ... }
sayHi(); // 执行sayHi()
function sayHi() {
console.log('Hi'); // 输出 "Hi"
}
var sayHi = 'sayHi';
console.log(sayHi); // 输出 "sayHi"
上述代码等价于下列代码。
function sayHi() {
console.log('Hi'); // 输出 "Hi"
}
console.log(sayHi); // 输出函数体定义 ƒ sayHi() { ... }
sayHi(); // 执行sayHi()
sayHi = 'sayHi';
console.log(sayHi); // 输出 "sayHi"
let
、const
和class
声明提升
let
、const
和class
声明提升会被提升到当前作用域顶端,但是不会给声明的变量赋值,而是指派给它们uninitialized的状态,访问uninitialized状态下的变量程序会报错。示例代码如下。
function run() {
console.log(a); // 报错:Cannot access 'a' before initialization
let a = 1;
}
run();
如果没有let
声明,代码的表现行为是不一样的,对比代码如下。
function run() {
console.log(a); // 报错:a is not defined
}
run();
因为var
声明无法绑定到块级作用域上,块级作用域上的var
声明会提升到当前其他类型作用域上进行绑定,所以下列代码运行结果和预期结果不同。
for(var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出三次 3
})
}
在let
,const
出现之前,开发人员使用立即执行函数来修复这个问题,let
,const
出现之后,在for...in
和for...of
循环语句中可以直接使用let
,const
解决这个问题。
// 使用立即执行函数进行修复
for(var i = 0; i < 3; i++) {
(function(i) {
setTimeout(() => {
console.log(i); // 分别输出 0, 1, 2
})
})(i)
}
// 使用 let/const
for(let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 分别输出 0, 1, 2
})
}
JavaScript 为什么要有声明提升
即使V8 JavaScript引擎已经支持了即时编译(JIT),但JavaScript仍然是一门解释型高级编程语言。解释型语言在解析代码的时候是逐行的,这个特点决定了它有声明提升的必要。同时,声明提升也会提高程序运行性能。
声明提升的必要性
假如没有函数声明提升,会发生什么。首先我们遇到的第一个问题是我们在使用一个函数时,一定要首先声明它,这很麻烦。虽然开发人员加点小心是可以妥善解决这个先后问题,但是会给项目开发带来很多额外的工作。 我们遇到的第二个问题涉及到函数相互递归。审查以下函数相互递归调用的例子,如果函数声明没有提升,代码会怎么执行。
function isEven(n) {
if (n == 0) {
return true;
}
return isOdd(n - 1);
}
alert(isEven(2));
function isOdd(n) {
if (n == 0) {
return false;
}
return isEven(n - 1);
}
当代码执行到alert(isEven(2));
一行的时候,isEven(2)
成功被调用,程序执行进入函数isEven
,在函数isEven
内调用isOdd()
时,因为此时函数isOdd
未声明,所以程序会报错。
解决这两种问题的方式就是函数声明提升,在程序执行模块逻辑之前,创建当前作用域执行上下文,绑定函数定义,然后执行代码逻辑内容,看上去就是作用域中的函数声明和定义提升到作用域顶端,这样,在整个作用域便可以直接调用声明的函数,也会避免函数相互递归情况下报错的问题。JavaScript程序的执行分为代码解析(预编译)和执行两个阶段,JavaScript引擎在代码解析(预编译)阶段隐式地完成了这种提升。
性能优化
声明提升有助于程序性能优化,我们知道,在执行程序时,声明语句会为一个变量创建堆栈,分配内存并可选地初始化变量值。思考下列代码。
var count = 0;
while(count < 100) {
var message = '第' + count + '次';
console.log(message);
count = count + 1;
}
如果没有声明提升,那么每次执行message
变量声明的时候都需要为其开辟内存,并将其指针压入执行栈,同时还需要将旧的message
变量销毁。如果应用了声明提升,message
变量只会声明一次,为其开辟内存一次,后续都是在现有内存上更新变量值。这样,程序性能得到提升。函数声明同理。所以JavaScript引擎都会对声明进行提升以优化程序执行性能。这是JavaScript引擎在代码解析(预编译)阶段的优化之一。
var
声明提升的问题
var
声明提升能够起到性能优化的作用,但是,在一些情景下,var
声明提升很容易引入程序bug,它另程序在某些情境下的行为很“反直觉”。请看以下代码。
var name = 'JavaScript';
function printName () {
console.log(name);
var name = 'ECMAScript';
}
printName();
但从直觉上来看,变量name
有赋值,应该输出"JavaScript",考虑var
声明提升,应该输出"ECMAScript",但实际上输出的是undefined
。var
声明提升并不会提升赋值语句,name = 'ECMAScript'
被留在原地在输出语句之后。var
重复声明时也不会报错,在维护大型程序时,这很容易引入bug,为了解决这个问题,ES6才提出了let
和const
声明和引入块级作用域机制。JavaScript引擎是如何实现作用域机制和这些声明提升呢?这就需要深入到JavaScript执行上下文和JavaScript执行栈了。
let
, const
,class
提升的另类说法
有关let
, const
,class
有另一种说法是视它们的声明不会提升,并提出“暂时性死区”的概念,这种说法可接受,但不严谨。说其可接受,是因为“提升”一词也不是普遍认同的术语。说其不严谨,是因为程序在执行声明变量语句之前的“暂时性死区”仍然是可观察到的“声明语句的影响”,既然有实质的影响,这也是某种形式的提升。查看以下代码的不同
const x = 1;
function func1() {
console.log(x); // ReferenceError: Cannot access 'x' before initialization.
// `const x = 2;`的提升造成了上面访问x报错的结果,如果视为不提升,那么是应该能够访问到全局作用域中的x。
const x = 2;
}
func1();
const y = 3;
function func2() {
console.log(y); // 输出 undefined. `var y = 4;`的提升造成了这个结果
var y = 4;
}
func2();
如果我们深入到JavaScript程序的运行堆栈,去观察let
, const
,class
这些声明的行为,我们也会发现将其视为提升是恰当的。理解JavaScript执行上下文和执行栈,对我们去理解作用域、提升、闭包这些概念都会有很大帮助,我们后面会对这个主题专门讨论。