一文读懂var,let,const,以及with语句中的性能问题。

275 阅读6分钟

各自特性

var

  1. 使用var定义变量,可以保存任何类型的值,可以不进行变量值的初始化,此时变量会保存undefined。var a; (注:使用const声明变量不进行赋值则会引发错误❌)
  2. var关键字存在变量提升的特性,变量的声明会提升到所在作用域顶部。(只会提升声明,不会提升赋值,此时打印值为undifined)
  3. 使用var声明变量,在函数体内,为局部变量,在函数体外则是全局变量,所以var,存在函数作用域全局作用域。(当在全局作用域通过var声明变量时,该变量会作为属性则会挂载到全局对象(一般是window)上,如已存在该属性,则更新属性值
  4. 当var在语句块中{}声明变量,会造成变量泄露和覆盖外层作用域变量。
  5. var在with语句块中的问题和性能忧患!!!(极坑,详解见文章末尾)。

let

  1. let存在块级作用域,即一个大括号{}就是一个块级作用域(es6新增),语句块外部无法访问内部的变量。

防止内部变量覆盖外部作用域变量或者泄露为全局变量

// var 没有块级作用域的概念,使用var此时会泄露为全局变量。
if (true) {
    let a = 10;
    var b = 20;
}
console.log(a); // a is not defined
console.log(b) // 20

// 覆盖外部作用域变量
var num = 1;
(function () {
	console.log('num',num) 
  // 期望打印num的值为1,实际打印为undifined,使用let则不会。
  if(true) {
  	var num = 2;
  }
})()
  1. let不存在变量提升的特性,在声明行前使用或打印,则会引发错误。

  2. let不允许在同一作用域内,重复声明同一个变量。

  3. let声明的变量不会挂载到全局对象下。

const

  1. const基本特性和let一致,不存在变量提升,不允许重复声明,声明不会挂载到全局对象下,也存在块级作用域的限制。
  2. 使用const声明变量,必须马上进行初始化赋值,否则报错。
  3. const声明的可以认为是个常量,一旦赋值就不能改变,否则报错。(当值为引用类型时,比如对象或数组,可以通过属性或者方法push来修改内部数据,实际上,此时常量储存的是引用类型数据的地址值,只要不修改该地址值,则不会引发错误)

不使用关键词声明

非严格模式下,直接给变量赋值或初始化,则该变量为全局变量,且会挂载到全局对象 (一般是window) 下。

严格模式下(use strict),未声明直接赋值,则会直接抛错(ReferenceError: a is not defined)

a = 1;
(function() {
  a = 2;
  b = 3;
})()
console.log(a); // 2
console.log(b); // 3

One More Thing

关于var变量提升的命名冲突

以下例子会打印函数而不是undifined,因为在推入执行上下文栈时,首先会处理函数声明,其次会处理变量声明,当变量名称跟已经声明的形参名称或上下文函数名称相同的时候,变量声明不会覆盖或影响已存在的声明。

console.log(a); // function a(){}

var a = 1;

function a(){}

暂时性死区Temporal Dead Zone(TDZ)

在块级作用域内,变量一旦用let声明,则该变量就直接绑定该作用域。ES6明确规定,如果语句块存在let,const命令,从一开始就形成了封闭区域,那么在声明之前,该变量都不可使用,术语称为“暂时性死区”

总之,暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。

var a = 1;

if (true) {
  a = 2; // ReferenceError
  let a = 3;
}

// 由于全局代码存在变量a,但是if语句块内又声明了一个变量aa从一开始直接绑定这个语句块,无法读取全局作用域的变量a,在执行到声明a那一行之前,都无法获取a,所以直接赋值会报错。

for循环为何推荐使用let?

如果你已仔细阅读以上关于变量作用域和变量提升的,大概已经大致知道答案了。

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}
// 最终会打印 5 5 5 5 5

首先,你得了解,setTimeout是异步的宏任务,执行顺序是,先执行5次for循环 =》 再执行5次宏任务的回调打印i, 执行顺序设计到js的事件循环机制,不懂可以另行科普。

梳理一遍:

  1. 因为var没有块级作用域的限制,使用var声明变量的会泄露为全局变量,每次循环更改的i都是同一个i。
  2. 在执行定时器回调的时候,循环已结束,i的值最终更新为5,所以打印i的值都是5。
  3. 将var修改为let,因为let存在块级作用域的特性,所以每次循环的块级作用域内部都会创建新的独立的i,所以最终打印会有正确的索引值。

在with中使用var的问题⭐️⭐️⭐️⭐️⭐️

什么是with?

JavaScript 查找某个未使用命名空间的变量时,会通过作用域链来查找,作用域链是跟执行代码的 context 或者包含这个变量的函数有关。'with'语句将某个对象添加到作用域链的顶部,如果在 statement 中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值。如果沒有同名的属性,则将拋出ReferenceError异常。

// 普通使用with指定命名空间
var person = {
  name: 'Alice',
  age: 30
};

with (person) {
  console.log(name); // 输出 'Alice'
  console.log(age); // 输出 30
}

当在with中使用var

const o1 = {};

with(o1) {
  var a = 1;
}

console.log(a); // 1
console.log(o1.a) // undifined
console.log(window.a) // 1

以上体现了一个问题,with语句块中变量a,在全局也可以访问到,说明变量泄露到了外层作用域,此时假如在with中使用变量a,js编译器需要顺着作用域链向上查找,存在性能问题,也无法在with语句下做局部缓存。

当o1对象已存在同名属性

const o1 = {a: 0};

with(o1) {
  var a = 1;
}

console.log(a); // undifined
console.log(o1.a) // 1
console.log(window.a) // undifined

可以看出,当with语句指定的命名空间对象中,存在和var声明的变量同名属性时,则会污染该属性,值将会被更新。

当换成let,const

const o1 = {a: 10, b: 20};

with(o1) {
  const a = 11;
  let b = 20;
}

console.log(window.a) // undifined
console.log(window.b) // undifined
console.log(o1.a) // 10
console.log(o1.b) // 20
console.log(a); // ReferenceError: a is not defined
console.log(b) // ReferenceError: b is not defined

总结:

当在with语句块中使用var关键字声明变量时,首先会检查with指定的命名空间对象(with决定的词法环境)中是否有同名属性,如果有则更新该属性值,如果没有,则该变量将泄露到外层作用域 **(with语句也可能处于函数体中) **,写到对应的外层词法环境中。

此时会影响到代码的性能,因为每次访问变量,都要沿着作用域查找变量,而这个过程比直接访问变量更耗时,换成let,const则可以直接将变量写入with下的词法环境。