本将会从 ES5 中一些怪诞的行为出发,然后再到 ES6 中的 let
、const
是否会“提升”的讨论。
一、前菜
先上个前菜,如下:
{
var a = 2
function b() { }
let c = 1
}
console.log(a) // 2
console.log(b) // ƒ b()
console.log(c) // ReferenceError: c is not defined
上述示例,相信这个谁都懂。再看下面这个示例:
console.log(foo)
{
function foo() { }
console.log(foo)
}
console.log(foo)
// 这三个 foo 将会打印什么呢?
我想很多人的答案都是打印出三个 ƒ foo()
,对吧。讲实话,在写下文章之前,我的答案也是这个。因为使用 var
、function
关键字的声明语句会提升啊,因而有此答案...
先不论答案对与错,我们看看各大浏览器的结果是什么:
1. Safari 14 依次打印出(JavaScriptCore 引擎)
ƒ foo()
ƒ foo()
ƒ foo()
2. Chrome 92、Edge 92 (Chromium)、Node 14.16.0 依次打印出(V8 引擎)
undefined
ƒ foo()
ƒ foo()
3. Firefox 92 依次打印出(SpiderMonkey 引擎)
undefined
ƒ foo()
ƒ foo()
从结果看,主要区别在于第一个 foo
打印的是 undefined
或 ƒ foo()
。可能 Safari 浏览器的结果更符合多数人的认知。
为什么会有这样的结果,留个悬念,原因下面会介绍...
一、ES5 的“提升”
先明确一点:
原来,上面示例在 ES5 规范中是不合法的。但由于浏览器厂商都支持这个不合法的语法,只不过各 JS 引擎的实现细节上存在差异,因此才出现了前面的差异。
就比如 __proto__
从来就不是 ECMAScript 规范的一部分,但所有浏览器都支持,庆幸的是 __proto__
在各引擎表现是一致的。再比如前段时间写的一篇文章:关于 Await、Promise 执行顺序差异问题,也是 JS 引擎实现存在差异导致的。
function foo() { } // 合法
{
function bar() { } // 不合法,且不被推荐
var fn = function () { } // 合法 & 合理
}
function baz() {
function fn() { } // 合法
}
在 ESLint 中规则 no-inner-declarations 就是专门检查这种情况的,若启用函数声明处会发出警告:Move function declaration to program root.
在 ES5 严格模式下,对函数声明的某些行为做了限制。
- 在早些版本中,在严格模式下含函数声明语句,会直接抛出
SyntaxError
。而在当前版本(如 Chrome 92)是不会抛出语法错误的。
'use strict'
{
function fn() { } // SyntaxError: in strict mode code, functions may be declared only at top level or immediately within another function
console.log(fn)
}
// ⚠️ 而当前最新版本浏览器中,以上代码是不会抛出错误的。
- 在代码块内函数声明,不能在代码块外部使用,否则会抛出
ReferenceError
。原因是在严格模式下,fn
被提升至代码块的顶层,而不是全局作用域顶层。这点各浏览器表现是一致的。
'use strict'
console.log(fn) // ReferenceError: fn is not defined
{
function fn() { }
}
前文示例
了解了这些之后,再回头看看前面的示例(非严格模式下):
console.log(foo)
{
function foo() { }
console.log(foo)
}
console.log(foo)
为什么行为那么怪,我们打个断点吧(以 Firefox 为例,由于 Chrome 那个 window
对象展开太多属性了,截图太影响篇幅了):
看到没有,代码块中 foo
的变量是有提升至全局作用域顶层的,可......初始值是 undefined
而不是 ƒ foo()
,Chrome 是一样的。
当代码往下执行到 function foo() { }
会更新 window.foo
,因而结果就是 undefined
、ƒ foo()
、ƒ foo()
。
而 Safari 中,一开始 function foo() {}
提升至全局顶层时就是一个函数,所以与 Chrome、Firefox 结果不同。
留个彩蛋
留两个示例,你们可以去看看都打印些什么,是否跟你们预期中的一致。尤其是第二个示例。
if (true) {
function foo() { console.log(1) }
} else {
function foo() { console.log(2) }
}
foo()
var a = 0
if (true) {
console.log(a)
a = 1
function a() { }
a = 21
console.log(a)
}
console.log(a)
若第二个示例看不懂,请看分析。
二、ES6 会“提升”?
我们知道 ES6 中引入了块级作用域,自此 JavaScript 就拥有了全局作用域、函数作用域和块级作用域。
只要通过 let
、const
、class
关键字声明的变量或类,都具有块级作用域。而且使用之前必须先声明,否则会抛出 ReferenceError
,这个错误与“暂时性死区”(Temporal Dead Zone,TDZ)有关。
let foo = true
if (true) { // enter new scope, TDZ starts
// Uninitialized binding for `foo` is created
console.log(foo) // ReferenceError
let foo // TDZ ends, `foo` is initialized with `undefined`
console.log(foo) // undefined
foo = 1
console.log(foo) // 1
}
console.log(foo) // true
即在 TDZ starts 与 TDZ ends 的时间跨度,称为“暂时性死区”。这种机制也使得 typeof
变得不再安全,在此区间内引用变量会抛出 ReferenceError
。关于更多 TDZ,请看:
1. let/const 会提升吗?
其实,民间对于 let
等是否提升的问题,分为两派:
- 一派认为
let
没有提升行为 - 另一派则认为
let
还是有提升行为的
无论提升与否,但我认为在实际编写代码中,大家对 let/const
的使用是毫无疑问的。因为大家对“使用前先声明”的认知是统一的。也相信很多人早就开始用 let/const
全面代替 var
了。
无论 let/const
提升与否,几乎不会影响大家在项目中的使用,而且不会造成混乱,它们比 var/function
的“提升”行为更容易区分。
2. 什么是提升?
关于“提升”行为是什么,我就不多说了,大家都知道。
但我想说,在 ECMAScript 规范中,尽管文档中不乏类似 hoisting
的单词,但就是没有对 “Hoisting” 一词作专门定义。
但在前端社区中,Hoisting
的说法确实很多。我想可能是因为,ECMAScript 就 var
、function
声明语句将会前置到所在作用域顶层的行为或现象,使用了 hoisting
、hoisted
等词去描述,然后在坊间互传时,在语言表述或认知理解上总会存在偏差,久而久之形成了 Hoisting
的说法。
说那么多,总结一句话:Hoisting 不是一个 ECMAScript 规范的术语,它只是描述了一种行为或现象。
3. 坊间对提升的理解
举个例子,在我们眼里它只是一个再简单不过的声明语句而已。
var a = 1
那么 JS 引擎是怎么理解的呢。就这条简单的语句,大概会经历这些步骤:
编译阶段
词法分析
拆分成一个个有意义的 token
语法分析
检查能否构成合法的语句,如无语法错误,将 tokens 形成 AST
代码生成
生成 JS 引擎看得懂的代码
执行阶段
创建全局上下文
在此之前 JS 引擎还会创建一个执行上下文栈去管理各种上下文。(非重点不展开)
进入全局上下文
主要是执行上下文初始化的一些工作:
JS 引擎识别到`var a` 知道这是一个声明操作。
首先以 a 作为标识符,在内存中创建一个空间(将用于存实际的值)。
然后根据 ECMAScript 实现要求,
这个 a 将会作为执行上下文(可理解为一个 JS 对象)
中变量对象(VO)的一个属性名,并默认存值为 undefined。
就是说,在前面分配的内存空间中存入 undefined 值。
初始化还有其他一些工作,如确定作用域链等。
执行全局上下文
前面初始化工作完成之后,接下来就是,
按顺序逐条执行代码(这里说的顺序,不一样与源代码编写顺序一致,你懂的)
当执行到 `var a = 1` 这行的时候,JS 引擎眼里其实是 `a = 1` 赋值操作。
于是根据标识符 a 找到它在内存中的位置,并将真实值 1 存入到该空间下,
以覆盖原先的 undefined 值。
还有一些如当前执行上下文 this 指向也是在这过程确定的。
当代码都执行完之后,执行栈就会空闲下来,摸会鱼。等待后面有执行任务再进入工作状态。
为什么又重新提一遍这个过程,原因是:
- 一派人,将分配内存空间的过程,称为提升。
- 另一派人,将分配内存后会默认存值的过程,称为提升。
根据社区上的普遍说法,let
与 var
的区别在于分配内存空间后是否默认存值,若使用 let
不会默认存一个 undefined
值。但我对这句话是有所保留的。
原因如下:
在 ECMAScript 规范中关于 #14.3.1 Let and Const Declarations 有一段话是这么说的(原文略长,这里分成两段):
以 let a = 1
为例,简单说明一下:
第一部分大致意思:通过 let/const
声明的变量,它会记录当前词法环境信息,且在 LexicalBinding 之前,不能以访问任何方式访问。LexicalBinding
是规则描述中的一个抽象操作,用 JS 代码比喻就是 let a = 1
中赋值操作。
第二部分,其实就是当执行代码 let a = 1
时,将赋值操作符右边的表达式的值 1
绑定到变量 a
上。若是 let a
这种形式的,那么将 undefined
值(是一个二进制的真实值)绑定到变量 a
上。这就是前一部分提到的 LexicalBinding
过程。
这段话里,由始至终没有提及执行上下文初始化时,要不要为变量默认存一个 undefined
的值。这就是我说有所保留的原因。
4. JS 引擎眼中的 TDZ
很多人都知道 TDZ 是什么回事。
但我还是那句话:TDZ 是 ECMAScript 规范中的一个术语吗?
按关键词通篇搜索 ECMAScript 文档可知,TDZ 并不是 ECMAScript 规范的术语。据说 TDZ 的说法最早出现在 ES Discussion 的讨论帖。因而,我认为 TDZ 跟 Hoisting 一样,它只是描述了一种行为或现象。这种现象就是:
前面提到 LexicalBinding
之前不允许访问,那么 JS 引擎总要告诉我们不能访问吧,于是就抛出一个 ReferenceError
来提醒我们:小伙子你不能这么使用。
5. 从浏览器的角度看是否会提升?
先看个示例:
console.log(a) // ReferenceError: a is not defined
let a = 1
上面这两行代码,问谁都知道将会抛出 ReferenceError
。但我发现这个报错的 Message 是不同的:
- 某旧版 Chrome:
ReferenceError: Cannot access 'a' before initialization
- 最新 Chrome 92:
ReferenceError: a is not defined
- 最新 Safari 14:
ReferenceError: Cannot access 'a' before initialization
自从 ES6 发布 let
、const
相关标准后,后续这块内容应该没有调整过了。从提示信息来看,我偏向认为 JS 引擎在实现时还是会存在“提升”行为的。
再看另一示例:
function foo() {
console.log('')
let a = 1
}
foo()
我们分别从 Chrome、Firefox、Safari 中观察以上示例:
我在 let a = 1
前一行语句添加了断点,从结果上看不完全相同。Chrome、Safari 中 a
的值是 undefined
,而 FireFox 中 a
为 uninitialized
。但是它们都在 let a = 1
之前就存在了变量 a
,即使在下一行中都将会报错。
也再次佐证了 let/const/class
声明语句是会产生提升行为。
6. 若提升,它被提升到哪?
请看示例
if (true) {
let a = 1
console.log(a) // 1
}
console.log(a) // ReferenceError: a is not defined
尽管会提升,它只会提升至当前代码块的顶层,所以在 if
语句外部无法访问 a
变量,因而报错。
三、总结
就本文内容,总结一下:
跟 let/const 无关,跟是否严格模式相关
let/const 会提升吗?
还是那句话,不必过分关注 let/const/class
等声明的变量或类是否存在“提升”现象。但如果作为基础拓展,还是有必要了解一下的...
建议
在遇到一些不太理解的 ES6 语法的时候,不妨使用 Babel 转换一下,看看它们是怎么实现的,说不定会有灵光一现的感觉。
比如开头的示例 Babel 转换之后,就变成下面这样,然后结合自己的理解,想想为什么它要这么做。
"use strict";
console.log(foo);
{
var _foo = function _foo() {};
console.log(_foo);
}
console.log(foo);
这一招是我看某篇文章的时候学到的...
The end.