JavaScript進階教程(5)-一文讓你搞懂作用域鏈和閉包

Albert Yang發表於2020-09-12

目錄

1 作用域

2 作用域鏈

3 預解析

3.1 變數預解析

3.2 函式預解析

4 閉包

4.1 閉包小案例:

4.2 閉包點贊案例

5 閉包的作用

6 閉包導致的一些問題

6.1 第一:使用更多的閉包

6.2 第二種方法:使用了匿名閉包

6.3 第三種方法:使用用ES2015引入的let關鍵詞

6.4 第四種方法:使用 forEach()來遍歷

7 效能

8 總結


1 作用域

在JS中變數可以分為區域性變數和全域性變數,對於變數不熟悉的可以看一下我這篇文章:https://blog.csdn.net/qq_23853743/article/details/106946100
作用域就是變數的使用範圍,分為區域性作用域和全域性作用域,區域性變數的使用範圍為區域性作用域,全域性變數的使用範圍是全域性作用域。在 ECMAScript 2015 引入let 關鍵字之前,js中沒有塊級作用域---即在JS中一對花括號({})中定義的變數,依然可以在花括號外面使用。

{
	var num2 = 100;
}
console.log(num2); // >100

2 作用域鏈

當內部函式訪問外部函式的變數時,採用的是鏈式查詢的方式進行獲取的,從裡向外層層的搜尋,搜尋到了就直接使用,搜尋到0級作用域的時候,如果還是沒有找到這個變數,就報錯。這種結構我們稱為作用域鏈。

// 作用域鏈:變數的使用,從裡向外,層層的搜尋,搜尋到了就直接使用
// 搜尋到0級作用域的時候,如果還是沒有找到這個變數,就會報錯
var num = 10; //作用域鏈 級別:0

function f1() {
	var num2 = 20;

	function f2() {
		var num3 = 30;
		console.log(num); // >10
	}
	f2();
}
f1();

3 預解析

JS程式碼在瀏覽器中是由JS引擎進行解析執行的,分為兩步,預解析和程式碼執行。預解析分為 變數預解析(變數提升) 和 函式預解析(函式提升),瀏覽器JS程式碼執行之前,會把變數的宣告和函式的宣告提前(提升)到該作用域的最上面。

3.1 變數預解析

把所有變數的宣告提升到當前作用域的最前面,不提升賦值操作。
示例:

console.log(num); // 沒有報錯,返回的是一個undefined
var num = 666;

預解析後:

// 預解析後:變數提升
var num;
console.log(num); // 所以返回的是一個undefined
num = 666;

3.2 函式預解析

將所有函式宣告提升到當前作用域的最前面。
示例:

f1(); // 能夠正常呼叫

function f1() {
	console.log("Albert唱歌太好聽了");
}

預解析後:

function f1() {
	console.log("Albert唱歌太好聽了");
}
f1(); //預解析後,程式碼是逐行執行的,執行到 f1()後,去呼叫函式 f1()

4 閉包

在專業書籍上對於閉包的解釋為:Javascript的閉包是指一個函式與周圍狀態(詞法環境)的引用捆綁在一起(封閉)的組合,在JavaScript中,每次建立函式時,都會同時建立閉包。閉包是一種保護私有變數的機制,在函式執行時形成私有的作用域,保護裡面的私有變數不受外界干擾,即形成一個不銷燬的棧環境。
這句話比較難以理解,對於閉包我的理解是,在函式A中,有一個函式B,在函式B中可以訪問函式A中定義的變數或者是資料x,被訪問的變數x可以和B函式一同存在。即使A函式已經執行結束,導致建立變數x的環境銷燬,B函式中x變數也依然會存在,直到訪問變數x的B函式被銷燬,此時形成了閉包。如下面程式碼所示:

function A() {
	var x = 0;

	function B() {
		return ++x;
	}
	return B // 返回B函式
}

var B = A(); // 建立B函式
console.log(B()); // 1
console.log(B()); // 2
console.log(B()); // 3
console.log(B()); // 4
console.log("%c%s", "color:red", "*******---------*********");
// 建立新的B函式
B = A();
console.log(B()); // 1

4.1 閉包小案例:

// 普通的函式
function f1() {
	var num = 0;
	num++;
	return num;
}
console.log(f1());
console.log(f1());
console.log(f1());

console.log("%c%s", "color:red", "*******---------*********");
// 閉包
function f2() {
	var num = 0;
	return function() {
		num++;
		return num;
	}
}
var ff = f2();
console.log(ff()); // 1
console.log(ff()); // 2
console.log(ff()); // 3

4.2 閉包點贊案例

演示地址:https://www.albertyy.com/2020/9/like.html



程式碼:

<!DOCTYPE html><html>
        <head>
                <meta charset="utf-8">
                <title>閉包點贊案例:公眾號AlbertYang</title>
                <style>
                        ul {
                                list-style-type: none;
                        }

                        li {
                                float: left;
                                margin-left: 10px;
                                margin-bottom: 20px;
                        }

                        img {
                                height: 300px;
                        }

                        input {
                                margin-left: 30%;
                        }
                </style>
        </head>
        <body>
                <ul>
                        <li><img src="1.jpg" alt=""><br /><input type="button" value="(1)贊"></li>
                        <li><img src="2.jpg" alt=""><br /><input type="button" value="(1)贊"></li>
                        <li><img src="3.jpg" alt=""><br /><input type="button" value="(1)贊"></li>
                        <li><img src="4.jpg" alt=""><br /><input type="button" value="(1)贊"></li>
                        <li><img src="5.jpg" alt=""><br /><input type="button" value="(1)贊"></li>
                        <li><img src="6.jpg" alt=""><br /><input type="button" value="(1)贊"></li>
                </ul>
        </body>
        <script>
                // 根據標籤名字獲取元素
                function my$(tagName) {
                        return document.getElementsByTagName(tagName);
                }
                // 使用閉包
                function getValue() {
                        var value = 2;
                        return function() {
                                // 每一次點選的時候,都應該改變當前點選按鈕的value值
                                this.value = "(" + (value++) + ")贊";
                        }
                }
                //獲取所有的按鈕
                var btnObjs = my$("input");
                //迴圈遍歷每個按鈕,註冊點選事件
                for (var i = 0; i < btnObjs.length; i++) {
                        //註冊事件
                        btnObjs[i].onclick = getValue();
                }
        </script></html>

5 閉包的作用

閉包很有用,因為它允許將函式與其所操作的某些資料(環境)關聯起來。這顯然類似於物件導向程式設計。在物件導向程式設計中,物件允許我們將某些資料(物件的屬性)與一個或者多個方法相關聯。在一些程式語言中,比如 Java,是支援將方法宣告為私有的(private),即它們只能被同一個類中的其它方法所呼叫。而 JavaScript 沒有這種原生支援,但我們可以使用閉包來模擬私有方法。私有方法不僅僅有利於限制對程式碼的訪問:還提供了管理全域性名稱空間的強大能力,避免非核心的方法弄亂了程式碼的公共介面部分。下面我們計數器為例,程式碼如下:

var myCounter = function() {
	var privateCounter = 0;

	function changeBy(val) {
		privateCounter += val;
	}

	return {
		increment: function() {
			changeBy(1);
		},
		decrement: function() {
			changeBy(-1);

		},
		value: function() {
			return privateCounter;
		}
	}
};

var Counter1 = myCounter();
var Counter2 = myCounter();
console.log(Counter1.value()); /* 計數器1現在為 0 */
Counter1.increment();
Counter1.increment();
console.log(Counter1.value()); /* 計數器1現在為 2 */
Counter1.decrement();
console.log(Counter1.value()); /* 計數器1現在為 1 */

console.log(Counter2.value()); /* 計數器2現在為 0 */
Counter2.increment();
console.log(Counter2.value()); /* 計數器2現在為 1 */

在上邊的程式碼中我們建立了一個匿名函式含兩個私有項:名為 privateCounter 的變數和名為 changeBy 的函式。這兩項都無法在這個匿名函式外部直接訪問。必須通過匿名函式返回的三個公共函式訪問,Counter.increment,Counter.decrement 和Counter.value,這三個公共函式共享同一個環境的閉包,多虧 JavaScript 的詞法作用域,它們都可以訪問 privateCounter 變數和 changeBy 函式。我們把匿名函式儲存在一個變數myCounter 中,並用它來建立多個計數器,每次建立都會同時建立閉包,因為每個閉包都有它自己的詞法環境,每個閉包都是引用自己詞法作用域內的變數 privateCounter ,所以兩個計數器 Counter1 和 Counter2 是各自獨立的。以這種方式使用閉包,提供了許多與物件導向程式設計相關的好處 —— 特別是資料隱藏和封裝。

6 閉包導致的一些問題

在 ECMAScript 2015 引入let 關鍵字之前,在迴圈中有一個常見的閉包建立問題。請看以下程式碼:

<!DOCTYPE html>
<html>
        <head>
                <meta charset="utf-8">
                <title>公眾號AlbertYang</title>
        </head>
        <body>
                <p id="help">提示資訊</p>
                <p>E-mail: <input type="text" id="email" name="email"></p>
                <p>Name: <input type="text" id="name" name="name"></p>
                <p>Age: <input type="text" id="age" name="age"></p>
 
        </body>
        <script>
                function showHelp(help) {
                        document.getElementById('help').innerHTML = help;
                }
 
                function setupHelp() {
                        var helpText = [{
                                        'id': 'email',
                                        'help': '你的郵件地址'
                                },
                                {
                                        'id': 'name',
                                        'help': '你的名字'
                                },
                                {
                                        'id': 'age',
                                        'help': '你的年齡'
                                }
                        ];
 
                        for (var i = 0; i < helpText.length; i++) {
                                var item = helpText[i];
                                document.getElementById(item.id).onfocus = function() {
                                        showHelp(item.help);
                                }
                        }
                }
                setupHelp();
        </script>
</html>

上邊程式碼中,我們在陣列 helpText 中定義了三個提示資訊,每一個都關聯於對應的文件中的input 的 ID。通過迴圈依次為相應input新增了一個 onfocus  事件處理函式,以便顯示幫助資訊。執行這段程式碼後,您會發現它沒有達到想要的效果。無論焦點在哪個input上,顯示的都是關於年齡的資訊。
演示地址:https://www.albertyy.com/2020/7/closure1.html
我們想要的正確效果:https://www.albertyy.com/2020/7/closure2.html

這是因為賦值給 onfocus 的是閉包。這些閉包是由他們的函式定義和在 setupHelp 作用域中捕獲的環境所組成的。這三個閉包在迴圈中被建立,但他們共享了同一個詞法作用域,在這個作用域中存在一個變數item。這裡因為變數item使用var進行宣告,由於變數提升(item可以在函式setupHelp的任何地方使用),所以item具有函式作用域。當onfocus的回撥執行時,item.help的值被決定。由於迴圈在onfocus 事件觸發之前早已執行完畢,變數物件item(被三個閉包所共享)已經指向了helpText的最後一項。要解決這個問題,有以下幾個方法。

6.1 第一:使用更多的閉包

function showHelp(help) {
        document.getElementById('help').innerHTML = help;
}

function makeHelpCallback(help) {
        return function() {
                showHelp(help);
        };
}

function setupHelp() {
        var helpText = [{
                        'id': 'email',
                        'help': '你的郵件地址'
                },
                {
                        'id': 'name',
                        'help': '你的名字'
                },
                {
                        'id': 'age',
                        'help': '你的年齡'
                }
        ];

        for (var i = 0; i &lt; helpText.length; i++) {
                var item = helpText[i];
                document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
        }
}
setupHelp();

這段程式碼可以正常的執行了。這是因為所有的回撥不再共享同一個環境, makeHelpCallback 函式為每一個回撥建立一個新的詞法環境。在這些環境中,help 指向 helpText 陣列中對應的字串。

6.2 第二種方法:使用了匿名閉包

function showHelp(help) {
        document.getElementById('help').innerHTML = help;
}

function setupHelp() {
        var helpText = [{
                        'id': 'email',
                        'help': '你的郵件地址'
                },
                {
                        'id': 'name',
                        'help': '你的名字'
                },
                {
                        'id': 'age',
                        'help': '你的年齡'
                }
        ];

        for (var i = 0; i &lt; helpText.length; i++) {
                (function() {
                        var item = helpText[i];
                        document.getElementById(item.id).onfocus = function() {
                                showHelp(item.help);
                        }
                })(); // 馬上把當前迴圈項的item與事件回撥相關聯起來
        }
}
setupHelp();

6.3 第三種方法:使用用ES2015引入的let關鍵詞

function showHelp(help) {
        document.getElementById('help').innerHTML = help;
}

function setupHelp() {
        var helpText = [{
                        'id': 'email',
                        'help': '你的郵件地址'
                },
                {
                        'id': 'name',
                        'help': '你的名字'
                },
                {
                        'id': 'age',
                        'help': '你的年齡'
                }
        ];

        for (var i = 0; i &lt; helpText.length; i++) {
                let item = helpText[i]; //使用let代替var
                document.getElementById(item.id).onfocus = function() {
                        showHelp(item.help);
                }
        }
}
setupHelp();

這個裡使用let而不是var,因為let是具有塊作用域的變數,即它所宣告的變數只在所在的程式碼塊({})內有效,因此每個閉包都繫結了塊作用域的變數,這意味著不再需要額外的閉包。

6.4 第四種方法:使用 forEach()來遍歷

function showHelp(help) {
        document.getElementById('help').innerHTML = help;
}

function setupHelp() {
        var helpText = [{
                        'id': 'email',
                        'help': '你的郵件地址'
                },
                {
                        'id': 'name',
                        'help': '你的名字'
                },
                {
                        'id': 'age',
                        'help': '你的年齡'
                }
        ];

        helpText.forEach(function(text) {
                document.getElementById(text.id).onfocus = function() {
                        showHelp(text.help);
                }
        });
}
setupHelp();

7 效能

由於閉包會使得函式中的變數都被儲存在記憶體中,記憶體消耗很大,所以不能濫用閉包,否則會造成網頁的效能問題。如果不是某些特定任務需要使用閉包,最好不要使用閉包。例如,在建立新的物件或者類時,方法通常應該放到原型物件中,而不是定義到物件的建構函式中。原因是這將導致每次建構函式被呼叫時,方法都會被重新賦值一次(也就是說,對於每一個例項物件,geName和 getMessage都是一模一樣的內容, 每生成一個例項,都必須為重複的內容,多佔用一些記憶體,如果例項物件很多,會造成極大的記憶體浪費。)。請看以下程式碼:

function MyObject(name, message) {
        this.name = name.toString();
        this.message = message.toString();

        this.getName = function() {
                return this.name;
        };

        this.getMessage = function() {
                return this.message;
        };
}

在上面的程式碼中,我們並沒有利用到閉包的好處,因此可以避免使用閉包。修改如下:

function MyObject(name, message) {
        this.name = name.toString();
        this.message = message.toString();
}
MyObject.prototype = {
        getName: function() {
                return this.name;
        },
        getMessage: function() {
                return this.message;
        }
};

如果我們不想重新定義原型,可修改如下:

function MyObject(name, message) {
        this.name = name.toString();
        this.message = message.toString();
}

MyObject.prototype.getName = function() {
        return this.name;
};

MyObject.prototype.getMessage = function() {
        return this.message;
};

思考:為了測試你是否理解閉包請看下面兩段程式碼,請思考它們的執行結果是什麼?並在留言區給出你的答案。

程式碼一:

var name = "Window";
var object = {
	name: "Object",

	getNameFunc: function() {
		return function() {
			return this.name;
		};
	}
};
console.log(object.getNameFunc()());

程式碼二:

var name = "Window";
var object = {
	name: "Object",
	getNameFunc: function() {
		var that = this;
		return function() {
			return that.name;
		};
	}
};
console.log(object.getNameFunc()());

8 總結

內部函式訪問外部函式的變數時,採用的是鏈式查詢的方式進行獲取的,從裡向外層層的搜尋,搜尋到了就直接使用,搜尋到0級作用域的時候,如果還是沒有找到這個變數,就報錯,這種結構我們稱為作用域鏈。本質上,閉包就是將函式內部和函式外部連線起來的一座橋樑   區域性變數是在函式中,函式使用結束後,區域性變數就會被自動的釋放,但是產生閉包後,裡面的區域性變數的使用作用域鏈就會被延長,閉包的作用是快取資料這是閉包的優點也是缺點,這會導致變數不能及時的釋放。如果想要快取資料,就把這個資料放在外層的函式和裡層的函式的中間位置。由於閉包會使得函式中的變數都被儲存在記憶體中,記憶體消耗很大,所以不要濫用閉包。

今天的學習就到這裡了,由於本人能力和知識有限,如果有寫的不對的地方,還請各位大佬批評指正。如果想繼續學習提高,歡迎關注我,每天學習進步一點點,就是領先的開始。如果覺得本文對你有幫助的話,歡迎轉發,評論,點贊!!!

相關文章