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

配图源自 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() { }
}

前文示例

了解了这些之后,再回头看看前面的示例(非严格模式下):

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

1. let/const 会提升吗?

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

  • 一派认为 let 没有提升行为
  • 另一派则认为 let 还是有提升行为的

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

也建议在项目 ESLint 中启用 no-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 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 的值。这就是我说有所保留的原因。

4. JS 引擎眼中的 TDZ

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

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

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

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

前面提到 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 中观察以上示例:

Chrome 92
Firefox 92
Safari 14

我在 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 确实是存在提升行为的。
  • 从使用角度看,不需要关心 let/const/class 是否会提升,它必须在使用前进行定义。
  • 从面试官角度,我认为他想听到的答案是:let/const/class 是会提升的”。若你简单从 ECMAScript 规范、浏览器调试结果去回答,这题就基本能拿满分。
  • 如果本文所说的“提升”会让你感到困惑的话,请坚定不移地相信 let/const/class 是不存在提升行为的。

还是那句话,不必过分关注 let/const/class 等声明的变量或类是否存在“提升”现象。但如果作为基础拓展,还是有必要了解一下的...

建议

在遇到一些不太理解的 ES6 语法的时候,不妨使用 Babel 转换一下,看看它们是怎么实现的,说不定会有灵光一现的感觉。

比如开头的示例 Babel 转换之后,就变成下面这样,然后结合自己的理解,想想为什么它要这么做。

"use strict";
console.log(foo);
{
  var _foo = function _foo() {};
  console.log(_foo);
}
console.log(foo);

这一招是我看某篇文章的时候学到的...

The end.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容