当前位置: 首页 > news >正文

搞定JavaScript异步原理,深入学习Promise

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引擎的处理。

  • 定时器线程: 我们常用的**setIntervalsetTimeout所在线程**。注意,浏览器的定时计数器并不是由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就会读取任务队列中的异步任务回调,并添加到执行栈中开始执行。流程图如下:

JavaScript Event Loop

1.3.2 ES6中的事件循环机制
  • 1. 两种异步任务类型

ES6在事件循环的概念之上新增加了任务队列(Job Queue) 的新概念,它将异步任务分成宏任务和微任务两种类型。宏任务Task(macrotask) 我们姑且叫它为正常的任务,它是由宿主(浏览器/Node)发起的,追加到下一轮事件循环。而微任务Jobs(microtask) 可以说是充了SVIP,它由JavaScript自身发起,追加到本轮事件循环,意味着微任务可以插队,在本轮事件循环结束前执行,不用等到下一轮。

  • 2. 流程图

宏任务和微任务

  • 3. 常见微任务和宏任务
宏任务(macrotask)微任务(microtask)
setTimeoutPromise.[ then/catch/finally ]、async(本质也是Promise)
setIntervalObject.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的两个特点我们知道,回调在决议是会且只会调用了注册的resolvereject两个回调之一,并且只接受第一次决议的结果,就成为了不可变值,这样就保证了回调调用的次数。但是若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实例,它有两个参数,通常用resolvereject来表示,它们由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)}
})

值得注意的是,resolvereject不会终止函数的执行,所以为了避免意外,一般会这样用: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) {// 写一个错误,改变常量xconst x = 1x = 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被触发状态转变为fulfilledrejected后,父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 两个附加方法donefinally

这两个方法不在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.constructorreturn 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》

相关文章:

  • 北京网站建设多少钱?
  • 辽宁网页制作哪家好_网站建设
  • 高端品牌网站建设_汉中网站制作
  • 什么是TypeScript?
  • python测试开发基础---multiprocessing.Pool
  • 机器人笛卡尔空间轨迹规划原理与MATLAB实现
  • OpenXR Monado compositor处理应用layers(cheduled->delivered)
  • 深入掌握 Go 语言中的数值类型与循环技巧
  • Gitlab删除本地标签和分支
  • 【操作系统原理】第三章——进程线程模型(上)
  • 【Python 千题 —— 算法篇】重复字符查找
  • 把设计模式用起来!(2)
  • 【全网首发】2024数学建模国赛E题31页word版成品论文【附带完整解题代码+可视化图表】
  • PostgreSQL的基础知识
  • 1. Fabric.js安装使用
  • 110001安庆巡检_工艺巡检
  • 原型与原型链
  • 模型中间部分的卷积可视化
  • 《网管员必读——网络组建》(第2版)电子课件下载
  • 08.Android之View事件问题
  • ES6系统学习----从Apollo Client看解构赋值
  • gops —— Go 程序诊断分析工具
  • happypack两次报错的问题
  • magento2项目上线注意事项
  • PHP CLI应用的调试原理
  • PHP 使用 Swoole - TaskWorker 实现异步操作 Mysql
  • underscore源码剖析之整体架构
  • 闭包,sync使用细节
  • 浮现式设计
  • 紧急通知:《观止-微软》请在经管柜购买!
  • 力扣(LeetCode)357
  • 聊聊hikari连接池的leakDetectionThreshold
  • 如何在 Tornado 中实现 Middleware
  • 推荐一个React的管理后台框架
  • 我从编程教室毕业
  • 用简单代码看卷积组块发展
  • 源码之下无秘密 ── 做最好的 Netty 源码分析教程
  • 云栖大讲堂Java基础入门(三)- 阿里巴巴Java开发手册介绍
  • [地铁译]使用SSD缓存应用数据——Moneta项目: 低成本优化的下一代EVCache ...
  • ​水经微图Web1.5.0版即将上线
  • (1)Hilt的基本概念和使用
  • (4)Elastix图像配准:3D图像
  • (4)事件处理——(2)在页面加载的时候执行任务(Performing tasks on page load)...
  • (delphi11最新学习资料) Object Pascal 学习笔记---第14章泛型第2节(泛型类的类构造函数)
  • (Java实习生)每日10道面试题打卡——JavaWeb篇
  • (Redis使用系列) SpringBoot中Redis的RedisConfig 二
  • (独孤九剑)--文件系统
  • (已解决)报错:Could not load the Qt platform plugin “xcb“
  • (最全解法)输入一个整数,输出该数二进制表示中1的个数。
  • ./configure,make,make install的作用(转)
  • .bat批处理(五):遍历指定目录下资源文件并更新
  • .gitignore
  • .Mobi域名介绍
  • .NET CF命令行调试器MDbg入门(三) 进程控制
  • .net 调用php,php 调用.net com组件 --
  • .NET分布式缓存Memcached从入门到实战
  • .NET简谈互操作(五:基础知识之Dynamic平台调用)
  • .Net中ListT 泛型转成DataTable、DataSet