搞定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) {
  // 写一个错误,改变常量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被触发状态转变为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.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》

  • 9
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值