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

Clloz · · 44次浏览 ·

前言

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

block 块语句

上面我们已经说过,在 ES5 中,JS 的作用域只有两种可能,要门在某个函数中,要么在全局作用域。对于像 if 或者 for 这样的语句,虽然他们也有大括号,但因为他们不是函数,所以在这些语句中定义的变量一样可以在外部访问的。而 JS 中的块语句其实就是指用大括号包裹起来的一系列复合语句,它并没有限定作用域的功能。在块语句中用 var 声明一个变量或者在非严格模式下进行函数声明(非函数表达式),他们都能在块语句外部调用。虽然在 JS 中直接使用块语句是合法的,但是他们并没有什么实际的作用。

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

{function a() {console.log('a')}}
a()  //输出a

'use strict'
{function a() {console.log('a')}}
a()   //Uncaught ReferenceError: a is not defined

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

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

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

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

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

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 (var 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

没有变量提升

当使用 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; 
}

函数声明在块级作用域的表现是很奇怪的,主要是为了兼容一些旧代码做出的妥协,函数声明在块级作用域内的行为类似于用 var 声明的变量,被同时提升到块级作用域和所在执行环境作用域顶部,被赋值为 undefined(只在 chrome 中次测试过)。考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需 要,也应该写成函数表达式,而不是函数声明语句。

// 浏览器的 ES6 环境
function f() { console.log('I am outside!'); }
(function () {
    if (false) {
        // 重复声明一次函数f
        function f() {
            console.log('I am inside!'); 
        }
    }
    f();// Uncaught TypeError: f is not a function
}());

//实际运行过程
function f() { console.log('I am outside!'); }
(function () {
    var f = undefined;
    if (false) {
        function f() {
            console.log('I am inside!'); 
        }
    }
    f();// Uncaught TypeError: f is not a function
}());

全局环境下用 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

变量提升

从概念的字面意义上说,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,但这么说并不准确。实际上变量和函数声明在代码里的位置是不会动的,而是在编译阶段被放入内存中。我总结了以下几个规则。

  1. 只提升声明不提升初始化,变量会被初始化为 undefined,函数则初始化为函数体。
  2. 函数声明的提升会比对象优先。且不会被变量的提升所覆盖,但是后面变量的初始化会覆盖函数声明提升。
  3. 函数表达式的提升和变量提升没有区别,所以函数声明可以提前调用,函数表达式不可以。
  4. 未声明的变量在运行到所在语句时才会进行提升,提升到全局环境。
function s () {
    m() //123
    var m = 10;
    function m() {
        console.log(123);
    }
    m() //m is not a function
}
s()

参考文章

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

Clloz

人生をやり直す

发表评论

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

我不是机器人*

 

EA PLAYER &

历史记录 [ 注意:部分数据仅限于当前浏览器 ]清空

      00:00/00:00