0
点赞
收藏
分享

微信扫一扫

细读 ES6 | let 真的会提升吗?

本将会从 ES5 中一些怪诞的行为出发,然后再到 ES6 中的 letconst 是否会“提升”的讨论。

一、前菜

先上个前菜,如下:

{
  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(),对吧。讲实话,在写下文章之前,我的答案也是这个。因为使用 varfunction 关键字的声明语句会提升啊,因而有此答案...

先不论答案对与错,我们看看各大浏览器的结果是什么:

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 严格模式下,对函数声明的某些行为做了限制。

  1. 在早些版本中,在严格模式下含函数声明语句,会直接抛出 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)
}

// ⚠️ 而当前最新版本浏览器中,以上代码是不会抛出错误的。
  1. 在代码块内函数声明,不能在代码块外部使用,否则会抛出 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 就拥有了全局作用域、函数作用域和块级作用域。

只要通过 letconstclass 关键字声明的变量或类,都具有块级作用域。而且使用之前必须先声明,否则会抛出 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 就 varfunction 声明语句将会前置到所在作用域顶层的行为或现象,使用了 hoistinghoisted 等词去描述,然后在坊间互传时,在语言表述或认知理解上总会存在偏差,久而久之形成了 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 指向也是在这过程确定的。
  
      当代码都执行完之后,执行栈就会空闲下来,摸会鱼。等待后面有执行任务再进入工作状态。

为什么又重新提一遍这个过程,原因是:

  • 一派人,将分配内存空间的过程,称为提升。
  • 另一派人,将分配内存后会默认存值的过程,称为提升。

根据社区上的普遍说法,letvar 的区别在于分配内存空间后是否默认存值,若使用 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 发布 letconst 相关标准后,后续这块内容应该没有调整过了。从提示信息来看,我偏向认为 JS 引擎在实现时还是会存在“提升”行为的。

再看另一示例:

function foo() {
  console.log('')
  let a = 1
}
foo()

我们分别从 Chrome、Firefox、Safari 中观察以上示例:

我在 let a = 1 前一行语句添加了断点,从结果上看不完全相同。Chrome、Safari 中 a 的值是 undefined,而 FireFox 中 auninitialized。但是它们都在 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.

举报

相关推荐

细读 ES6 | Class 下篇

细读 ES6 | async/await

ES6 - let、const

es6 var let const

ES6新特性 - let

0 条评论