var,let,const和变量提升(hoist)

Clloz · · 1,830次浏览 ·

前言

ES6 以前,JavaScript 中是不存在块级作用域的,变量的作用域是靠执行环境来控制的,要么是在某个函数内要么是在全局作用于中。由于 JS 中的变量提升 Hoist 机制的存在,我们的定义的变量或者函数很可能发生命名冲突引发错误。所以在 ES6 中引入了 letconst 来应对这个问题,本文就讨论一下三个命令之间的区别,以及 JS 中的变量提升机制。

block 块语句

上面我们已经说过,在 ES5 中,JS 的作用域只有两种可能,要么在某个函数中,要么在全局作用域。对于像 if 或者 for 这样的语句,虽然他们也有大括号,但因为他们不是函数,所以在这些语句中定义的变量一样可以在外部访问的。

ES6 中引入了块级作用域,用于组合零个或多个语句。块级作用域由一对大括号界定,可以添加 label。块级作用域的出现主要是为了配合 letconstclass。现在我们在块级作用域中的 letconstclass 声明将不能在块级作用域之外访问。并且块级作用域可以任意嵌套,每一对大括号都是一个独立的块级作用域。

注意,块级作用域只对 letconstclass 生效。var 声明的变量是没有块级作用域的,无论严格模式还是非严格模式。函数声明的行为则比较特别,我在下面介绍。

{
    let m = 10;
    const n = 20;
}
console.log(m); //ReferenceError: m is not defined
console.log(n); //ReferenceError: n is not defined

var x = 1;
{
    var x = 2;
}
console.log(x); // 输出 2

没有块级作用域的情况下,结合变量提升机制,经常会产生一些奇怪的现象:

var arr = []
for (var i = 0; i < 5; i++) {
    arr[i] = function () {
        return i;
    }
}
console.log(arr[0]()) //5
console.log(arr[1]()) //5
console.log(arr[2]()) //5
console.log(arr[3]()) //5
console.log(arr[4]()) //5


var tmp = new Date();
function f() {
  console.log(tmp); // 想打印外层的时间作用域
  if (false) {
    var tmp = 'hello world'; // 这里声明的作用域为整个函数
  }
}
f(); // undefined

ES6 引入了块级作用域,明确允许在块级作用域之中声明函数(ES5 中是不允许函数声明在块级作用域中)。ES6 规定,块级作用域之中,函数声明语句的行为类似于 let,在块级作用域之外不可引用。但是实际上各个浏览器并没有遵循标准进行实现,因为如果按标准进行实现的话对老代码的影响会非常大。具体规定参考标准:https://tc39.es/ecma262/#sec-block-level-function-declarations-web-legacy-compatibility-semantics

对于支持 ES6 的浏览器,块级作用域中的函数声明表现如下:

  • 允许在块级作用域内声明函数。
  • 在非严格模式中,函数声明类似于 var,即会提升到全局作用域或函数作用域的头部。同时,函数声明还会提升到所在的块级作用域的头部。
  • 在严格模式中函数声明则只会提升到所在块级作用域的头部。

其他环境的 JavaScript 实现不用遵守这个规定,还是将块级作用域的函数声明当作 let 处理。可以看下面两个例子。

//非严格模式
// 'use strict';
function test() {
    console.log(a); //undefined
    {
        console.log(a); //[Function: a]
        function a() {
            console.log('a');
        }
    }
    a(); //a
}
test();

//严格模式
'use strict';
function test() {
    // console.log(a); //ReferenceError: a is not defined
    {
        console.log(a); //[Function: a]
        function a() {
            console.log('a');
        }
    }
}
test();

考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。

有了块级作用域,原来一些为了防止全局环境被污染的立即执行函数 IIFE 不在必要。

// IIFE 写法
(function () {
    var tmp = ...;
    ...
}());

// 块级作用域写法
{
    let tmp = ...;
    ...
}

补充:block 块语句有一个用法就是我们可以在函数内部用花括号把函数分成一个个独立的小结,我们在小结内部可以放心地用 letconst 定义变量常量和方法。这也是现在框架发展的一个功能。

块语句还可以使用 label 标记,在块语句内进行 break

test: {
    console.log(1);
    break test;
    console.log(2) //这一句不会执行
}

var

var 语句会声明一个函数作用域或者全局作用域的变量,取决于声明所处的执行环境。当重复声明一个变量时,变量的值不会丢失。当赋值给未声明的变量, 则执行赋值后, 该变量会被隐式地创建为全局变量(它将成为全局对象的属性)。声明变量在任何代码执行前创建,而非声明变量只有在执行赋值操作的时候才会被创建。声明变量是它所在上下文环境的不可配置属性,非声明变量是可配置的(如非声明变量可以被删除)。在严格模式下,使用未声明变量时不合法的。

变量的声明有多种形式,特别是声明多个变量的时候。

var a = 0, b = 0;

var a = "A";
var b = a;

// 等效于:
var a, b = a = "A";

var x = y, y = 'A';
console.log(x + y); // undefinedA

var x = 0;

function f(){
  var x = y = 1; // x在函数内部声明,y不是!
}
f();

console.log(x, y); // 0, 1
// x 是全局变量。
// y 是隐式声明的全局变量。
//JS在执行语句之前会先检查是否有未声明的变量,如果有则将其声明提升到全局作用域

需要注意的是,var 语句中的逗号不是逗号操作符,因为它不是存在于一个表达式中。尽管从实际效果来看,那个逗号同逗号运算符的表现很相似。但确切地说,它是 var 语句中的一个特殊符号,用于把多个变量声明结合成一个。

let

letvar 的不同主要有以下几点。

let 只在代码块内有效

{
  let a = 10;
  var b = 1;
}
a // ReferenceError: a is not defined. b // 1

上面 for 循环的问题也可以用 let 解决

var arr = []
for (let i = 0; i < 5; i++) {
    arr[i] = function () {
        return i;
    }
}
console.log(arr[0]()) //0
console.log(arr[1]()) //1
console.log(arr[2]()) //2
console.log(arr[3]()) //3
console.log(arr[4]()) //4

需要注意的是,for 语句的作用域是比较特殊的,小括号内是一个父级块作用域,而大括号内是一个子级块作用域。

for (let i = 0; i < 3; i++) {
    let i = 'abc';
    console.log(i);
}
// abc
// abc
// abc

for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。真实的执行过程如下:

{
    let j;
    for (j=0; j<10; j++) {
        let i = j; // 每次迭代重新绑定
        console.log( i );
    }
}

没有变量提升

当使用 var 语句声明的变量会发生变量提升,也就是进入执行环境的时候,引擎最先做的就是扫描所有的 var 语句,把这些变量声明提到执行环境顶部并赋值为 undefined。这样即使我们在变量声明之前使用变量也不会报错,因为引擎已经把变量提升到执行环境顶部,但是初始化依然要到执行到对应语句才会执行。

由于这种行为是有点违反逻辑的,所以 let 就修复了这个问题,我们必须在 let 语句执行之后才能使用对应的变量,否则会报错。

// var 的情况
console.log(foo); // 输出undefined var foo = 2;
// let 的情况
console.log(bar); // 报错ReferenceError let bar = 2;

暂时性死区

只要在一个块级作用域能用 let 语句声明了一个变量,那么在该块级作用域内,将只有一个该变量,外部的同名变量将无法被访问。可以理解为 let 语句创建的变量与所在的语句块绑定了。

var tmp = 123;
if (true) {
  tmp = 'abc'; // Uncaught ReferenceError: Cannot access 'tmp' before initialization
  let tmp;
}

这个地方和上面的没有变量提升似乎是有冲突的,我们在块语句内的 let 语句声明 tmp 之前使用该变量,报错是 无法在tmp初始化之前访问,说明在声明语句之前就引擎就已经知道这个变量的存在了。我的理解是还是存在某种形式的变量提升,只不过这种提升并没有像 var 那样给变量一个个初始的 undefined,并且变量的使用在初始化之前是被拒绝的。也就是所谓的暂时性死区 temporal dead zone,简称 TDZ

暂时性死区机制也意味着 typeof 不再是一个百分之百安全的操作,在 let 语句前对变量进行 typeof 操作一样会报错。

使用 let 声明变量时,只要变量在还没有 声明完成前使用,就会报错。

var x = x; //undefined

let x = x; //Uncaught SyntaxError: Identifier 'x' has already been declared

另外还有一点就是函数参数似乎也有和 let 的相似的行为,我们不能再函数内部用 letconst给参数重新赋值,也不能像 x = x 这样给函数初始值。但是与 let 不同的是,用 var 可以给参数赋值。具体的过程可能需要查看标准。

function foo(x = 5) {
  var x = 1;
  console.log(x) //1
}

function foo(x = 5) {
  let x = 1; // Uncaught SyntaxError: Identifier 'x' has already been declared
}

function a(x = x) {
} //Uncaught ReferenceError: Cannot access 'x' before initialization

不允许重复声明

let 不可以在同一个块级作用域内重复声明。

// 报错
function () {
    let a = 10;
    var a = 1;
}

// 报错
function () {
    let a = 10;
    let a = 1; 
}

全局环境下用 letconst 声明的变量不会作为属性挂载在全局对象上。

const

const 的大部分行为和 let 是保持一致的,不同的地方时,const 声明的是一个只读的常量,声明的时候必须进行初始化,且不能更改。

const PI = 3.1415;
PI = 3; //Uncaught TypeError: Assignment to constant variable.

const foo;  // SyntaxError: Missing initializer in const declaration

但其实 const 并不是绝对安全的,因为当 const 声明的变量保存的是一个引用类型的时候,他保存的只是一个指向引用类型的指针,他能保证的是这个指针不变,但如果指针指向的的引用类型的内容发生变化,它是无法控制的。

const foo = {};
// 为 foo 添加一个属性,可以成功
foo.prop = 123;
foo.prop // 123
// 将 foo 指向另一个对象,就会报错
foo = {}; // Uncaught SyntaxError: Identifier 'foo' has already been declared

class

class 关键字也是有块作用域的,即使在非严格模式下。

{
    class A {}
    let a = new A()
    console.log(a) //A {}
}
let a = new A() //ReferenceError: A is not defined

变量提升

从概念的字面意义上说,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但这么说并不准确。变量和函数声明在代码结构里的位置是不会动的,而是在编译阶段被放入内存中。实际上变量提升行为是 JavaScript 预编译机制中的一种行为,搞懂预编译过程中发生了什么,我们自然就知道变量提升是如何进行的了。

引擎在接收到 JavaScript 文件到执行中间大概分为三步:

  1. 词法分析
  2. 预编译
  3. 解释执行

这里我们主要说一下第二步 预编译,全局环境的预编译和函数执行环境的预编译的略有不同的。全局预编译发生在页面加载完成时执行,而局部预编译发生在函数执行的前一刻。预编译阶段发生变量声明和函数声明的提升行为,但没有初始化行为(赋值),匿名函数不参与预编译,未声明的变量也不会参与提升(虽然未声明变量也是作为全局变量,但是未声明变量在预编译阶段是不会处理的,只有到解释执行阶段才会进行处理,严格模式下不可以使用未声明变量) 。只有在解释执行阶段才会进行变量初始化 。

//未声明变量只有在解释执行阶段才会处理,没有提升行为
console.log(b)
var a = b = 110 //Uncaught ReferenceError: b is not defined

console.log(b)
b = 110 //Uncaught ReferenceError: b is not defined

b = 110
console.log(b) //110

//严格模式下不可以使用未声明变量
'use strict'
m = 10;
console.log(window.m)  //Uncaught ReferenceError: m is not defined

//未声明的对象属性也会在解释执行之前提前创建
var a = {n:1};
var b = a;
a.x = a = {n:2}; //a.x 在执行完 a = {n:2} 表达式之后依然能正确赋值是因为执行之前已经县创建了 {n:1} 对象的 x属性,这一行语句可以等价为 {n:1}.x = a = {n:2}
console.log(a.x); //undefined
console.log(b.x); //{n:2} 

对于全局环境,预编译大概分为如下几步:

  1. 创建 GO 对象( Global Object )全局对象。
  2. 找到用 var 语句进行的变量声明,将变量名作为 GO 属性名,值为 undefined
  3. 查找函数声明,作为 GO 属性,属性名为函数名,值为函数体(如果函数名与上一步的变量名冲突,那么上一步值为 undefined 的变量提升将被函数声明的提升所覆盖)

需要注意的是只有函数声明被提升,函数表达式和变量是没有区别的,因为引擎是扫描 var 语句来寻找变量,他在预编译阶段只关心 var 语句声明的变量名,而不关心初始化的值。

函数的执行环境是当引擎解释执行到函数调用的地方才会创建,预编译也是在这时进行,预编译完成后才会解释执行函数。函数执行上下文的预编译整体步骤和全局环境是差不多的,不同的地方就是在多了形参和实参的加入。

  1. 创建执行上下文的活动对象 AO(Activation Object)
  2. 找形参和 var 语句进行的变量声明,将变量和形参名作为 AO 属性名,值为 undefined
  3. 将实参值和形参统一。
  4. 在函数体里面找函数声明,值赋予函数体。

在同一个执行环境中出现的形参,变量声明,函数声明,只要出现重名,在预编译中我们可以完全看做同一个东西,即 AO 对象中的一个属性。我们需要注意的是他们发生的先后顺序。

下面来分析几个例子

例一

function s () {
    m() //123
    var m = 10;
    function m() {
        console.log(123);
    }
    m() //m is not a function
}
s()

这个例子比较简单,在进入 s 函数的执行环境,创建活动对象后,先是 var 语句声明的 m 变量被提升(在 AO 中创建一个属性 m,然后是 function m(){} 被提升,由于和 m 对象重名,直接覆盖变量的声明(即修改 AO.m 的值为函数体),所以第一个 m() 就是执行的 function m。但是之后出现的 m 变量的初始化语句再次将 AO.m 修改为初始化语句中的 10,所以当再次想要执行 m() 时,此时 m 已经不是一个函数。

函数声明的提升也就是为什么我们能够在函数声明之前调用函数的原因。在代码结构上我们好像是在函数声明之前调用了函数,其实对于引擎来说,我们还是在函数声明之后进行的调用。而且函数声明在预编译阶段被提前后,我们在解释执行阶段就可以无视它了。

例二

function fn(a){
     console.log(a); //function a() {}
    // 变量声明+变量赋值(只提升变量声明,不提升变量赋值)
    var a = 123;
    console.log(a); //123
    // 函数声明
    function a(){};
    console.log(a);//123
    // 函数表达式
    var b = function(){};
    console.log(b); //function () {}
    // 函数
     function d(){};
}
//调用函数
fn(1);

这个例子我们按照上面的局部环境预编译的四步来分析:

  1. 创建执行上下文的活动对象 AO(Activation Object)
    AO{
    
    }
    
  2. 找形参和 var 语句进行的变量声明,将变量和形参名作为 AO 属性名,值为 undefined
    AO{
         a : undefined,
         b : undefined
    }
    
  3. 将实参值和形参统一。
    AO{
         a : 1,
         b : function(){...}
    }
    
  4. 在函数体里面找函数声明,值赋予函数体。
    AO{
         a : function a(){...},
         b : undefined,
         d : function d(){...}
    }
    

所以显然第一个 console.log(a) 输出的是 AO 中的 a,为 function a() {},然后 a 被初始化语句修改为 123 ,所以第二个 console.log(a) 的结果为 123。下面的函数声明在解释执行阶段可以忽略,所以第三个 console.log(a) 依然输出 123。接下来是对 b 进行初始化,值为一个匿名函数的引用,所以 b 的值输出该匿名函数。

例三

var foo={n:1};

(function (foo) {
    console.log(foo.n); //1
    foo.n = 3;
    var foo = {n:2};
    console.log(foo.n); //2
})(foo);

console.log(foo.n); //3

这个例子跟上面两个不一样的地方时这一题不再是单纯的变量,而是引用类型。在立即执行函数内部,预编译后的活动对象就是 {foo: {n:1}},所以第一个输出结果为 1。然后改 foo.n3。这里需要注意的是引用类型并不是值传递,所以我们此时的 foo 和全局那个 foo 指向同一个对象,也就是全局 foo 指向的对象也被修改了,所以最后一行的 foo.n 就是输出修改后的值 3。回到函数内部,var foo = {n:2} 这一句直接将 AO.foo 指向了一个新的对象 {n:2},这个新对象已经跟全局的那个 foo 指向的对象没有关系了,所以函数内部的最后一个输出结果为 2

参考文章

  1. 《ECMAScript6 入门》
  2. 我花了两个月的时间才理解let
  3. JavaScript预编译

Clloz

人生をやり直す

发表评论

电子邮件地址不会被公开。 必填项已用*标注

我不是机器人*

 

00:00/00:00