一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第12天,点击查看活动详情。
生活中的同步与异步
计算机里面的同步和异步,其实和生活中的同步异步类似。
比如你去餐馆点餐,付款只需要1分钟,但是等菜上来需要10分钟,如果是同步的话,那么在这十分钟里面你不能走开,只能和服务员大眼瞪小眼,后面的顾客心里已经要骂娘了,同时老板也会亏死。如果是异步的话,当你点完餐之后就可以去干其他事情,不用一直站在哪里阻碍后面的人点餐,等菜做好之后会通知你来取餐。
在这个过程中,点餐和取餐分离到两条任务线里,点餐1分钟1分钟地点,出餐慢慢来出,只要出完之后通知到取餐人就行了。这就是异步的智慧。
js中的同步和异步
通过上面的故事来理解JS中的同步和异步:
同步,就是说后一个任务必须严格等待前一个任务执行完再执行,任务的执行顺序和排列顺序是高度一致的(上一个人取到餐之前,下一个人不许点餐);
异步,则恰恰相反,任务的执行顺序不必遵循排列顺序。比如说前一个任务就算没执行完(菜还没出餐),也没关系,先执行下一个任务就好(让下一个人先点餐),等前一个任务的执行结果啥时候出来了(菜好了),我再把它临时穿插进来这其中,异步模式至关重要。
对我们前端来说,和餐饮这样的服务行业一样,用户体验就是命。餐馆让客人苦等半天吃个饭,你这个店要挨骂;我们页面让用户苦等2分钟等一个表单提交的返回结果,这样页面就是一个卡死的状态,同样是极不友好的一种交互体验。假如我们的主线程里,充斥着用户事件、ajax任务等高耗时的操作,这种情况下还不采用异步方案,页面的卡顿甚至卡死将是不可避免的。
异步进化史
从整体上来说,异步方案经历了如下的四个进化阶段:回调函数 —> Promise —> Generator —> async/await。
"回调函数"时期
所谓"回调函数"时期,这里严格来说指代的其实是 Promise 出现前的这么一个相对早期的阶段。在这个阶段里,回调是异步最常见、最基本的实现手段,却不是唯一的招数 —— 像事件监听、发布订阅这样的方式,也经常为我们所用。
事件监听
document.getElementById('#myDiv').addEventListener('click', function (e) {
console.log('我被点击了')
}, false);
通过给id为myDiv的一个元素绑定了点击事件的监听函数,我们把任务的执行时机推迟到了点击这个动作发生时。此时,任务的执行顺序与代码的编写顺序无关,只与点击事件有没有被触发有关。
发布订阅
发布订阅,是一种相当经典的设计模式,比如说我们想在名为trigger的信号被触发后,做点事情,我们可以订阅trigger信号:
function consoleTrigger() {
console.log('trigger事件被触发')
}
jQuery.subscribe('trigger',consoleTrigger);
这样当trigger被触发时,上面对应的回调任务就会执行了:
function publishTrigger() {
jQuery.publish('trigger');
}
// 2s后,publishTrigger方法执行,trigger信号发布,consoleTrigger就会执行了
setTimeout(publishTrigger, 2000)
这种模式和事件监听下的异步处理非常相似,它们都把任务执行的时机和某一事件的发生紧密关联了起来。
回调函数
回调函数用的最多的地方其实是在 Node 环境下,我们难免需要和引擎外部的环境有一些交流:比如说我要利用网络模块发起请求、或者要对外部文件进行读写等等。这些任务都是异步的,我们通过回调的形式来实现它们。
// -- 异步读取文件
fs.readFile(filePath,'utf-8',function(err,data){
if(err) {
throw err;
}
console.log(data);// 输出文件内容
});
当回调只有一层的时候,看起来感觉没什么问题。但是一旦回调函数嵌套的层级变多了之后,代码的可读性和可维护性将面临严峻的挑战。在这样的时代背景下,Promise 出现了。
Promise
const https = require('https');
function httpPromise(url){
return new Promise(function(resolve,reject){
https.get(url, (res) => {
resolve(data);
}).on("error", (err) => {
reject(error);
});
})
}
httpPromise().then(function(data){
}).catch(function(error){})
Promise会接收一个执行器,在这个执行器里,我们需要把目标的异步任务给"填进去"。在 Promise 实例创建后,执行器里的逻辑会立刻执行,在执行的过程中,根据异步返回的结果,决定如何使用 resolve 或 reject 来改变 Promise实例的状态。 Promise 实例有三种状态:
- pending 状态,表示进行中。这是 Promise 实例创建后的一个初始态;
- fulfilled 状态,表示成功完成。这是我们在执行器中调用 resolve 后,达成的状态;
- rejected 状态,表示操作失败、被拒绝。这是我们在执行器中调用 reject后,达成的状态。
当我们用resolve切换到了成功态后,Promise的逻辑就会走到 then 中的传入的方法里去;用 reject 切换到失败态后,Promise 的逻辑就会走到 catch 传入的方法中去。
这样的逻辑,本质上与回调函数中的成功回调和失败回调无异。但这种写法毫无疑问大大地提高了代码的质量。
Generator
Generator 一个有利于异步的特性是,它可以在执行中被中断、然后等待一段时间再被我们唤醒。通过这个"中断后唤醒"的机制,我们可以把 Generator看作是异步任务的容器,利用 yield 关键字,实现对异步任务的等待。
比如咱们用 Promise 链式调用这么写的例子:
httpPromise(url1)
.then(res => {
console.log(res);
return httpPromise(url2);
})
.then(res => {
console.log(res);
return httpPromise(url3);
})
.then(res => {
console.log(res);
return httpPromise(url4);
})
.then(res => console.log(res));
其实完全可以用 yield 来这么写:
function* httpGenerator() {
let res1 = yield httpPromise(url1)
console.log(res1);
let res2 = yield httpPromise(url2)
console.log(res2);
let res3 = yield httpPromise(url3)
console.log(res3);
let res4 = yield httpPromise(url4)
console.log(res4);
}
就单纯看这种写法,是不是比 Promise 链式调用更好看、更清晰了?这时候你一眼看过去就知道这段逻辑在干嘛,而不必再对所谓的"链"作分析。干干净净、一目了然!
Async/Await
它的用法非常简单。首先,我们用 async 关键字声明一个函数为"异步函数":
async function httpRequest() {
let res1 = await httpPromise(url1)
console.log(res1)
}
这个 await 关键字很绝,它的意思就是“我要异步了,可能会花点时间,后面的语句都给我等着”。当我们给 httpPromise(url1) 这个异步任务应用了 await 关键字后,整个函数会像被"yield"了一样,暂停下来,直到异步任务的结果返回后,它才会被"唤醒",继续执行后面的语句。
是不是觉得这个"暂停"、"唤醒"的操作,和 generator 异步非常相似?事实上,async/await 本身就是 generator 异步方案的语法糖。它的诞生主要就是为了这个单纯而美好的目的——让你写得更爽,让你写出来的代码更美。
注意:async/await 和 generator 方案,相较于 Promise 而言,有一个重要的优势:Promise 的错误需要通过回调函数捕获,try catch 是行不通的。而 async/await 和 generator 允许 try/catch。