JavaScript經典面試題之for迴圈click
經典重現
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title></title> <script type="text/javascript"> function onMyLoad(){ var arr = document.getElementsByTagName("p"); for(var i = 0; i < arr.length; i++){ arr[i].onclick = function(){ alert(i); } } } </script> </head> <body onload="onMyLoad()"> <p>0</p> <p>1</p> <p>2</p> <p>3</p> <p>4</p> </body> </html>
該段程式碼期望實現效果如下:點選p標籤,彈出該p標籤位置序號。請問上述程式碼能否實現該需求,如果不能,應該如何實現?
原題分析
答案顯而易見,不能。點選每個p標籤都會彈出5。 要解決此問題,首先要了解閉包的概念。閉包是JavaScript語言的一個難點,也是它的特色,很多高階應用都要依靠它來實現。
變數作用域
JavaScript變數作用域的特點在於,函式內部可以讀取該函式外部的變數,但函式外部無法讀取該函式內部定義的變數,但是我們可以通過變通的方式獲得。那就是在函式內部再定義一個函式:
function foo(){ var n=999; function fee(){ alert(n); // 999 } }
在上面的程式碼中,函式fee就被包括在函式foo內部,這時foo內部的所有區域性變數,對fee都是可見的。但是反過來就不行,fee內部的區域性變數,對foo就是不可見的。這就是Javascript語言特有的”鏈式作用域”結構(chain scope),子物件會一級一級地向上尋找所有父物件的變數。所以,父物件的所有變數,對子物件都是可見的,反之則不成立。 既然fee可以讀取foo中的區域性變數,那麼只要把fee作為返回值,我們不就可以在foo外部讀取它的內部變量了嗎!
閉包的概念
簡而言之,閉包就是能夠讀取其他函式內部變數的函式,由於在JavaScript語言中,只有函式內部的子函式才能讀取區域性變數,因此可以把閉包簡單理解成定義在一個函式內部的函式
閉包的用途
閉包可以用在許多地方。它的最大用處有兩個,一個是前面提到的可以讀取函式內部的變數,另一個就是讓這些變數的值始終保持在記憶體中。
function f1(){
var n=999;
nAdd=function(){
n+=1
}
function f2(){
alert(n);
}
return f2;
}
var result=f1();
result(); // 999
nAdd();
result(); // 1000
在這段程式碼中,result實際上就是閉包f2函式,它一共運行了兩次,第一次的值是999,第二次的值是1000。這證明了,函式f1中的區域性變數n一直儲存在記憶體中,並沒有在f1呼叫後被自動清除。
為什麼會這樣呢?原因就在於f1是f2的父函式,而f2被賦給了一個全域性變數,這導致f2始終在記憶體中,而f2的存在依賴於f1,因此f1也始終在記憶體中,不會在呼叫結束後,被垃圾回收機制(garbage collection)回收。
這段程式碼中另一個值得注意的地方,就是”nAdd=function(){n+=1}”這一行,首先在nAdd前面沒有使用var關鍵字,因此nAdd是一個全域性變數,而不是區域性變數。其次,nAdd的值是一個匿名函式(anonymous function),而這個匿名函式本身也是一個閉包,所以nAdd相當於是一個setter,可以在函式外部對函式內部的區域性變數進行操作。
使用閉包的注意點
- 由於閉包會使得函式中的變數都被儲存在記憶體中,記憶體消耗很大,所以不能濫用閉包,否則會造成網頁的效能問題,在IE中可能導致記憶體洩露。解決方法是,在退出函式之前,將不使用的區域性變數全部刪除。
- 閉包會在父函式外部,改變父函式內部變數的值。所以,如果你把父函式當作物件(object)使用,把閉包當作它的公用方法(Public Method),把內部變數當作它的私有屬性(private value),這時一定要小心,不要隨便改變父函式內部變數的值。
思考題
如果你能理解下面兩段程式碼的執行結果,應該就算理解閉包的執行機制了。
程式碼一:
var name = "The Window";
var object = {
name : "My Object",
getNameFunc : function(){
return function(){
return this.name;
};
}
};
alert(object.getNameFunc()())
程式碼二:
var name = "The Window";
var object = {
name : "My Object",
getNameFunc : function(){
var that = this;
return function(){
return that.name;
};
}
};
alert(object.getNameFunc()());
接下來回到文章開始的題目,解釋一下為什麼打印出來的數字都是5.
arr中的每一項onclick都是一個函式例項,這個函式也產生了一個閉包域,這個閉包域引用了外部閉包域的變數,即i,外部閉包域的私有變數內容發生變化,內部閉包域得到的值自然會發生改變(參照閉包的用途一節中的示例程式碼)
解決方案
方法一:增加若干個對應的閉包域空間(採用匿名函式實現)專門用來儲存原先需要引用的內容(下標值),只限於基本型別(基本型別值傳遞,物件型別引用傳遞)
for(var i = 0; i<arr.length; i++){
(function (arg){//這個函式物件有一個本地私有變數arg(形參),該函式的function scope的closure物件屬性有兩個引用:arr和i。i的值隨外部改變,但是本地的私有變數(形參)arg不會受影響,其值在一開始被呼叫時就決定了
arr[i].onclick = function () {//onclick函式例項的function scope的closure物件屬性有一個引用arg
alert(arg);//只要外部空間的arg不變,這裡的引用值就不會改變
}
})(i);//立即執行匿名函式,傳遞下標i(實參)
}
方法二:將下標作為物件屬性(name:”i”,value:i的值)新增到每個陣列項(p物件)中
for(var i=0; i<arr.length; i++){
//為當前陣列項(當前p物件)新增一個名為i的屬性,值為迴圈體i變數的值
//此時當前p物件的i屬性並不是對迴圈體的i變數的引用,而是一個獨立p物件的屬性,屬性值在宣告的時候就確定了
arr[i].i = i;
arr[i].onclick = function (){
alert(this.i);
}
}
方法三:增加若干個對應的閉包域空間用來儲存下標。新增的匿名閉包空間內完成事件繫結。 //繫結的函式中的function scope中的closure物件的引用arg是指向將其返回的匿名函式的私有變數arg
for(var i = 0; i<arr.length; i++){
arr[i].onclick = (function(arg){
return function () {
alert(arg);
}
})(i);
方法四:使用ES6的let關鍵字
"use strict";
var arr = document.getElementsByTagName("p");
for(var i = 0; i<arr.length; i++){
let j = i;//塊級變數
arr[i].onclick = function () {
alert(j);
}
}