一、參考一個例子讓你讀懂什么是JS閉包
需要在公司頁面上顯示一個瀏覽時間,從打開頁面的瞬間開始計時,每過一秒鐘加一
1.全局變量解決方案
//秒數(shù)
let second = 0;
//累加器
function counter(){
second += 1;
return second;
}
const recordSecond = setInterval(function(){
//到達(dá)10秒后停止
if(second === 10){
clearInterval(recordSecond);
console.log('計時結(jié)束!');
return;
}
//調(diào)用累加器,輸出當(dāng)前秒數(shù)
console.log(`${counter()}秒`);
},1000);
在chrome控制臺執(zhí)行一下結(jié)果,可以看到我們已經(jīng)實現(xiàn)了所需要的功能,每次都更新一個全局變量。但是,所有編程語言中有一條不成文的鐵律:盡可能的少定義全局變量。
- 全局變量不好控制,可以在任何地方進(jìn)行讀寫,這意味著可能會被不想干的程序改寫。
- 全局變量占用內(nèi)存的生命周期長,一般局部變量,定義在函數(shù)中,在函數(shù)調(diào)用完成后,與之對應(yīng)的執(zhí)行環(huán)境會退出執(zhí)行棧,回收機(jī)制會每隔一段時間進(jìn)行一次回收。而全局變量因為隨時可以被任何程序在任何地方讀寫,會導(dǎo)致全局變量一般在全局執(zhí)行環(huán)境被銷毀時才會釋放。
2.優(yōu)化一下
首先,將判斷停止定時任務(wù)的條件放在counter函數(shù)中
//秒數(shù)
let second = 0;
//累加器
function counter(){
//到達(dá)10秒后停止
if(second === 10){
clearInterval(recordSecond);
console.log('計時結(jié)束!');
return;
}
second += 1;
console.log(`${second}秒`);
}
const recordSecond = setInterval(function(){
//調(diào)用累加器,輸出當(dāng)前秒數(shù)
counter();
},1000);
接下來,我們將second定義為局部變量,因為setInterval的回調(diào)函數(shù)每隔一秒就執(zhí)行一次,second聲明在回調(diào)函數(shù)中,每次回調(diào)函數(shù)被調(diào)用的同時second就會被初始化,那么就實現(xiàn)不了累加的效果:
//累加器
function counter(){
//秒數(shù)
let second = 0;
//到達(dá)10秒后停止
if(second === 10){
clearInterval(recordSecond);
console.log('計時結(jié)束!');
return;
}
second += 1;
console.log(`${second}秒`);
}
const recordSecond = setInterval(function(){
//調(diào)用累加器,輸出當(dāng)前秒數(shù)
counter();
},1000);
想必大家已經(jīng)發(fā)現(xiàn)問題了,因為counter的調(diào)用是在回調(diào)函數(shù)中的,那么單純將second定義在counter中,也避免不了被初始化的操作。
//累加器
function counter(){
//秒數(shù)
let second = 0;
function doCounter(){
//到達(dá)10秒后停止
if(second === 10){
clearInterval(recordSecond);
console.log('計時結(jié)束!');
return;
}
second += 1;
console.log(`${second}秒`);
}
}
const recordSecond = setInterval(function(){
//調(diào)用累加器,輸出當(dāng)前秒數(shù)
counter();
},1000);
現(xiàn)在,從代碼結(jié)構(gòu)上,我們將counter分成了兩部分,因為doCounter是counter的內(nèi)部函數(shù),有權(quán)訪問外部函數(shù)作用域中的變量second。但是,還有一個問題,我們沒有將doCounter返回,那么就算counter被調(diào)用,也只是執(zhí)行了對其內(nèi)部second和doCounter()的聲明操作。很簡單,我們把doCounter前面添加return即可:
//累加器
function counter(){
//秒數(shù)
let second = 0;
function doCounter(){
//到達(dá)10秒后停止
if(second === 10){
clearInterval(recordSecond);
console.log('計時結(jié)束!');
return;
}
second += 1;
console.log(`${second}秒`);
}
return doCounter;
}
const recordSecond = setInterval(function(){
//調(diào)用累加器,輸出當(dāng)前秒數(shù)
counter();
},1000);
但是執(zhí)行這段代碼,卻發(fā)現(xiàn)控制臺沒有輸出任何信息。那么問題在哪呢?counter()函數(shù)確實執(zhí)行了,但是它只是拿到了返回的doCounter函數(shù),但并未調(diào)用。為了提高可讀性,修改如下:
//累加器
function counter(){
//秒數(shù)
let second = 0;
function doCounter(){
//到達(dá)10秒后停止
if(second === 10){
clearInterval(recordSecond);
console.log('計時結(jié)束!');
return;
}
second += 1;
console.log(`${second}秒`);
}
return doCounter;
}
const doCounterFn = counter();
const recordSecond = setInterval(function(){
//調(diào)用累加器
doCounterFn();
},1000);
當(dāng)我們通過doCounterFn 間接調(diào)用doCounter時,雖然doCounterFn 的作用域鏈上并不存在變量second,但doCounter被執(zhí)行時依舊能訪問它的作用域鏈上的變量,也就是它聲明時所在的作用域內(nèi)的任何變量,這就是作用域延長的典型例子。
通過counter 和 doCounter兩個函數(shù)嵌套,形成作用域的嵌套,被嵌套函數(shù)需要對所在作用域進(jìn)行訪問,再將被嵌套的函數(shù)在另一個作用域中調(diào)用,這一整個過程就是我們所說的閉包。
3.總結(jié)
- 什么時間需要使用閉包?當(dāng)我們需要重復(fù)使用一個對象,但又想保護(hù)這個對象不被其他代碼污染
- 閉包的作用?使得一個外部函數(shù)有權(quán)訪問一個內(nèi)部函數(shù)作用域。
- 閉包的形成必備條件?需要訪問作用域;函數(shù)嵌套(物理條件);被嵌套函數(shù)在另一個外部作用域中被調(diào)用
- 閉包的缺點?比起普通函數(shù)閉包對內(nèi)存的占用更多,建議使用完畢后,手動標(biāo)空fn=null
二、參考「每日一題」JS 中的閉包是什么?
function foo(){
var local = 1
function bar(){
local++
return local
}
return bar
}
var func = foo()
func()
1.為什么要函數(shù)套函數(shù)呢?
是因為需要局部變量,所以才把 local 放在一個函數(shù)里,如果不把 local 放在一個函數(shù)里,local 就是一個全局變量了,達(dá)不到使用閉包的目的——隱藏變量(等會會講)。這也是為什么我上面要說「運行在一個立即執(zhí)行函數(shù)中」。
有些人看到「閉包」這個名字,就一定覺得要用什么包起來才行。其實這是翻譯問題,閉包的原文是 Closure,跟「包」沒有任何關(guān)系。所以函數(shù)套函數(shù)只是為了造出一個局部變量,跟閉包無關(guān)。
2.為什么要 return bar 呢?
因為如果不 return,你就無法使用這個閉包。把 return bar 改成 window.bar = bar 也是一樣的,只要讓外面可以訪問到這個 bar 函數(shù)就行了。所以 return bar 只是為了 bar 能被使用,也跟閉包無關(guān)。
三、參考破解前端面試(80% 應(yīng)聘者不及格系列):從 閉包說起
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
}
console.log(new Date, i);
- A. 20% 的人會快速掃描代碼,然后給出結(jié)果:0,1,2,3,4,5;
- B. 30% 的人會拿著代碼逐行看,然后給出結(jié)果:5,0,1,2,3,4;
- C. 50% 的人會拿著代碼仔細(xì)琢磨,然后給出結(jié)果:5,5,5,5,5,5;
只要你對 JS 中同步和異步代碼的區(qū)別、變量作用域、閉包等概念有正確的理解,就知道正確答案是 C,代碼的實際輸出是:
2017-03-18T00:43:45.873Z 5
2017-03-18T00:43:46.866Z 5
2017-03-18T00:43:46.868Z 5
2017-03-18T00:43:46.868Z 5
2017-03-18T00:43:46.868Z 5
2017-03-18T00:43:46.868Z 5
接下來我會追問:如果我們約定,用箭頭表示其前后的兩次輸出之間有 1 秒的時間間隔,而逗號表示其前后的兩次輸出之間的時間間隔可以忽略,代碼實際運行的結(jié)果該如何描述?會有下面兩種答案:
- A. 60% 的人會描述為:5 -> 5 -> 5 -> 5 -> 5,即每個 5 之間都有 1 秒的時間間隔;
- B. 40% 的人會描述為:5 -> 5,5,5,5,5,即第 1 個 5 直接輸出,1 秒之后,輸出 5 個 5;
這就要求候選人對 JS 中的定時器工作機(jī)制非常熟悉,循環(huán)執(zhí)行過程中,幾乎同時設(shè)置了 5 個定時器,一般情況下,這些定時器都會在 1 秒之后觸發(fā),而循環(huán)完的輸出是立即執(zhí)行的,顯而易見,正確的描述是 B。
1.追問 1:閉包
如果這道題僅僅是考察候選人對 JS 異步代碼、變量作用域的理解,局限性未免太大,接下來我會追問,如果期望代碼的輸出變成:5 -> 0,1,2,3,4,該怎么改造代碼?熟悉閉包的同學(xué)很快能給出下面的解決辦法:
for (var i = 0; i < 5; i++) {
(function(j) { // j = i
setTimeout(function() {
console.log(new Date, j);
}, 1000);
})(i);
}
console.log(new Date, i);
巧妙的利用 IIFE(Immediately Invoked Function Expression:聲明即執(zhí)行的函數(shù)表達(dá)式)來解決閉包造成的問題,確實是不錯的思路,但是初學(xué)者可能并不覺得這樣的代碼很好懂,至少筆者初入門的時候這里琢磨了一會兒才真正理解。
有沒有更符合直覺的做法?答案是有,我們只需要對循環(huán)體稍做手腳,讓負(fù)責(zé)輸出的那段代碼能拿到每次循環(huán)的 i 值即可。該怎么做呢?利用 JS 中基本類型(Primitive Type)的參數(shù)傳遞是按值傳遞(Pass by Value)的特征,不難改造出下面的代碼:
var output = function (i) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
};
for (var i = 0; i < 5; i++) {
output(i); // 這里傳過去的 i 值被復(fù)制了
}
console.log(new Date, i);
能給出上述 2 種解決方案的候選人可以認(rèn)為對 JS 基礎(chǔ)的理解和運用是不錯的,可以各加 10 分。當(dāng)然實際面試中還有候選人給出如下的代碼:
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
}
console.log(new Date, i);
細(xì)心的同學(xué)會發(fā)現(xiàn),這里只有個非常細(xì)微的變動,即使用 ES6 塊級作用域(Block Scope)中的 let 替代了 var,但是代碼在實際運行時會報錯,因為最后那個輸出使用的 i 在其所在的作用域中并不存在,i 只存在于循環(huán)內(nèi)部。能想到 ES6 特性的同學(xué)雖然沒有答對,但是展示了自己對 ES6 的了解,可以加 5 分,繼續(xù)進(jìn)行下面的追問。
2.追問 2:ES6
有經(jīng)驗的前端同學(xué)讀到這里可能有些不耐煩了,扯了這么多,都是他知道的內(nèi)容,先別著急,挑戰(zhàn)的難度會繼續(xù)增加。
接著上文繼續(xù)追問:如果期望代碼的輸出變成 0 -> 1 -> 2 -> 3 -> 4 -> 5,并且要求原有的代碼塊中的循環(huán)和兩處 console.log 不變,該怎么改造代碼?新的需求可以精確的描述為:代碼執(zhí)行時,立即輸出 0,之后每隔 1 秒依次輸出 1,2,3,4,循環(huán)結(jié)束后在大概第 5 秒的時候輸出 5(這里使用大概,是為了避免鉆牛角尖的同學(xué)陷進(jìn)去,因為 JS 中的定時器觸發(fā)時機(jī)有可能是不確定的,具體可參見 How Javascript Timers Work)。
順著下來,不難給出基于 Promise 的解決方案(既然 Promise 是 ES6 中的新特性,我們的新代碼使用 ES6 編寫是不是會更好?如果你這么寫了,大概率會讓面試官心生好感):
const tasks = [];
for (var i = 0; i < 5; i++) { // 這里 i 的聲明不能改成 let,如果要改該怎么做?
((j) => {
tasks.push(new Promise((resolve) => {
setTimeout(() => {
console.log(new Date, j);
resolve(); // 這里一定要 resolve,否則代碼不會按預(yù)期 work
}, 1000 * j); // 定時器的超時時間逐步增加
}));
})(i);
}
Promise.all(tasks).then(() => {
setTimeout(() => {
console.log(new Date, i);
}, 1000); // 注意這里只需要把超時設(shè)置為 1 秒
});
相比而言,筆者更傾向于下面這樣看起來更簡潔的代碼,要知道編程風(fēng)格也是很多面試官重點考察的點,代碼閱讀時的顆粒度更小,模塊化更好,無疑會是加分點。
const tasks = []; // 這里存放異步操作的 Promise
const output = (i) => new Promise((resolve) => {
setTimeout(() => {
console.log(new Date, i);
resolve();
}, 1000 * i);
});
// 生成全部的異步操作
for (var i = 0; i < 5; i++) {
tasks.push(output(i));
}
// 異步操作完成之后,輸出最后的 i
Promise.all(tasks).then(() => {
setTimeout(() => {
console.log(new Date, i);
}, 1000);
});
讀到這里的同學(xué),恭喜你,你下次面試遇到類似的問題,至少能拿到 80 分。
3.追問 3:ES7
既然你都看到這里了,那就再堅持 2 分鐘,接下來的內(nèi)容會讓你明白你的堅持是值得的。
多數(shù)面試官在決定聘用某個候選人之前還需要考察另外一項重要能力,即技術(shù)自驅(qū)力,直白的說就是候選人像有內(nèi)部的馬達(dá)在驅(qū)動他,用漂亮的方式解決工程領(lǐng)域的問題,不斷的跟隨業(yè)務(wù)和技術(shù)變得越來越牛逼,究竟什么是牛逼?建議閱讀程序人生的這篇剖析。
回到正題,既然 Promise 已經(jīng)被拿下,如何使用 ES7 中的 async await 特性來讓這段代碼變的更簡潔?你是否能夠根據(jù)自己目前掌握的知識給出答案?請在這里暫停 1 分鐘,思考下。
下面是筆者給出的參考代碼:
// 模擬其他語言中的 sleep,實際上可以是任何異步操作
const sleep = (timeountMS) => new Promise((resolve) => {
setTimeout(resolve, timeountMS);
});
(async () => { // 聲明即執(zhí)行的 async 函數(shù)表達(dá)式
for (var i = 0; i < 5; i++) {
await sleep(1000);
console.log(new Date, i);
}
await sleep(1000);
console.log(new Date, i);
})();