JS的作用域

对于任何编程语言来说,都有一个很基础但也很重要的概念:变量的管理;它包括变量的声明,变量的赋值,变量的存储,变量的查找,变量的更改,变量的销毁等。而从另外一个角度来看这一系列问题就可以理解为:这个变量存在哪儿?存活多久?怎样才能找到它?在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,总体来说有两种找寻方式,分别为:

  • LHS:Left-hand Side
  • RHS:Right-hand Side

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

  • var a = 1;,变量在赋值操作符"="的左边,所以属于LHS
  • console.log( a );,变量不在赋值操作符的左边,而是直接获取变量的值,所以属于RHS

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

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

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

  • 语义作用域,即在"分词/语法分析"定义的作用域,或者说在代码编写阶段就已经决定了作用域的结构范围,JS使用的就是语义作用域
  • 动态作用域,Bash脚本,Perl中依然使用的是动态作用域,本文不予讨论

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

  • 函数作用域,顾名思义,函数作用域就是通过定义一个JS的function而生成的作用域,在JS中所谓的语义作用域指的就是函数作用域,这一点一定要记清楚
  • 块级作用域,而块级作用域则是通过定义一个JS的代码块生成的作用域,块级作用域的典型示例:
    • {},即单独的代码块
    • for(;;) {},即for循环代码块
    • if() {},即if判断代码块

块级作用域其实只是形式上的作用域,它并是严格意义上的语义作用域,所以会出现代码块里的变量声明直接被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
  • IIFE(Invoking Function Expressions Immediately)
  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:全局作用域,只定义了一个变量foo
  • 作用域2:foo方法内的作用域,定义了三个变量,b,abar
  • 作用域3:方法bar内的作用域,定义了一个变量c

其中,作用域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的垃圾回收机制及时清理

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容

  • 在之前的文章中我已经介绍了执行上下文的变量对象。在这一篇文章我要介绍执行上下文的作用域链了。 执行上下文.作用域链...
    csRyan阅读 3,846评论 1 17
  • js的词法作用域 对于JavaScript来说,无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只有函数...
    魔法少女王遗疯阅读 176评论 0 0
  • You don't KnowJS 引语:你不懂的JS这本书�github上已经有了7w的star最近也是张野大大给...
    Sleet阅读 577评论 0 0
  • 雲中誰寄錦書來;雁字回時,月滿西樓。這狂熱的時代,唯剩焦躁。誰又會記得未曾泛起一絲漣漪的她;誰又怎會想起撰一書問候...
    浪子衍阅读 208评论 0 0
  • 临近毕业还有不到一个月了,b还是没有找到工作。 正午的阳光像块厚厚的棉垫披在他身上,没走出就业指导中心几步汗就落下...
    苏语阅读 826评论 4 5