跳到主要内容

5. 提升与暂时性死区

1 提升 Hoisting

在编译阶段,同一个运行环境(或者说同一个词法作用域)中所有声明的变量和函数,都会在代码运行前首先被声明,这个过程就叫提升。

1.1 变量的声明、初始化、赋值

var name = "Moxy"

这段代码在 JS 引擎看来,实际划分为三个步骤:

var name            // 声明(创建)
name = undefined // 初始化
name = "Moxy" // 赋值
  • 所谓创建,就是把变量的名称(key)登记在执行上下文中对应的区域;

  • 所谓初始化,就是把变量的值(value)赋值为 undefinde

  • 所谓赋值,就是执行过程中,根据代码含义对变量进行赋值操作,更新变量的值。

在 JavaScript 代码执行过程中,JS 引擎会把同一个运行环境变量和函数的声明部分提升到代码开头。这个行为发生在编译阶段,叫提升。因为历史关系,不同的标识符,对提升有不同的表现。

ECMAScript 规定:

  • varfunction 声明的变量,只在函数 / 全局作用域中提升。
  • letconst 声明的变量,不存在提升。

而事实上:V8 引擎也提升了letconst 声明的变量,只是不允许这些变量在赋值操作前被访问,从形式上符合了 ECMA 标准。

所以,浏览器最终的实现:

  • function 函数的创建、初始化和赋值均会被提升。
  • var 变量的创建和初始化被提升,赋值不会被提升。
  • letconst 变量的创建被提升,初始化和赋值不会被提升。它们被提升到了块作用域中。

通过一个小案例进一步理解:

console.log(a1)     // ReferenceError: Cannot access 'a1' before initialization
console.log(a2) // undefined
console.log(a3) // ƒ a3() {}

let a1 = "let variable"
var a2 = "var variable"
function a3() {}

可以看到,编译阶段刚结束后,

  • let 变量不可被读取,因为它在编译阶段只完成了 创建,没有被初始化;

  • var 变量返回 undefined,因为它在编译阶段完成了 创建初始化

  • function 变量返回函数体,因为它在编译阶段完成了 创建初始化赋值

1.2 声明的重名

当同一个作用域中,出现变量(varletconst)和函数重名的情况,按照如下规则:

  • 函数变量 出现同名,
    • 如果变量是 var 声明的,则变量声明被忽略,函数声明优先;
    • 如果变量是 letconst 声明的,则语法报错: SyntaxError: Identifier 'a' has already been declared
  • 函数函数 出现同名:代码后面的函数声明,会覆盖代码前面的函数声明;
  • 变量变量 出现同名:不区分 letconstvar ,会语法报错: SyntaxError: Identifier 'a' has already been declared

总结:ES6 新增的 letconst 声明,更加符合“直觉”,不会存在函数和变量同时声明,有优先顺序的问题了。只要 letconst 声明的变量出现了重名,就会报语法错误。但是为了兼容历史版本,var 和函数还是和以前一样,会函数优先。

下面是测试:

  1. 函数变量 出现同名
// 1 如果变量是 `var` 声明
console.log(a) // ƒ a() {}
var a = 2
function a() {}

// 2 如果变量是 `let` 和 `const` 声明
// Uncaught SyntaxError: Identifier 'a' has already been declared
let a = 2
function a() {}
  1. 函数函数 出现同名
a()     // 3
function a() { console.log(1)}
function a() { console.log(2)}
function a() { console.log(3)}
a() // 3
  1. 变量变量 出现同名
// Uncaught SyntaxError: Identifier 'a' has already been declared 
let a = 2
let a = 3

1.3 函数声明方式的干扰

我们知道,函数有两种声明方式:

  1. function 直接声明
  2. 函数表达式声明
  3. IIFE 立即执行函数表达式声明

从结果来说,不同的函数声明方式,会影响到函数提升效果。实际上,这是 作用域 导致的结果。

1.3.1 函数声明

直接声明后,该函数会绑定到当前的作用域中,对于当前作用域而言存在正常的变量提升。

console.log(foo)    // ƒ foo() {}
function foo() {}

1.3.2 函数表达式

使用函数表达式声明,则会先声明一个变量,然后通过赋值操作,把函数的地址值传递给这个变量。这里是一个赋值操作,所以在当前运行环境中,不会对函数进行提升。

  • 函数表达式和直接声明的区分:function 如果是第一个词,就是直接声明;function 不是第一个词,就是函数表达式。

举例来说:

console.log(foo)    // undefined
var foo = function bar() {
}
console.log(bar) // ReferenceError: bar is not defined

可以看到 foo 在编译时,被当成变量对待,只提升了变量的声明和初始化,没有进行函数提升。

这是因为,bar 函数创建了个新的作用域,是 foo 所在的全局作用域的子作用域。可以看到,在 foo 的作用域中,无法访问到 bar 函数。

这里代码的正确的执行流程是这样的:

  1. JS 引擎创建 全局执行上下文,进入全局执行上下文的 编译阶段
  2. 对当前运行环境(全局)进行 提升,读取到 foo 变量。初始化为:foo = undefined
  3. JS 引擎进入全局执行上下文的 运行阶段,开始逐行执行代码。
  4. 当执行到 var foo = function bar() {} 时,对 bar 函数进行声明、初始化、赋值。
  5. 逐行执行剩余的代码。

1.3.3 IIFE Immediately Invoked Expression

IIFE 立即执行函数表达式

  • (function foo(){ ... }) 给函数声明添加括号后,变成了函数表达式;后面添加的 ()则是立即执行这个函数,在括号中可以添加传给函数的参数。

出现立即执行函数表达式会创建一个新的作用域。所以,这个函数在自己的作用域中被声明、初始化和赋值后,会立即调用。在执行完函数内的代码后,也会立即被销毁。

这样做有两个好处:

  1. 函数不需要函数名:可以让函数名不污染所在所有域,不必让这个函数绑定到当前作用域中。
  2. 函数自动运行:如果这个函数不会在其他地方调用,声明后立刻让他被调用即可。

有两种形式,都可以:

var v1 = "Moxy";
var v2 = "Ninjee";

(function (name){ console.log(name)})(v1); // Moxy
(function (name){ console.log(name)}(v2)); // Ninjee

IIFE 属于函数表达式的声明方式,所以自然也不会存在函数提升。

tips:

直接函数声明方式:函数名绑定在声明它的作用域中;

函数表达式声明方式:函数名绑定在该函数的内部。

  • (function foo(){ ... }) 作为函数表达式,意味着 foo 只能在 ... 所代表的位置中被访问,外部作用域无法访问该函数。也就是说,除了 函数本身内部可以调用该函数外,其余位置都无法访问它(出现闭包除外),达到了不会污染外部作用域的效果。

1.4 提升和赋值的误导

运行时的赋值操作,可能会导致隐式类型转换的发生,进而影响对变量和函数提升的判断。

看代码识结果:

// 情况1
console.log(a) // ƒ a() {}
var a;
console.log(a) // ƒ a() {}
function a() {}
console.log(a) // ƒ a() {}

// 情况2 发生了强制类型转换 number ==> function
console.log(a) // ƒ a() {}
var a = 2;
console.log(a) // 2
function a() {}
console.log(a) // 2

情况1:

在编译时,引擎发现 a 变量出现声明冲突,则函数声明优先,忽略变量声明。编译结果为 a : function(){},运行前的可执行代码为:

console.log(a)
console.log(a)
console.log(a)

所以,三个输出结果都是名为 a 的函数。

情况2:

在编译时,引擎处理结果相同,函数声明优先,编译结果为 a : function(){}。不同的是,运行前的可执行代码:

console.log(a)
a = 2 // 此处有赋值操作
console.log(a)
console.log(a)

所以引擎在运行时,执行 a = 2 时发生了强制类型转换。a 此时由 function 转换为了一个 number 变量。所以后面的两个输出都是 2。这里一个提升和类型转换的综合问题。

同样的问题,当遇到 let 声明的代码,是否是相同的结果?

// 情况3
console.log(a)
let a = 2;
console.log(a)
function a() {}
console.log(a)

// 情况4
function a() {}
let a = 2;

情况 3 和情况 4 都出现了语法错误:SyntaxError: Identifier 'a' has already been declared

这里涉及到上文说过的 “声明的重名” 问题。letconst 这些 ES6 增加的变量声明,修正了以前的 函数变量 的重复声明,函数优先的问题。只要 letconst 声明的变量出现了重名,就会报语法错误。

2 块级作用域

  • 任何用大括号括起来的区域,都是一个块级作用域:
    • if else、function、switch ...
  • ES6 标准规定:constletfunctionclass 都受块级作用域的限制,意思是仅有 var 不受限制。
  • 但大多数浏览器为了兼容旧代码,对 function 不受限制,所以仅有 constletclass 受到约束。

关于 for 循环使用 let 和 var 的区别:

下面的例子中,html 定义了 4 个 buttom,js 设置监听函数:如果点击到第 n 个按钮,就输出 “第 n 个按钮被点击”。

  • 例1:var 声明变量不受 for 块作用域限制,所以i声明到 for 外部的全局作用域中了。这导致对每个 btn 绑定监听函数后,i 此时值为 4。当某个按钮被惦记,触发回掉函数,函数内部访问的全局作用域 i。不论点击哪个按钮,都是输出 4。
  • 例2:var 声明的解决方案,利用 IIFE 多设置一层有效的函数作用域,在轮 for 循环时,都执行一遍 IIFE,将此时的 i 值赋值给 IIFE 作用域内的 n,所以 for 循环执行了 4 次,创建了 4 个 IIFE,每个 IIFE 内部也有不一样的 n。最终当用户点击按钮后,触发回掉函数,访问的是当前 IIFE 内部的 n,可以正常输出按钮 1 2 3 4 序号了。
  • 例3:let 声明受到块作用域的限制,这就意味着每一轮 for 循环内,都创建了一个 i 变量,进行了 4 次 for 循环,创建了 4 个 i 变量(闭包作用域),每个变量都在自己的块级作用域(闭包)中使用,所以序号可以正常显示。
const btns = document.getElementsByTagName('button');

// 例1
for (var i = 0; i < btns.length; i++) {
btns[i].onclick = function() {
console.log("第" + i + "个按钮被点击");
}
}

// 例2
for (var i = 0; i < btns.length; i++) {
(function(n) {
btns[i].onclick = function() {
console.log("第" + n + "个按钮被点击");
}
})(i)
}

// 例3
for (let i = 0; i < btns.length; i++) {
btns[i].onclick = function() {
console.log("第" + i + "个按钮被点击");
}
}

3 暂时性死区 Temporal Dead Zone

一个变量或函数,在编译时和运行时会经历三个阶段:创建、初始化、赋值。

举个例子:var a = 2,这段代码在编译时, JavaScript 引擎会对它进行两个操作:创建变量和初始化变量。编译时的操作会提升。

  • 创建变量:var a
  • 初始化变量:a = undefined

然后代码会生成可执行代码,进入运行时。在运行时, JavaScript 引擎会对变量进行赋值操作:

  • 赋值操作:a = 2

在 JS 引擎尚未正式执行代码之前,便提前对代码块中的变量和函数进行预先创建,叫 提升 Hoisting

提升通常是在编译时进行的操作,只有 var 声明的变量和 function 声明的函数会在编译时 提升

事实上,在 JS 引擎进入某个代码块的运行时,当读取到一个块级作用域时,也会先对该块级作用域中的所有 letconst 声明的变量进行 块级作用域提升 ,然后再执行这个块级作用域中的代码。

所以,ECMAScript 规定:

  • function 的创建、初始化和赋值均会被提升。
  • var 的创建和初始化被提升,赋值不会被提升。
  • letconst 的创建被提升,初始化和赋值不会被提升。该提升发生在运行时,在块级作用域内提升。

3.1 广义的提升

这里想再强调一句,狭义的 “提升”,指的是引擎在编译阶段对变量和函数的提前声明;而广义的说,是指引擎在真正开始执行代码块之前,是否会对该代码块内的变量和函数提前声明。

如果只讨论狭义的提升,则只有 var 声明的变量和 function 声明的函数会提升。letconst 不会提升。

这里讨论广义的提升,在函数(全局)作用域内,有 var 声明的变量和 function 声明的函数会提升,这个提升发生在 编译时;在块作用域内,有 letconst 声明的变量提升,这个提升发生在 运行时

3.2 标准和现实

上面说到,ECMAScript 规定执行上下文中的变量环境提升,仅限 letconst 声明的变量名称,不会初始化为 unedfined 。然而在 V8 引擎中,实际上 letconst 的创建和初始化都被提升了,对它们进行了初始化,赋值 undefined

为了确保符合 ECMAScript 标准,V8 引擎规定在没有对 letconst 声明的变量进行赋值操作之前,访问该变量 JavaScript 引擎就会抛出错误,禁止访问。

这样现实和标准就实现了结果上的统一。

注:

ECMAScript 规定 letconst 声明的变量不允许在提升时初始化为 undefined。有其中一个原因是const 的特性是常数,也就是说不论何时调用该变量,其表现都应当是一致的。如果 const 变量在提升时初始化为 undefined,在代码执行到对这个 const 变量赋值操作的前后,const 的表现会不一致(赋值前值为undefined,赋值后改变了值),违背了其常数的特性。

3.3 思考

下面代码的打印结果是:

// 代码一
let myname= "Ninjee"
{
console.log(myname) // ReferenceError: Cannot access 'myname' before initialization
let myname= "Nin"
}

// 代码二
let myname= "Ninjee"
{
console.log(myname) // Ninjee
}

分析

先分析第一段代码,有一个全局作用域,被一个括号分割为内外两个块作用域。在运行时,当引擎进入第二个块作用域时,let myname= 'Nin' 中的 myname 会被提升。引擎会对 myname 的的创建和初始化进行提升,在开发者工具可以看到 myname 赋值为 undefined。但该变量在尚未执行到赋值操作前,引擎不允许访问该变量。所以,当执行 RHS 查找变量时会报语法错误,这达到了浏览器实现和标准的统一。

标准规定,letconst 创建的变量仅允许创建提升,不允许初始化和赋值提升。在变量仅创建、尚未初始化和赋值的阶段,不允许 JS 引擎访问,此时处于暂时性死区,报错:ReferenceError

疑问

第一段代码中, console.log() 既然无法访问第二个块作用域内的 myname,为什么不去访问外部的 myname = "Ninjee"

涉及到作用域链的查找。在执行 console.log() 时,已经登记了在第二个块作用域内的 myname 变量。当对该变量进行查找的时候,会在当前块作用域中找到同名的 myname 变量即返回,不会在到外部作用域中找了。

3.4 总结

var bar = {
myName:"barName",
printName: function () {
console.log(myName)
}
}
function foo() {
let myName = "fooName"
return bar.printName
}
let myName = "globalName"
let _printName = foo()
_printName()
bar.printName()

答案:

两个输出都是 globalName

bar 只是一个对象,本身不是函数作用域。 bar.printName()outer 指向 Window 全局作用域。

tips:

执行:bar.printName()

  • bar.printName() 的内容是:console.log(myName),则输出:globalName。是通过作用域,访问到全局作用域中的 myName 变量。
  • bar.printName() 的内容是:console.log(this.myName),则输出:barName。是通过 this 指向,访问到 bar 对象自身。
  • bar.printName() 的内容是:console.log(bar.myName),则输出:barName。是先通过作用域访问到全局作用域中的 bar 变量;然后通过对象属性访问,访问 bar.myName

❗️执行流程分析:

1 全局执行上下文

编译时:

变量环境:

  • bar : undefined
  • foo : function

词法环境:

  • myname : undefined
  • _printName : undefined

运行时:

bar : {myname: "barName", printName: function(){...}}
myName = "globalName"
_printName = foo() // 调用foo函数

调用foo函数,压执行上下文入调用栈

2 foo 函数执行上下文:

编译时:

变量环境: 空

词法环境: myName : undefined

运行时:

myName = "fooName"
return bar.printName // RHS,查找变量bar地址

开始查询变量 bar, 查找当前词法环境(没有)-> 查找当前变量环境(没有)--> 查找 outer 词法环境(没有)--> 查找 outer 语法环境(找到了)并且返回找到的值。

弹出 foo 执行上下文

3 全局执行上下文

运行时:

_printName = bar.printName
bar.printName() // RHS,查找变量bar地址

调用 bar.printName() 函数,压 bar.printName 方法的执行上下文入调用栈

4 bar.printName 函数执行上下文:

编译时:

变量环境: 空

词法环境: 空

运行时:

console.log(myName);

开始查询变量 myName, 查找当前词法环境(没有)--> 查找当前变量环境(没有)--> 查找 outer 词法环境(找到了)

打印 "globalName"

弹出 bar.printName 执行上下文

5 全局执行上下文

运行时:

bar.printName() 

调用 bar.printName() 函数,压 bar.printName 方法的执行上下文入调用栈

6 bar.printName 函数执行上下文:

编译时:

变量环境: 空

词法环境: 空

运行时:

console.log(myName);

开始查询变量 myName, 查找当前词法环境(没有)--> 查找当前变量环境(没有)--> 查找 outer 词法环境(找到了)

打印 "globalName"

弹出 bar.printName 执行上下文

弹出 全局执行上下文

执行完毕。

引用

《你不知道的JavaScript》

winter - 重学前端

李兵 - 浏览器工作原理与实战