80% 應聘者都不及格的 JS 面試題

王仕軍發表於2017-03-20

共 5024 字,讀完需 6 分鐘,速讀需 2 分鐘,本文首發於知乎專欄前端週刊。寫在前面,筆者在做面試官這 2 年多的時間內,面試了數百個前端工程師,驚訝的發現,超過 80% 的候選人對下面這道題的回答情況連及格都達不到。這究竟是怎樣神奇的一道面試題?他考察了候選人的哪些能力?對正在讀本文的你有什麼啟示?且聽我慢慢道來

不起眼的開始

招聘前端工程師,尤其是中高階前端工程師,紮實的 JS 基礎絕對是必要條件,基礎不紮實的工程師在面對前端開發中的各種問題時大概率會束手無策。在考察候選人 JS 基礎的時候,我經常會提供下面這段程式碼,然後讓候選人分析它實際執行的結果:

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
}

console.log(new Date, i);複製程式碼

這段程式碼很短,只有 7 行,我想,能讀到這裡的同學應該不需要我逐行解釋這段程式碼在做什麼吧。候選人面對這段程式碼時給出的結果也不盡相同,以下是典型的答案:

  • A. 20% 的人會快速掃描程式碼,然後給出結果:0,1,2,3,4,5
  • B. 30% 的人會拿著程式碼逐行看,然後給出結果:5,0,1,2,3,4
  • C. 50% 的人會拿著程式碼仔細琢磨,然後給出結果:5,5,5,5,5,5

只要你對 JS 中同步和非同步程式碼的區別、變數作用域、閉包等概念有正確的理解,就知道正確答案是 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 秒的時間間隔,而逗號表示其前後的兩次輸出之間的時間間隔可以忽略,程式碼實際執行的結果該如何描述?會有下面兩種答案:

  • A. 60% 的人會描述為:5 -> 5 -> 5 -> 5 -> 5,即每個 5 之間都有 1 秒的時間間隔;
  • B. 40% 的人會描述為:5 -> 5,5,5,5,5,即第 1 個 5 直接輸出,1 秒之後,輸出 5 個 5;

這就要求候選人對 JS 中的定時器工作機制非常熟悉,迴圈執行過程中,幾乎同時設定了 5 個定時器,一般情況下,這些定時器都會在 1 秒之後觸發,而迴圈完的輸出是立即執行的,顯而易見,正確的描述是 B。

如果到這裡算是及格的話,100 個人參加面試只有 20 人能及格,讀到這裡的同學可以仔細思考,你及格了麼?

追問 1:閉包

如果這道題僅僅是考察候選人對 JS 非同步程式碼、變數作用域的理解,侷限性未免太大,接下來我會追問,如果期望程式碼的輸出變成:5 -> 0,1,2,3,4,該怎麼改造程式碼?熟悉閉包的同學很快能給出下面的解決辦法:

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:宣告即執行的函式表示式)來解決閉包造成的問題,確實是不錯的思路,但是初學者可能並不覺得這樣的程式碼很好懂,至少筆者初入門的時候這裡琢磨了一會兒才真正理解。

有沒有更符合直覺的做法?答案是有,我們只需要對迴圈體稍做手腳,讓負責輸出的那段程式碼能拿到每次迴圈的 i 值即可。該怎麼做呢?利用 JS 中基本型別(Primitive Type)的引數傳遞是按值傳遞(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 值被複制了
}

console.log(new Date, i);複製程式碼

能給出上述 2 種解決方案的候選人可以認為對 JS 基礎的理解和運用是不錯的,可以各加 10 分。當然實際面試中還有候選人給出如下的程式碼:

for (let i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
}

console.log(new Date, i);複製程式碼

細心的同學會發現,這裡只有個非常細微的變動,即使用 ES6 塊級作用域(Block Scope)中的 let 替代了 var,但是程式碼在實際執行時會報錯,因為最後那個輸出使用的 i 在其所在的作用域中並不存在,i 只存在於迴圈內部。

能想到 ES6 特性的同學雖然沒有答對,但是展示了自己對 ES6 的瞭解,可以加 5 分,繼續進行下面的追問。

追問 2:ES6

有經驗的前端同學讀到這裡可能有些不耐煩了,扯了這麼多,都是他知道的內容,先彆著急,挑戰的難度會繼續增加。

接著上文繼續追問:如果期望程式碼的輸出變成 0 -> 1 -> 2 -> 3 -> 4 -> 5,並且要求原有的程式碼塊中的迴圈和兩處 console.log 不變,該怎麼改造程式碼?新的需求可以精確的描述為:程式碼執行時,立即輸出 0,之後每隔 1 秒依次輸出 1,2,3,4,迴圈結束後在大概第 5 秒的時候輸出 5(這裡使用大概,是為了避免鑽牛角尖的同學陷進去,因為 JS 中的定時器觸發時機有可能是不確定的,具體可參見 How Javascript Timers Work)。

看到這裡,部分同學會給出下面的可行解:

for (var i = 0; i < 5; i++) {
    (function(j) {
        setTimeout(function() {
            console.log(new Date, j);
        }, 1000 * j);  // 這裡修改 0~4 的定時器時間
    })(i);
}

setTimeout(function() { // 這裡增加定時器,超時設定為 5 秒
    console.log(new Date, i);
}, 1000 * i);複製程式碼

不得不承認,這種做法雖粗暴有效,但是不算是能額外加分的方案。如果把這次的需求抽象為:在系列非同步操作完成(每次迴圈都產生了 1 個非同步操作)之後,再做其他的事情,程式碼該怎麼組織?聰明的你是不是想起了什麼?對,就是 Promise

可能有的同學會問,不就是在控制檯輸出幾個數字麼?至於這樣殺雞用牛刀?你要知道,面試官真正想考察的是候選人是否具備某種能力和素質,因為在現代的前端開發中,處理非同步的程式碼隨處可見,熟悉和掌握非同步操作的流程控制是成為合格開發者的基本功。

順著下來,不難給出基於 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,否則程式碼不會按預期 work
            }, 1000 * j);   // 定時器的超時時間逐步增加
        }));
    })(i);
}

Promise.all(tasks).then(() => {
    setTimeout(() => {
        console.log(new Date, i);
    }, 1000);   // 注意這裡只需要把超時設定為 1 秒
});複製程式碼

相比而言,筆者更傾向於下面這樣看起來更簡潔的程式碼,要知道程式設計風格也是很多面試官重點考察的點,程式碼閱讀時的顆粒度更小,模組化更好,無疑會是加分點。

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);
});複製程式碼

讀到這裡的同學,恭喜你,你下次面試遇到類似的問題,至少能拿到 80 分。

我們都知道使用 Promise 處理非同步程式碼比回撥機制讓程式碼可讀性更高,但是使用 Promise 的問題也很明顯,即如果沒有處理 Promise 的 reject,會導致錯誤被丟進黑洞,好在新版的 Chrome 和 Node 7.x 能對未處理的異常給出 Unhandled Rejection Warning,而排查這些錯誤還需要一些特別的技巧(瀏覽器Node.js)。

追問 3:ES7

既然你都看到這裡了,那就再堅持 2 分鐘,接下來的內容會讓你明白你的堅持是值得的。

多數面試官在決定聘用某個候選人之前還需要考察另外一項重要能力,即技術自驅力,直白的說就是候選人像有內部的馬達在驅動他,用漂亮的方式解決工程領域的問題,不斷的跟隨業務和技術變得越來越牛逼,究竟什麼是牛逼?建議閱讀程式人生的這篇剖析

回到正題,既然 Promise 已經被拿下,如何使用 ES7 中的 async await 特性來讓這段程式碼變的更簡潔?你是否能夠根據自己目前掌握的知識給出答案?請在這裡暫停 1 分鐘,思考下。

下面是筆者給出的參考程式碼:

// 模擬其他語言中的 sleep,實際上可以是任何非同步操作
const sleep = (timeountMS) => new Promise((resolve) => {
    setTimeout(resolve, timeountMS);
});

(async () => {  // 宣告即執行的 async 函式表示式
    for (var i = 0; i < 5; i++) {
        await sleep(1000);
        console.log(new Date, i);
    }

    await sleep(1000);
    console.log(new Date, i);
})();複製程式碼

總結

感謝你花時間讀到這裡,相信你收穫的不僅僅是用 JS 精確控制程式碼輸出的各種技巧,更是對於前端工程師的成長期許:紮實的語言基礎、與時俱進的能力、強大技術自驅力。

One More Thing

本文首發知乎專欄,商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。如果你覺得本文對你有幫助,請點贊!如果對文中的內容有任何疑問,歡迎留言討論。想知道我接下來會寫些什麼?歡迎訂閱知乎專欄:《前端週刊:讓你在前端領域跟上時代的腳步》

相關文章