众所周知,JS 是一门单线程语言。
JS 在单线程上执行所有操作,虽然是单线程,但是能够高效地解决问题,并能给我们带来一种“多线程”的错觉,这其实是通过使用一些比较合理的数据结构来达到此效果的。
JS 异步运行机制
浏览器的 Eventloop
1.调用堆栈(call stack)负责跟踪所有要执行的代码。每当一个函数执行完成时,就会从堆栈中弹出(pop)该执行完成函数;如果有代码需要进去执行的话,就进行 push 操作,如下图所示:
2.事件队列(event queue)负责将新的 function 发送到队列中进行处理。它遵循 queue 的数据结构特性,先进先出,在该顺序下发送所有操作以进行执行。如下图所示:
3.每当调用事件队列(event queue)中的异步函数时,都会将其发送到浏览器 API。根据从调用堆栈收到的命令,API 开始自己的单线程操作。其中 setTimeout 方法就是一个比较典型的例子,在堆栈中处理 setTimeout 操作时,会将其发送到相应的 API,该 API 一直等到指定的时间将此操作送回进行处理。它将操作发送到哪里去呢?答案是事件队列(event queue)。这样,就有了一个循环系统,用于在 JavaScript 中运行异步操作。
JavaScript 语言本身是单线程的,而浏览器 API 充当单独的线程。
宏任务和微任务
EventLoop 内部通过两个队列来处理 事件队列 放进来的异步队列
- 以 setTimeout 为代表的任务被称为宏任务,放到宏任务队列(macrotask queue)中;
- Promise 为代表的任务被称为微任务,放到微任务队列(microtask queue)中。
日常工作中经常遇到的哪些是宏任务,哪些是微任务:
- macrotasks(宏任务): script(整体代码),setTimeout,setInterval,setImmediate,I/O,UI rendering,``event listner
- microtasks(微任务):
process.nextTick, Promises, Object.observe, MutationObserver
宏任务和微任务的执行顺序基本是,在 EventLoop 中,每一次循环称为一次 tick,主要的任务顺序如下:
- 执行栈选择最先进入队列的宏任务,执行其同步代码直至结束;
- 检查是否有微任务,如果有则执行直到微任务队列为空;
- 如果是在浏览器端,那么基本要渲染页面了;
- 开始下一轮的循环(tick),执行宏任务中的一些异步代码,例如 setTimeout 等。
Call-Stack(调用栈)也就是执行栈,它是一个栈的结构,符合先进后出的机制,每次一个循环,最先入队的宏任务,然后是微任务。不管微任务还是宏任务,它们只要按照顺序进入了执行栈,那么执行栈就还是按照先进后出的规则,一步一步执行。
因此根据这个原则,最先进行调用栈的宏任务,一般情况下都是最后返回执行的结果。
因此下面这段代码执行顺序,可以看到 setTimeout 的确最后执行了打印的结果
console.log('begin');
setTimeout(() => {
console.log('setTimeout')
}, 0);
new Promise((resolve) => {
console.log('promise');
resolve()
}).then(() => {
console.log('then1');
}).then(() => {
console.log('then2');
});
console.log('end');
begin
promise
end
then1
then2
setTimeout
JS 异步编程方案
回调函数
早些年为了实现 JS 的异步编程,一般都采用回调函数的方式,比如比较典型的事件的回调,或者用 setTimeout/ setInterval 来实现一些异步编程的操作。
fs.readFile(A, 'utf-8', function(err, data) {
fs.readFile(B, 'utf-8', function(err, data) {
fs.readFile(C, 'utf-8', function(err, data) {
fs.readFile(D, 'utf-8', function(err, data) {
//....
});
});
});
});
上面实现的代码就很容易形成回调地狱。回调实现异步编程的场景也有很多,比如:
- ajax 请求的回调;
- 定时器中的回调;
- 事件回调;
- Nodejs 中的一些方法回调。
Promise
为了解决回调地狱的问题,之后社区提出了 Promise 的解决方案,ES6 又将其写进了语言标准,采用 Promise 的实现方式在一定程度上解决了回调地狱的问题。 我们还是针对上面的这个场景来看下,这样的实现通过 Promise 改造之后是什么样的。
function read(url) {
return new Promise((resolve, reject) => {
fs.readFile(url, 'utf8', (err, data) => {
if(err) reject(err);
resolve(data);
});
});
}
read(A).then(data => {
return read(B);
}).then(data => {
return read(C);
}).then(data => {
return read(D);
}).catch(reason => {
console.log(reason);
});
从上面的代码可以看出,针对回调地狱进行这样的改进,可读性的确有一定的提升,优点是可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数,但是 Promise 也存在一些问题,即便是使用 Promise 的链式调用,如果操作过多,其实并没有从根本上解决回调地狱的问题,只是换了一种写法,可读性虽然有所提升,但是依旧很难维护。不过 Promise 又提供了一个 all 方法,对于这个业务场景的代码,用 all 来实现可能效果会更好。
function read(url) {
return new Promise((resolve, reject) => {
fs.readFile(url, 'utf8', (err, data) => {
if(err) reject(err);
resolve(data);
});
});
}
// 通过 Promise.all 可以实现多个异步并行执行,同一时刻获取最终结果的问题
Promise.all([read(A), read(B), read(C)]).then(data => {
console.log(data);
}).catch(err =>
console.log(err)
);
Generator
Generator 也是一种异步编程解决方案,它最大的特点就是可以交出函数的执行权,Generator 函数可以看出是异步任务的容器,需要暂停的地方,都用 yield 语法来标注。Generator 函数一般配合 yield 使用,Generator 函数最后返回的是迭代器。
实现一个简单的迭代器
function* gen() {
let a = yield 111;
console.log(a);
let b = yield 222;
console.log(b);
let c = yield 333;
console.log(c);
let d = yield 444;
console.log(d);
}
let t = gen();
t.next(1); //第一次调用next函数时,传递的参数无效,故无打印结果
t.next(2); // a输出2;
t.next(3); // b输出3;
t.next(4); // c输出4;
t.next(5); // d输出5;
async/await
ES6 之后 ES7 中又提出了新的异步解决方案:async/await,async 是 Generator 函数的语法糖,async/await 的优点是代码清晰(不像使用 Promise 的时候需要写很多 then 的方法链),可以处理回调地狱的问题。 async/await 写起来使得 JS 的异步代码看起来像同步代码,其实异步编程发展的目标就是让异步逻辑的代码看起来像同步一样容易理解。
function testWait() {
return new Promise((resolve,reject)=>{
setTimeout(function(){
console.log("testWait");
resolve();
}, 1000);
})
}
async function testAwaitUse(){
await testWait()
console.log("hello");
return 123;
// 输出顺序:testWait,hello
// 第十行如果不使用await输出顺序:hello , testWait
}
console.log(testAwaitUse());