JavaScript 中的提升 Hoisting
1. 声明、赋值与提升的概念
1.1 声明 declaration
在不考虑 ES6 的情况下,声明有以下两种:
- 变量声明
var a - 函数声明
function f() {}
注意
函数声明使用 function 关键字,是一个整体,它包含了函数名、参数以及函数体,不能拆解成声明变量随后把一个函数赋值给该变量这两个步骤。
- 代码段一
// 使用 function 声明函数 f
function f() {
console.log('hello')
}
- 代码段二
// 1. 声明变量 f
var f
// 2. 把一个函数赋值给变量 f
f = function() {
console.log('hello')
}
上述两段代码虽然在调用时表现一致,但是在考虑提升问题时,是不等价的!其中代码段二实际上是函数表达式的拆解形式,也就是说,函数声明不会被引擎拆解执行,而函数表达式会被引擎拆解成两步执行,详见下节。
1.2 赋值 assignment
声明和赋值虽然往往写在一条语句中,但是我们需要对它们区分对待。具体来讲,引擎把声明放到编译阶段提前执行,把赋值操作留在原地放到执行阶段执行。此处也考虑两种情况:
- 一般变量的声明与赋值
- 函数表达式的声明与赋值
1.2.1 一般变量的声明与赋值
比如说有如下语句:
var a = 666
这看起来是一条语句,实际上引擎会把它拆解成声明与赋值两个步骤:
- 声明:
var a这一步引擎会放到编译阶段compilation phase提前执行。 - 赋值:
a = 666这一步会被引擎留在原地,在执行阶段execution phase执行。
1.2.2 函数表达式的声明与赋值
类似地,对于函数表达式 var f = function() {},也会被引擎拆解成声明与赋值两个步骤执行:
- 声明:
var f在编译阶段提前执行 - 赋值:
f = function() {}留在原地,在执行阶段执行
可以看出,函数表达式的声明与赋值过程与一般变量极为相似。
1.3 提升 hoisting
现在我们能够区分声明和赋值,并且知道它们被引擎放置于不同的阶段执行,那什么是提升呢?
引擎把声明与赋值或其他可执行逻辑分开,其中声明会在编译阶段提前执行,而赋值或其他可执行逻辑会被留在原地,放到执行阶段执行。引擎的这一行为在我们看起来就好像发生了提升。
提升 Hoisting:变量和函数声明从原始位置移动到所在作用域的最上面,而赋值或其他可执行逻辑则留在原地。
1.4 常见误区
提升实际上是一种“看起来”的效果,并不意味着声明部分的代码真的发生了移动。
这里引用 MDN 的原文:
developer.mozilla.org/en-US/docs/… the variable and function declarations are put into memory during the compile phase, but stay exactly where you typed them in your code.
也就是说变量和函数的声明只是在编译阶段被放到了内存里,这些代码仍然位于原来的位置,并没有发生移动,提升只是一种便于我们理解引擎处理思路的等效方法。
2. 提升详解
2.1 提升的分类及其等价形式
提升可以分为两种
- 变量或函数表达式的提升
如 1.2.2 节所述,函数表达式的声明与赋值过程与一般变量极为相似,所以函数表达式的提升也和变量的提升类似。
- 函数的提升
这里的函数不包括函数表达式。
2.1.1 变量或函数表达式的提升
变量的提升
var a = 666
等价形式:
var a
a = 666
注:为了体现编译阶段和执行阶段,本来应该放在一段里的代码被我分成了两段,后文不再赘述。
函数表达式的提升
var f = function() {
console.log('hello')
}
等价形式:
var f
f = function() {
console.log('hello')
}
2.1.2 函数的提升
function f() {
console.log('hello')
}
等价形式:
// 编译阶段
function f() {
console.log('hello')
}
// 运行阶段
由此可见,函数的提升不会拆成类似函数表达式的样子,先声明一个变量,再为该变量赋值一个函数。
2.2 提升举例
2.2.1 变量提升
考虑如下代码:
console.log(a)
var a = 666
// undefined
本文所有执行结果都是
Node.js得出的运行结果,一些显示会和浏览器中的有所不同,但不影响什么。
这里之所以输出 undefined 而不是报错 ReferenceError,是因为 var a 被引擎放到了编译阶段执行,也就是发生了变量的提升。它相当于下面的代码。
var a
console.log(a)
a = 666
// undefined
2.2.2 函数表达式提升
考虑下面的代码:
f() // TypeError
var f = function() {
console.log(666)
}
上面代码会报错是因为函数表达式类似一般变量,引擎会将其拆解为声明和赋值两个步骤,声明会提前在编译阶段执行,而赋值会被留在原地,放到执行阶段执行。
注意:报的错是 TypeError 类型错误,而不是 ReferenceError 引用错误,是因为在编译阶段 f 已经被 var f 声明为一个变量,所以引擎是可以找到 f 这个名字的,不会报 ReferenceError。但是执行 f() 时 f 的值仍然是 undefined,相当于执行了 undefined(),所以会报 TypeError。
上述代码经过提升等价于:
var f // 函数表达式提升
f() // TypeError
f = function() {
consoel.log(666)
}
对于具名的函数表达式,它的名称标识符在赋值之前也无法在所在作用域中使用:
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
};
上述代码相当于下面的形式:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
2.2.3 函数提升
考虑以下代码:
f() // hello
function f() {
console.log('hello')
}
上述代码经过提升等价于:
function f() {
console.log('hello')
}
f() // hello
由于函数提升的存在,先调用函数再定义函数也不会报错,在有些编程语言中这样是不可以的。
2.3 重复声明会被引擎忽略
考虑下面的代码:
var a = 666
var a
console.log(a)
// 666
按照提升理论,上述代码应该与下面的代码等价:
var a // var a = 666 拆解出来的声明步骤
var a // 重复声明会被引擎忽略
a = 666
console.log(a)
// 666
可见重复声明并没有让变量 a 重新初始化为 undefined,而是会被忽略。
只要 var name 后面的 name 是已经存在的,不管它是变量还是函数,引擎都会忽略对它的重复声明。
请记住重复声明会被引擎忽略这一点,在下一节马上会用到。
2.4 同名函数和变量或函数表达式的提升
结论:如果出现同名函数和变量(或函数表达式),函数会首先被提升,其次才是变量(或函数表达式)。
2.4.1 同名函数和变量
考虑如下代码:
function f() {}
var f
console.log(f)
// [Function: f]
经过提升等价于:
function f() {}
var f // 对已有名字 f 的重复声明,被引擎忽略
console.log(f)
// [Function: f]
尝试调换一下顺序:
var f
function f() {}
console.log(f)
// [Function: f]
结果还是 [Function: f],原因如下:
function f() {} // 函数声明首先被提升
var f // 重复声明被忽略
console.log(f)
// [Function: f]
2.4.2 同名函数和函数表达式
函数表达式的情况与一般变量声明赋值类似。 考虑以下代码:
function f() {
console.log(1)
}
var f = function() {
console.log(2)
}
f()
// 2
经过提升等价于:
function f() { // 函数声明首先被提升
console.log(1)
}
var f // 重复声明被忽略
f = function() {
console.log(2)
}
f()
// 2
尝试调换一下函数表达式和函数声明的位置:
var f = function() {
console.log(2)
}
function f() {
console.log(1)
}
f()
// 2
经过提升等价于:
function f() { // 函数声明被首先提升
console.log(1)
}
var f // 重复声明被忽略
f = function() {
console.log(2)
}
f()
// 2
在前面也加一次函数调用,看看什么情况:
f() // 1
function f() {
console.log(1)
}
var f = function() {
console.log(2)
}
f() // 2
经过提升等价于:
function f() { // 函数声明被首先提升
console.log(1)
}
var f // 重复声明被忽略
f() // 1
f = function() {
console.log(2)
}
f() // 2
调换一下顺序,看看有没有变化:
f() // 1
var f = function() {
console.log(2)
}
function f() {
console.log(1)
}
f() // 2
输出没有变化,上述代码经过提升等价于:
function f() { // 函数声明首先被提升
console.log(1)
}
var f // 重复声明被忽略
f() // 1
f = function() {
console.log(2)
}
f() // 2
2.4.3 同名函数、变量、函数表达式
如果出现同名函数、变量、函数表达式,首先被提升的仍然是变量声明,其次按照顺序提升变量和函数表达式的声明,当然重复声明还是会被忽略,但是位于后面的声明和赋值会覆盖位于前面的声明和赋值。
f() // 4
function f() {
console.log(1)
}
var f = function() {
console.log(2)
}
var f = 3
function f() {
console.log(4)
}
f() // TypeError
上述代码经过提升等价于:
function f() { // 函数声明首先被提升
console.log(1)
}
function f() { // 位于后面的声明会覆盖前面的
console.log(4)
}
var f // 来自函数表达式的重复声明被忽略
var f // 来自变量的重复声明被忽略
f() // 4
f = function() {
console.log(2)
}
f = 3 // 这里 f 被赋值成一个数字
f() // TypeError
2.5 提升发生在当前作用域
这个问题比较坑,我也没有得到确切的结论,这里记录一下暂时的观点。
- 在某些浏览器中,提升会忽略块级作用域,发生在当前函数作用域。这也是《你不知道的JavaScript》中的说法,同时该书也提到不要过于依赖这一特性,因为随时可能改变。
- 在另外一些浏览器中,提升的工作流程不是很清楚。可以肯定的是,如果在当前块作用域以外、提升之前的位置使用变量名或函数名,不会报
ReferenceError的错误,因为该变量名或函数名的值已经被初始化为undefined。 - 可以看看《你不知道的JavaScript》的
GitHub仓库上的issue,仍然是未解决的状态。
考虑下面的代码:
f()
if (true) {
function f() {
console.log('hello')
}
} else {
function f() {
console.log('world')
}
}
在某些浏览器中(具体是哪些我也不知道),提升后等价于:
function f() {
console.log('hello')
}
function f() {
console.log('world')
}
f() // world
if (true) {
} else {
}
所以输出 world。
对于另外一些浏览器,比如我在用的 Firefox Quantum 69.0.1 和 Chrome 76.0.3809.132 都会报 TypeError 错误,注意并不是 ReferenceError,因为 f 在一开始是 undefined,这就很奇怪了。
更让我难以理解的是下面这段代码:
console.log(a) // undefined
{
console.log(a) // function a()
function a() {}
a = 50
console.log(a) // 50
}
console.log(a) // function a()
console.log(b) // undefined
{
console.log(b) // function b()
b = 50
function b() {}
console.log(b) // 50
}
console.log(b) // 50
另外如果把 a = 50 和 b = 50 前面加上 var,居然会报错 SyntaxError: Identifier has already been declared,这完全颠覆了我的认知。
如果有朋友知道怎么回事,务必告诉我,谢谢!
2.6 不声明直接赋值不会提升
没有声明就直接给一个变量赋值,该语句不会被提升,会被引擎留在原地。
2.6.1 不声明直接赋值一个变量
console.log(a) // ReferenceError: a is not defined
a = 666
报错 ReferenceError,可见并没有把 a = 666 提升,也没有隐式地提升 var a。
2.6.2 不声明直接赋值一个函数
console.log(f) // ReferenceError: f is not defined
f = function() {
console.log('hello')
}
报错 ReferenceError,可见并没有把 f 函数的声明提升。
2.7 位于 return 后面的提升
在 return 后面的语句不会执行,但是如果有变量声明和函数声明时,会把声明部分提升,赋值和其余可执行逻辑留在原地,不会执行。
2.7.1 位于 return 后面的变量声明提升
考虑以下代码:
var num = 2
function f() {
num = 1
console.log(num) // 1
return
var num = 3
}
f()
console.log(num) // 2
等价于:
var num
num = 2
function f() {
var num // var num = 3 的声明部分被提升
num = 1 // 这里的 num 修改的是局部变量
console.log(num) // 1
return
num = 3
}
f()
console.log(num) // 2
2.7.2 位于 return 后面的函数声明提升
考虑下面代码:
var a = 1
function b() {
a = 10
return
function a(){
console.log(a)
}
}
b()
console.log(a) // 1
经过提升等价于:
var a = 1
function b() {
a = 10
return
function a(){
console.log(a)
}
}
b()
console.log(a) // 1
var a
function b() {
function a() { // 函数声明被提升至当前作用域
console.log(a)
}
a = 10 // 用 10 覆盖了局部作用域的 a
return
}
a = 1
b()
console.log(a) // 打印全局作用域的 a