JavaScript 中的提升详解与疑惑

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.1Chrome 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 = 50b = 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

3. 参考资料

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值