js基石系列(一)——变量的那些事

621 阅读11分钟

js基石系列大纲

js基石系列.png

变量

变量 是存储值的容器,变量就是为“值”起名,然后引用这个名字,就等同于引用这个值。变量的名字就是变量名。

变量的声明

var

作用是声明一个变量,按照早期JavaScript的约定,在全局代码块使用var声明时,相当于在window上声明了一个属性,进而使所有代码都能将这些声明作为全局变量来访问。

 var names = 'jimmy'
 console.log(window) // 打印window,变量已经挂载上去

image.png


也可以通过省略var来声明变量,这时变量会变成全局变量,挂载到window下,比如在函数里用此方式声明一个变量,函数外层也可以访问到。这种做法是不推荐或者说禁止的,因为在局部作用域中不用var等声明变量很难维护,也会造成困惑,不利于表达意图,作用域也变得更加宽泛,不能一下子断定省略var是不是有意而为之。在严格模式下,如果像这样给未声明的变量赋值,则会导致抛出 ReferenceError

   function test() {
      names = "jimmy"; // 全局变量
    }
    test();
    console.log(names); // "jimmy"

目前实际开发中,定义变量已经不在使用var

let/const

let,const 跟 var 的作用都是声明变量,但它们有着非常重要的区别。最明显的区别是 块级作用域,下面我们来详细看看它们的区别:

var,let,const三者的区别:

  • 块级作用域

块作用域由 { }包括,let和const具有块级作用域,var不存在块级作用域。

  • 变量提升

var存在变量提升,let和const不存在变量提升.

  • 给全局添加属性

浏览器的全局对象是window,Node的全局对象是global。var声明的变量为全局变量,并且会将该变量添加为全局对象的属性,但是let和const不会。

  • 重复声明

var声明变量时,可以重复声明变量,后声明的同名变量会覆盖之前声明的遍历。const和let不允许重复声明变量。

暂时性死区

在使用let、const命令声明变量之前,该变量都是不可用的。这在语法上,称为暂时性死区。使用var声明的变量不存在暂时性死区。

  • 初始值设置

在变量声明时,var 和 let 可以不用设置初始值。而const声明变量必须设置初始值。

  • 指针指向

let和const都是ES6新增的用于创建变量的语法。 let创建的变量是可以更改指针指向(可以重新赋值)。但const声明的变量是不允许改变指针的指向。

image.png

注意

const实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单类型的数据(数值、字符串、布尔值等),值就保存在变量指向的那个内存地址。但对于引用类型的数据(主要是对象和数组),变量指向的是内存地址,保存的只是一个指向实际数据的指针,const只能保证这个指针是固定的,至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。

const person = {};

// 为 person 添加一个属性,可以成功
person.age = 123; 

// 将 person 指向另一个对象,就会报错
person = {}; // TypeError: "person" is read-only

块级作用域

块级作用域由最近的一对包含花括号{} 界定。换句话说, if 块、while 块、function块,单独的块块级作用域。

letconst实际上为 JavaScript 新增了块级作用域。

function f1() {
  let n = 5;
  if (true) {
    let n = 10;
  }
  console.log(n); // 5
}

上面的函数有两个代码块,都声明了变量n,运行后输出 5。这表示外层代码块不受内层代码块的影响。如果两次都使用var定义变量n,最后输出的值是 10。

块级作用域的出现,实际上使得获得广泛应用的匿名立即执行函数表达式(匿名 IIFE)不再必要了。

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

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

变量提升

前面我们讲了,var与let,const的区别之一是变量提升,那我们来具体了解一下变量提升。

什么是变量提升?

先来看看MDN中对变量提升的描述:

变量提升(Hoisting)被认为是,Javascript 中执行上下文(特别是创建和执行阶段)工作方式的一种认识。在 ECMAScript® 2015 Language Specification 之前的 JavaScript 文档中找不到变量提升(Hoisting)这个词。

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


通俗来说,变量提升是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的行为。变量被提升后,会给变量设置默认值为 undefined。 正是由于 JavaScript 存在变量提升这种特性,导致了很多与直觉不太相符的代码,这也是 JavaScript 的一个设计缺陷。虽然 ECMAScript6 已经通过引入块级作用域并配合使用 let、const 关键字,避开了这种设计缺陷,但是由于 JavaScript 需要向下兼容,所以变量提升在很长时间内还会继续存在。

在 ECMAScript6 之前,JS 引擎用 var 关键字声明变量。在 var 时代,不管变量声明是写在哪里,最后都会被提到作用域的顶端。 下面在全局作用域中声明一个num 变量,并在声明之前打印它:

console.log(num) 
var num = 1

这里会输出 undefined,因为变量的声明被提升了,它等价于:

var num
console.log(num)
num = 1

可以看到,num 作为全局变量会被提升到全局作用域的顶端。

除此之外,在函数作用域中也存在变量提升:

function getNum() {
  console.log(num) 
  var num = 1  
}
getNum()

这里也会输出 undefined,因为函数内部的变量声明会被提升至函数作用域的顶端。它等价于:

function getNum() {
  var num 
  console.log(num) 
  num = 1  
}
getNum()

除了变量提升,函数实际上也是存在提升的。JavaScript中函数的声明形式有两种:

//函数声明式:
function foo () {}

//变量形式声明: 
var fn = function () {}

当使用变量形式声明函数时,和普通的变量一样会存在提升的现象,而函数声明式函数会提升到作用域最前边,并且将声明内容一起提升到最上边。如下:

fn()
var fn = function () {
 console.log(1)  
}
// 输出结果:Uncaught TypeError: fn is not a function

foo()
function foo () {
 console.log(2)
}
// 输出结果:2

可以看到,使用变量形式声明fn并在其前面执行时,会报错fn不是一个函数,因为此时fn只是一个变量,还没有赋值为一个函数,所以是不能执行fn方法的。

变量提升导致的问题

由于变量提升的存在,使用 JavaScript 来编写和其他语言相同逻辑的代码,都有可能会导致不一样的执行结果。主要有以下两种情况。

(1)变量被覆盖

来看下面的代码:

var name = "JavaScript"
function showName(){
  console.log(name);
  if(0){
   var name = "CSS"
  }
}
showName()

这里会输出 undefined,而并没有输出“JavaScript”,为什么呢?

首先,当刚执行 showName 函数调用时,会创建 showName 函数的执行上下文。之后,JavaScript 引擎便开始执行 showName 函数内部的代码。首先执行的是:

console.log(name);

执行这段代码需要使用变量 name,代码中有两个 name 变量:一个在全局执行上下文中,其值是JavaScript;另外一个在 showName 函数的执行上下文中,在函数执行过程中,JavaScript 会优先从当前的执行上下文中查找变量,由于变量提升的存在,当前的执行上下文的头部就声明了name,其值是 undefined,所以获取到的 name 的值就是 undefined

这里输出的结果和其他支持块级作用域的语言不太一样,比如 C 语言输出的就是全局变量,所以这里会很容易造成误解。

(2)变量没有被销毁

function foo(){
  for (var i = 0; i < 5; i++) {
   // Todo
  }
  console.log(i);  // 5
}
foo()

使用其他的大部分语言实现类似代码时,在 for 循环结束之后,i 就已经被销毁了,但是在 JavaScript 代码中,i 的值并未被销毁,所以最后打印出来的是 5。这也是由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 被console.log使用了,并没有被销毁。

暂时性死区

我们再来看看暂时性死区的概念:

只要块级作用域内存在let命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。

var tmp = 123;

if (true) {
  tmp = 'abc'; // ReferenceError
  let tmp;
}

上面代码中,存在全局变量tmp,但是块级作用域内let又声明了一个局部变量tmp,导致后者绑定这个块级作用域,所以在let声明变量前,对tmp赋值会报错。

ES6 明确规定,如果区块中存在letconst命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。

总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。

if (true) {
  // TDZ开始
  tmp = 'abc'; // ReferenceError
  console.log(tmp); // ReferenceError

  let tmp; // TDZ结束
  console.log(tmp); // undefined

  tmp = 123;
  console.log(tmp); // 123
}

上面代码中,在let命令声明变量tmp之前,都属于变量tmp的“死区”。

“暂时性死区”也意味着typeof不再是一个百分之百安全的操作。

typeof x; // ReferenceError
let x;

上面代码中,变量x使用let命令声明,所以在声明之前,都属于x的“死区”,只要用到该变量就会报错。因此,typeof运行时就会抛出一个ReferenceError

作为比较,如果一个变量根本没有被声明,使用typeof反而不会报错。

typeof undeclared_variable // "undefined"

上面代码中,undeclared_variable是一个不存在的变量名,结果返回“undefined”。所以,在没有let之前,typeof运算符是百分之百安全的,永远不会报错。现在这一点不成立了。这样的设计是为了让大家养成良好的编程习惯,变量一定要在声明之后使用,否则就报错。

有些“死区”比较隐蔽,不太容易发现。

function bar(x = y, y = 2) {
  return [x, y];
}

bar(); // 报错

上面代码中,调用bar函数之所以报错(某些实现可能不报错),是因为参数x默认值等于另一个参数y,而此时y还没有声明,属于“死区”。如果y的默认值是x,就不会报错,因为此时x已经声明了。

function bar(x = 2, y = x) {
  return [x, y];
}
bar(); // [2, 2]

另外,下面的代码也会报错,与var的行为不同。

// 不报错
var x = x;

// 报错
let x = x;
// ReferenceError: x is not defined

上面代码报错,也是因为暂时性死区。使用let声明变量时,只要变量在还没有声明完成前使用,就会报错。上面这行就属于这个情况,在变量x的声明语句还没有执行完成前,就去取x的值,导致报错”x 未定义“。

ES6 规定暂时性死区和letconst语句不出现变量提升,主要是为了减少运行时错误,防止在变量声明前就使用这个变量,从而导致意料之外的行为。这样的错误在 ES5 是很常见的,现在有了这种规定,避免此类错误就很容易了。

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

思考

var 为撒会被淘汰?

结合上面的知识,笔者认为var被淘汰的原因有以下几个方面

  • 局部变量挂载到全局对象,造成全局对象的污染
  • 允许重复变量的声明,导致数据被覆盖
  • 无法声明常量,没有块级作用域
  • 变量提升

任何一种技术或者新语法的出现,大都是为了解决现有的痛点或者问题,所以ES6中let和const的出现原因以及作用,解决了什么问题,我想大家现在应该都有了自己的答案!!