JS作用域和var let 在for循环中的区别


什么是作用域?

先来看2个例子:

栗子1:

var a = [];
for (var i = 0; i < 10; i++) {
    a[i] = function () {
        console.log(i);
    };
}

a[5](); // 10

栗子2:

var a = [];
for (let i = 0; i < 10; i++) {
    a[i] = function () {
        console.log(i);
    };
}

a[5](); // 5

为什么栗子1中结果是10而不是5呢?反而栗子2中是5呢?

首先,在栗子1中,我们在for循环里使用的是var声明里一个i

在for循环中使用var i = 0;相当于声明了一个全局变量,每次改变都是改变同一个全局变量i,后面声明的会覆盖前面声明的全局变量;

其次,循环中,数组 a 中的所有值都指向function(){},这个方法被不断的重写,即i=0时,指向log(0)i=1时,i=0和i=1都指向了log(1);执行到a[9]时,a[0]~a[9] 都指向了log(9);

当循环结束后,i又++,i自然等于10,所以a[5]()输出为10。

再来看一个栗子3:

for(var i = 0; i<5;i++){
    setTimeout(()=>{
        console.log(i)
    },0) 
}
// 5 5 5 5 5

上面的代码因为setTimeout是一个异步,所以它拿到的是循环结束后的i的值,因为上面我们说的var是全局变量会被覆盖掉所以最后的i值是5,而且一共循环了5次(0-4),所以会打印触五个5。

以下栗子4也说明了这个问题:

{
    var i = 0;
    setTimeout(() => {
        console.log(i); //输出2
    }, 1000);
}
{
    var i = 1;
    setTimeout(() => {
        console.log(i); //输出2
    }, 1000);
}
{
    var i = 2;
    setTimeout(() => {
        console.log(i); //输出2
    }, 1000);
}
//相当于每次循环给i重复声明一次全局变量,后面覆盖前面的,
//循环结束,打印时访问的变量在局部找不到,就去向上级查找,找到全局变量,都为2

在栗子2中,我们在for循环里使用的是let声明里一个i

let具有块级作用域,相当于声明了一个局部变量。

当前的i只在本轮循环有效,每一次循环的i其实都是一个新的局部变量;

循环结束,打印时访问每次循环的局部变量,所以打印0,1,2,3,4,5,6,7,8,9,所以a[5]()输出为5;

把上面栗子3里的var改完let,我们再看看效果,栗子5

for(let i = 0; i<5;i++){
    setTimeout(()=>{
        console.log(i)
    },0) 
}
// 0
// 1
// 2
// 3
// 4

一、作用域

回到初始问题,什么是作用域?

作用域是可访问变量的集合。

在 JavaScript 中,对象和函数同样也是变量。

在 JavaScript 中,作用域为可访问变量,对象,函数的集合。

作用域永远都是任何一门编程语言中的重中之重,因为它控制着变量与参数的可见性与生命周期。

在JS中一共有两种作用域:全局作用域和函数作用域。

在ES6中新增的块级作用域。

全局作用域

  • 直接编写在script标签中的JS代码,都在全局作用域
  • 全局作用域在页面打开时创建,在页面关闭时销毁
  • 在全局作用域中有一个全局对象window(它代表的是一个浏览器的窗口),我们可以直接使用
  • 在全局作用域中:
    • 创建的变量都会作为window对象的属性保存
    • 创建的函数都会作为window对象的方法保存
  • 全局作用域中的变量都是全局变量
    • 在页面的任意的部分都可以访问的到

变量的声明提前

  • 使用var关键字声明的变量,会在所有的代码执行之前被提前声明(但是不会赋值),但是如果声明变量时不使用var关键字,则变量不会被声明提前

函数的声明提前

  • 使用函数声明形式创建的函数var fn = function(){ },它会在所有的代码执行之前就被创建

函数作用域

也称为局部作用域,变量在函数内声明。

  • 调用函数时创建函数作用域,函数执行完毕以后,函数作用域销毁
  • 每调用一次函数就会创建一个新的函数作用域,他们之间是互相独立的
  • 在函数作用域中可以访问到全局作用域的变量
    • 在全局作用域中无法访问到函数作用域的变量
  • 当在函数作用域操作一个变量时,它会先在自身作用域中寻找,如果有就直接使用
    • 如果没有则向上一级作用域中寻找,直到找到全局作用域,如果全局作用域中依然没有找到,则会报错ReferenceError: xxx is not defined

在函数作用域也有声明提前的特性,使用var关键字声明的变量,会在函数中所有的代码执行之前被提前声明

如果变量在函数内没有声明(没有使用 var 关键字),该变量为全局变量。

//函数作用域 : 在函数里面声明的变量,只能在函数内部被访问
//函数作用域:函数里面声明的变量,无论是var声明,还是let声明,都是局部变量

function fn() {
  var a = 10;//局部变量
  let b = 20;//局部变量

  console.log(a,b);//10,20
};

fn();

console.log(a);//报错  a is not defined
console.log(b);//报错  b is not defined

块级作用域

ES5 中没有块级作用域,因此在全局作用域下的 iffor{}中声明的变量都是全局变量,如果 iffor{} 在函数作用域中,则在其内部声明的变量可以在函数作用域中使用;

块级作用域是新增命令letconst来体现。

同时,块级作用域并不影响var声明的变量,var声明的变量的性质和原来一样,还是具有变量提升的特性。

注:由于letconst属于ES6,所以都必须使用严格模式,否则会报错。

任何一对花括号 {} 中的语句集都属于一个块,在这之中定义的所有变量在代码块外都是不可见的,我们称之为块级作用域。

//在大括号里面 且 使用let声明的变量
if(true){
  var a = 10;//全局变量
  let b = 20;//块级变量(块级作用域 :只在大括号内部起作用)
  console.log(a,b);//10,20

};

console.log(a);//10
// console.log(b);//报错  b is not defined

为什么需要块级作用域?

ES5只有全局作用域和函数作用域,没有块级作用域,这带来很多不合理的场景。

第一种场景,内层变量可能覆盖外层变量:

var tmp = new Date();

function fn(){
    console.log(tmp);
    if(false){
        var tmp = "hello";
    }
}

fn(); // undefined

上面代码中,函数fn()执行后,输出结果为 undefined ,原因在于变量提升,导致内层的 tmp 变量覆盖了外层的 tmp 变量。(当在函数作用域操作一个变量时,它会先在自身作用域中寻找,如果有就直接使用)

第二种场景,用来技术的循环变量泄露为全局变量:

var s = "hello";
for(var i=0;i<s.length;i++){
    console.log(s[i]);
}

console.log(i); // 5

上面代码中,变量i只用来控制循环,但是循环结束后,它并没有消失,泄露成了全局变量。

ES6的块级作用域

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

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

1.外层作用域无法读取内层作用域的变量:

{
    {let insane = "hello"}
    console.log(insance); // 报错 ReferenceError: insance is not defined
}

2.内层作用域可以定义外层作用域的同名变量:

{
    let a = "hello";
    {let a = "hello"}
}

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

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

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

ES6 以前变量的作用域是函数范围,有时在函数内局部需要一些临时变量,因为没有块级作用域,所以就会将局部代码封装到IIEF中,这样达到了想要的效果又不引入多余的临时变量。而块作用域引入后,IIEF当然就不必要了!

function f(){
    ...
    swap(var_a,var_b);
    (function swap(a,b){
        var tmp;
        tmp = a;
        a = b;
        b=tmp;
    })(var_a,var_b);
}

如上面的代码, tmp 被封装在IIFE中,就不会污染上层函数;而有块级作用域,就不用封装成IIEF,直接放到一个块级中就好。

function f(){
    let a,b;
    ...
    {
        let tmp;
        tmp = a;
        a = b;
        b=tmp;
    }
}

更简单的说法是,立即执行匿名函数的目的是建立一个块级作用域,那么现在已经有了真正的块级作用域,所以立即执行匿名函数就不需要了。

二、let、const和var的区别

1. let/const不允许重复声明变量,var可以

注:var已经声明的变量名,let/const也不能重复声明

var a = 1;
var a = 2;
console.log(a); //输出结果为 2

let b = 2;
let b = 3;
console.log(b);  //报错但是,如果let声明了一个变量,变量的值可以改变。

let b = 2;
b = 3;//这里是赋值,不是声明
console.log(b);  //3  

2. let/const没有变量提升

也就是不会在预解析的时候进行解析。

  • var : 具有变量提升。 (在声明前可以访问变量,获取的是 undefined
  • let、const : 暂时性死区。 (在声明前不可以访问变量,会报错 ReferenceError

暂时性死区:与通过 var 声明的有初始化值 undefined 的变量不同,通过 let 声明的变量直到它们的定义被执行时才初始化。在变量初始化前访问该变量会导致 ReferenceError 。该变量处在一个自块顶部到初始化处理的“暂存死区”中。

console.log(a); //undefined
var a = 1;

console.log(b);
let b = 2; //报错 ReferenceError: Cannot access 'b' before initialization

console.log(c);
const c = 8; //报错 ReferenceError: Cannot access 'c' before initialization

3. let/const 具有块级作用域

{
    let a=1;
    console.log(a) //1
}

console.log(a); //ReferenceError: a is not defined

{
    let a=1;
    console.log(a) //1
}

console.log(a); //1

ES6中允许块作用域任意嵌套,外层无法访问内层的变量。但是内层可以访问外层,也就是子级访问父级;

4.常量const

注意:一旦声明,常量的值不能改变的!

1.const常量一定要赋值,否则报错

const a;
console.log(a); //SyntaxError: Missing initializer in const declaration 

2. const常量的值不能改变,改变会报错

const a = 10;
a = 99;
console.log(a); //TypeError: Assignment to constant variable.

//使用 const 定义的对象或者数组,其实是可变的。
// 创建常量对象
const car = {type:"Fiat", model:"500", color:"white"};

// 修改属性
car.color = "red";

// 添加属性
car.owner = "Johnson";

//但是不能对常量对象重新赋值:
const car = {type:"Fiat", model:"500", color:"white"};
car = {type:"Volvo", model:"EX60", color:"red"};    // 错误 TypeError: Assignment to constant variable.

//数组同样道理
// 创建常量数组
const cars = ["Saab", "Volvo", "BMW"];

// 修改元素
cars[0] = "Toyota";

// 添加元素
cars.push("Audi");

//但是不能对常量数组重新赋值:
const cars = ["Saab", "Volvo", "BMW"];
cars = ["Toyota", "Volvo", "Audi"];    // 错误 TypeError: Assignment to constant variable.

3. 常量不能重复声明,否则报错

const a = 10;
const a = 99;
console.log(a); //SyntaxError: Identifier 'a' has already been declared

四、创建、初始化和赋值

1.我们来看看 var 声明的「创建、初始化和赋值」过程

假设有如下代码:

function fn(){  
    var x = 1 ;
    var y = 2 ;
}
fn()

在执行 fn 时,会有以下过程(不完全):

  1. 进入 fn ,为 fn 创建一个环境。
  2. 找到 fn 中所有用 var 声明的变量,在这个环境中「创建」这些变量(即 x 和 y)。
  3. 将这些变量「初始化」为 undefined。
  4. 开始执行代码
  5. x = 1 将 x 变量「赋值」为 1
  6. y = 2 将 y 变量「赋值」为 2

也就是说 var 声明会在代码执行之前就将「创建变量,并将其初始化为 undefined」。

这就解释了为什么在 var x = 1之前 console.log(x) 会得到 undefined。

2.接下来来看 function 声明的「创建、初始化和赋值」过程

假设代码如下:

fn2()
function fn2(){  
    console.log(2)
}

JS 引擎会有以下过程:

  1. 找到所有用 function 声明的变量,在环境中「创建」这些变量。
  2. 将这些变量「初始化」并「赋值」为 function(){ console.log(2) }
  3. 开始执行代码 fn2()

也就是说 function 声明会在代码执行之前就「创建、初始化并赋值」。

3.接下来看 let 声明的「创建、初始化和赋值」过程

假设代码如下:

{  
    let x = 1;
    x = 2;
}

我们只看 {} 里面的过程:

  1. 找到所有用 let 声明的变量,在环境中「创建」这些变量
  2. 开始执行代码(注意现在还没有初始化)
  3. 执行 x = 1,将 x 「初始化」为 1(这并不是一次赋值,如果代码是 let x,就将 x 初始化为 undefined
  4. 执行 x = 2,对 x 进行「赋值」

这就解释了为什么在 let x 之前使用 x 会报错:

let x = 'global'; 
{
    console.log(x) // ReferenceError: Cannot access 'x' before initialization
    let x = 1
}

原因有两个

  1. console.log(x) 中的 x 指的是下面的 x,而不是全局的 x
  2. 执行 log 时 x 还没「初始化」,所以不能使用(也就是所谓的暂时死区)

看到这里,你应该明白了 let 到底有没有提升:

  1. let 的「创建」过程被提升了,但是初始化没有提升。
  2. var 的「创建」和「初始化」都被提升了。
  3. function 的「创建」「初始化」和「赋值」都被提升了。

最后看 const ,其实 constlet 只有一个区别,那就是 const 只有「创建」和「初始化」,没有「赋值」过程。

五、关于let/const声明的变量在window里无法获取到的问题

为什么会出现这种问题,就需要知道ES6与ES5变量声明方面的区别了:

  • ES5 声明变量只有两种方式:var和function。
  • ES6 有let、const、import、class再加上 ES5 的var、function共有六种声明变量的方式。
  • 还需要了解顶层对象:浏览器环境中顶层对象是window,Node中是global对象。
  • ES5中,顶层对象的属性等价于全局变量。(敲黑板了啊)
  • ES6中,有所改变:var、function声明的全局变量,依然是顶层对象的属性;let、const、class声明的全局变量不属于顶层对象的属性,也就是说 ES6 开始,全局变量和顶层对象的属性开始分离、脱钩。

所以ES6非严格模式下,与var声明的全局变量都会成为window的属性:

a = 1;
console.log(window.a);  // 1

var b = 2;
console.log(window.b);  // 2

而使用let/const声明的全局变量,不会成为window的属性:

let c = 3;
console.info(window.c);  // undefined

文章作者: 弈心
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 弈心 !
评论
  目录