JavaScript Closures 的應用實例

Juriy Zaytsev | 2010 年 5 月 14 日

 

理解 JavaScript 的 closure 背後的理論很重要,但實際去應用它也同樣重要。在本篇文章中,我會用一些例子說明 closure 的具體作法。這些大多代表身為工程師的我們,在每天開發工作中會遇到的麻煩。我希望這可以幫助你理解 colusre 的通用模式,何時用它是合理的,何時又該避開它。

我假設你已經知道什麼是 closure。但作為提醒之用,讓我們來看一下一個較為流行的定義

什麼是 Closure

所謂 closure 是一個表達式(通常是一個函式)擁有一些變數以及綁定這些變數的環境(該環境「關住」了這個表達式)

在 JavaScript 中,closure 形成在每次函式一實體化,而這個函式捕捉到一些變數。在沒有變數參與時,closure 是否還算數仍存在一些爭議,不過這個差別和我們的討論無關。

注意 closure 是在函式實體化時成形而不是在呼叫時。而函式是用什麼形式定義而成,在這裡沒也沒有差別-它可以是函式宣告或是函式表達式。

一個簡單的 closure 形式可以是像下面這樣:

function outer() {
  var x = 'foo';
  return function () {
    return x;
  };
}

當「outer」這個函式被呼叫時,內部的函式就被實體化。在這實體化之際,內部的函式存取變數「x」,也因此函式將變數關起來。即使在「outer」函式執行過後,內部的函式依然存取著變數「x」。

不過與其看技術性的範例,讓我們來看一下較接近現實生活的例子。

典型範例:迴圈中的事件處理器

一個最自然使用 closure 的情境,來自於處理迴圈時。這是一個典型「終值」(last value)的問題,當函式在迴圈中宣告,而最後使用的是遞增變數的終值:

for (var i = 0; i < 10; i++) {
  document.getElementById('box' + i).onclick = function() {
    alert('You clicked on box #' + i);
  };
}

如你所見,這段程式指定了 10 個函式作為事件處理器,來與元素對應。作者想做的是,讓每個事件處理器對應不同的「i」值。但這在現實生活中不會發生。如果你仔細看,很容易發現究竟發生了什麼事。每一個指派給「onclick」的函式會存取變數「i」。而既然變數「i」和整個封閉的範圍綁住—就像任何其他使用「var」的變數或是函式宣告—每一個函式存取到的是相同的變數「i」。 一旦這個程式執行,變數「i」維持它的終值—當迴圈結束時所設定的值。

所以我們該怎麼修正它?當然是利用 closure 的優點。解決之道很簡單:將每個「i」值的範圍對應到事件處理器的層次。讓我們看看作法:

for (var i = 0; i < 10; i++) {
  document.getElementById('box' + i).onclick = (function(index){
    return function() {
      alert('You clicked on box #' + index);
    };
  })(i);
}

我們不直接指定函式給「onclick」,而改用建立一個匿名函式來增加新範圍。我們將「i」值傳遞給這個函式,然後傳回內部的事件處理器函式。注意匿名函式如何取得「index」參數。當函式被執行時,傳遞了「i」值,而包裹匿名函式則指派給「index」參數形成變數,並建立範圍 [1]。而既然它在內部,也就是事件處理器函式宣告在包裹它的函式中,因此它就能存取「index」變數。

我們剛剛就建立了一個 closure。現在每一個事件處理器可能存取到適當的「i」值— 0, 1, 2, 3 等等。

如果這個匿名函式的包裹方式看起來有點深奧,你可以使用獨立的函式來處理:

function makeHandler(index) {
  return function() {
    alert('You clicked on box #' + index);
  };
}
for (var i = 0; i < 10; i++) {
  document.getElementById('box' + i).onclick = makeHandler(i);
}

值得說明的是,傳給包裹函式的變數名稱,與其對應的參數,並不一定要用不同名稱。我使用「i」和「index」,只是為了讓它們更清晰。如果你不需要從內部的事件處理器存取外部的「i」變數,那當然可以使用相同的命名:

...
document.getElementById('box' + i).onclick = (function(i){
  return function() {
    alert('You clicked on box #' + index);
  };
})(i);

Function#bind 與 Function#curry

在 JavaScript 世界中,一個常見的用語是 函式綁定。第一個導入綁定的是 Prototype JavaScript 函式庫,後來由 ECMAScript 第 5 版標準化,「Function.prototype.bind」是一個現實中完美的 closure 範例。「bind」的工作很簡單:確認特定函式被呼叫時,會使用特定的「this」值。在函式被呼叫時,bind 返回一個新的函式,而這個函式利用原本函式的「this」值來參照特定物件。

在 JavaScript 中,函式綁定之所以如此獨特,是因為「this」的本質。「this」有趣的地方在於,它總是由函式被呼叫的所在之處,決定它是什麼。如果一個函式被當作物件的屬性來呼叫,「this」參照的就是這個物件。如果函式被指定給另一個物件的屬性,並且執行它,「this」的值將會指向新的物件。

因此,函式的「this」在它從一個物件轉換到另一個物件的屬性時,它的作用不是「保留」。此外,這個函式也可以在沒有任何物件作「基底」下被呼叫,這時候,「this」參照的就是全域物件。而這時,就是「bind」登場救援的時刻。讓我們來看一個簡單的實作:

/* ES5「Function.prototype.bind 」的近似版本(沒有錯誤檢查) */
Function.prototype.bind = function(thisArg) {
  var fn = this, args = Array.prototype.slice.call(arguments, 1);
  return function() {
    return fn.apply(thisArg, args.concat(Array.prototype.slice.call(arguments, 0)));
  };
};

在內部被返回的函式,存取了宣告在「bind」裡面的變數:「fn」-原本的函式、「thisArg」-用來呼叫函式的特定的「this」值,以及「args」-在「thisArg」之後傳給「bind」的參數。在「bind」執行一段期間後,內部函式仍可使用這些函式來展現它的魔力。

function Person(name) {
  this.name = name;
}
Person.prototype.speak = function() {
  alert('Hello, my name is ' + this.name);
};
    
var john = new Person('John');
john.speak(); // 'Hello, my name is John'
document.body.onclick = john.speak; // 沒有如預期運作

「onclick」事件處理器沒有照預期運作的原因,是因為函式被呼叫時,「this」指向「document.body」(這是為什麼「onclick」能運作的原因)。但是綁定確保了函式能用「this」來呼叫特定物件—「john」就是一例。

    document.body.onclick = john.speak.bind(john); // 如預期般運作

注意「Function.prototype.bind」是如何捕捉「this」值以及傳給它的參數。這些參數值在綁定的函式傳入自身參數之前,搶先一步傳入。這個過程也以 currying之名為人所知,它同時也善用了 closure 的功能。讓我們來看一個範例:

function Circle(radius) {
  this.radius = radius;
}

Circle.prototype.setRadius = function(value) {
  this.radius = value;
};

var myCircle = new Circle(10);
var setRadiusTo20 = myCircle.setRadius.bind(myCircle, 20);
document.body.onclick = setRadiusTo20;

「document.body.onclick」現在指向函式,而這函式同時綁定「myCircle」,並將 20 作為它的第一個參數。我們也可以「串接」傳給「bind」以及傳向綁定函式的參數:

Circle.prototype.setXY = function(x, y) {
  this.x = x;
  this.y = y;
};
var myCircle = new Circle(10);
var setXY = myCircle.setXY.bind(myCircle, 10);
setXY(20);

傳給 bind 的第一個值是「10」,在這個例子中對應到「x」。第二個傳給綁定函式的值是「20」,對應的是「y」。我們可以前後對調這些值,仍然可以得到相同的結果(注意括號的放置):

myCircle.setXY.bind(myCircle, 10, 20)();
myCircle.setXY.bind(myCircle, 10)(20);
myCircle.setXY.bind(myCircle)(10, 20);

上面這些的功能都一樣,因為綁定的函式總是呼叫相同的參數— 10 和 20。不同之處在於,第一個例子將 10 和 20 同樣放在 closure 中,而第二個例子,只有 10 存放在 closure。最後一個例子,10 和 20 都沒有放在 closure 中,而是直接傳給綁定的函式。

setTimeout

這個例子也許並沒有那麼明顯,不過 closure 通常會在暗中建立,例如在處理「setTimeout」、「setInterval」這樣的函式時。實體化並傳入一個函式作為第一個參數,能讓函式補捉那些一開始就已經建立的變數。

var x = 10;
window.setTimeout(function(){
  alert(x);
}, 100);

在這個例子中,傳給「setTimeout」函式在程式執行一段時間後,可以存取到「x」-大約 100 毫秒。

模組模式

所謂模組模式,是因為 Douglas Crockford 的緣故而盛行,它是在現實中應用 closure 的完美例子。這個概念是封裝私有的邏輯,只開放部分「公用」方法。讓我們用相同的「bind」實作作點變化,這次讓它作為「functionUtils」物件的屬性,而不是「Function.prototype」:

var functionUtils = (function(){
  /* 私有的「slice」方法 */
  function slice(arrayLike, index) {
    return Array.prototype.slice.call(arrayLike, index || 0);
  }
  return {
      
    /* 對外、公用的「bind」方法 */
    bind: function(fn, thisArg) {
      var args = slice(arguments, 2);
      return function() {
        return fn.apply(thisArg, args.concat(slice(arguments)));
      };
    }
  };
})();

注意那個眼熟的自我執行匿名函式。在函式中,我們有一個私有的「slice」方法,以及一個公用的「bind」方法。這方式之所以可行,再次歸功於 closure。自我執行函式返回一個帶有「bind」屬性的物件,並參照第二個函式。因為函式和「slice」函式宣告在相同範圍,即使在包裹函式已經執行過,而且「functionUtils」也被指定給物件,這個函式仍然能存取「slice」。

「slice」放置的位置在這裡無關緊要,在函式宣告時,它被放置在封裝範圍的頂部。把它放在 return 敘述的後面,仍會有相同的效果;不過這樣做經常被認為是不好的作法(容易造成混淆)。

var functionUtils = (function(){
  return {
    bind: function() {
      /* 「slice」在此使用 */
    }
  };
  function slice() {
    /* ... */
  }
})();

另一個比較沒那麼流行的模組模式變體—更確切地說,它的封裝是透過匿名函式實體化。不要被混淆;下面的程式片段也處理了 closure,在公用的方法中捕捉私有方法。

var functionUtils = new function(){
  function slice(){
    /* ... */
  }
  this.bind = function() {
    /* 「slice」在此使用 */
  };
};

私有方法

一個無處不在的私有方法實作,莫過於將模組模式套用在建構式中。就像模組模式,它依賴 closures 提供私有性,並賦與公用方法來存取私有的資料。這裡是一個「Circle」建構式的簡單範例,其中只有「getRadius」和「setRadius」是公用方法,而它們得以存取「內部」的半徑值:

function Circle(radius) {
  this.getRadius = function() {
    return radius;
  };
  this.setRadius = function(value) {
    if (value < 0) {
      throw new Error('半徑值必須為正數e');
    }
    radius = value;
  };
}

在「Circle」實體化時,「getRadius」和「setRadius」函式也被內部的建構式實體化。結果他們透過傳給「Circle」函式的參數—「radius」(半徑值)形成了 closure。 這個「radius」只存在於 Circle 的公用方法的範圍中,而不會曝露在外:

var myCircle = new Circle(10);
myCircle.getRadius(); // 10
    
myCircle.setRadius(-2); // 錯誤,半徑值必須是正數值
myCircle.getRadius(); // 10 (一樣)
    
myCircle.setRadius(25);
myCircle.getRadius(); // 25 (現在半徑值已經改變)

注意透過這個模式實作私有方法時,最好了解效能的問題。首先,在「Circle」中建立「getRadius」和「setRadius」函式,對執行期的效能有所影響。其次,更為重要的,現在每個「Circle」實體化的物件,都同時建立了 2 個函式物件。如果換個作法,我們可以建立「getRadius」和「setRadius」作為「Circle.prototype」的方法,函式物件的數量就會固定:

Circle.prototype.getRadius = function() {
  return this._radius;
};
Circle.prototype.setRadius = function(value) {
  if (value < 0) {
    throw new Error('radius should be of positive value');
  }
  this._radius = value;
};

但是,在這例子中,我們喪失了真正私有成員這個寶貝,而必須恢復其他的方法,像是透過慣例(標底線的屬性名稱)表明私有性。因此最後抉擇所在,是在需要真正的私有成員或需要更有效率的實作。

給與元素獨有 id

一個有趣的 closure 實例是給與元素一個唯一的id。有時在文件中給與元素一個唯一的識別是相當有用的。使用 id 通常可以達到這個問題,不過不是所有的元素都有指定。一個常見的解決方法是,如果有 id 的元素就用 id 識別,如果沒有,就指派一個給它。有些 JavaScript 函式庫甚至公開這樣的方法作為他們公用的 API(舉例來說,Prototype.js 裡有 Element#identify 這樣的方法)。

這樣協助程式實作看起來可能像這樣:

var getUniqueId = (function(){
  var id = 0;
  return function(element) {
    if (!element.id) {
      element.id = 'generated-uid-' + id++;
    }
    return element.id;
  };
})();

這裡使用 closure 的目的是存儲數字的計數器。為了確保唯一性,每次元素沒有id時,計數次就增加一個新的、唯一的 id,並指派給一個元素。「getUniqueId」的用法看起來就像這樣:

var elementWithId = document.createElement('p');
elementWithId.id = 'foo-bar';
var elementWithoutId = document.createElement('p');
getUniqueId(elementWithId); // 'foo-bar'
getUniqueId(elementWithoutId); // 'generated-id-0'

暫存(或 Memoization)

透過暫存來改善應用程式的效能是一個眾所皆知的方法。再一次,closure 可以優雅地實作這個優化的技術。舉例來說,「hasClassName」—開發瀏覽器腳本程式時無可替代的協助工具。一個「hasClassName」的可能實作方法是透過正規表達式來解析元素的 className。然而,這意味著正規表達式需要根據 className 的值來動態建立。

function hasClassName(element, className) {
  var re = new RegExp('(?:^|\\s)' + className + '(?:\\s|$)');
  return re.test(element.className);
}

正規表達示在每次「hasClassName」執行時,都必須被重新編譯一次。這通常會很慢,所以讓我們來應用暫存。

var hasClassName = (function(){
  var cache = { };
  return function(element, className) {
    var re = (cache[className] || 
      (cache[className] = new RegExp('(?:^|\\s)' + className + '(?:\\s|$)')));
    return re.test(element.className);
  };
})();

照例自我執行的函式將暫存結果關在內部,並回傳函式。在內部,回傳函式指定給「hasClassName」。因為回傳的函式和「暫存」被宣告在同一個範圍,回傳函式可以存取「暫存」,即使在指定給「hasClassName」之後。

document.body.className = 'foo bar baz-qux';
hasClassName(document.body, 'foo'); // true
hasClassName(document.body, 'baz'); // false

注意「hasClassName」的實作和「Object.prototype.*」成員,像是:「toString」、「valueOf」、「propertyIsEnumerable」等發生名稱衝突時,會顯得不可靠。這雖然是一個邊緣案例,不過知道這個限制是好事。

「短一點」的變數/屬性解析

延續效能最佳化議題,讓我來看一下識別字解析。

識別字解析是在範圍鏈上評估識別值的過程。識別字是 ECMAScript 的語彙單位。他們是組成變數名稱、函式宣告/表達式,函式的參數等。當程式評估識別字後,它會跟循範圍鏈,找出其中同名的屬性。識別字位於範圍鏈越遠,解析過程就會越慢。讓我們來看一個例子:

(function(){
  var outer = 'foo';
  (function(){
    var inner = 'bar';
    return [inner, outer];
  })();
})();

在識別字解析過程中,「內部」變數—定義在區域的—馬上在最近的範圍鏈物件中發現。「外部」變數,從另一方面來說,宣告在包含的範圍中。解析它首先需要檢查範圍鏈中最近的物件(也就是「內部」所定義的),然後處理外部的變數。範圍鏈中有越多的物件,解析過程就需要花費越多的時間。

所以 closures 能幫上什麼忙?藉由建立區域「別名」(也就是區域變數),我們可以加速這個解析過程。然後要「隱藏」這些區域變數,我們可需要將它們存在 closure 中。

var keys = (function(){
  var hasOwnProperty = Object.prototype.hasOwnProperty;
  return function(object) {
    var keys = [ ];
    for (var property in object) {
      if (hasOwnProperty.call(object, property)) {
        keys.push(property);
      }
    }
    return keys;
  };
})();

這是一個簡單「keys」屬性的實作,和第 5 版的 ECMAScript 的「Object.keys」方法相似。這個方法返回一個陣列名稱,相當於物件擁有的屬性。

keys({ x: 1, y: 2 }); // ['x', 'y']
keys({ }); // [ ]

回顧實作,你可以看到「Object.prototype.hasOwnProperty」的別名對應區域變數「hasOwnProperty」。因為變數存在 closure 中,現在解析「keys」迴圈中的「hasOwnProperty」識別字會更快。無需在全域範圍遍歷「Object」定義,現在「hasOwnProperty」會在範圍鏈(對應於外部的包裝的函式)的下一個物件被解析。

這種別名用法的另一個優點,是避免多重屬性存取。「Object.prototype.hasOwnProperty」先必須解析「Object」上的「prototype」屬性,接著是「Object.prototype」的「hasOwnProperty」屬性。這裡無需和區域的「hasOwnProperty」變數產生關聯。

因此使用「Object.prototype.hasOwnProperty」,解譯器首先需要沿著範圍鏈來解析「Object」,一直到最後一個物件—全域物件。接著它執行兩個屬性的解析:在「Object」上的「prototype」,以及「Object.prototype」上的「hasOwnProperty」。使用區域的「hasOwnProperty」,只有一個識別字解析,並且是很短的解析過程。後面這個方面的優勢相當明顯。

注意這個「keys」的實作,沒有處理煩人的 JScript DontEnum 問題。為了清楚起見,我省略了作法,不過它應該不難加上去。

說到識別字解析,必須提到的是額外的 closure 會阻礙它自身的效能。假如一個方法定義已經包含了一個匿名、包裹函式,通常不需要再將它包在另一個函式中:

(function(){
  /* ... 其他函式庫程式碼 */
  var keys = (function(){
    var hasOwnProperty = Object.prototype.hasOwnProperty;
    return function() {
      /* ... */
    };
  })();
})();

在這例子中,我們可以透過在包裹函式的範圍中宣告「hasOwnProperty」,避掉多餘的 closure。無論如何,這避免了名稱洩露到全域範圍中:

(function(){
  /* ... 其他函式庫程式碼 */
  var hasOwnProperty = Object.prototype.hasOwnProperty;
  function keys(){
    /* ... */
  }
})();

這裡的優點是在「keys」函式中,範圍鏈的 closure 和額外的物件較少。缺點是「hasOwnProperty」識別字現在可能在「包裹」範圍和其他程式碼發生衝突,尤其是如果其他的函式也依照相同模式,將變數「懸掛」在外層範圍。

如同以往,你必須視情況選擇哪一種方法比較合適。

物件重用

另一個和 closure 有關,也很有意思的優化應用是物件重用。這個概念和暫存類似(也就是避免重建物件)只是這次物件是在「載入」(load)階段建立一次,避免在執行期多次建立。

讓我們看一下知名的「clone/beget」方法,作為這個精進的範例。這個方法首先在 2003 年由 Lasse Reichstein Nielsen 介紹,之後則因為 Douglas Crockford 推廣而盛行,「clone」提供一個從另一個物件繼承而來的物件建立方式。在最簡單且流行的形式,「clone」看起來會像這樣:

function clone(parent) {
  function F(){}
  F.prototype = parent;
  return new F;
}

注意「F」函式在每次「clone」被呼叫時是如何被建立。這不但關乎執行期的效能,也導致了了耗用較高的記憶體。其實如果函式只需要建立一次,實在沒有必要在執行期建立。就我所知,下面的模式最早是由 Richard Cornford 提出。

var clone = (function(){
  function F(){}
  return function(parent) {
    F.prototype = parent;
    return new F;
  };
})();

這次「F」函式在匿名「包裹」函式中被建立,然後藉由「clone」返回函式來重用。它只建立一次,而且不是在執行期,這樣我們就省下了記憶體和執行時間。

一個物件重用的類似的例子可以在「escapeHTML」方法中窺見,如同它的名字的意思,提供一個跳脫 HTML 字串的方法。最直覺的實作方式是導入正規表達式:

function escapeHTML(string) {
  return string.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
escapeHTML('foo < bar & baz '); // "foo &lt; bar &amp; baz "

然而,有一個有趣的捷徑,透過使用非標準(不過事實上,目前 HTML5 已經在定義)的「innerHTML」屬性的優勢。這個方法實際被 Prototype.js 函式庫使用一段時間了。

var escapeHTML = (function(){
  var div = document.createElement('div');
  var text = document.createTextNode('');
  div.appendChild(text);
  return function(str) {
    text.data = str;
    return div.innerHTML;
  };
})();

這裡沒有去手動調整每一個「<」、「>」和「&」以及對應的字元實體(「&lt;」、「&gt;」和「&amp;」),而是建立一個文字節點,而它的值是利用 data 屬性來設定。光是文字節點本身是不夠的,所以我們需要「innerHTML」屬性來取得要逸脫後的元素及文字節點。這便是為什麼要建立 HTML 元素,以及用文字節加附加其上。文字節點和和 HTML 元素是可重用的物件。「escapeHTML」函式同時存取它們。在呼叫期間,它在文字節點設定 data 屬性,然後從包含文字節點的元素中,從「innerHTML」屬性裡取得逸脫字串。

這是個不錯的技巧,但不是沒有問題。因為「innerHTML」是一個專有的 API,並不保證返回的東西具有一致性。舉例來說,在 Internet Explorer 透過「innerHTML」處理新的一行的方法,就和其他瀏覽器不同。如果你打算使用這個方法,最好是測試每一個瀏覽器。另一個問題是在某些 Internet Explorer 版本的記憶體耗用。它本身沒有記憶體洩漏問題,不過 DOM 物件被存在 closure 中時,IE 必須等到透過關閉頁籤或視窗中的應用程式,才會釋出記憶體。

使用時請小心。

後記

我希望這個篇概述,可以精闢地切入 closure 在 JavaScript 程式中的具體應用。我們在這只抓到表面的癢處,我確信如果你繼續往下深入,會發現許多更有趣的應用案例。

如果你想了解 closure 相關的細節,我會建議讀 Richard Cornford 在這主題上的深入文章。它深入一些幕後有趣的面向:介紹啟用物件以及許多物件、範圍鏈和識別字的解析過程、記憶體洩漏等。即使不是為了 closure,理解這個語言底層的機制-文章中有介紹—也可讓你在許多事情上大開眼界。

[1] 理論而言,參數不是變數,但這階段我們不會深入其中細節的差異。

About the Author

Juriy Zaytsev, otherwise known as "kangax", is a front end web developer based in New York. Most of his work involves exploring and taming various aspects of Javascript. He blogs about some of his findings at http://perfectionkills.com/. Juriy has been contributing to various projects ranging from libraries and frameworks, to articles and books.

Find Juriy on:

More from Juriy:

 

Videos

Articles