toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
21 stars 1 forks source link

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

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

配图源自 Freepik

本将会从 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(或更早)规定,函数只能在顶层作用域或函数作用域顶层声明,不能在代码块中声明。

原来,上面示例在 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() { }
}

JavaScript 严格模式详解

回到前面的示例(非严格模式下):

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 结果不同。

不妨用 Babel 转换一下。

留两个示例,你们可以去看看都打印些什么,是否跟你们预期中的一致。尤其是第二个示例。

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,请看:

let/const 会提升吗?

其实,民间对于 let 等是否提升的问题,分为两派:

无论提升与否,但我认为在实际编写代码中,大家对 let/const 的使用是毫无疑问的。因为大家对“使用前先声明”的认知是统一的。也相信很多人早就开始用 let/const 全面代替 var 了。

也建议在项目 ESLint 中启用 no-var 规则。

无论 let/const 提升与否,几乎不会影响大家在项目中的使用,而且不会造成混乱,它们比 var/function 的“提升”行为更容易区分。

什么是提升?

关于“提升”行为是什么,我就不多说了,大家都知道。

但我想说,在 ECMAScript 规范中,尽管文档中不乏类似 hoisting 的单词,但就是没有对 “Hoisting” 一词作专门定义。

但在前端社区中,Hoisting 的说法确实很多。我想可能是因为,ECMAScript 就 varfunction 声明语句将会前置到所在作用域顶层的行为或现象,使用了 hoistinghoisted 等词去描述,然后在坊间互传时,在语言表述或认知理解上总会存在偏差,久而久之形成了 Hoisting 的说法。

综上:Hoisting 不是一个 ECMAScript 规范的术语,它只是描述了一种行为或现象。

坊间对提升的理解

举个例子,在我们眼里它只是一个再简单不过的声明语句而已。

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 and const declarations define variables that are scoped to the running execution context's LexicalEnvironment. The variables are created when their containing Environment Record is instantiated but may not be accessed in any way until the variable's LexicalBinding is evaluated.

A variable defined by a LexicalBinding with an Initializer is assigned the value of its Initializer's AssignmentExpression when the LexicalBinding is evaluated, not when the variable is created. If a LexicalBinding in a let declaration does not have an Initializer the variable is assigned the value undefined when the LexicalBinding is evaluated.

let a = 1 为例,简单说明一下:

第一部分大致意思:通过 let/const 声明的变量,它会记录当前词法环境信息,且在 LexicalBinding 之前,不能以访问任何方式访问。LexicalBinding 是规则描述中的一个抽象操作,用 JS 代码比喻就是 let a = 1 中赋值操作。

第二部分,其实就是当执行代码 let a = 1 时,将赋值操作符右边的表达式的值 1 绑定到变量 a 上。若是 let a 这种形式的,那么将 undefined 值(是一个二进制的真实值)绑定到变量 a 上。这就是前一部分提到的 LexicalBinding 过程。

这段话里,由始至终没有提及执行上下文初始化时,要不要为变量默认存一个 undefined 的值。这就是我说有所保留的原因。

JS 引擎眼中的 TDZ

很多人都知道 TDZ 是什么回事。

但我还是那句话:TDZ 是 ECMAScript 规范中的一个术语吗?

按关键词通篇搜索 ECMAScript 文档可知,TDZ 并不是 ECMAScript 规范的术语。据说 TDZ 的说法最早出现在 ES Discussion 的讨论帖。因而,我认为 TDZ 跟 Hoisting 一样,它只是描述了一种行为或现象。这种现象就是:

通过 letconstclass 关键字声明的变量或类,它不能在声明之前调用,否则会抛出 ReferenceError

前面提到 LexicalBinding 之前不允许访问,那么 JS 引擎总要告诉我们不能访问吧,于是就抛出一个 ReferenceError 来提醒我们:小伙子你不能这么使用。

从浏览器的角度看是否会提升?

先看个示例:

console.log(a) // ReferenceError: a is not defined
let a = 1

上面这两行代码,问谁都知道将会抛出 ReferenceError。但我发现这个报错的 Message 是不同的:

自从 ES6 发布 letconst 相关标准后,后续这块内容应该没有调整过了。从提示信息来看,我偏向认为 JS 引擎在实现时还是会存在“提升”行为的。

再看另一示例:

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

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

Chrome 92

Firefox 92

Safari 14

我在 let a = 1 前一行语句添加了断点,从结果上看不完全相同。Chrome、Safari 中 a 的值是 undefined,而 FireFox 中 auninitialized。但是它们都在 let a = 1 之前就存在了变量 a,即使在下一行中都将会报错。

也再次佐证了 let/const/class 声明语句是会产生提升行为。

尽管如此,还是必须在使用之前进行定义,否则会报错。

若提升,它被提升到哪?

请看示例

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.