你不知道的JS-上(三)

35 阅读9分钟

你不知道的 JS-上

作用域和闭包

函数作用域和块作用域

函数中的作用域

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

隐藏内部实现

在软件设计中,应该最小限度地暴露必要内容,从而将其他内容都“隐藏”起来

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(..)的适用条件。应该修改为

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

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

doSomething(2); //15
规避冲突

“隐藏”作用域中的变量和函数,可以避免同名标识符之间的冲突。

function foo() {
  function bar(a) {
    i = 3; // 修改for循环所属作用域中的i 正确做法:var i = 3
    console.log(a + i);
  }
  for (var i = 0; i < 10; i++) {
    bar(i * 2); // 糟糕,无限循环了
  }
}

foo();

bar(..)内部的赋值表达式 i = 3 将 foo(..)内部 for 循环中的 i 覆盖掉,导致 i 被固定为,永远满足不了 < 10 的条件

  • 全局命名空间 使用第三方库时,这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间。所有需要暴露给外界的功能都会成为这个对象的属性。从而避免将标识符暴露在顶级的词法作用域中。
var MyReallyCoolLibrary = {
  awesome:"stuff",
  doSomething:function(){
    //...
  }
  doAnotherThing:function(){
    //...
  }
}
  • 模块管理

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

函数作用域

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

例如:

var a = 2;
function foo() {  //<-- 添加这一行
  var a = 3;
  console.log(a); // 3
} //<-- 以及这一行
foo(); //<-- 以及这一行
console.log(a); // 2

虽然这样能够解决一些问题,但任不是最理想的。首先,必须声明出具名函数 foo(),意味着 foo 名称本身“污染”了所在的作用域,并且必须显示地通过函数名“foo()”调用这个函数才能运行其中的代码。

JS 通过函数表达式同时解决了上述的两个问题。

var a = 2;
(function foo() { //<-- 添加这一行
  var a = 3;
  console.log(a); // 3
})(); //<-- 以及这一行

console.log(a); //2

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

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

前两段代码中,第一段foo被绑定在所在的作用域中,可以直接通过foo()来调用它。第二个片段中foo被绑定在函数表达式自身的函数中而不是所在的作用域中,使得其变量名被隐藏在自身,不会非必要地污染外部作用域。

匿名和具名

对于函数表达式我们最熟悉额场景可能就是回调参数了,如:

setTimeout( function() {
    console.log("I waited 1 second!");
}, 1000)

这叫做匿名函数表达式,因为function(){..}没有名称和标识符。函数表达式可以是匿名的,而函数声明则不可以省略函数名。

匿名函数虽然书写起来简单快捷,但它也具有以下缺点。

  • 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  • 如果没有函数名,但函数需要应用自身时只能使用已经过期的argument.callee引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
  • 匿名函数省略了对于代码可读性/可理解性很重要的函数名。
立即执行函数表达式

立即执行函数表达式(IIFE)有两种形式:第一种,(function foo(){..})()。第一个()将函数变成表达式,第二个()执行执行了这个函数;第二种,(function(){..}()),第二种形式中用来调用的()括号被移进了用来包装的()括号中。两种形式在功能上是一致的。

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

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

console.log( a ); //2

可以从外部作用域传递任何东西,并将变量命名为人格你觉得合适的名字。这对于改进代码风格是非常有帮助的。

这种模式的另一个应用场景是解决undefined标识符的默认值被错误覆盖导致的异常。

undefined = true; //给其他代码挖了一个大坑!绝对不要这样做!

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

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

var a = 2;
(function IIFE(def){
  def(window);
})(function def(global){
  var a = 3;
  console.log(a); // 3
  console.log(global.a); // 2
})
块作用域

函数作用域是最常见的作用域单元,也是现行大多数JS中最普遍的设计方法,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可以实现维护起来更加优秀、简介的代码。

除JS外很多的编程语言都支持块作用域。

以下为带有块作用域风格的代码:

for(var i = 0; i < 10; i++){
  console.log( i );
}

我们想在for循环内部使用i,而忽略i会被绑定在外部的作用域中的事实。这就是块作用域的用处。变量的声明应该距离使用的地方越近越好,并且最大程度地本地化。

with

我们之前讨论过with关键字。它不仅是一个难于理解的结构,同时也是块作用域的一个例子,用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。

try/catch

非常少有人会注意到JS的ES3规范中规定try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。

try {
  undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
  console.log( err ); // 能够正常执行
}

console.log( err ); // ReferenceError: err not found

正如我们所看到的,err仅存在catch分句内部,当试图从别处引用它时会抛出错误。

let

ES6引入了let关键字,提供除了var以外的另一种变量声明方式。

let关键字可以将变量绑定到所在的任意作用域中(通常是{..}内部)。换句话说,let为其声明的变量隐式地劫持了所在的块作用域。

var foo = true;

if(foo){
  let bar = foo * 2;
  bar = something(bar);
  console.log(bar);
}

console.log(bar); // ReferenceError

用let将变量附加在一个已经纯在的块作用域上的行为是隐式的。在开发中不注意块作用域中又绑定的变量,习惯性地移动这些块或者将其包含在其他块中,就会使代码变得混乱。

为块作用域显示地创建块可以部分解决这个问题。

var foo = true;
if(foo){
  {
    let bar = foo * 2;
    bar = something(bar);
    console.log(bar);
  }
}

console.log(bar); // ReferenceError

只要声明的块是有效的,在声明中的任意位置都可以使用{..}括号来为let创建一个用于绑定的块。

使用let进行的声明不会在块作用域中进行提升。声明的代码在被运行之前,声明并不“存在”。

{
  console.log(bar); // ReferenceError
  let bar = 2;
}
  • 垃圾收集

块作用域对于闭包和回收内存垃圾的回收机制相关。

function process(data) {
  // 这里做些有意思的事情
}

var someReallyBigData = {..};

process(someReallyBigData);

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

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

click函数的点击回调并不需要someReallyBigData变量。理论上这意味着当process(..)执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但由于click函数形成了一个覆盖整个作用域的闭包,JavaScript引擎极有可能依然保存着这个结构。

块作用域可以打消这种顾虑,可以让引擎清楚地知道有没有继续保存someReallyBigData:

function process(data) {
  // 这里做些有意思的事情
}

// 在这个块中定义的内容可以销毁了!
{
  let someReallyBigData = {..};

  process(someReallyBigData);
}


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

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

  • let循环 一个let可以发挥优势的典型例子就是之前讨论的for循环。
for(let i = 0; i < 0; i++){
  console.log(i);
}

console.log(i); // ReferenceError

for循环头部的let将重新i绑定到循环的每一次迭代,确保使用上一个循环迭代结束时的值重新赋值。

const

ES6还引入了const,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。

var foo = true;

if(foo){
  var a = 2;
  const b = 3; // 包含在if中的块作用域常量
  
  a = 3; // 正常
  b = 4; // 错误!
}

console.log(a); // 3
console.log(b); // ReferenceError!