如果摆在你面前有一百套面试题,随便翻开哪一套,你会发现绝对会少不了关于 变量提升(Hoisting) 的题目。如果翻不到,那肯定是假的面试题。嗯!

# 概述

摘抄来自 MDN 的描述:

变量提升(Hoisting)被认为是, Javascript中执行上下文 (特别是创建和执行阶段)工作方式的一种认识。您在 ECMAScript® 2015 Language Specification 之前的JavaScript文档中找不到变量提升(Hoisting)这个词。

不过,需要注意的是,这个概念可能产生一点点误解 。

例如,从概念的字面意义上说,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但这么说并不准确。实际上变量和函数声明在代码里的位置是不会动的,而是在编译阶段被放入内存中。

正如上面所说的,其实我很不喜欢用 提升 这个词来描述 JS 中出现的这种现象,所以我们今天必须揭开它的内幕。

# 问题引入

假设我们的网页中有这么一段代码:

console.log(imNotExist);

在浏览器中打开它会报这样一个错: ReferenceError

这就相当于你去冰箱里拿一瓶根本就不存在的汽水!肯定会报错! 从内存的角度上讲,在内存中并没有存放这么一个变量,所以报错了。

那如果是下面这样的代码呢?

console.log(imNotExist);
var imNotExist;

在浏览器中它将输出一个 undefined。 这就相当于我们去冰箱里拿了一罐汽水,但是它是空的,而且因为它没有贴标签,你也不知道它是放什么汽水的(未定义)。 从内存的角度上讲,在内存里面已经存放了这个变量的引用,但是变量没有指向任何东西。

# 提升的出现

如果按照常规的理解逻辑,程序都是从上往下运行的,那上面的两段代码其实没什么区别啊!

这是因为我们用着自己的理解方式而忽略了 浏览器的理解方式

在浏览器运行 JavaScript 的时候,它会使用某种引擎将 JavaScript 代码转化成机器码。这个过程可以理解为是某种翻译(编译/解释)过程。

编译过程

而恰恰在这个编译的过程中,浏览器会将我们的代码进行所谓的 变量提升

所以这段代码在浏览器编译之后就相当于这样:

var imNotExist = undefined;
console.log(imNotExist);

真的非常的字面意义!

# 提升所带来的影响

例如有下面一段代码,你觉得会输出什么?

var myName = "tart";
function printMyName(){
    console.log(myName);
    var myName = "tricky";
    console.log(myName);
}
printMyName();

两次输出的结果分别是 undefinedtricky。这是因为从浏览器的视角上,这个代码形如:

var myName = "tart";
function printMyName(){
    var myName = undefined; // <- 所谓的”变量提升“
    console.log(myName);
    myName = "tricky";
    console.log(myName);
}
printMyName();

在工作中可能会经常因为这种潜在的 提升 而导致了许多的 bug。

但是其实这种非常字面化理解的方式其实不足够好,因为从内存的角度上讲它是另一回事。并且,笼统地使用一句 let声明变量不存在变量提升 很不符合我们喜欢探究的精神。

# 作用域范围

在让大家得到更好的理解之前,我们先认识一个概念:(作用域范围Scope)

相信几乎每一个学习编程的人都听说过 公有变量(public)私有变量(private) 这些多应用于面向对象语言的概念。

在 js 中也不例外,一个变量总有他的适用范围,在适用范围之外如果你尝试去访问它,肯定会产生错误。 就好像你去冰箱里拿了一罐不存在的汽水??

看下面一段代码:

var name="tart";
function printName(){
    var myName = "tricky";
    console.log(name);
    console.log(myName);
    
    function anotherPrintName(){
        var name="ajerk";
        console.log(name);
        console.log(myName);
    }
    
    anotherPrintName();
}
printName();

// 这段代码应该输出:
// tart
// tricky
// ajerk
// tricky

浏览器会在编译阶段扫描这些变量并 记录他们的适用范围:

作用域范围

在 js 中的作用域范围最基本的分为两种:一种是局部的(函数范围内),一种是全局的(Window范围内)。在 es6 之后还会有 块级作用域,就是一个简简单单的 if 语句也会有自己的作用域了。

作用域和作用域之间如上图所示好像有一种嵌套的关系,基于这种关系,我们能很快明白一个原理:变量的使用将遵循就近原则

在上面的代码中其实有两个地方声明了 name 这个变量,而真正使用 name 变量时,浏览器会在作用域链条之中往上层寻找 最近的 name 是在哪里声明的,并且使用这个变量的值。

所以再次回头看看上文中的这段代码:

var myName = "tart";
function printMyName(){
    console.log(myName);
    var myName = "tricky";
    console.log(myName);
}
printMyName();

各个变量的作用域范围如下图所示:

作用域范围2

这是在浏览器的编译过程中,对于这段代码的变量扫描阶段就已经确定的事情。

直到这里为止,我们先不要讨论为什么第一个 console.log(myName) 输出的是 undefined。因为这个地方很关键,它和使用 let 语句声明的变量有很大的行为差别。我们得先用上一种更好的理解方式:

# 更好的理解

为什么用 更好的 而不是 最佳的,是因为从某种角度上说,还有更加严谨、更加正确的理解,但在现在的学习阶段,为了帮助大家建立概念,我觉得用上更好的理解方式,是很棒的!

我在一些博文中看到三个单词,用于描述一个变量的生命周期:Declaration phaseInitialization phaseAssignment phase

# Declaration phase(声明阶段)

在浏览器进行变量扫描的时候,将告知浏览器这个变量是处在于哪一个作用域中的,比如这个变量是声明在了一个函数里面?还是一个if语句的块级作用域里面?

在这个阶段中,变量还未在内存中申请空间。

就好像你心里面知道汽水是要放冰箱一样,但实际上冰箱里没有存在这么一罐汽水。

正如我们上面所画的作用域范围图一样,浏览器会对这些变量的作用域范围进行记录,并不会真正为这些变量开辟内存空间。

正因为变量还没拥有自己的内存空间,如果这时候我们尝试着去访问这个变量,它就会抛出一个我们篇头所展示的 ReferenceError 的错误。

# Initialization phase(初始化阶段)

在这个阶段,将为变量申请一个内存空间,并且将它的值初始化为 undefined

就好像你放了一个空的汽水罐子在冰箱里,存在了,但是没东西。

这个过程就是被我们粗俗的理解为:把 var xxx = undefined 这样的语句提升到作用域的最前面。但实际上 浏览器并不是真的粗俗地在作用域前面放了这么一个语句,而是因为当浏览器为你的变量开辟完一个内存空间之后,这个内存空间里面本来就是空的,看起来与执行了这个语句一样而已!

# Assignment phase(赋值阶段)

赋值阶段就是顾名思义啦。myName = 'tricky' 就是一个赋值的过程。

就好像我往汽水罐子里面倒了可乐一样。

注意:再次强调,初始化阶段 中所进行的类似于 var xxx = undefined 的操作并不是赋值操作!这就是为什么我一直强调 “提升” 这种理解很表面的原因。

# let 为什么就没有”变量提升“

console.log(imNotExist);
let imNotExist;

这段代码只是把 var 换成了 let,但是这一次不会输出 undefined 而是抛出了一个 ReferenceError 的错误。

这是因为在浏览器扫描变量的时候,对于 let 执行了与 var 不一样的操作。

对于 let 而言,是严格遵循了我们上述的三个阶段。浏览器扫描完成之后,这个变量的生命周期已经通过了第一阶段:生命阶段。第二阶段:初始化阶段发生在代码的运行阶段 let imNotExist 这里。

所以,在第二阶段之前尝试去访问这个变量,会报 ReferenceError 的错误。

对于 var 而言,浏览器的变量扫描过程中就已经依次执行了第一阶段和第二阶段了。所以如果把这段代码换成用 var 来执行,就会输出 undefined

let 与 var 的对比

# function 关键字

假设我们把上面的代码变成

console.log(hello());
function hello(){return "hello"};

这段代码是可以成功输出 "hello" 的,证明了变量提升也存在于函数声明之中。

其实我以前从来不敢以这种视角去看待 function 关键字:使用 function 去声明一个函数和使用 var 去声明一个函数都一样存在变量提升

需要注意的是,function 所声明的函数也有自己的作用域范围。也是遵循着上文说了一大堆的各种原则。

# 总结

即使我们头脑里知道 变量提升 这个说法不准确,但是它已经是大家约定俗成的称呼了,所以我们就不要强行改变它,只要我们脑子里知道它到底经历了什么就ok了。

现在在 es6 中,除了 functionletvar 还有各种各样的关键字,而他们的行为又是是否遵循变量提升的原则呢?我们来个表格总结一下:

关键字 存在变量提升吗?
var true
let false
function true
class false
const false