JS异步及Promise基础
一、从浏览器运行机制说起
来破案啦,常说的“多线程的浏览器、单线程的Javascript”到底是什么意思?单线程的Javascript是否真的需要抽身去计时、去发http请求?第一部分我们重新从浏览器运行机制开始认识一下JavaScript异步!
1.1 多进程的浏览器
浏览器是多进程的,我们每打开标签页就会产生一个进程,因此打开的标签页越多,进程就越多,对CPU的消耗就越严重,从而出现卡顿。以Chrome浏览器为例,我们可以通过浏览器右侧设置-更多工具-任务管理器查看当前浏览器进程。Chrome浏览器主要进程包括1个浏览器进程(Browser进程)、1个GPU进程、1个网络进程、多个渲染进程和多个插件进程。
- 浏览器主进程(Browser进程): 主要负责界面显示、用户交互、子进程管理,同时提供存储等功能
- GPU进程: GPU 进程的使用初衷是为了实现3D CSS的效果,只是后来网页、Chrome的UI界面都选择采用 GPU进行绘制,使得GPU成为浏览器的普遍需求。终于,Chrome 在其多进程架构上也引入了 GPU 进程
- 网络进程: 主要负责页面的网络资源加载
- 插件进程: 主要是负责插件的运行,因为插件容易崩溃,所以需要通过单独的插件进程将其隔离,以保证插件进程崩溃不会对浏览器和页面造成影响
- 渲染进程: 即我们通常所说的浏览器内核,其内部是多线程的,每个标签页至少有一个渲染进程,并且多个渲染进程之间互不影响,主要负责页面渲染、脚本执行、事件处理等,我们常说的JavaScript V8引擎就运行在这个进程中,也是本文的重点
1.2 多线程的渲染进程
浏览器的渲染进程是多线程的,主要由Javascript引擎线程、GUI渲染线程、事件监听线程、定时器线程、异步http请求线程等组成。
-
Javascript引擎线程: 是Javascript内核,主要负责解析并运行Javascript脚本,不管打开了多少个标签页,一个浏览器只能有一个Javascript引擎线程运行JavaScript代码,因此JavaScript是单线程的。而异步是由子线程完成的,Javascript引擎负责调度子线程。
-
GUI渲染线程: 负责浏览器界面渲染,包括解析HTML、CSS、构建DOM树、RenderObject树、布局、页面绘制等。注意Javascript引擎线程与GUI渲染线程是互斥的,GUI更新会被保存在一个队列中等到Javascript引擎空闲时才会被执行。
-
事件监听线程: 负责对事件进行处理,归属于浏览器而不是Javascript引擎,对Javascript引擎起到辅助作用,用来控制事件循环。它管理着一个事件队列(Task Queue),当JavaScript执行碰到诸如事件绑定、Ajax异步请求、setTimeOut等,会把它交给相应线程处理(如定时器线程、异步http请求线程),拿到结果后将其回调对应任务添加到事件队列的队尾,排队等待Javascript引擎的处理。
-
定时器线程: 我们常用的**
setInterval
与setTimeout
所在线程**。注意,浏览器的定时计数器并不是由Javascript引擎计数的,因为JavaScript引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确性,因此通过单独线程来计时并触发定时,计时完毕后,添加到事件队列中,等待Javascript引擎空闲后执行。W3C在HTML标准中规定,规定要求setTimeout
中低于4ms的时间间隔算为4ms,也就是说0ms也算是4ms。 -
**异步http请求线程:**负责处理http请求。在XMLHttpRequest连接后是通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中,再由JavaScript引擎执行。
1.3 事件循环
简单了解过浏览器运行机制过后,为什么说“多线程的浏览器、单线程的Javascript”应该已经相对明白了,现在我们就从JavaScript事件循环(Event Loop)开始,重新认识一下异步。
1.3.1 初识事件循环机制
目前我们已经明确了Javascript是单线程的,但是这样的话,如果我们需要执行一些高耗时的操作考虑会造成阻塞,为此Javascript将任务分为同步任务和异步任务两类,同步任务在主线程上运行(这里的主线程之的是JavaScript引擎线程),形成执行栈。而在主线程之外,事件监听线程管理着一个事件队列,只要异步任务执行完毕,就会检查其是否存在回调,并把回调加入到任务队列中,当Javascript完成了同步任务清空执行栈之后就空闲下来了,JavaScript就会读取任务队列中的异步任务回调,并添加到执行栈中开始执行。流程图如下:
1.3.2 ES6中的事件循环机制
- 1. 两种异步任务类型
ES6在事件循环的概念之上新增加了任务队列(Job Queue) 的新概念,它将异步任务分成宏任务和微任务两种类型。宏任务Task(macrotask) 我们姑且叫它为正常的任务,它是由宿主(浏览器/Node)发起的,追加到下一轮事件循环。而微任务Jobs(microtask) 可以说是充了SVIP,它由JavaScript自身发起,追加到本轮事件循环,意味着微任务可以插队,在本轮事件循环结束前执行,不用等到下一轮。
- 2. 流程图
- 3. 常见微任务和宏任务
宏任务(macrotask) | 微任务(microtask) |
---|---|
setTimeout | Promise.[ then/catch/finally ]、async(本质也是Promise) |
setInterval | Object.observe |
I/O任务 | MutationObserver(浏览器环境) |
setImmediate(nodejs) | process.nextTick(nodejs) |
script代码块 | queueMicrotask |
PS: 这里把 script 代码块想象成多个中的一个,那么会执行第一个 script 代码块中的同步代码开启宏任务,并清空微任务队列,然后再去执行第二个 script 代码块中的代码,这样就可以很好的理解了。也就是说,在一个宏任务中开启执行清理全部微任务,然后开启下一个宏任务,这样也就理解了微任务是在宏任务中追加开启的这个事实了。
二、异步回调有什么问题
从第一部分,我们已经基本了解了JavaScript异步执行机制,接下来我们讲讲ES6之前最传统的异步解决方案之一——回调(callback),从回调存在的问题理解Promise的出现原因
回调是编写和处理JavaScript程序异步逻辑的最常方式,是JavaScript最基础的异步模式。MDN中这样定义回调函数:回调函数是作为参数传给另一个函数的函数,然后通过在外部函数内部调用该回调函数以完成某种操作。太绕了!换句话说,回调函数是一个函数,他会在外部函数完成执行其他动作后再最后执行。从第一部分内容来看,回调确实可以解决单线程JavaScript线程阻塞的问题,但是它也存在许许多多的问题。
2.1 回调地狱
考虑以下代码,这就是传说中的回调地狱,一层一层的往下嵌套,形似倒三角形状,因此也叫毁灭金字塔,它使得代码的逻辑变得难以理解和维护。
setTimeout(function () {
console.log('地狱1层到了,请不要倚靠车门,注意安全')
setTimeout(function () {
console.log('地狱2层到了,请不要倚靠车门,注意安全')
setTimeout(function () {
console.log('地狱3层到了,请不要倚靠车门,注意安全')
// ...还有15层呢
}, 1000)
}, 1000)
}, 1000)
2.2 控制反转
控制反转顾名思义就是了本来想控制它,结果反过来被控制了,我们的程序考虑需要用到许多第三方工具,你把回调传给第三方工具,让它的去执行回调,那么问题来了,你并不知道你的回调函数会不会调用过早或者过晚?会不会调用过多或过多?有没有用到提供的参数?会不会吞掉出现的错误? 这就像了手里捏着一个定时炸弹,而你却把控制权交到了人家手里,有时候会给我们带来意想不到的危害。
三、认识Promise
回调函数存在诸多问题,为此ES 6推出了Promise,有效的避免了回调地狱的出现,打开了异步世界的新大门
所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。Promise 提供统一的 API,各种异步操作都可以用同样的方法进行处理。
3.1 先来谈谈Promise的两个特点
在理解Promise如何规避回调函数产生的问题前,我们应该先了解Promise这两个特点:
- 对象的状态不受外界影响。
Promise
对象代表一个异步操作,有且只有三种状态:pending
(进行中)、fulfilled
(已成功)和rejected
(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态 - 一旦状态改变,就不会再变,任何时候都是这个状态,都是这个结果。
Promise
对象的状态改变,只有两种可能:从pending
变为fulfilled
和从pending
变为rejected
。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。
3.2 Promise如何避免回调函数的诸多问题
3.2.1 调用时间问题
回调分为异步回调和同步回调,典型同步回调如forEach
,异步回调如setTimeout
。在回调函数中,会在调用完成时得到结果,这个时间点你无法把控,它可能是同步的,也可能是异步的,尤其是在调用一些第三方库是根本无法确定。而在Pormise中,你无需担心这个问题,因为即使Promise被立即resolved,它也无法被同步的观察到,不可能存在调用过早的问题,而作为微任务的then
,会在本次宏任务结束后、下一次宏任务启动前插队运行,也就不会出现调用过晚的问题了。
3.2.2 调用次数问题
回调被正确调用的次数应该是一次。而根据Promise的两个特点我们知道,回调在决议是会且只会调用了注册的resolve
和reject
两个回调之一,并且只接受第一次决议的结果,就成为了不可变值,这样就保证了回调调用的次数。但是若Promise永远不被决议呢?当然也有相应的解决方案,Promiset提供了竞态接口来帮助我们解决这个问题,在之后的静态方法Promise.race
中会介绍。
3.2.3 参数问题
resolve
只接收一个或者零个参数,当不传参时,它会决议为undefined
,而如果传入多个参数,生效的只会是第一个参数,其他参数都会被忽略,如果要传入多个参数,需要传入数组或对象,使用解构来进行传递。
3.2.4 错误或异常吞并问题
try...catch
在异步代码中显然无法发挥作用,但Promise提供了catch
方法供我们进行错误处理,当然也可以使用then
的第二个参数处理,将错误暴露出去,但从美观和思维模式上来说并不推荐。
3.2.4 回调地狱
最烦人的是回调地狱,但是我们的Promise会通过.then
的链式流完美的将异步流程以同步操作式的流程表达出来,你甚至可以像写同步代码一样写异步代码,自然也就可以规避了它
3.3 使用Promise
3.3.1 new Promise
在ES6中,我们可以提供构造函数Promise来创建Promise实例,它有两个参数,通常用resolve
和reject
来表示,它们由JavaScript引擎提供,无需自己部署,resolve
可以将promise的状态由pending
转变为fulfilled
**或rejected
(比如传入的参数是另一个promise,在then中会举例)**并将结果作为参数传递出去,reject
则可以将promise的状态由pending
转变为rejected
,并将错误暴露出去,基本构造方法如下:
const promise = new Promise(function(resolve, reject) {
// 在这里干些什么,如果是同步代码会加入执行栈立即执行
if (/* 操作成功 */){
resolve(value)
} else {
reject(error)
}
})
值得注意的是,resolve
和reject
不会终止函数的执行,所以为了避免意外,一般会这样用:return resolve(...)
/ return reject(...)
3.3.2 Promise.prototype.then
- 基本用法
then
方法接受两个回调函数作为参数,第一个回调函数是Promise
对象的状态变为fulfilled
时调用,第二个回调函数是Promise
对象的状态变为rejected
时调用。但这两个函数都是可选的。
promise.then(function(value) {
// 成功了,做些什么
}, function(error) {
// 失败了,捕捉到了错误
})
- 回调函数参数类型:正常值、promise、thenable
这两个回调函数都接受Promise对象传出的值作为参数,这个参数可以是正常值,也可以是Promise实例,如果是Promise实例或者thenable值则会递归的展开,等到前面的Promise实例决议后才会决议。
何为thenable
可以使用鸭子类型检测判断:
// 长得像只鸭子?
// 一个有then函数作为属性的对象或函数
function isThenable(p) {
if (
p !== null &&
(typeof p === 'object' || typeof p === 'function') &&
typeof p.then === 'function'
) {
return true
} else {
return false
}
}
举个例子:
// p2会等p1决议后才会决议
const p1 = new Promise(function (resolve, reject) {
resolve(2)
})
const p2 = new Promise(function (resolve, reject) {
resolve(p1)
})
p2.then(console.log) // 2
// 如果p1出错了,p2也会
const p1 = new Promise(function (resolve, reject) {
// ...
reject("出错了")
})
const p2 = new Promise(function (resolve, reject) {
// ...
resolve(p1)
})
p2.then() // Uncaught (in promise) 出错了,并且p1、p2的状态都变成了rejected!
- 执行顺序:同步代码、异步代码和决议
promise在创建时,同步代码会立即执行,异步代码会根据宏任务还是微任务来判断执行顺序,而then
会在决议后的那一轮轮循环前追加到最后运行,举个例子:
// 以下打印顺序为“1 2 3 4”
let promise = new Promise(function(resolve, reject) {
console.log('1')
setTimeout(console.log,0,'4')
resolve()
})
promise.then(function() {
console.log('3')
})
console.log('2')
// 以下打印顺序为“1 2 4 3”
let promise = new Promise(function(resolve, reject) {
console.log('1')
setTimeout(m => {
resolve()
console.log(m)
},0,'4')
})
promise.then(function() {
console.log('3')
})
console.log('2')
- 链式调用
注意,then
方法的返回值是一个新的Promise对象,这就意味着我们可以链式的调用它!这样的话我们就可以像写同步代码一样写出异步代码
new Promise(function (resolve, reject) {
resolve(1)
}).then(v => {
console.log(v)
return 2
}).then(console.log)
// 1
// 2
Promise.prototype.catch
在Promise中,reject
方法可以理解为抛出错误,而Promise.prototype.catch
等价于.then(null, rejection)
或.then(undefined, rejection)
,用于捕捉错误。但是一般来说,我们不会在then()
方法里面使用第二个参数,而总是使用catch
方法,因为更加接近同步try…catch写法,视觉上也更加直观。我们可以通过try…catch来理解它:
// 不用catch而使用try...catch
const p = new Promise(function(resolve, reject) {
try {
throw new Error('发生了错误');
} catch(e) {
reject(e)
}
})
promise.catch(function(error) {
console.log(error)
})
// 使用catch,更加简洁
const p = new Promise(function(resolve, reject) {
reject(new Error('发生了错误'))
})
promise.catch(function(error) {
console.log(error)
})
但是需要注意两点:
- ①如果 Promise 状态已经变成
fulfilled
,再抛出错误是无效的
const promise = new Promise(function(resolve, reject) {
resolve('完成了决议,结果为fulfilled')
throw new Error('test')
})
promise
.then(function(value) { console.log(value) }) // 完成了决议,结果为fulfilled
.catch(function(error) { console.log(error) }) // 不会运行这里的代码
- ②如果没有使用
catch()
方法指定错误处理的回调函数,Promise 对象抛出的错误不会传递到外层代码,即不会有任何反应,但浏览器会打印错误
const p1 = new Promise(function (resolve, reject) {
// 写一个错误,改变常量x
const x = 1
x = 2
// 决议
resolve("fulfilled")
})
console.log('我还是运行了,想不到吧')
// 我还是运行了,想不到吧
// Uncaught (in promise) TypeError: Assignment to constant variable. 虽然报错,但无法阻止代码继续稳健运行
3.3.3 静态方法Promise.all(iterable)
- 这个方法返回一个新的promise对象,该promise对象在iterable参数对象里所有的promise对象状态都为
fulfilled
的时候才为fulfilled
,一旦有任何一个iterable里面的promise对象状态为rejected
,则立即让该promise对象状态转变为rejected
- 这个新的promise对象在触发
fulfilled
状态以后,会把一个包含iterable里所有promise返回值的数组作为成功回调的返回值,顺序跟iterable的顺序保持一致 - 如果这个新的promise对象触发了
rejected
状态,它会把iterable里第一个触发失败的promise对象的错误信息作为它的失败错误信息
const promises = [1, 2, 3].map(function (id) {
return request('https://juejin.cn/post/${id}');
});
Promise.all(promises).then(function (readArticles) {
// ...
}).catch(function(handleError){
// ...
});
3.3.4 静态方法Promise.race(iterable)
当iterable参数里的任意一个子promise被触发状态转变为fulfilled
或rejected
后,父promise马上也会用子promise的成功返回值或失败详情作为参数调用父promise绑定的相应句柄,并返回该promise对象。可以用来处理超时或者说promise永远不决议的情况,给它应该特定的时间,每决议就返回另一个promise:
// timeoutPromise辅助函数
function timeoutPromise(delay){
return new Promise(function(resolve, reject) {
setTimeout(function(){
reject("一不小心就超时了")
})
})
}
// 利用竞态Promise.race处理超时情况
Promise.race([
foo(),
timeoutPromise(3000)
]).then(
function () {
// foo在约定时间类完成决议
},
function (err) {
// foo()被拒绝或超时,查看err了解是什么情况
}
)
3.3.5 静态方法Promise.resolve(value)
返回一个状态由给定value决定的Promise对象。注意,返回的promise状态不一定是fulfilled
的!
-
参数是一个Promise实例:
将不做任何修改、原封不动地返回这个实例
-
参数是一个thenable对象:
将这个对象转为 Promise 对象,然后就立即执行
thenable
对象的then()
方法,其状态可能转变为fulfilled
/rejected
-
参数不是具有
then()
方法的对象,或根本就不是对象:返回一个新的 Promise 对象,状态为
fulfilled
-
不传递任何参数:
直接返回一个
fulfilled
状态的 Promise 对象
// 通过 Promise.resolve 返回 rejected 状态的例子
let rejectThenable = {
then: (resolve, reject) => {
reject("reject reason")
}
}
let rejectedPromise = Promise.resolve(rejectThenable)
3.3.6 静态方法Promise.reject(reason)
返回一个状态为rejected
的Promise对象,并将给定的失败信息原封不动的传递给对应的处理方法
const promise = Promise.reject('一不小心出错了')
// 等价于
const promise = new Promise((resolve, reject) => reject('一不小心出错了'))
promise.catch(error => {
console.log(error) // 一不小心出错了
})
3.3.7 Promise.allSettled(iterable)
ES2020引入,该方法返回一个promise,该promise在所有promise定型后完成。并带有一个对象数组,每个对象对应每个promise的结果。返回的新的 Promise 实例,一旦发生状态变更,状态总是fulfilled
。状态变成fulfilled
后,它的回调函数会接收到一个数组作为参数,该数组的每个成员对应前面数组的每个 Promise 对象。
const resolved = Promise.resolve('成功了')
const rejected = Promise.reject('失败了')
const allSettledPromise = Promise.allSettled([resolved, rejected])
allSettledPromise.then(res => {
console.log(res)
})
// [
// { status: 'fulfilled', value: '成功了' },
// { status: 'rejected', reason: '失败了' }
// ]
3.3.8 Promise.any(iterable)
ES2021引入,接收一个Promise对象的集合,当其中的一个promise状态变为fulfilled
时,就返回那个成功的promise,如果所有promise实例都变成rejected
状态,这返回的promise实例也会变成rejected
状态,并可以捕获错误AggregateError: All promises were rejected
// 成功时
const pErr = new Promise((resolve, reject) => {
reject("总是失败")
})
const pSlow = new Promise((resolve, reject) => {
setTimeout(resolve, 500, "最终完成")
})
const pFast = new Promise((resolve, reject) => {
setTimeout(resolve, 100, "很快完成")
})
Promise.any([pErr, pSlow, pFast]).then((value) => {
console.log(value) // "很快完成"
})
// 失败时
const pErr = new Promise((resolve, reject) => {
reject('总是失败')
})
Promise.any([pErr]).catch((err) => {
console.log(err) // "AggregateError: No Promise in Promise.any was resolved"
})
3.3.9 两个附加方法done
和finally
这两个方法不在ES6中,需要自行部署,但十分有用
Promise.prototype.done
Promise
对象的回调链中,就算你在最后加入catch
,如果你catch
内本身出现了错误还是无法被捕捉并暴露出去,因此部署一个done
方法,它总是处于回调链的尾端,保证抛出任何可能出现的错误。其实现十分简单:
Promise.prototype.done = function (onFulfilled, onRejected) {
this
.then(onFulfilled, onRejected)
.catch(function (reason) {
// 在最后抛出一个全局错误
setTimeout(() => {
throw reason
}, 0)
})
}
Promise.prototype.finally
finally
方法用于指定不管Promise
对象最后状态如何,都会执行的操作。它与done
方法的最大区别,它接受一个普通的回调函数作为参数,该函数不管怎样都必须执行。其实现也非常简单:
Promise.prototype.finally = function (callback) {
let P = this.constructor
return this.then(
value => P.resolve(callback()).then(() => value),
reason =>
P.resolve(callback()).then(() => {
throw reason
})
)
}
四、Promise性能如何
4.1 毋庸置疑的结果
把基本的基于回调的异步任务链与 Promise 链中需要移动的部分数量进行比较。很显然, Promise 进行的动作要多一些,这自然意味着它也会稍慢一些。但是与之相对的你得到了大量的内建的可信任性和更易于理解、更加直观的异步模式,规避了回调模式产生的回调地狱和控制反转问题,推荐使用。
参考书籍
《你不知道的JavaScript中卷》
《ES6标准入门》
《MDN web docs》