logo头像

云影sky

前端面试必会 | 一文读懂现代 JavaScript 中的变量提升 - let、const 和 var



本文翻译自 https://blog.bitsrc.io/hoisting-in-modern-javascript-let-const-and-var-b290405adfda,作者 Sukhjinder Arora,内容有部分删改,标题有改动。



许多 JavaScript 程序员将提升解释为 JavaScript 将声明(变量和函数)移至其当前作用域(函数或全局)顶部的行为。好像它们实际上已经移到了代码的顶部,事实并非如此。例如:


console.log(a);
var a = ‘Hello World!’;

他们会说,上面的代码将在提升后转换为以下代码:


var a;
console.log(a);
a = ‘Hello World!’;

尽管看起来是这样,因为代码也工作正常了,但是 JavaScript 引擎事实上并不是这么做的,你的代码还是在这里。


那么,提升是什么呢?


在编译阶段,即在代码执行前的几微秒内,将对其进行扫描以查找函数和变量声明。所有这些函数和变量声明都添加到内存中称为词法环境的 JavaScript 数据结构内部。这样,即使在源代码中实际声明它们之前也可以使用它们。


词法环境是什么?


词法环境是用来保存标识符和变量映射关系的地方。标识符是变量或者函数的名字,变量是对实际对象(包括函数对象和数组对象)或者原始值的引用。


简而言之,词法环境是存储变量和对象引用的地方


词法环境的结构如下:


LexicalEnvironment = {
Identifier: <value>,
Identifier: <function object>
}

如果想要了解更多词法环境相关的内容,可以查看我翻译的这篇文章 面试必备 | 一文读懂 JavaScript 中的执行上下文


现在我们知道了提升的内部原理是什么,让我们看看函数和变量(letconstvar)声明的提升是如何发生的。


函数声明提升


helloWorld();  // prints ‘Hello World!’ to the console
function helloWorld(){
console.log(‘Hello World!’);
}

我们已经知道函数声明是在编译阶段添加到内存的,因此我们可以在实际函数声明之前在代码中对其进行访问


因此,以上代码的词法环境如下所示:


lexicalEnvironment = {
helloWorld: <func>
}

因此,当 JavaScript 引擎遇到 helloWorld() 时,它将查看词法环境,找到该函数并能够执行它。


函数表达式提升


JavaScript 引擎只会提升函数声明,并不会提升函数表达式。看下面的例子:


helloWorld();  // TypeError: helloWorld is not a function
var helloWorld = function(){
console.log(‘Hello World!’);
}

由于 JavaScript 仅提升声明,而不赋值,helloWorld 会被视为变量而不是函数。因为 helloWorld 是一个 var 声明的变量,所以在提升阶段引擎将会给它赋值 undefined,所以上述代码会报错。


下面的代码是正常的:


var helloWorld = function(){
console.log(‘Hello World!’); prints ‘Hello World!’
}
helloWorld();

var 变量提升


让我们看一些示例,以了解 var 变量的提升。


console.log(a); // outputs ‘undefined’
var a = 3;

我们期望得到 3,但是得到了 undefined。为什么?


请记住,JavaScript 仅是提升声明,并不会提升赋值操作。也就是说,在编译期间,JavaScript 仅将函数和变量声明存储在内存中,并没把赋值操作也一起提升,而 function 声明的函数会被整体提升


但为什么是 undefined 呢?


当 JavaScript 引擎在编译阶段找到一个 var 变量声明时,它会把该变量添加到词法环境中,并给它赋值 undefined 作为初始值,然后当代码执行到赋值语句时,会把实际的值赋到词法环境中对应的变量。


因此,以上代码的初始词法环境如下所示:


LexicalEnvironment = {
a: undefined
}

这就是我们得到 undefined 而不是 3 的原因。在执行阶段,当代码执行到实际赋值的那一行的时候,会把值赋给词法环境中对应的变量。所以赋值之后的词法环境将会是下面这样:


LexicalEnvironment = {
a: 3
}

let 和 const 的提升


先看下面的例子:


console.log(a);
let a = 3;

执行代码,将会报错 Uncaught ReferenceError: Cannot access ‘a’ before initialization


那么,报错是因为 letconst 声明的变量没有提升吗。


答案要复杂得多。 所有声明(functionvarletconstclass)都会提升,而 var 声明会被初始化为 undefined,但是 letconst 声明保持未初始化 uninitialized


只有当 JavaScript 事实上执行过了声明语句之后,它们才会被初始化,JS 引擎做了限制,你不能在初始化它们之前就使用它们。这也就是我们说的暂时性死区


如果 JavaScript 引擎在声明它们的行上仍找不到 letconst 的值,它将为它们分配 undefined 或返回错误(如果为 const


看下面的例子,由于 const 声明的变量是不可改变的,所以声明的时候没有赋值将会直接报错。


const ast
// VM275:1 Uncaught SyntaxError: Missing initializer in const declaration

再看下面的例子:


let a;
console.log(a); // outputs undefined
a = 5;

在编译阶段,JavaScript 引擎遇到该变量 a 并将其存储在词法环境中,但是由于它是 let 变量,因此引擎不会使用任何值对其进行初始化。因此,在编译阶段,词法环境将如下所示:


lexicalEnvironment = {
a: <uninitialized>
}

现在,如果我们尝试在声明变量之前访问变量,则 JavaScript 引擎将尝试从词法环境中获取变量的值,因为该变量未初始化,因此将引发引用错误


在执行期间,当引擎到达声明该变量的行时,它将尝试给该变量赋值,因为该变量没有与之关联的值,因此将为其分配 undefined


因此,执行第二行后,词法环境将如下所示:


lexicalEnvironment = {
a: undefined
}

所以 undefined将会打印到控制台,然后词法环境中的 a 将会更新,a 将会被赋值为 5


只要在变量声明之前不执行该代码,我们甚至可以在声明它们之前在代码(例如,函数主体)中引用 letconst 变量


例如,此代码是完全有效的。


function foo () {
console.log(a);
}
let a = 20;
foo(); // This is perfectly valid

但是下面的代码将会报错。


function foo() {
console.log(a); // ReferenceError: a is not defined
}
foo(); // This is not valid
let a = 20;

原因是函数后执行时,在作用域链中找到的 a 的值是已经被赋予了 20 的,如果函数先执行然后再赋值,访问到的 a 是未被初始化的。


class 声明提升


class 是 ES6 中出现的一个关键字,它也会提升,方式和 let const 一致,也会产生暂时性死区,它在初始情况下也是未初始化的,直到执行赋值。


// Uncaught ReferenceError: Cannot access ‘Person’ before initialization
let peter = new Person(‘Peter’, 25);
console.log(peter);
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}

因此,要访问 class,你必须先声明它们。例如:


class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
let peter = new Person(‘Peter’, 25);
console.log(peter);
// Person { name: ‘Peter’, age: 25 }

让我们从词法环境的角度分析下。在编译阶段,上面代码的词法环境如下:


lexicalEnvironment = {
Person: <uninitialized>
}

当执行到 class 声明的那段代码,Person 会被初始化为对应的值。


lexicalEnvironment = {
Person: <Person object>
}

class 表达式的提升


就像函数表达式一样,类表达式也不会提升。例如,此代码将不起作用。


// VM266:1 Uncaught ReferenceError: Cannot access ‘Person’ before initialization
let peter = new Person(‘Peter’, 25);
console.log(peter);
let Person = class {
constructor(name, age) {
this.name = name;
this.age = age;
}
}

正确的方法是这样的:


let Person = class {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
let peter = new Person(‘Peter’, 25);
console.log(peter);
// Person { name: ‘Peter’, age: 25 }

结论


因此,现在我们知道在提升过程中,JavaScript 引擎实际不会移动代码。正确理解提升机制将有助于您避免将来由于提升而引起的任何错误和混乱。为避免未定义的变量或引用错误(ReferenceError)等提升的副作用,请始终尝试在变量的各自作用域顶部声明变量,并始终在声明变量时尝试初始化变量。


加餐:块级作用域的提升问题


上面章节都是翻译的,接下来要讲解一个漏掉的知识点。我们知道在 ES6 中提出了块级作用域的概念,块级作用域中声明的变量也会存在变量提升,但是部分提升的方式和其他作用域稍微不同。


看下面的例子,是不是和我们平常碰到的情况不太一样:


// undefined
console.log(‘a1’, a)
{
// function a
console.log(‘a2’, a)
a = 100
// 100
console.log(‘a3’, a)
function a() {}
// 100
console.log(‘a4’, a)
}
// 100
console.log(‘a5’, a)


以下内容来自 阮一峰-ES6 入门 http://es6.ruanyifeng.com/#docs/let#%E5%9D%97%E7%BA%A7%E4%BD%9C%E7%94%A8%E5%9F%9F:允许在块级作用域内声明函数。函数声明类似于 var,即会提升到全局作用域或函数作用域的头部。同时,函数声明还会提升到所在的块级作用域的头部。



由以上我们知道,块级作用域内声明的函数会有两个操作:1. 提升到全局作用域;2. 提升到所在块级作用域内部。


这两个过程以及提升的时机用下面的代码来描述(来自 https://stackoverflow.com/questions/31419897/what-are-the-precise-semantics-of-block-level-functions-in-es6):


// 在函数内的块级作用域内声明了一个函数 compat
function enclosing() {

{

function compat() { … }

}

}

提升的过程表示如下:


function enclosing() {
var compat₀ = undefined; // function-scoped

{
let compat₁ = function compat() { … }; // block-scoped

compat₀ = compat₁;

}

}

提升的过程存在三个步骤:



  1. 在块级作用域外层,产生一个用 var 声明的变量,并赋值为 undefined,类似于块级作用域内部 var 声明的变量;
  2. 在块级作用域词法分析阶段,在顶部用 let 声明一个同名变量,并赋值为这个函数。注意内层不仅提升了而且赋值了。
  3. 在原来函数声明的那一行,把内层用 let 声明的变量的值赋值给块级作用域外层用 var 声明的同名变量。外层的变量就是在这个时候被赋值的。

从第1步可以知道 a1undefined,从第2步可以知道 a2function a,从第3步可以知道 a5100


最后


往期精彩:



关注公众号可以看更多哦。


感谢阅读,欢迎关注我的公众号 云影 sky,带你解读前端技术,掌握最本质的技能。关注公众号可以拉你进讨论群,有任何问题都会回复。


公众号
公众号

交流群
交流群