Web前端之路程序员JavaScript 进阶营

JS的作用域

2017-10-27  本文已影响136人  简xiaoyao

对于任何编程语言来说,都有一个很基础但也很重要的概念:变量的管理;它包括变量的声明,变量的赋值,变量的存储,变量的查找,变量的更改,变量的销毁等。而从另外一个角度来看这一系列问题就可以理解为:这个变量存在哪儿?存活多久?怎样才能找到它?在JS中,解决这些问题的基础就是作用域,同时了解作用域也是学习闭包的基础

1. 需要理解的概念

在阐述作用域的概念之前,首先需要了解的是,在面对一段程序的时候,JS内部是如何进行处理的,有一个流传很广的说法是JS是解释型语言,而非编译型语言,其实JS程序的执行也是需要编译的,只是其不是预编译的,而是在程序段执行之前进行的临时编译,其编译过程分为下面几步:

  1. 分词/语法分析,如var a = 2;就会被分为var,a,=,2等标记
  2. 解析,将上一步得出的所有标记转换为一个元素树,其实可以看做是该段程序的语法结构;这个元素树统称为"AST"(abstract syntax tree)
  3. 生成可执行码,即将上一步代码块对应的AST转换为机器可执行的指令

上面三部过程需要涉及到三个重要的角色:

  1. 引擎,负责JS代码的编译与执行
  2. 编译器,引擎的好朋友;主要为引擎做一些准备工作,如解析,生成可执行码
  3. 作用域,引擎的另一个好朋友;主要负责管理程序对应的元素(变量,方法等),同时定义一套规则,该规则约束当前程序可以访问哪些元素

当面对代码段var a = 2;的时候,编译器会执行下列步骤:

  1. 编译器询问作用域是否已经存在一个叫a的变量,若存在,则进入下一步,若不存在,则通知作用域创建一个叫a的变量
  2. 编译器为引擎生成可执行码,然后引擎询问当前作用域是否在可以访问的a变量,若存在,则用之,否则,引擎将前往别处寻找(嵌套作用域)
  3. 如果引擎最终找到了变量a,则将2赋值给变量a,否则引擎将报错

2. 作用域中变量的声明与赋值

2.1 Hoisting

JS在面对一个变量的声明与赋值的时候,会首先在编译期对变量声明进行处理,然后在执行期对变量进行赋值;而在编译器进行代码编译的时候,会将变量或方法的声明由其代码申明处提至语义作用域(语义作用域将在后续章节中做详细解释)的顶部,这个过程就称为Hoisting或变量提升,Hoisting也是作用域中变量声明与赋值的核心和难点,首先看下面两段代码:

  a = 2;

  var a;

  console.log( a );
  console.log( a );

  var a = 2;

经过编译器编译后,上面第一段代码会被转换为:

  var a;

  a = 2;

  console.log( a );//2

而第二段代码将被转换为:

  var a;

  console.log( a );

  a = 2;//undefined

可以发现,由于Hoisting的存在,在一个语义作用域内,只要存在变量声明,无论该声明语句处于什么位置,都会在执行前被提至语义作用域的顶部,需要注意的是只有声明会被Hoisting,而赋值不会做任何处理,维持原顺序;同时Hoisting只会在当前语义作用域中起效

方法的声明也一样会在编译期执行Hoisting,如:

  foo();

  function foo() {
      console.log( a ); // undefined

      var a = 2;
  }

将会在编译期是转换为:

  function foo() {
    var a;

    console.log( a ); // undefined

    a = 2;
  }

  foo();

又如:

  foo();

  var foo = function bar() {
    // ...
  };

将会在编译期是转换为:

  var foo;
  foo();

  foo = function bar() {
    // ...
  };

又又如:

  foo();
  bar();

  var foo = function bar() {
    // ...
  };

将会在编译期是转换为:

  var foo;

  foo(); // TypeError
  bar(); // ReferenceError

  foo = function() {
      var bar = ...self...
      // ...
  }

初学JS的人经常会很奇怪为什么JS代码中,经常会出现对某个变量或方法的使用出现在其声明的前面,而JS引擎照样可以正常的执行,不会报错,这些要归功于Hoisting

2.2 变量Hoisting与方法Hoisting的优先级

如果在语义作用域中同时存在变量Hoisting和方法Hoisting,JS也规定了它们的优先级:
方法Hoisting优先级 > 变量Hoisting优先级

如:

  foo();

  var foo;

  function foo() {
      console.log( 1 );
  }

  foo = function() {
      console.log( 2 );
  };

将会在编译期是转换为:

  function foo() {
    console.log( 1 );
  }

  foo(); // 1

  foo = function() {
    console.log( 2 );
  };

在这段代码中,有两个同名的声明,变量foo与方法foo,首先,方法foo将会被Hoisting,同时后续的变量foo的声明将会被忽略(因为JS引擎已经找到了变量foo,那么它就不会重新去声明一个同名变量)

在代码块里定义的方法也将被Hoisting

  foo(); // "b"

  var a = true;
  if (a) {
   function foo() { console.log( "a" ); }
  }
  else {
   function foo() { console.log( "b" ); }
  }

将会在编译期是转换为:

  function foo() { console.log( "a" ); }
  function foo() { console.log( "b" ); }
  foo(); // "b"

  var a = true;
  if (a) {

  }
  else {

  }

3. 作用域中变量的找寻机制

JS引擎在编译包含有变量a的代码时,会在作用域中找寻变量a,总体来说有两种找寻方式,分别为:

这里的side指的是assignment operation,即通过赋值操作区分是LHS还是RHS,如果变量在赋值操作的左边,则是LHS;而RHS却不能简单定义为变量在assignment operation的右边,应该理解为非LHS的即为RHS,从变量找寻与赋值的角度来说,LHS指的是找寻变量本身,而RHS指的是获取变量的值,如:

这里之所以要介绍着两种变量找寻方式,是因为这两种变量找寻方式在作用域中会有不同的表现,如:在RHS模式下,如果找到了对应变量,则返回该变量,反之未找到对应变量,会弹出ReferenceError;而在LHS模式下,如果未找到对应变量,则根据不同情况作出不同反应,如果是“Strict Mode”下,则会弹出ReferenceError,而非“Strict Mode”则在当前作用域下自动创建该变量

4. JS中的作用域(语义作用域)

说了这么多,如何识别JS中的作用域呢?首先从大的层面了解一下作用域的分类,一般来说作用域可分为两种:

而在JS中,根据代码形式,作用域也可以分为两种:

块级作用域其实只是形式上的作用域,它并是严格意义上的语义作用域,所以会出现代码块里的变量声明直接被Hoisting其外部语义作用域(函数作用域)顶部的情况

那么除开写法上的不同,函数作用域和块级作用域主要有什么区别呢?其实它们最重要的区别在于函数作用域可以进行有效的变量隔离,即在函数作用域里定义的变量不会影响其嵌套作用域,这在模块化开发里尤其有用,它可以保证在A模块定义的变量不会影响与B模块的同名变量,更不会污染global作用域,典型的函数作用域示例:

  function foo(a) {

      var b = a * 2;

      function bar(c) {
          console.log( a, b, c );
      }

      bar(b * 3);
  }

  foo( 2 ); // 2 4 12
  var a = 2;

  (function foo(){

      var a = 3;
      console.log( a ); // 3

  })();

  console.log( a ); // 2

需要注意的是IIFE的方法不能在外部语义scope里再次调用,如:

  (function foo() {
      a = 2;
      console.log("a is " + a);
  })();

  foo();//ReferenceError

看下列示例,并思考这段代码中包含有几个函数(语义)作用域:

  function foo(a) {

      var b = a * 2;

      function bar(c) {
          console.log( a, b, c );
      }

      bar(b * 3);
  }

  foo( 2 ); // 2 4 12

这段代码有三个函数作用域:

scope.png

其中,作用域1是作用域2的嵌套作用域,而2又是3的嵌套作用域,如在作用域3中需要使用变量a的值,但是此时在自己的作用域中并未找到变量a,那么就会到其上一级嵌套作用域,也就是作用域2中找寻变量a,以此类推;同时语义作用域只与方法的定义位置有关,与其调用位置毫无关系(所以也叫[语义]作用域) ;另外,在根据语义作用域进行变量找寻的时候,只适用于单独变量的情况,如a,b等,而对于通过对象属性找寻变量的情况,如foo.bar.baz就不是根据语义作用域进行变量的找寻,而是通过对象属性访问规则找寻其对应变量

上面已经说过,块级作用域其实只是相当于形式上的作用域,没有任何变量隔离效果,如下面代码:

  function foo() {
      function bar(a) {
          i = 3; // 就是for循环中创建的变量i
          console.log( a + i );
      }

      for (var i=0; i<10; i++) {// i属于foo方法所创造的作用域
          bar( i * 2 ); // 死循环
      }
  }

  foo();

即在块级作用域中定义的变量实际上还是属于其对应的语义作用域内,或者说离它最近的函数作用域,这一点很容易造成错误

  function foo() {
      var i = 1;

      for (var i=0; i<10; i++) {// 由于在foo方法创造的作用域中,变量i已经存在,所以此时for循环中的i其实就是
                                          //上面的"var i = 1;"创建的i
          //do something
      }

      console.log("now i is " + i);//10
  }

  foo();

到了ES6,可以通过letconst实现块级作用域的变量隔离,即通过let在块级作用域中声明变量,该变量将只会存在于该块级作用域中

  function foo() {
      var i = 1;

      for (let i=0; i<10; i++) {
          //do something
      }

      console.log("now i is " + i);//1
  }

  foo();

虽然说块级作用域并没有变量隔离的效果,但是使用得当,块级作用域也能发挥意想不到的用处,如:加快垃圾清理,来看下面代码

  function process(data) {
      // do process
  }

  var someReallyBigData = { .. };

  process( someReallyBigData );

  var btn = document.getElementById( "my_button" );

  btn.addEventListener( "click", function click(evt){// 闭包的存在
      console.log("button clicked");
  }, /*capturingPhase=*/false );

可以发现在click事件对应的方法中,someReallyBigData完全无用,可以将其回收掉,以减轻内存负担,但由于有闭包的存在,JS并不会马上对其进行回收,那么此时可以采用下列写法

  function process(data) {
      // do process
  }

  // block scope定义的任何数据都可以在scope结束后清理掉
  {
      let someReallyBigData = { .. };

      process( someReallyBigData );
  }

  var btn = document.getElementById( "my_button" );

  btn.addEventListener( "click", function click(evt){
      console.log("button clicked");
  }, /*capturingPhase=*/false );

这段代码里,将大量临时数据的处理放置于外部语义作用域的块级作用域中,它不会受到闭包的影响,在执行完成后会被JS的垃圾回收机制及时清理

上一篇 下一篇

猜你喜欢

热点阅读