JS基础学习——作用域

时间:2023-03-09 02:24:37
JS基础学习——作用域

JS基础学习——作用域

什么是作用域

变量的作用域就是变量能被访问到的代码范围,比如在下面的这个js代码中,变量a的作用域就是函数foo,因此在全局作用域内的console.log(a)语句不能访问到变量a,报ReferenceError错误。

function foo()
{
var a =3;
console.log(a);
}
foo();/*3*/
console.log(a);/*ReferenceError: a is not defined*/

作用域可以分为词法作用域和动态作用域两种类型。词法作用域也叫静态作用域,变量的作用域是在词法分析阶段确定的,由变量写在代码上的位置决定,与调用其的上下文无关。动态作用域中变量的作用域是由调用代码的堆栈决定的, 它在代码运行时确定。

比如下面这个例子,如果是词法作用域,bar1()和bar2()都会返回2,因为foo函数中变量a的作用域链为foo函数>全局;但如果是动态作用域的话,bar1()会返回3,bar2()会返回4,即foo函数中的a变量的值与执行环境上下文有关,由调用堆栈动态决定。

var a = 2;
function foo()
{
return a;
} function bar1()
{
var a = 3;
var b1 = foo();
return b1;
} function bar2()
{
var a = 4;
var b2 = foo();
return b2;
} bar1();/*return 2 or 3?*/
bar2();/*return 2 or 4?*/

JS的作用域

JS的作用域属于词法作用域,JS的作用域按作用域范围区分,可以分为全局作用域、函数作用域,且默认不包含块作用域,比如对于下面这段JS代码,因为不存在块作用域,if代码块内部申明的变量a是可以被语句console.log(a)访问到。但在某些特殊情况下,变量可以有块作用域,比如可以通过let、const、catch等关键词设置。

/*-----------code 1----------*/
if(true){
var a = 10;
}
console.log(a);/*10*/

全局作用域

不在函数内部申明的变量或是没有申明就直接使用的变量(非严格模式下),都会成为全局变量,拥有全局作用域,即网页上所有的语句都能访问到它。全局变量会自动成为浏览器全局对象window的属性,所以可以以“window.变量”的形式来引用变量,全局变量在页面关闭后才会消亡。

函数作用域

在函数内部用var声明的变量拥有函数作用域,变量只能被也在该函数内部的语句访问,且它在函数运行完之后立刻消亡。

函数作用域的作用(个人理解)

  1. 保证代码正确执行,若函数内部的变量能被外部语句访问到,变量的值容易被修改,导致运行结果出错;
  2. 允许在不同函数内部的函数变量重名,方便代码编写;
  3. 减少内存空间的占用;
  4. 隐藏函数内部实现,保证函数私有;

延伸阅读:全局变量和局部变量在内存中的区别

块作用域

有时候我们希望函数内部的部分代码块也能拥有自己的作用域,一方面可以提高代码的可读性更高,同时利用块作用域可以实现最小化变量的作用域,从而更加灵活的控制变量内存的占有和释放。生成块作用的方式如下。

  1. 立即执行函数表达IIFE(Immediately Invoking Function Expressions)

    立即执行函数表达IIFE形如(function (){ .. })(),第一个括号包裹函数表达,第二个括号表示立即执行函数,第二个括号里可以传递函数所需的参数。

    我们知道JS中函数定义的方式之一是函数表达式定义,包括匿名函数表达式定义和内联函数表达式定义,那么IIFE也有两种写法,匿名函数表达如下面code 2所示,内联函数表达如code 3所示,推荐使用第二种,它的好处是可以方便实现函数调用自己,同时代码的可读性比较高。

     /*-----------code 2----------*/
    var a = 2;
    (function (){
    var a = 3;
    console.log( a ); // 3
    })();
    console.log( a ); // 2 /*-----------code 3----------*/
    var a = 2;
    (function IIFE(){
    var a = 3;
    console.log( a ); // 3
    })();
    console.log( a ); // 2
  2. with关键词

    with关键词申明的块的最初目的是为了减少对同一对象的重复引用,方便代码的编写,但是with关键词还有一个特性就是with的包含块是一个独立的作用域,但是这个关键词已经被弃用了。

     /*-----------code 4----------*/
    var obj = {
    a: 1,
    b: 2,
    c: 3
    }; // more "tedious" to repeat "obj"
    obj.a = 2;
    obj.b = 3;
    obj.c = 4; // "easier" short-hand
    with (obj) {
    a = 3;
    b = 4;
    c = 5;
    }
  3. catch关键词

    从ES3开始定义catch的包含块是一个独立的块作用域,所以在code 4中,catch外面的console.log( a );是访问不到变量a的。

     /*-----------code 4----------*/
    try{
    throw 2;
    }catch(a){
    console.log( a ); // 2
    }
    console.log( a ); //ReferenceError: a is not defined
  4. let关键词

    ES6引入了let关键字用来为变量创建块作用域,它是代替var关键词的的一种新的变量申明方式。let关键词申明的变量的作用域为包含它的最小{...}内部,如code 5所示。

     /*-----------code 5----------*/
    {
    let i = 1;
    };
    console.log(i);//ReferenceError: i is not defined

    利用let关键字为变量指定现有块为作用域是为变量添加块作用域的隐式写法,这种写法容易混淆变量的作用域,在移动复制代码时容易发生错误,因此建议使用显式写法为变量增加块作用域,即用新的{}给出变量作用域,如code 6所示,这样写更容易进行代码的重构,保证代码语义正确。

     /*-----------code 6----------*/
    var foo = true;
    if (foo) {
    { // <-- explicit block
    let bar = foo * 2;
    bar = something( bar );
    console.log( bar );
    }
    }
    console.log( bar ); // ReferenceError

    for循环里用let声明循环变量时,循环变量的作用域不是整个for循环过程,而是每一次迭代都会重新生成一个新的循环变量,一次迭代结束之后,这个变量就会消亡,下一次迭代又会有一个新的同名循环变量生成。如code 7所示例子,code 8是它的等价代码。

     /*-----------code 7----------*/
    for (let i=0; i<10; i++) {
    console.log( i );
    }
    console.log( i ); // ReferenceError /*-----------code 8----------*/
    {
    let j;
    for (j=0; j<10; j++) {
    let i = j; // re-bound for each iteration!
    console.log( i );
    }
    console.log( i ); // ReferenceError
    }
  5. const关键词

    const也是ES6引入了一个新的关键字,const声明的变量会和let声明的变量一样拥有块作用域,除此之外,const声明的变量的值是不可改变的,任何想要修改const变量值的操作都会报错。

JS作用域延伸——变量、函数提升

JS代码中有一个神奇的现象,函数作用域或全局作用域中后申明的var变量和函数可以提前被调用,如code 9所示,这段代码等价于code 10,这种现象称为变量、函数提升,需要注意变量是只有其申明被提升,赋值以及其他可执行逻辑还是按照原先的顺序执行,函数是整体被提升,包括函数声明和函数内容。

/*-----------code 9----------*/
foo();
function foo() {
console.log( a ); // Undefined
var a = 2;
} /*-----------code 10----------*/
function foo() {//函数提升
var a; //变量提升
console.log( a ); // Undefined
a = 2;
}
foo();

变量、函数提升现象时由JS代码运行的内部原理决定的。JS是一种解释型语言,依靠JS引擎对代码进行实时解释执行,JS自上而下的执行过程包括两个阶段:编译和执行,在编译阶段进行形参分析、变量函数申明,在执行阶段再顺序执行脚本语言。因为JS引擎是先进行变量函数申明再执行脚本,因此所有变量就好像是提到了它所在范围的最前端进行执行了一样。

关于变量函数提升还有几点需要注意。

  1. 表达式中的函数定义是不会被提升的,它只遵循变量提升的原则,如code 11所示,它等价于code 12;

     /*-----------code 11----------*/
    foo(); // not ReferenceError, but TypeError!
    var foo = function bar() {
    // ...
    }; /*-----------code 12----------*/
    var foo;
    foo(); // not ReferenceError, but TypeError!
    foo = function bar() {
    // ...
    };
  2. 函数提升的优先级高于变量,即如果同时定义了两个同名的变量和函数,函数会覆盖变量,如code 13,最终会打印出1而不是显示TypeError,code 14是它的等价代码。

     /*-----------code 13----------*/
    foo(); // 1
    var foo;
    function foo() {
    console.log( 1 );
    }
    foo = function() {
    console.log( 2 );
    }; /*-----------code 14----------*/
    function foo() {
    console.log( 1 );
    }
    foo(); // 1
    foo = function() {
    console.log( 2 );
    };
  3. 两个同名的函数定义,后出现的函数定义会覆盖前面的函数定义。如code 15等价于code 16。

     /*-----------code 15----------*/
    foo(); // 3
    function foo() {
    console.log( 1 );
    }
    var foo = function() {
    console.log( 2 );
    };
    function foo() {
    console.log( 3 );
    } /*-----------code 16----------*/
    function foo() {
    console.log( 1 );
    }
    function foo() {
    console.log( 3 );
    }
    foo(); // 3
    foo = function() {
    console.log( 2 );
    };
  4. JS默认只有函数作用域,因此代码块(if、for等)里面的函数定义也会被提升,如code 18等价于code 17。

     /*-----------code 17----------*/
    
     foo(); // "b"
    var a = true;
    if (a) {
    function foo() { console.log("a"); }
    }
    else {
    function foo() { console.log("b"); }
    } /*-----------code 17----------*/ function foo() { console.log("a"); }
    function foo() { console.log("b"); }
    foo(); // "b"
    var a = true;
    if (a) {
    }
    else {
    }
  5. let、const关键词声明的变量不存在变量提升现象。

延伸阅读:解释型语言和编译型语言的区别

由于计算机无法直接执行高级语言,它只能识别二进制的机器码,因此高级语言一定要翻译成机器语言计算机才能识别运行。语言翻译的方式有两种:编译和解释,两者的主要区别是翻译语言的时间不同。按照语言翻译方式的不同,高级语言被分为编译型语言和解释型语言。

编译型语言需要在执行代码之前通过编译器将代码翻译成机器语言,生成.exe可执行二进制代码,依次编译可多次执行,因此执行效率比较高。比如:C/C++、Delphi、Pascal、Fortran。

解释型语言不需要提前编译,在执行的时候再通过解释器进行语言的翻译,因为每次执行都需要重新尽心语言的翻译,因此解释型语言的效率比较慢;但解释型语言的跨平台性比较好,只要在特定的平台安装对应的解释型就能运行代码。如JAVA、Basic、javascrip、Python。

JS作用域延伸——变量查询

上面提到,JS自上而下的执行过程包括两个阶段:编译和执行,在执行阶段大部分的语句都涉及变量查询,因此了解变量查询的概念是重要的。

根据查找的内容不同变量查询可分为LHS(left-Hand-Side)查询和RHS(right-Hand-Side)查询。根据字面理解,LHS表示要查找的变量处于“=”的左边,RHS表示要查找的变量处于“=”的右边。更加准确的说,LHS要查找的是存在变量的内存地址,RHS要查找的是变量的值。

变量查询的范围是变量的作用域链,作用域链由包含变量的从内到外的多个作用域组成(函数\块作用域>函数\块作用域>...>全局域),按顺序依次查找从内到外每个作用域,知道找到变量。

参考资料:

[1] You don't know js -- Scope & Closures

[2] 深入理解javascript作用域系列第一篇——内部原理

[3] 深入理解javascript作用域系列第二篇——词法作用域和动态作用域

[4] 深入理解javascript作用域系列第三篇——声明提升(hoisting)

[5] 深入理解javascript作用域系列第四篇——块作用域

[6] 脚本语言、编译性语言和解释性语言的区别

[7] 什么是脚本语言?什么是解释性语言?什么是编译性语言?