本文通过一道经典前端面试题,深入解析JavaScript中同步与异步执行机制、变量作用域及闭包原理。文章逐步展示从基础错误到使用IIFE、let块级作用域,再到ES6 Promise.all及ES7 async/await的优化过程,帮助开发者掌握精确控制异步流程的核心技巧。
引言:一道让80%候选人不及格的面试题
本文共计 5024 字,预计阅读时间 6 分钟,速读需 2 分钟,首发于知乎专栏前端周刊。在担任面试官的两年多时间里,我面试了数百位前端工程师,惊讶地发现,超过 80% 的候选人对接下来要介绍的这道题目回答得连及格线都达不到。这究竟是一道怎样的面试题?它考察了候选人的哪些核心能力?对正在阅读本文的读者又有何启示?下文将详细展开。
看似简单的开始
基础考察:同步与异步、变量作用域及闭包
招聘前端工程师,尤其是中高级职位时,扎实的 JavaScript 基础是必备条件。基础薄弱的工程师在面对前端开发中的各类问题时,往往难以应对。在考察候选人的 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; 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 人能及格。读到这里的读者可以仔细思考,自己是否及格了?
进阶方案:利用IIFE与let解决闭包问题
追问 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(声明即执行的函数表达式)来解决闭包问题,确实是不错的思路,但初学者可能觉得此类代码不易理解,至少笔者初入门时在此处琢磨了一会儿才真正理解。
是否有更符合直觉的做法?答案是肯定的,我们只需对循环体稍作调整,使负责输出的代码能获取每次循环的 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 分,并继续进行后续追问。
ES6异步处理:Promise.all的应用
追问 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)。
ES7异步语法:Async/Await的简洁实现
追问 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 精确控制代码输出的各种技巧,更是对前端工程师成长的期许:扎实的语言基础、与时俱进的能力、强大的技术自驱力。
更多面试题学习资料请关注专题《面试题大全》。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。