JavaScript中的函数作用域

正如上篇文章介绍的那样,作用域包含了一些列的”气泡“,每一个都可以作为容器,其中包含了标识符(变量、函数)的定义,这些气泡互相嵌套,并且整齐地排列成蜂窝形,排列的结构是在写代码的时候定义的。

但是,究竟是什么生成了一个新的气泡?只有函数会生成新的气泡吗?js中的其他结构能生成作用域气泡吗?

函数中的作用域

对于上面的问题,最常见的答案是,js中具有基于函数的,意味着每声明一个函数都会为其自身创建一个气泡,而其他结构都不会创建作用域气泡,但是事实上并不完全正确,下面我们看一下:

首先需要研究一下函数作用域及其背后的一些内容。

考虑以下代码:

function foo(a){
  var b = 2;
  // 一些代码
  function bar(){
    // ...

    //  更多的代码
  }
  var c = 3;
}

在这个代码中,foo(...)的作用域气泡包含了标识符 a,b,c 和bar 无论标识符声明出现在作用域何处,这个标识符所代表的变量或者函数都将附属于所属作用域的气泡

bar(...)拥有自己的作用域气泡,全局作用域也有自己的作用域气泡它只包含了一个标识符:foo。

由于标识符a,b,c 和bar 都附属于 foo(...)的作用域气泡,因此无法从 foo(..)的外部对它进行访问。也就是说,这些标识符全部都无法从全局的作用域中进行访问,因此下面的代码会导致 ReferenceError的错误:

bar(); // 失败

console.log(a,b,c); // 三个全部失败。

但是,这些标识符在 foo(..)的内部是可以被访问的,同样在bar(..)内部也可以被访问,假设bar(..)内部没有同名的标识符声明)。

函数作用域的含义是指:属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)

这种设计方案是非常有用的,能充分利用js变量可以根据需要改变值的类型的”动态“特性。

但与此同时,如果不细心处理那些可以再整个作用域内被访问的变量,可能会带来意想不到的问题。

隐藏内部实现

对函数的传统认知就是先声明一个函数,然后再向内部里面添加代码。但是反过来想也是可以的从所写的代码中挑选一个任意的片段,然后用函数声明对它进行包装,实际上就是把这些代码隐藏起来了。

实际的结果就是在这个代码片段的周围创建了一个作用域气泡,也就是说这段代码中的任何声明(变量或者函数)都将绑定在这个新创建的包装函数的作用域中,换句话说,可以将函数和变量包裹在一个函数的作用域中,然后用这个作用域来“隐藏”它们;

为什么隐藏作用域和函数是一个有用的技术?

有很多的原因促成了这种基于作用域的隐藏方法,他们大都是从最小特权原则中引申出来的,也叫最小授权或者最小暴露原则,这个原则是指在软件设计中,应该最小限度的暴露必要的内容,而将其他的内容都隐藏起来,比如某一个模块或者对象的API设计。

这个原则可以延伸到如何选择作用域来包含变量和函数,如果所有变量和函数都在全局 作用域中,当然可以在所有的内部嵌套作用域中访问到它们。但是这样会破坏前面提到的最小特权原则,因为很可能暴露过多的变量或者函数,而这些变量或者函数本应该是私有的,正确的代码应该是在可以阻止对这些变量或者函数进行访问的。

function doSomething(a) {
   b = a + doSomethingElse(a * 2);
   console.log(b * 3);
}

function doSomethingElse(a) {
   return a - 1;
}
var b;
doSomething(2);// 15

在这个代码片段中,变量 b 和函数 doSomethingElse(..) 应该是 doSomething(..) 内部具体 实现的“私有”内容。给予外部作用域对 b 和 doSomethingElse(..)的“访问权限”不仅 没有必要,而且可能是“危险”的,因为它们可能被有意或无意地以非预期的方式使用, 从而导致超出了 doSomething(..) 的适用条件。更“合理”的设计会将这些私有的具体内 容隐藏在 doSomething(..) 内部,例如:

    function doSomething(a) {
      function doSomethingElse(a) {
        return a - 1;
      }
      var b;
      b = a + doSomethingElse(a * 2);
      console.log(b * 3);
    }
    doSomething(2); // 15

现在,b 和 doSomethingElse(..) 都无法从外部被访问,而只能被 doSomething(..) 所控制。 功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会 依此进行实现。

规避冲突

“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但是用于却不一样,无意间可能造成命名冲突冲突会导致变量的值被意外覆盖。

例如:

function foo(){
  function bar(a){
    i = 3; 
     console.log(a+i);
}

  for(var i = 0;i<10;i++){
    bar(i+2);
  }
}
foo();

bar(..)内部的赋值表达式 i = 3 意外覆盖了声明在 foo(..)内部 for循环中的 i 在这个例子中将导致无限循环,因为 i 被固定设置为3 永远满足教育10这个条件。

bar(..)内部赋值操作需要声明一个本地变量来使用,采用任何名字都可以,var i = 3 就可以满足这个需求另一种是采用完全不同的标识符名称,比如 var j = 3; 但是软件设计在某种情况下是可能自然而然的要求使用同样的标识符名称,因此在这种情况下使用作用域“隐藏”内部声明式唯一的最佳选择。

1、全局命名空间

变量冲突的一个典型例子存在于全局作用域中,当程序中加载了多个第三方库时候,如果它们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引起冲突。

这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象,这个对象被用作库的命名空间,所有需要暴露给外部的功能都成为这个对象的属性,而不是将自己的标识符暴露在顶级的词法作用域中。

例如:

var MyReallyCoolLibrary = {
  awesome: "stuff",
  doSomething: function () {
   // ... 
 },
  doAnotherThing: function () {
  // ...
 }
};
2、模块管理

另外一种避免冲突的办法和现代的模块机制很相似,就是从众多模块管理器中挑选一个来使用使用这些工具任何库都无需将标识符假如到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入一个特定的作用域中。

函数作用域

我们已经知道,在任意代码片段外部添加包装函数,可以将内部的变量和函数隐藏起来,外部作用域无法访问包装函数内部的任何内容。

例如:

    var a = 2;
    function foo() {
      var a = 3;
      console.log(a); // 3
    }

    foo(); //

    console.log(a);// 2

虽然这种技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题,首先,必须声明一个具名函数foo() 意味着foo这个名称本身“污染”了所在的作用域(在这个例子中是全局作用域)其次,必须显式地通过函数名调用这个函数才能运行其中的代码。

如果函数不需要函数名(或者函数名可以不污染所在的作用域),并且能够自动运行,这将会更加理想。

幸好 js提供了能够同时解决这两个问题的方案。

var a = 2;

(function foo() {
  var a = 3;
  console.log(a); // 3
})()
console.log(a); // 2

接下来我们来分别介绍这里发生的事情,

首先包装函数声明以 (function...)而不是function...开始尽管看上去这不是一个显眼的细节但是这是一个非常重要的区别,函数会被当做函数表达式而不是一个标准的函数声明来处理。

区分函数声明和表达式最简单的方法是看function关键字出现在声明中的位置(不仅仅是一行代码)而是整个声明中的位置 如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式

函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。

比较一下前面两个代码片段,第一个片段中foo被绑定在所在作用域中,可以直接通过 foo()来调用它。第二个片段中foo 被绑定在函数表达式自身的函数中而不是所在的作用域中。

换句话说,(function foo() {... }) 作为函数表达式意味着foo只能在..所代表的位置中被访问,外部作用域则不行。 foo变量名被隐藏在自身意味着不会非必要的污染外部变量作用域。

匿名和具名

对于函数表达式你最熟悉的场景可能就是回调函数了, 比如:

    setTimeout(function () {
      console.log("wait 1 second");
    }, 1000)

这叫做匿名函数表达式,因为function().. 没有名称标识符。函数表达式可以是匿名的。而函数声明则不可以省略函数名——在js的语法中这是非法的

匿名函数表达式书写起来快捷,很多库和工作也倾向鼓励使用这种风格的代码,但是它也有几个缺点需要考虑。

1.匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
2.如果没有函数名,当函数需要引用自身时候只能使用已经过期的arguments.callee 引用,比如在递归中另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
3.匿名函数省略了对于代码可读性 / 可理解性很重要的函数名,一个描述性的名称可以让代码不言自明。

行内函数表达式非常强大且有用,——匿名和具名之间的区别并不会对这点有任何影响,给函数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践。

    setTimeout(function timeoutHandler() { // <-- 快看,我有名字了!
      console.log("I waited 1 second!");
    }, 1000);

立即执行函数表达式

    var a = 2;
    (function foo() {
      var a = 3;
      console.log(a); // 3
    })();
    console.log(a); // 2

由于函数被包裹在一对( )括号内部,因此成为了一个表达式,而在末尾加上一个( )可以立即执行这个函数比如(function foo() {...})() 第一个( ) 将函数变成表达式,第二个( )执行了这个函数

这种模式很常见,几年前 社区给它规定了一个术语 IIFE 代表立即执行函数表达式。

函数名称对于IIFE当然不是必须的,IIFE 最常见的用法是使用一个匿名函数表达式,虽然使用具名函数的IIFE并不常见,但是它具有上述匿名函数表达是的所有优势

    var a = 2; (function IIFE() {
      var a = 3; console.log(a); // 3
    })();
    console.log(a); // 2

相对于传统的IIFE形式,很多人都更加喜欢 另一种改进的形式,(function(){}());仔细观察一下其中的区别,第二种形式用来调用的() 写在了最外层的( )的里面。

IIFE的另外一个非常普遍的进阶用法是把它们当做函数调用并传递参数进去。

例如:

    var a = 2;
    (function IIFE(global){
      var a = 3;
      console.log(a); // 3
      console.log(global.a) // 2
    })(window)

    console.log(2);

我们将window对象的引用传递进去,但将参数命名为 global 因此在代码 风格上面对全局对象的引用,变得比引用一个没有“全局”字样的变量更加清晰。当然可以从外部作用域传入任何你需要的东西,并将变量命名为任何你觉得合适的名字。这对于改进代码风格是 非常有帮助的。

这个模式的另外一种场景是解决undefined 标识符的默认值被错误覆盖导致的异常,将一个参数命名为undefined,但是在对应的位置不传入任何值,这样就保证在代码中undefined标识符的值是真的 undefined:

undefined = true; // 给其他代码挖了一个大坑,千万不要这样做;

    (function IIFE(undefined){
      var a;
      if(a === undefined){
        console.log("undefined is safe here");
      }
    })

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE执行后当做参数传递进去这种模式在UMD项目中被广泛使用,尽管这种模式略显冗长,但是有些人认为它更容易理解。

    var a = 2;

    (function IIFE(def){
      def(window);
    })(function def(global){
      var a = 3;
      console.log(a); // 3
      console.log(global.b); // 2
    })

函数表达式def 定义在片段的第二部分,然后当做参数 (这个参数也叫做def) 被传递进IIFE函数定义的第一部分。最后,参数def(也就是传递进去的函数)被调用,并将window传入当做global参数的值。

参考《你不知道的javascript》上卷

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

推荐阅读更多精彩内容