此文章著作权归饥人谷_Lyndon和饥人谷所有,转载请注明
前言
比较绕的并不是作用域与变量提升,而是作用域链,经常容易在写伪码时遇到死循环/(ㄒoㄒ)/~~相对于作用域来说,变量提升会稍微绕一些,不过只需牢记原则就不会出错,熟悉变量提升的机制能够更好地理解作用域链,降低犯错风险。
一、作用域
在大多数编程语言中,会用花括号{}
来形成一个作用域,俗称“块作用域”,例如C语言、C++等。但是在JS中{}
并不能产生块作用域,JS中的作用域是依靠函数形成的。
在ECMAScript5中,JS只有两类作用域:全局作用域、函数作用域。
全局作用域:全局对象的作用域,在代码的任何地方都可访问,但有时会被函数作用域覆盖
函数作用域:作用于整个函数范围内,不管到底是在函数中的何处进行声明
// 全局变量
var i = 100;
// 函数声明,outer是一个外部函数
function outer(){
// 访问全局变量
console.log(i); // 100
// 函数声明,inner是一个内部函数
function inner(){
// 内部函数的内部进行了变量提升,也就是第二部分叙述的内容
console.log(i); // undefined
// 这里的i是局部变量,作用域仅在函数内
var i = 1;
// 局部变量覆盖全局变量,或者说是函数作用域覆盖全局作用域
console.log(i); // 1
}
inner();
// 这里的i是全局变量
console.log(i); // 100
}
outer();
定义变量时,如果不写var
,那么就会相当于声明了一个全局变量,作用域为全局作用域;否则声明的是局部变量,作用域为函数作用域。在以上代码段中,第一行的var i = 0
是全局变量,虽然它添加var
,但是在全局范畴中声明,而且不在函数范围内,因此效果等同于i = 0
。但是在JS编程中应该尽力避免不加var
,即使真的需要全局变量,也应该在最外层作用域中使用var
声明。
二、变量提升
关于变量提升(hoisting)的定义,Kenneth Truyers
曾经在博客中这样写道
In Javascript, you can have multiple var-statements in a function. All of these statements act as if they were declared at the top of the function. Hoisting is the act of moving the declarations to the top of the function.
变量的声明会被自动移到函数或者全局代码的最顶上。移动的仅仅是declarations
,变量的定义并不会随之提升。
因为变量提升非常的weird,所以很多代码的欺骗性非常强,尤其是在前端面试或者笔试中考官非常青睐于类似的题目,因此我建议理解代码的等价形式,也就是在纸上根据变量提升原则来书写新的等价代码从而找出正确答案。如以下代码段:
var date = new Date();
function fn(){
console.log(date);
if(true){
var date = 'hello';
}
}
fn();
结果并不是date
的toString
方法返回的结果,而是undefined
,因为以上代码等价于:
// 变量声明提升
var date;
date = new Date();
function fn(){
// 变量声明提升,但是此时未定义变量的值
var date;
console.log(date);
if(true){
date = "hello";
}
}
fn();
但是在变量提升中还存在着一些特殊情况,因为在ES5中,变量声明、函数声明都会被提升,这就衍生出很多值得辨析的问题。
在ES6中,function *
, let
, class
, const
也会被提升,但是提升机制又与变量提升、函数提升有所区别。
>>> 情境1:重复声明
如以下代码,重复声明变量a
:
var a = 10;
console.log(a); // 10
if(true){
var a = 20;
console.log(a); // 20
}
console.log(a); // 20
分析这一段代码时,记住两点:JS变量只有全局作用域、函数作用域两种作用域形态,a
只会在代码顶部声明一次,而var a = 20
的作用仅是赋值,因此以上代码等价于:
var a;
var a; // 是流程控制语句中的a,实际上在JS解析中这一句是不存在的,因为变量`a`已经声明过了
a = 10;
console.log(a);
if(true){
a = 20;
console.log(a);
}
console.log(a);
>>> 情境2:命名冲突
当console.log
处于需要提升的变量与方法的下方时,如果在同一个作用域中定义了名字相同的变量与方法,那么无论顺序如何,变量的赋值都会覆盖掉方法的赋值。其实用正常思维方式就可以理解。
var fn = 3;
function fn(){};
console.log(fn); // 3
以上代码等价于:
var fn;
function fn(){};
fn = 3;
console.log(fn);
可以明显看出:经过转换后是很容易被理解的。但是还需要考虑到当函数执行有命名冲突的时候,函数执行的载入顺序是变量、函数、参数
,如以下代码:
function f(f){
console.log(f);
var f = 100;
console.log(f);
}
f(20);
这是一段复杂的代码,但是结合载入顺序来理解,可以将代码等价转换为如下形式:
function f(f){
var f;
console.log(f);
f = 100;
console.log(f);
}
传入参数f = 20
后,函数内部相当于虚拟形成:var f = 20
,这个变量声明其实可以认为是覆盖了函数内部变量提升的var f
,因此第一个console.log(f)
的结果为20
,接下来是第二个f = 100
覆盖之前的变量值,那么第二个console.log(f)
的结果为100
,所以在执行这个很annoying的函数的时候,先提升变量,再执行函数体,接下来传入参数20
,事实上也很好理解。
>>> 情况3: 函数与变量同时提升
如下代码示例:
console.log(f);
function f(){};
var f = 'text';
输出结果是:function f(){}
但是稍作转变成如下形式:
console.log(f);
var f = function(){};
var f = 'text';
输出结果是:undefined
这里涉及到的知识实际上和变量提升关系不大,而是和函数声明方式有关:
ECMAScript里面规定三种声明函数方式,常用的有以下两种:
// 第一种:函数声明
function f(){
statement;
}
// 第二种:函数表达式
var f = function(){
statement;
}
针对第一段代码,其中运用函数声明,函数声明的方式所能保证的是:即使函数写在最后也能在之前语句中进行调用,但是函数声明部分必须已经被下载至本地;
而第二段代码,其中运用函数表达式,实质上是定义了一个变量f
,然后把function(){}
赋给变量,因此第二段代码实际上等价于:
var f;
console.log(f);
f = function(){};
f = 'text';
第一段代码,函数声明在提升的时候,实际上是会把整个函数提升上去,包括函数定义的部分,所以第一段代码的等价形式是:
var f;
function f(){};
console.log(f);
f = 'text';
但是再将代码进行转换,会得到不一样的结果:
console.log(foo);
var foo = 'text';
function foo(){};
返回的结果是:function foo(){}
>>> 情况4:函数与函数重复声明
当两个函数声明重复时,其原则是后者覆盖前者。以下代码段:
console.log(foo);
function foo(n){return n+2};
function foo(n){return n+1};
输出结果是:function foo(n){return n+1}
原理:在函数声明提升时,遵循先来后到的函数声明提升原则,之后后者会覆盖前者,因此以上代码等价于:
function foo(n){return n+2};
function foo(n){return n+1};
console.log(foo);
如果调换代码秩序,那么代码输出结果会变化:
function foo(n){return n+1};
function foo(n){return n+2};
console.log(foo); // function foo(n){return n+2};
总结
经过以上操作,可以归纳出四项原则:
- 所有声明都会被提升到对应作用域的顶上(
as if they were declared at the top of the function
) - 同一个变量声明只进行一次,其他重复声明会被JS解析忽略
- 函数声明进行提升时会连带函数定义一起提升
- 遵循前三项原则多多动手写等价转换,就一定不会出错