你不知道的JavaScript(五)|作用域和閉包

作用域閉包
當(dāng)函數(shù)可以記住并訪問所在的詞法作用域時(shí),就產(chǎn)生了閉包,即使函數(shù)式在當(dāng)前詞法作用域之外執(zhí)行。
下面用一些代碼來(lái)解釋這個(gè)定義:

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

這段代碼看起來(lái)和嵌套作用域中的示例代碼很相似?;谠~法作用域的查找規(guī)則,函數(shù)bar()可以訪問外部作用域中的變量a(RHS引用查詢)。
這是閉包嗎?
技術(shù)上來(lái)講,也許是。但根據(jù)前面的定義,確切地說并不是。最準(zhǔn)確地用來(lái)解釋bar()對(duì)a的引用的方法是詞法作用域的查找規(guī)則,而這些規(guī)則只是閉包的一部分。
下面代碼清晰地展示了閉包:

function foo() {
  var a = 2;
  function bar() {
    console.log( a );
  }
  return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,這就是閉包的效果。

以上代碼,函數(shù)bar()的詞法作用域能夠訪問foo()的內(nèi)部作用域。然后我們將bar()函數(shù)本身當(dāng)做一個(gè)類型進(jìn)行傳遞。在這個(gè)例子中,我們將bar所引用的函數(shù)對(duì)象本身當(dāng)做返回值。
在foo()執(zhí)行后,其返回值(也就是內(nèi)部的bar()函數(shù))賦值給變量baz并調(diào)用baz(),實(shí)際上只是通過不同的標(biāo)識(shí)符引用調(diào)用了內(nèi)部的函數(shù)bar()。
bar()顯然可以被正常執(zhí)行。但是在這個(gè)例子中,它在自己定義的詞法作用域以外的地方執(zhí)行。
在foo()執(zhí)行后,通常會(huì)期待foo()的整個(gè)內(nèi)部作用域都被銷毀,因?yàn)槲覀冎酪嬗欣厥掌饔脕?lái)釋放不再使用的內(nèi)存空間。由于看上去foo()的內(nèi)容不會(huì)再被使用,所以很自然地考慮對(duì)其進(jìn)行回收。
而閉包的“神奇”之處正是可以阻止這件事情的發(fā)生。事實(shí)上內(nèi)部作用域依然存在,因此沒有被回收。誰(shuí)在使用這個(gè)內(nèi)部作用域?原來(lái)是bar()本身在使用。
拜bar()所聲明的位置所賜,它擁有涵蓋foo()內(nèi)部作用域的閉包,使得該作用域能夠一直存活,以供bar()在之后任何時(shí)間進(jìn)行引用。
bar()依然持有對(duì)該作用域的引用,而這個(gè)引用就叫做閉包。
無(wú)論使用何種方式對(duì)函數(shù)類型的值進(jìn)行傳遞,當(dāng)函數(shù)在別處被調(diào)用時(shí)都可以觀察到閉包。

function foo() {
  var a = 2;
  function baz() {
    console.log( a ); // 2
  }
  bar( baz );
}
function bar(fn) {
  fn(); // 媽媽快看呀,這就是閉包!
}

把內(nèi)部函數(shù)baz傳遞給bar,當(dāng)調(diào)用這個(gè)內(nèi)部函數(shù)時(shí)(現(xiàn)在叫fn),它涵蓋的foo()內(nèi)部作用域的閉包就可以觀察到了,因?yàn)樗軌蛟L問a。
傳遞函數(shù)也可以是間接的:

var fn;
function foo() {
  var a = 2;
  function baz() {
    console.log( a );
  }
  fn = baz; // 將baz 分配給全局變量
}
function bar() {
  fn(); // 媽媽快看呀,這就是閉包!
}
foo();
bar(); // 2

循環(huán)和閉包

for (var i=1; i<=5; i++) {
  setTimeout( function timer() {
    console.log( i );
  }, i*1000 );
}

正常情況下,我們對(duì)這段代碼行為的預(yù)期是分別輸出數(shù)字1-5,每秒一次,每次一個(gè)。
但實(shí)際上,這段代碼在運(yùn)行時(shí)會(huì)以每秒一次的頻率輸出五次6。
我們?cè)噲D假設(shè)循環(huán)中的每個(gè)迭代在運(yùn)行時(shí)都會(huì)給自己“捕獲”一個(gè)i的副本。但是根據(jù)作用域的工作原理,實(shí)際情況是盡管循環(huán)中的五個(gè)函數(shù)式在各個(gè)迭代中分別定義的,但是它們都被封閉在一個(gè)共享的全局作用域中,因此實(shí)際上只有一個(gè)i。
這樣說的話,當(dāng)然所有函數(shù)共享一個(gè)i的引用。循環(huán)結(jié)構(gòu)讓我們誤以為背后還有更復(fù)雜的機(jī)制在起作用,但實(shí)際上沒有。如果將延遲函數(shù)的回調(diào)重復(fù)定義五次,完全不使用循環(huán),那它同這段代碼是完全等價(jià)的。
以上代碼的缺陷是什么?我們需要更多的閉包作用域,特別是在循環(huán)的過程中每個(gè)迭代都需要一個(gè)閉包作用域。

for (var i=1; i<=5; i++) {
  (function() {
    setTimeout( function timer() {
      console.log( i );
    }, i*1000 );
  })();
}

如果改成以上代碼,也是不行的。的確我們現(xiàn)在擁有了更多的詞法作用域,每個(gè)延遲函數(shù)都會(huì)將IIFE在每次迭代中創(chuàng)建的作用域封閉起來(lái)。但如果作用域是空的,那么僅僅將它們進(jìn)行封閉是不夠的。仔細(xì)看一下,IIFE只是一個(gè)什么都沒有的空作用域。它需要有自己的變量,用來(lái)在每個(gè)迭代中存儲(chǔ)i的值,如下:

for (var i=1; i<=5; i++) {
  (function() {
    var j = i;
    setTimeout( function timer() {
      console.log( j );
    }, j*1000 );
  })();
}

或是:

for (var i=1; i<=5; i++) {
  (function(j) {
    setTimeout( function timer() {
      console.log( j );
    }, j*1000 );
  })( i );
}

重返塊作用域
仔細(xì)思考我們對(duì)前面的解決方案的分析。我們使用IIFE在每次迭代時(shí)都創(chuàng)建一個(gè)新的作用域。換句話說,每次迭代我們都需要一個(gè)塊作用域。

for (var i=1; i<=5; i++) {
  let j = i; // 是的,閉包的塊作用域!
  setTimeout( function timer() {
    console.log( j );
  }, j*1000 );
}

但是以上代碼還不全部。for循環(huán)頭部的let聲明還會(huì)有一個(gè)特殊的行為。這個(gè)行為指出變量在循環(huán)過程中不止被聲明一次,每次迭代都會(huì)聲明。隨后的每個(gè)迭代都會(huì)使用上一個(gè)迭代結(jié)束時(shí)的值來(lái)初始化這個(gè)變量。

for (let i=1; i<=5; i++) {
   setTimeout( function timer() {
    console.log( i );
    }, i*1000 );
}

這就是塊作用域和閉包的結(jié)合使用。

模塊
還有其他的代碼模式利用閉包的強(qiáng)大威力,但從表面上看,它們似乎與回調(diào)無(wú)關(guān)。

function CoolModule() {
  var something = "cool";
  var another = [1, 2, 3];
  function doSomething() {
    console.log( something );
  }
  function doAnother() {
    console.log( another.join( " ! " ) );
  }
  return {
    doSomething: doSomething,
    doAnother: doAnother
  };
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

這個(gè)模式在JavaScript中被稱為模塊。最常見的實(shí)現(xiàn)模塊模式的方法通常被稱為模塊暴露,這里展示的是其變體。
從模塊中返回一個(gè)實(shí)際的對(duì)象并不是必須的,也可以直接返回一個(gè)內(nèi)部函數(shù)。JQuery就是一個(gè)很好的例子。JQuery和$標(biāo)識(shí)符就是JQuery模塊的公共API,但它們本身都是函數(shù)(由于函數(shù)也是對(duì)象,它們本身也可以擁有屬性)。
如果要更簡(jiǎn)單的描述,模塊模式需要具備兩個(gè)必要條件:
1、必須有外部的封裝函數(shù),該函數(shù)必須至少被調(diào)用一次(每次調(diào)用都會(huì)創(chuàng)建一個(gè)新的模塊實(shí)例)。
2、封裝函數(shù)必須返回至少一個(gè)內(nèi)部函數(shù),這樣內(nèi)部函數(shù)才能在私有作用域中形成閉包,并且可以訪問或者修改私有的狀態(tài)。
一個(gè)具有函數(shù)屬性的對(duì)象本身并不是真正的模塊。從方便觀察的角度看,一個(gè)從函數(shù)調(diào)用所返回的,只有數(shù)據(jù)屬性而沒有閉包函數(shù)的對(duì)象并不是真正的模塊。
上一個(gè)示例代碼中有一個(gè)叫做CoolModule()的獨(dú)立的模塊創(chuàng)建器,可以被調(diào)用任意多次,每次調(diào)用都會(huì)創(chuàng)建一個(gè)新的模塊實(shí)例。當(dāng)只需要一個(gè)實(shí)例時(shí),可以對(duì)這個(gè)模式進(jìn)行簡(jiǎn)單的改進(jìn)來(lái)實(shí)現(xiàn)單例模式:

var foo = (function CoolModule() {
  var something = "cool";
  var another = [1, 2, 3];
  function doSomething() {
    console.log( something );
  }
  function doAnother() {
    console.log( another.join( " ! " ) );
  }
  return {
    doSomething: doSomething,
    doAnother: doAnother
  };
})();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

現(xiàn)代的模塊機(jī)制
大多數(shù)模塊依賴加載器/管理器本質(zhì)上都是將這種模塊定義封裝進(jìn)一個(gè)友好的API。

var MyModules = (function Manager() {
    var modules = {};
    function define(name, deps, impl) {
        for (var i = 0; i < deps.length; i++) {
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply(impl, deps);
    }
    function get(name) {
        return modules[name];
    }
    return {
        define: define,
        get: get
    };
})();
MyModules.define("bar", [], function () {
    function hello(who) {
        return "Let me introduce: " + who;
    }
    return {
        hello: hello
    };
});
MyModules.define("foo", ["bar"], function (bar) {
    var hungry = "hippo";
    function awesome() {
        console.log(bar.hello(hungry).toUpperCase());
    }
    return {
        awesome: awesome
    };
});
var bar = MyModules.get("bar");
var foo = MyModules.get("foo");
console.log(
    bar.hello("hippo"),
); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

未來(lái)的模塊機(jī)制
ES6中為模塊增加了一級(jí)語(yǔ)法支持。但通過模塊系統(tǒng)進(jìn)行加載時(shí),ES6會(huì)將文件當(dāng)做獨(dú)立的模塊來(lái)處理。每個(gè)模塊都可以導(dǎo)入其他模塊或特定的API成員,同樣也可以導(dǎo)出自己的API成員。


// bar.js
function hello(who) {
    return "Let me introduce: " + who;
}
export hello;
// foo.js
// 僅從"bar" 模塊導(dǎo)入hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
    console.log(
        hello(hungry).toUpperCase()
    );
}
export awesome;
baz.js
// 導(dǎo)入完整的"foo" 和"bar" 模塊
module foo from "foo";
module bar from "bar";
console.log(
    bar.hello("rhino")
); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO

import可以將一個(gè)模塊中的一個(gè)或多個(gè)API導(dǎo)入到當(dāng)前作用域中,并分別綁定在一個(gè)變量上(在我們的例子里是hello)。module會(huì)將整個(gè)模塊的API導(dǎo)入并綁定到一個(gè)變量上(在我們的例子里是foo和bar)。export會(huì)將當(dāng)前模塊的一個(gè)標(biāo)識(shí)符(變量、函數(shù))導(dǎo)出為公共API。這些操作可以在模塊定義中根據(jù)需要使用任意多次。

閉上眼睛就是天黑
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容