JS学习系列 03 - 函数做用域和块做用域

在 ES5 及以前版本,JavaScript 只拥有函数做用域,没有块做用域(with 和 try...catch 除外)。在 ES6 中,JS 引入了块做用域,{ } 内是单独的一个做用域。采用 let 或者 const 声明的变量会挟持所在块的做用域,也就是说,这声明关键字会将变量绑定到所在的任意做用域中(一般是 {...} 内部)。javascript

今天,咱们就来深刻研究一下函数做用域块做用域java

1. 函数中的做用域

函数做用域的含义是指,属于这个函数的任何声明(变量或函数)均可以在这个函数的范围内使用及复用(包括这个函数嵌套内的做用域)。浏览器

举个例子:bash

function foo (a) {
   var b = 2;

   // something else

   function bar () {
      // something else   
   }

   var c = 3;
}

bar();      // 报错,ReferenceError: bar is not defined
console.log(a, b, c);        // 报错,缘由同上
复制代码

在这段代码中,函数 foo 的做用域包含了标识符a、b、c 和 bar ,函数 bar 的做用域中又包含别的标识符。微信

因为标识符 a、b、c 和 bar都属于函数 foo 的做用域,因此在全局做用域中访问会报错,由于它们都没有定义,可是在函数 foo 内部,这些标识符都是能够访问的,这就是函数做用域。数据结构

1.1 为何要有这些做用域

当咱们用做用域把代码包起来的时候,其实就是对它们进行了“隐藏”,让咱们对其有控制权,想让谁访问就可让谁访问,想禁止访问也很容易。闭包

想像一下,若是全部的变量和函数都在全局做用域中,固然咱们能够在内部的嵌套做用域中访问它们,可是由于暴露了太多的变量或函数,它们可能被有意或者无心的篡改,以非预期的方式使用,这就致使咱们的程序会出现各类各样的问题,严重会致使数据泄露,形成没法挽回的后果。函数

例如:ui

var obj = {
   a: 2,
   getA: function () {
      return this.a;
   }
};

obj.a = 4;
obj.getA();      // 4
复制代码

这个例子中,咱们能够任意修改对象 obj 内部的值,在某种状况下这并非咱们所指望的,采用函数做用域就能够解决这个问题,私有化变量 a 。this

var obj = (function () {
  var a = 2;
  return {
     getA: function () {
        return a;
     },
     setA: function (val) {
        a = val;
     }
  }
}());

obj.a = 4;
obj.getA();      // 2
obj.setA(8);
obj.getA();      // 8
复制代码

这里经过当即执行函数(IIFE)返回一个对象,只能经过对象内的方法对变量 a 进行操做,其实这里有闭包的存在,这个咱们在之后会深刻讨论。

“隐藏”做用域中的变量和函数所带来的另外一个好处,是能够避免同名标识符之间的冲突,冲突会致使变量的值被意外覆盖。

例如:

function foo () {
   function bar (a) {
      i = 3;        // 修改了 for 循环所属做用域中的 i
      console.log(a + i);
   }

   for (var i = 0; i < 10; i++) {
      bar(i * 2);      // 这里由于 i 总会被设置为 3 ,致使无限循环
   }
}

foo();
复制代码

bar(...) 内部的赋值表达式 i = 3 意外的覆盖了声明在 foo(...) 内部 for 循环中的 i ,在这个例子中由于 i 始终被设置为 3 ,永远知足小于 10 这个条件,致使无限循环。

bar(...) 内部的赋值操做须要声明一个本地变量来使用,采用任何名字均可以,var i = 3; 就能够知足这个要求。另一种方法是采用一个彻底不一样的标识符名称,好比 var j = 3; 。可是软件设计在某种状况下可能天然而然的要求使用一样的标识符名称,所以在这种状况下使用做用域来“隐藏”内部声明是惟一的最佳选择。

总结来讲,做用域能够起到两个做用:

  • 私有化变量或函数
  • 规避同名冲突
1.2 函数声明和函数表达式

若是 function 是声明中的第一个词,那么就是一个函数声明,不然就是一个函数表达式。

函数声明举个例子:

function foo () {
   // something else
}
复制代码

这就是一个函数声明。

函数表达式分为匿名函数表达式和具名函数表达式。

对于函数表达式来讲,最熟悉的场景可能就是回调参数了,例如:

setTimeout(function () {
   console.log("I wait for one second.")
}, 1000);
复制代码

这个叫做匿名函数表达式,由于 function ()... 没有名称标识符。函数表达式能够是匿名的,可是函数声明不能够省略函数名,在 javascript 中这是非法的。

匿名函数表达式书写简便,可是它也有几个缺点须要注意:

  1. 匿名函数在浏览器栈追踪中不会显示出有意义的函数名,这会加大调试难度。
  2. 若是没有函数名,当函数须要引用自身的时候就只能使用已经不是标准的 arguments.callee 来引用,好比递归。在事件触发后的事件监听器中也有可能须要经过函数名来解绑自身。
  3. 匿名函数对代码的可读性和可理解性有必定的影响。一个有意义的函数名可让代码不言自明。

具名函数表达式又叫行内函数表达式,例如:

setTimeout(function timerHandler () {
   console.log("I wait for one second.")
}, 1000);
复制代码

这样,在函数内部须要引用自身的时候就能够经过函数名来引用,固然要注意,这个函数名只能在这个函数内部使用,在函数外使用时未定义的。

1.3 当即执行函数表达式(IIFE)

IIFE 全写是 Immediately Invoked Function Expression,当即执行函数。

var a = 2;

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

console.log(a);      // 2
复制代码

因为函数被包含在一对 ( ) 括号内部,所以成为了一个函数表达式,经过在末尾加上另外一对 ( ) 括号能够当即执行这个函数,好比 (function () {})() 。第一个 ( ) 将函数变成函数表达式,第二个 ( ) 执行了这个函数。

也有另一种当即执行函数的写法,(function () {}()) 也能够当即执行这个函数。

var a = 2;

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

console.log(a);      // 2
复制代码

这两种写法功能是彻底同样的,具体看你们使用。

IIFE 的另外一种广泛的进阶用法是把它们当作函数调用并传递参数进去。

var a = 2;

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

console.log(a);      // 2
复制代码

咱们将 window 对象的引用传递进去,但将参数命名为 global,所以在代码风格上对全局对象的引用变得比引用一个没有“全局”字样的变量更加清晰。固然能够从外部做用域传递你须要的任何东西,并将变量命名为任何你以为合适的文字。这对于改进代码风格是很是有帮助的。

这个模式的另一个应用场景是解决 undefined 标识符的默认值被错误覆盖的异常(这并不常见)。将一个参数命名为 undefined ,可是并不传入任何值,这样就能够保证在代码块中 undefined 的标识符的值就是 undefined 。

undefined = true;

(function IIFE (undefined) {
   var a;
   if (a === undefined) {
      console.log("Undefined is safe here.")
   }
}()); 
复制代码

2. 块做用域

ES5 及之前 JavaScript 中具备块做用域的只有 with 和 try...catch 语句,在 ES6 及之后的版本添加了具备块做用域的变量标识符 let 和 const 。

2.1 with
var obj = {
   a: 2,
   b: 3
};

with (obj) {
   console.log(a);      // 2
   console.log(b);      // 3
}

console.log(a);      // 报错,a is not defined
console.log(b);      // 报错,a is not defined
复制代码

用 with 从对象中建立出的做用域仅在 with 声明中而非外部做用域中有效。

2.2 try...catch
try {
  undefined();      // 非法操做
} catch (err) {
  console.log(err);      // 正常执行
}

console.log(err);      // 报错,err is not defined
复制代码

try/catch 中的 catch 分句会建立一个块做用域,其中的变量声明仅在 catch 内部有效。

2.3 let

let 关键字能够将变量绑定到任意做用域中(一般是 {...} 内部)。换句话说,let 为其声明的变量隐式的劫持了所在的块做用域。

var foo = true;

if (foo) {
   let a = 2;
   var b = 2;
   console.log(a);      // 2
   console.log(b);      // 2
}

console.log(b);      // 2
console.log(a);      // 报错,a is not defined
复制代码

用 let 将变量附加在一个已经存在的块做用域上的行为是隐式的。在开发和修改代码的过程当中,若是没有密切关注哪些代码块做用域中有绑定的变量,而且习惯性的移动这些块或者将其包含到其余块中,就会致使代码混乱。

为块做用域显示的建立块能够部分解决这个问题,使变量的附属关系变得更加清晰。

var foo = true;

if (foo) {
   {
      let a = 2;
      console.log(a);      // 2
   }
}
复制代码

在代码的任意位置均可以使用 {...} 括号来为 let 建立一个用于绑定的块。

还有一点要注意的是,在使用 var 进行变量声明的时候会存在变量提高,提高是指声明会被视为存在于其所出现的做用域的整个范围内。可是使用 let 进行的声明不会存在做用域提高,声明的变量在被运行以前,并不存在。

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

// 在浏览器中运行这段代码时,由于前面报错了,因此不会看到接下来打印的结果,可是理论上就是这样的结果
var a = 2;
console.log(a);      // 2 

let b = 4;
console.log(b);      // 4
复制代码

2.3.1 垃圾收集 另外一个块做用域很是有用的缘由和闭包及垃圾内存的回收机制有关。 举个例子:

function processData (data) {
   // do something
}

var bigData = {...};

processData(bigData);

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

btn.addEventListener('click', function () {
   console.log('button clicked');
}, false);
复制代码

这个按钮点击事件的回调函数中并不须要 bigData 这个很是占内存的数据,理论上来讲,当 processData 函数处理完以后,这个占有大量空间的数据结构就能够被垃圾回收了。可是,因为这个事件回调函数造成了一个覆盖当前做用域的闭包,JavaScript 引擎极有可能依然保存着这个数据结构(取决于具体实现)。

使用块做用域能够解决这个问题,可让引擎清楚的知道没有必要继续保存这个 bigData 。

function processData (data) {
   // do something
}

{
   let bigData = {...};

   processData(bigData);
}

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

btn.addEventListener('click', function () {
   console.log('button clicked');
}, false);
复制代码

2.3.2 let 循环 一个 let 能够发挥优点的典型例子就是 for 循环。

var lists = document.getElementsByTagName('li');

for (let i = 0, length = lists.length; i < length; i++) {
   console.log(i);
   lists[i].onclick = function () {
     console.log(i);      // 点击每一个 li 元素的时候,都是相对应的 i 值,而不像用 var 声明 i 的时候,由于没有块做用域,因此在回调函数经过闭包查找 i 的时候找到的都是最后的 i 值
   };
};

console.log(i);      // 报错,i is not defined
复制代码

for 循环头部的 let 不只将 i 绑定到 fir 循环的块中,事实上它将其从新绑定到了循环的每个迭代中,确保上一个循环迭代结束时的值从新进行赋值。

固然,咱们在 for 循环中使用 var 时也能够经过当即执行函数造成一个新的闭包来解决这个问题。

var lists = document.getElementsByTagName('li');

for (var i = 0, length = lists.length; i < length; i++) {
   lists[i].onclick = (function (j) {
        return function () {
           console.log(j);
        }
   }(i));
}
复制代码

或者

var lists = document.getElementsByTagName('li');

for (var i = 0, length = lists.length; i < length; i++) {
   (function (i) {
      lists[i].onclick = function () {
         console.log(i);
      }
   }(i));
}
复制代码

其实原理无非就是,为每一个迭代建立新的闭包,当即执行函数执行完后原本应该销毁变量,释放内存,可是由于这里有回调函数的存在,因此造成了闭包,而后经过形参进行同名变量覆盖,因此找到的 i 值就是每一个迭代新闭包中的形参 i 。

2.4 const

除了 let 之外,ES6 还引入了 const ,一样能够用来建立做用域变量,但其值是固定的(常亮)。以后任何试图修改值的操做都会引发错误。

var foo = true;

if (foo) {
   var a = 2;
   const b = 3;      // 包含在 if 中的块做用域常亮

   a = 3;      // 正常
   b = 4;      // 报错,TypeError: Assignment to constant variable
}

console.log(a);      // 3
console.log(b);      // 报错, b is not defined
复制代码

和 let 同样,const 声明的变量也不存在“变量提高”。

3. 总结

函数是 JavaScript 中最多见的做用域单元。块做用域指的是变量和函数不只能够属于所处的函数做用域,也能够属于某个代码块。

本质上,声明在一个函数内部的变量或函数会在所处的做用域中“隐藏”起来,这是有意为之的良好软件的设计原则。

有些人认为块做用域不该该彻底做为函数做用域的替代方案。两种功能应该同时存在,开发者能够而且也应该根据须要选择使用哪一种做用域,创造可读、可维护的优良代码。

欢迎关注个人公众号

微信公众号
相关文章
相关标签/搜索