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
的原文:
https://developer.mozilla.org/en-US/docs/Glossary/Hoisting
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