# 异步编程 与 EventLoop
# 并发(concurrency)和并行(parallelism)区别
涉及面试题:并发与并行的区别?
异步和这小节的知识点其实并不是一个概念,但是这两个名词确实是很多人都常会混淆的知识点。其实混淆的原因可能只是两个名词在中文上的相似,在英文上来说完全是不同的单词。
并发是宏观概念,我分别有任务 A 和任务 B,在一段时间内通过任务间的切换完成了这两个任务,这种情况就可以称之为并发。
并行是微观概念,假设 CPU 中存在两个核心,那么我就可以同时完成任务 A、B。同时完成多个任务的情况就可以称之为并行。
# 回调函数(Callback)
涉及面试题:什么是回调函数?回调函数有什么缺点?如何解决回调地狱问题?
回调函数应该是大家经常使用到的,以下代码就是一个回调函数的例子:
ajax(url, () => {
// 处理逻辑
});
但是回调函数有一个致命的弱点,就是容易写出回调地狱(Callback hell)。假设多个请求存在依赖性,你可能就会写出如下代码:
ajax(url, () => {
// 处理逻辑
ajax(url1, () => {
// 处理逻辑
ajax(url2, () => {
// 处理逻辑
});
});
});
以上代码看起来不利于阅读和维护,当然,你可能会想说解决这个问题还不简单,把函数分开来写不就得了
function firstAjax() {
ajax(url1, () => {
// 处理逻辑
secondAjax();
});
}
function secondAjax() {
ajax(url2, () => {
// 处理逻辑
});
}
ajax(url, () => {
// 处理逻辑
firstAjax();
});
以上的代码虽然看上去利于阅读了,但是还是没有解决根本问题。
回调地狱的根本问题就是:
嵌套函数存在耦合性,一旦有所改动,就会牵一发而动全身 嵌套函数一多,就很难处理错误 当然,回调函数还存在着别的几个缺点,比如不能使用 try catch 捕获错误,不能直接 return。在接下来的几小节中,我们将来学习通过别的技术解决这些问题。
# Promise
涉及面试题:Promise 的特点是什么,分别有什么优缺点?什么是 Promise 链?Promise 构造函数执行和 then 函数执行有什么区别?
Promise
翻译过来就是承诺的意思,这个承诺会在未来有一个确切的答复,并且该承诺有三种状态,分别是:
- 等待中(pending)
- 完成了 (resolved)
- 拒绝了(rejected)
这个承诺一旦从等待状态变成为其他状态就永远不能更改状态了,也就是说一旦状态变为
resolved
后,就不能再次改变
new Promise((resolve, reject) => {
resolve("success");
// 无效
reject("reject");
});
当我们在构造 Promise
的时候,构造函数内部的代码是立即执行的
new Promise((resolve, reject) => {
console.log("new Promise");
resolve("success");
});
console.log("finifsh");
// new Promise -> finifsh
Promise
实现了链式调用,也就是说每次调用 then
之后返回的都是一个 Promise
,并且是一个全新的 Promise
,原因也是因为状态不可变。如果你在 then
中 使用了 return
,那么 return
的值会被 Promise.resolve()
包装
Promise.resolve(1)
.then((res) => {
console.log(res); // => 1
return 2; // 包装成 Promise.resolve(2)
})
.then((res) => {
console.log(res); // => 2
});
当然了,Promise
也很好地解决了回调地狱的问题,可以把之前的回调地狱例子改写为如下代码:
ajax(url)
.then((res) => {
console.log(res);
return ajax(url1);
})
.then((res) => {
console.log(res);
return ajax(url2);
})
.then((res) => console.log(res));
前面都是在讲述 Promise
的一些优点和特点,其实它也是存在一些缺点的,比如无法取消 Promise
,错误需要通过回调函数捕获。
# async 及 await
涉及面试题:async 及 await 的特点,它们的优点和缺点分别是什么?await 原理是什么?
一个函数如果加上 async
,那么该函数就会返回一个 Promise
async function test() {
return "1";
}
console.log(test()); // -> Promise {<resolved>: "1"}
async
就是将函数返回值使用 Promise.resolve()
包裹了下,和 then
中处理返回值一样,并且 await
只能配套 async
使用
async function test() {
let value = await sleep();
}
async 和 await 可以说是异步终极解决方案了,相比直接使用 Promise 来说,优势在于处理 then 的调用链,能够更清晰准确的写出代码,毕竟写一大堆 then 也很恶心,并且也能优雅地解决回调地狱问题。当然也存在一些缺点,因为 await 将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低。
async function test() {
// 以下代码没有依赖性的话,完全可以使用 Promise.all 的方式
// 如果有依赖性的话,其实就是解决回调地狱的例子了
await ajax(url);
await ajax(url1);
await ajax(url2);
}
# 常用定时器函数
涉及面试题:setTimeout、setInterval、requestAnimationFrame 各有什么特点?
异步编程当然少不了定时器了,常见的定时器函数有 setTimeout、setInterval、requestAnimationFrame。我们先来讲讲最常用的 setTimeout,很多人认为 setTimeout 是延时多久,那就应该是多久后执行。
其实这个观点是错误的,因为 JS 是单线程执行的,如果前面的代码影响了性能,就会导致 setTimeout 不会按期执行。当然了,我们可以通过代码去修正 setTimeout,从而使定时器相对准确。
接下来我们来看 setInterval,其实这个函数作用和 setTimeout 基本一致,只是该函数是每隔一段时间执行一次回调函数。
通常来说不建议使用 setInterval。第一,它和 setTimeout 一样,不能保证在预期的时间执行任务。第二,它存在执行累积的问题,请看以下伪代码
function demo() {
setInterval(function() {
console.log(2);
}, 1000);
// 耗时操作
sleep(2000);
}
demo();
以上代码在浏览器环境中,如果定时器执行过程中出现了耗时操作,多个回调函数会在耗时操作结束以后同时执行,这样可能就会带来性能上的问题。
如果你有循环定时器的需求,其实完全可以通过 requestAnimationFrame 来实现
首先 requestAnimationFrame 自带函数节流功能,基本可以保证在 16.6 毫秒内只执行一次(不掉帧的情况下),并且该函数的延时效果是精确的,没有其他定时器时间不准的问题。
<div
id="a"
style="width: 100px;
height: 100px;
background-color: red;
position: absolute;"
></div>
<script>
/**
* 定义一个div盒子,动画设置向右平移 500px
*
* 注:这里使用的requestAnimaFrame和cancelAnimFrame方法名为自己封装的方法
*/
let start = 0,
end = 500,
ele = document.querySelector("#a"),
req;
let f = function() {
start += 10;
ele.style.left = start + "px";
// 距离页面左边的距离
let left = ele.getBoundingClientRect().left;
if (left < end) {
req = requestAnimaFrame(f);
} else {
cancelAnimFrame(req);
}
};
</script>
# Event Loop
在前面我们了解了 JS 异步相关的知识。在实践的过程中,你是否遇到过以下场景,为什么 setTimeout 会比 Promise 后执行,明明代码写在 Promise 之前。这其实涉及到了 Event Loop 相关的知识,这一章节我们会来详细地了解 Event Loop 相关知识,知道 JS 异步运行代码的原理,并且这一部分也是面试常考知识点。
# 进程与线程
涉及面试题:进程与线程区别?JS 单线程带来的好处?
相信大家经常会听到 JS 是单线程执行的,但是你是否疑惑过什么是线程?
讲到线程,那么肯定也得说一下进程。本质上来说,两个名词都是 CPU 工作时间片的一个描述。
进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序。线程是进程中的更小单位,描述了执行一段指令所需的时间。
把这些概念拿到浏览器中来说,当你打开一个 Tab 页时,其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等等。当你发起一个请求时,其实就是创建了一个线程,当请求结束后,该线程可能就会被销毁。
上文说到了 JS 引擎线程和渲染线程,大家应该都知道,在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这其中的原因是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。这其实也是一个单线程的好处,得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间,没有锁的问题的好处。当然前面两点在服务端中更容易体现,对于锁的问题,形象的来说就是当我读取一个数字 15 的时候,同时有两个操作对数字进行了加减,这时候结果就出现了错误。解决这个问题也不难,只需要在读取的时候加锁,直到读取完毕之前都不能进行写入操作。
# 执行栈
涉及面试题:什么是执行栈?
可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。
执行栈可视化 当开始执行 JS 代码时,首先会执行一个 main 函数,然后执行我们的代码。根据先进后出的原则,后执行的函数会先弹出栈,在图中我们也可以发现,foo 函数后执行,当执行完毕后就从栈中弹出了。
平时在开发中,大家也可以在报错中找到执行栈的痕迹
function foo() {
throw new Error("error");
}
function bar() {
foo();
}
bar();
大家可以在上图清晰的看到报错在 foo 函数,foo 函数又是在 bar 函数中调用的。
当我们使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题
function bar() {
bar();
}
bar();
# 浏览器中的 Event Loop
涉及面试题:异步代码执行顺序?解释一下什么是 Event Loop ?
大家知道了当我们执行 JS 代码的时候其实就是往执行栈中放入函数,那么遇到异步代码的时候该怎么办?其实当遇到异步的代码时,会被挂起并在需要执行的时候加入到 Task(有多种 Task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为。
# 事件循环
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。下面来看以下代码的执行顺序:
console.log("script start");
async function async1() {
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2 end");
}
async1();
setTimeout(function() {
console.log("setTimeout");
}, 0);
new Promise((resolve) => {
console.log("Promise");
resolve();
})
.then(function() {
console.log("promise1");
})
.then(function() {
console.log("promise2");
});
console.log("script end");
// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout
注意:新的浏览器中不是如上打印的,因为 await 变快了,具体内容可以往下看
首先先来解释下上述代码的 async 和 await 的执行顺序。当我们调用 async1 函数时,会马上输出 async2 end,并且函数返回一个 Promise,接下来在遇到 await 的时候会就让出线程开始执行 async1 外的代码,所以我们完全可以把 await 看成是让出线程的标志。
然后当同步代码全部执行完毕以后,就会去执行所有的异步代码,那么又会回到 await 的位置执行返回的 Promise 的 resolve 函数,这又会把 resolve 丢到微任务队列中,接下来去执行 then 中的回调,当两个 then 中的回调全部执行完毕以后,又会回到 await 的位置处理返回值,这时候你可以看成是 Promise.resolve(返回值).then(),然后 await 后的代码全部被包裹进了 then 的回调中,所以 console.log('async1 end') 会优先执行于 setTimeout。
所以 Event Loop 执行顺序如下所示:
首先执行同步代码,这属于宏任务 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行 执行所有微任务 当执行完所有微任务后,如有必要会渲染页面 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是 setTimeout 中的回调函数 所以以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务,所以会有以上的打印。
微任务包括 process.nextTick ,promise ,MutationObserver。
宏任务包括 script , setTimeout ,setInterval ,setImmediate ,I/O ,UI rendering。
这里很多人会有个误区,认为微任务快于宏任务,其实是错误的。因为宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话才会先执行微任务。
← 原型与继承 JS-Web-API →