提升----你所不知道的JavaScript系列(3)
很多编程语言在执行的时候都是自上而下执行,但实际上这种想法在JavaScript中并不完全正确, 有一种特殊情况会导致这个假设是错误的。来看看下面的代码,
a = 2; var a; console.log( a );
console.log(a) 会输出什么呢?
有些人可能会认为是 undefined,因为 var a 声明在 a = 2 之后,他们自然而然地认为变量被重新赋值了,因此会被赋予默认值 undefined。但是,真正的输出结果是 2。
先不急为什么,我们再继续看另外一段代码,
console.log( a ); var a = 2;
鉴于上一个代码片段所表现出来的某种非自上而下的行为特点,你可能会认为这个代码片段也会有同样的行为而输出 2。还有人可能会认为,由于变量 a 在使用前没有先进行声明,因此会抛出 ReferenceError 异常。
其实不然,两种猜测都是不对的。输出来的会是 undefined。
提升
引擎会在解释 JavaScript 代码之前首先对其进行编译,简单地说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前,说通常是因为JavaScript 中存在两个机制可以“欺骗” 词法作用域: eval(..) 和 with)。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。这就是我们通常说的“提升”。
注:只有声明本身会被提升, 而赋值或其他运行逻辑会留在原地。
foo(); function foo() { console.log( a ); // undefined var a = 2; }
每个作用域都会进行提升操作。所以 foo(..)函数自身也会在内部对 var a 进行提升(显然并不是提升到了整个程序的最上方)。在这里,你或许会发现,为什么代码里面是先调用 foo() ,再声明 foo() 这样的顺序,却不会报错。这是因为除了变量声明会在其作用域内提升之外,函数声明也具有相似的特效。因此这段代码可以暂时理解为下面的形式:
function foo() { var a; console.log( a ); // undefined a = 2; } foo();
可以看到,函数声明会被提升在作用域的顶部。但是有一点需要和变量声明提升做区别的是:变量提升只是提升了变量的声明,而变量赋值并没有被提升。但是,函数的声明有点不一样,函数体也会一同被提升。
所以上面的一段暂时性的代码实际上可以这样理解:
var foo = { var a; console.log( a ); // undefined a = 2; } foo();
foo 函数的声明(这个例子还包括实际函数的隐含值)被提升了,因此第一行中的调用可以正常执行。
然而并不是所有的函数都能提升!函数声明会被提升,但是函数表达式却不会被提升。
foo(); // 不是 ReferenceError, 而是 TypeError! var foo = function bar() { // ... };
上面这段程序中的变量标识符 foo() 被提升并分配给所在作用域,因此 foo() 不会导致 ReferenceError。但是 foo 此时并没有赋值(如果它是一个函数声明而不是函数表达式,那么就会赋值)。foo() 由于对 undefined 值进行函数调用而导致非法操作,因此抛出 TypeError 异常。
同时也要记住,即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用:
foo(); // TypeError bar(); // ReferenceError var foo = function bar() { // ... };
这个代码片段经过提升后,实际上会被理解为以下形式:
var foo; foo(); // TypeError bar(); // ReferenceError foo = function() { var bar = ...self... // ... }
这里我们说到具名函数表达式,就顺便插如一点具名函数表达式的知识点。我们看看下面的例子:
function test() { var fn = function fn1() { log(fn === fn1); // true log(fn == fn1); // true } fn(); log(fn === fn1); // Uncaught ReferenceError: fn1 is not defined log(fn == fn1); // Uncaught ReferenceError: fn1 is not defined } test();
看上面这例子,是不是很疑惑?
具名函数表达式,是带名字的函数赋值给一个变量,这个名字只在新定义的函数作用域内有效,因为规范规定了标示符不能在外围的作用域内有效。也就是说,这个函数名只能在此函数内部使用,可以理解为这个函数名成了函数体内部的一个变量。
这里还有一点需要注意的,函数定义了一个非标准的name属性,通过这个属性可以访问到给定函数指定的名字,这个属性的值永远等于跟在function关键字后面的标识符,匿名函数的name属性为空,而具名的函数表达式会修改到这个属性。
var foo = function(){ //... }; console.log(foo.name); //foo var bar = function foobar(){ //... }; console.log(bar.name); //foobar name值被修改
函数优先
函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复” 声明的代码中)是函数会首先被提升,然后才是变量。
看一下下面的代码:
foo(); // 1 var foo; function foo() { console.log( 1 ); } foo = function() { console.log( 2 ); };
会输出 1 而不是 2 ! 这个代码片段会被引擎理解为如下形式:
function foo() { console.log( 1 ); } foo(); // 1 foo = function() { console.log( 2 ); };
var foo 尽管出现在 function foo()… 的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。尽管重复的 var 声明会被忽略掉, 但出现在后面的函数声明还是可以覆盖前面的。
foo(); // 3 function foo() { console.log( 1 ); } var foo = function() { console.log( 2 ); }; function foo() { console.log( 3 ); }
我们来看看下面这个,
function text1() { var a = 1; function b() { a = 10; return; function a() {} } b(); console.log(a); // ? } text1(); function text2() { var a = 1; function b() { a = 10; function a() {} } b(); console.log(a); // ? } text2();
想一想,这两段代码输出的结果会是什么?
结果都是1!为啥???
这里需要注意的是,在 function b() 中,function a() 由于存在函数提升,上述代码实际上的运行代码是这样子的,
function text{ var a = 1; function b() { var a = function(){}; a = 10; //return; //这个return对这段代码没有任何影响 } b(); console.log(a); 1 }
是不是很神奇~~~~所以在写代码的时候,就要特别注意了,不要因为 JavaScript 的提升机制导致很多莫名其妙的bug出来。
最后还有一个要强调一下,由于一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断所控制:
foo(); // "b" var a = true; if (a) { function foo() { console.log("a"); } } else { function foo() { console.log("b"); } }
function hoistVariable() { if (!foo) { var foo = 5; } console.log(foo); // 5 } hoistVariable();
小结:
我们习惯将 var a = 2; 看作一个声明,而实际上 JavaScript 引擎并不这么认为。它将 var a和 a = 2 当作两个单独的声明, 第一个是编译阶段的任务,而第二个则是执行阶段的任务。这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升。
声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。
要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,否则会引起很多危险的问题!
理解变量提升和函数提升可以使我们更了解这门语言,更好地驾驭它,但是在开发中,我们不应该使用这些技巧,而是要规范我们的代码,做到可读性和可维护性。具体的做法是:无论变量还是函数,都必须先声明后使用。
如果对于新的项目,可以使用let替换var,会变得更可靠,可维护性更高。值得一提的是,ES6中的class声明也存在提升,不过它和let、const一样,被约束和限制了,其规定,如果再声明位置之前引用,则是不合法的,会抛出一个异常。
所以,无论是早期的代码,还是ES6中的代码,我们都需要遵循一点,先声明,后使用。