5. 提升与暂时性死区
1 提升 Hoisting
在编译阶段,同一个运行环境(或者说同一个词法作用域)中所有声明的变量和函数,都会在代码运行前首先被声明,这个过程就叫提升。
1.1 变量的声明、初始化、赋值
var name = "Moxy"
这段代码在 JS 引擎看来,实际划分为三个步骤:
var name // 声明(创建)
name = undefined // 初始化
name = "Moxy" // 赋值
所谓创建,就是把变量的名称(key)登记在执行上下文中对应的区域;
所谓初始化,就是把变量的值(value)赋值为
undefinde
;所谓赋值,就是执行过程中,根据代码含义对变量进行赋值操作,更新变量的值。
在 JavaScript 代码执行过程中,JS 引擎会把同一个运行环境变量和函数的声明部分提升到代码开头。这个行为发生在编译阶段,叫提升。因为历史关系,不同的标识符,对提升有不同的表现。
ECMAScript 规定:
var
和function
声明的变量,只在函数 / 全局作用域中提升。let
和const
声明的变量,不存在提升。
而事实上:V8 引擎也提升了let
和 const
声明的变量,只是不允许这些变量在赋值操作前被访问,从形式上符合了 ECMA 标准。
所以,浏览器最终的实现:
function
函数的创建、初始化和赋值均会被提升。var
变量的创建和初始化被提升,赋值不会被提升。let
和const
变量的创建被提升,初始化和赋值不会被提升。它们被提升到了块作用域中。
通过一个小案例进一步理解:
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 声明的重名
当同一个作用域中,出现变量(var
,let
,const
)和函数重名的情况,按照如下规则:
- 当 函数 和 变量 出现同名,
- 如果变量是
var
声明的,则变量声明被忽略,函数声明优先; - 如果变量是
let
和const
声明的,则语法报错:SyntaxError: Identifier 'a' has already been declared
- 如果变量是
- 当 函数 和 函数 出现同名:代码后面的函数声明,会覆盖代码前面的函数声明;
- 当 变量 和 变量 出现同名:不区分
let
、const
、var
,会语法报错:SyntaxError: Identifier 'a' has already been declared
总结:ES6 新增的 let
和 const
声明,更加符合“直觉”,不会存在函数和变量同时声明,有优先顺序的问题了。只要 let
和 const
声明的变量出现了重名,就会报语法错误。但是为了兼容历史版本,var
和函数还是和以前一样,会函数优先。
下面是测试:
- 当 函数 和 变量 出现同名
// 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() {}
- 当 函数 和 函数 出现同名
a() // 3
function a() { console.log(1)}
function a() { console.log(2)}
function a() { console.log(3)}
a() // 3
- 当 变量 和 变量 出现同名
// Uncaught SyntaxError: Identifier 'a' has already been declared
let a = 2
let a = 3
1.3 函数声明方式的干扰
我们知道,函数有两种声明方式:
function
直接声明- 函数表达式声明
- 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
函数。
这里代码的正确的执行流程是这样的:
- JS 引擎创建 全局执行上下文,进入全局执行上下文的 编译阶段。
- 对当前运行环境(全局)进行 提升,读取到
foo
变量。初始化为:foo = undefined
; - JS 引擎进入全局执行上下文的 运行阶段,开始逐行执行代码。
- 当执行到
var foo = function bar() {}
时,对bar
函数进行声明、初始化、赋值。 - 逐行执行剩余的代码。
1.3.3 IIFE Immediately Invoked Expression
IIFE 立即执行函数表达式
(function foo(){ ... })
给函数声明添加括号后,变成了函数表达式;后面添加的()
则是立即执行这个函数,在括号中可以添加传给函数的参数。
出现立即执行函数表达式会创建一个新的作用域。所以,这个函数在自己的作用域中被声明、初始化和赋值后,会立即调用。在执行完函数内的代码后,也会立即被销毁。
这样做有两个好处:
- 函数不需要函数名:可以让函数名不污染所在所有域,不必让这个函数绑定到当前作用域中。
- 函数自动运行:如果这个函数不会在其他地方调用,声明后立刻让他被调用即可。
有两种形式,都可以:
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
这里涉及到上文说过的 “声明的重名” 问题。let
和 const
这些 ES6 增加的变量声明,修正了以前的 函数 和 变量 的重复声明,函数优先的问题。只要 let
和 const
声明的变量出现了重名,就会报语法错误。
2 块级作用域
- 任何用大括号括起来的区域,都是一个块级作用域:
- if else、function、switch ...
- ES6 标准规定:
const
、let
、function
、class
都受块级作用域的限制,意思是仅有var
不受限制。 - 但大多数浏览器为了兼容旧代码,对
function
不受限制,所以仅有const
、let
、class
受到约束。
关于 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 引擎进入某个代码块的运行时,当读取到一个块级作用域时,也会先对该块级作用域中的所有 let
和 const
声明的变量进行 块级作用域提升 ,然后再执行这个块级作用域中的代码。
所以,ECMAScript 规定:
function
的创建、初始化和赋值均会被提升。var
的创建和初始化被提升,赋值不会被提升。let
和const
的创建被提升,初始化和赋值不会被提升。该提升发生在运行时,在块级作用域内提升。
3.1 广义的提升
这里想再强调一句,狭义的 “提升”,指的是引擎在编译阶段对变量和函数的提前声明;而广义的说,是指引擎在真正开始执行代码块之前,是否会对该代码块内的变量和函数提前声明。
如果只讨论狭义的提升,则只有 var
声明的变量和 function
声明的函数会提升。let
和 const
不会提升。
这里讨论广义的提升,在函数(全局)作用域内,有 var
声明的变量和 function
声明的函数会提升,这个提升发生在 编译时;在块作用域内,有 let
和 const
声明的变量提升,这个提升发生在 运行时。
3.2 标准和现实
上面说到,ECMAScript 规定执行上下文中的变量环境提升,仅限 let
和 const
声明的变量名称,不会初始化为 unedfined
。然而在 V8 引擎中,实际上 let
和 const
的创建和初始化都被提升了,对它们进行了初始化,赋值 undefined
。
为了确保符合 ECMAScript 标准,V8 引擎规定在没有对 let
和 const
声明的变量进行赋值操作之前,访问该变量 JavaScript 引擎就会抛出错误,禁止访问。
这样现实和标准就实现了结果上的统一。
注:
ECMAScript 规定 let
和 const
声明的变量不允许在提升时初始化为 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 查找变量时会报语法错误,这达到了浏览器实现和标准的统一。
标准规定,let
和 const
创建的变量仅允许创建提升,不允许初始化和赋值提升。在变量仅创建、尚未初始化和赋值的阶段,不允许 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 - 重学前端
李兵 - 浏览器工作原理与实战