我一度认为本人很懂 Promise,直到前段时间尝试去实现 Promise/A+ 标准时,才发现自己对 Promise 的了解还过于肤浅。在我依照 Promise/A+ 标准去写具体代码实现的过程中,我经验了从“很懂”到“生疏”,再到“体会”的过山车式的认知转变,对 Promise 有了更粗浅的意识!
TL;DR:鉴于很多人不想看长文,这里间接给出我写的 Promise/A+ 标准的 Javascript 实现。
- github 仓库:promises-aplus-robin(棘手点个 star 就更好了)
- 源码
- 源码正文版
promises-tests 测试用例是全副通过的。
Promise 源于事实世界
Promise 直译过去就是 承诺 ,最新的红宝书曾经将其翻译为 期约。当然,这都不重要,程序员之间只有一个眼神就懂了。
许下承诺
作为打工人,咱们不可避免地会接到各种饼,比方口头吹捧的饼、贬值加薪的饼、股权激励的饼 ……
有些饼马上就兑现了,比方口头贬责,因为它自身没有给企业带来什么老本;有些饼却关乎企业理论利益,它们可能将来可期,也可能猴年马月,或是无疾而终,又或者间接宣告画饼失败。
画饼这个动作,于 Javascript 而言,就是创立一个 Promise 实例:
const bing = new Promise((resolve, reject) => {
// 祝各位的饼都能圆满成功
if (‘ 画饼胜利 ’) {
resolve(‘ 大家 happy’)
} else {
reject(‘ 有难同当 ’)
}
})
Promise 跟这些饼很像,分为三种状态:
- pending: 饼已画好,坐等实现。
- fulfilled: 饼真的实现了,走上人生巅峰。
- rejected: 不好意思,画饼失败,emmm…
订阅承诺
有人画饼,天然有人接饼。所谓“接饼”,就是对于这张饼的可能性做下构想。如果饼真的实现了,鄙人将别墅靠海;如果饼失败了,本打工仔以泪洗面。
转换成 Promise 中的概念,这是一种订阅的模式,胜利和失败的状况咱们都要订阅,并作出反应。订阅是通过 then
,catch
等办法实现的。
// 通过 then 办法进行订阅
bing.then(
// 对画饼胜利的状况作出反应
success => {
console.log(‘ 别墅靠海 ’)
},
// 对画饼失败的状况作出反应
fail => {
console.log(‘ 以泪洗面 …’)
}
)
链式流传
家喻户晓,老板能够给高层或领导们画饼,而领导们拿着老板画的饼,也必须给底下员工持续画饼,让打工人们鸡血不停,这样大家的饼才都有可能兑现。
这种自上而下发饼的行为与 Promise 的链式调用在思路上不约而同。
bossBing.then(
success => {
// leader 接过 boss 的饼,持续往下面发饼
return leaderBing
}
).then(
success => {
console.log(‘leader 画的饼真的实现了,别墅靠海 ’)
},
fail => {
console.log(‘leader 画的饼炸了,以泪洗面 …’)
}
)
总体来说,Promise 与事实世界的承诺还是挺类似的。
而 Promise 在具体实现上还有很多细节,比方异步解决的细节,Resolution 算法,等等,这些在前面都会讲到。上面我会从本人 对 Promise 的第一印象 讲起,继而过渡到对 宏工作与微工作 的意识,最终揭开 Promise/A+ 标准 的神秘面纱。
初识 Promise
还记得最早接触 Promise 的时候,我感觉能把 ajax 过程封装起来就挺“厉害”了。那个时候对 Promise 的印象大略就是:优雅的异步封装,不再须要写高耦合的 callback。
这里长期手撸一个简略的 ajax 封装作为示例阐明:
function isObject(val) {
return Object.prototype.toString.call(val) === ‘[object Object]’;
}
function serialize(params) {
let result = ”;
if (isObject(params)) {
Object.keys(params).forEach((key) => {
let val = encodeURIComponent(params[key]);
result += ${key}=${val}&
;
});
}
return result;
}
const defaultHeaders = {
“Content-Type”: “application/x-www-form-urlencoded”
}
// ajax 简略封装
function request(options) {
return new Promise((resolve, reject) => {
const {method, url, params, headers} = options
const xhr = new XMLHttpRequest();
if (method === ‘GET’ || method === ‘DELETE’) {
// GET 和 DELETE 个别用 querystring 传参
const requestURL = url + ‘?’ + serialize(params)
xhr.open(method, requestURL, true);
} else {
xhr.open(method, url, true);
}
// 设置申请头
const mergedHeaders = Object.assign({}, defaultHeaders, headers)
Object.keys(mergedHeaders).forEach(key => {
xhr.setRequestHeader(key, mergedHeaders[key]);
})
// 状态监听
xhr.onreadystatechange = function () {
if(xhr.readyState === 4 && xhr.status === 200) {
resolve(xhr.response)
} else {
reject(xhr.status)
}
}
xhr.onerror = function(e) {
reject(e)
}
// 解决 body 数据,发送申请
const data = method === ‘POST’ || method === ‘PUT’ ? serialize(params) : null
xhr.send(data);
})
}
const options = {
method: ‘GET’,
url: ‘/user/page’,
params: {
pageNo: 1,
pageSize: 10
}
}
// 通过 Promise 的模式调用接口
request(options).then(res => {
// 申请胜利
}, fail => {
// 申请失败
})
以上代码封装了 ajax 的次要过程,而其余很多细节和各种场景笼罩就不是几十行代码能说完的。不过咱们能够看到,Promise 封装的外围就是:
- 封装一个函数,将蕴含异步过程的代码包裹在结构 Promise 的 executor 中,所封装的函数最初须要 return 这个 Promise 实例。
- Promise 有三种状态,Pending, Fulfilled, Rejected。而
resolve()
,reject()
是状态转移的触发器。 - 确定状态转移的条件,在本例中,咱们认为 ajax 响应且状态码为 200 时,申请胜利(执行
resolve()
),否则申请失败(执行reject()
)。
ps: 理论业务中,除了判断 HTTP 状态码,咱们还会另外判断 外部错误码(业务零碎中前后端约定的状态 code)。
实际上当初有了 axios 这类的解决方案,咱们也不会轻易抉择自行封装 ajax,不激励反复造这种根底且重要的轮子,更别说有些场景咱们往往难以思考周全。当然,在工夫容许的状况下,能够学习其源码实现。
宏工作与微工作
要了解 Promise/A+ 标准,必须先溯本求源,Promise 与微工作非亲非故,所以咱们有必要先对宏工作和微工作有个根本意识。
在很长一段时间里,我都没有太多去关注 宏工作(Task)与微工作(Microtask)。甚至有一段时间,我感觉 setTimeout(fn, 0)
在操作动静生成的 DOM 元素时十分好用,然而并不知道其背地的原理,本质上这跟 Task 分割严密。
var button = document.createElement(‘button’);
button.innerText = ‘ 新增输入框 ’
document.body.append(button)
button.onmousedown = function() {
var input = document.createElement(‘input’);
document.body.appendChild(input);
setTimeout(function() {
input.focus();
}, 0)
}
如果不应用 setTimeout 0
,focus()
会没有成果。
那么,什么是宏工作和微工作呢?咱们慢慢来揭开答案。
古代浏览器采纳 多过程架构 ,这一点能够参考 Inside look at modern web browser。而和咱们前端关系最严密的就是其中的Renderer Process,Javascript 便是运行在 Renderer Process 的Main Thread 中。
Renderer: Controls anything inside of the tab where a website is displayed.
渲染过程管制了展现在 Tab 页中的网页的所有事件。能够了解为渲染过程就是专门为具体的某个网页服务的。
咱们晓得,Javascript 能够间接与界面交互。假想一下,如果 Javascript 采纳多线程策略,各个线程都能操作 DOM,那最终的界面出现到底以谁为准呢?这显然是存在矛盾的。因而,Javascript 抉择应用单线程模型的一个重要起因就是:为了保障用户界面的强一致性。
为了保障界面交互的连贯性和平滑度,Main Thread 中,Javascript 的执行和页面的渲染会交替执行(出于性能思考,某些状况下,浏览器判断不须要执行界面渲染,会略过渲染的步骤)。目前大多数设施的屏幕刷新率为 60 次 / 秒,1 帧大概是 16.67ms,在这 1 帧的周期内,既要实现 Javascript 的执行,还要实现界面的渲染(if necessary),利用人眼的残影效应,让用户感觉界面交互是十分晦涩的。
用一张图看看 1 帧的根本过程,援用自 https://aerotwist.com/blog/the-anatomy-of-a-frame/
PS:requestIdleCallback 是闲暇回调,在 1 帧的开端,如果还有工夫充裕,就会调用 requestIdleCallback。留神不要在 requestIdleCallback 中批改 DOM,或者读取布局信息导致触发Forced Synchronized Layout,否则会引发性能和体验问题。具体见 Using requestIdleCallback。
咱们晓得,一个网页中的 Render Process 只有一个 Main Thread,实质上来说,Javascript 的工作在执行阶段都是按程序执行,然而 JS 引擎在解析 Javascript 代码时,会把代码分为同步工作和异步工作。同步工作间接进入 Main Thread 执行;异步工作进入工作队列,并关联着一个异步回调。
在一个 web app 中,咱们会写一些 Javascript 代码或者援用一些脚本,用作利用的初始化工作。在这些初始代码中,会依照程序执行其中的同步代码。而在这些同步代码执行的过程中,会陆陆续续监听一些事件或者注册一些异步 API(网络相干,IO 相干,等等 …)的回调,这些事件处理程序和回调就是异步工作,异步工作会进入工作队列,并且在接下来的 Event Loop 中被解决。
异步工作又分为 Task 和Microtask,各自有独自的数据结构和内存来保护。
用一个简略的例子来感触下:
var a = 1;
console.log(‘a:’, a)
var b = 2;
console.log(‘b:’, b)
setTimeout(function task1(){
console.log(‘task1:’, 5)
Promise.resolve(6).then(function microtask2(res){
console.log(‘microtask2:’, res)
})
}, 0)
Promise.resolve(4).then(function microtask1(res){
console.log(‘microtask1:’, res)
})
var b = 3;
console.log(‘c:’, c)
以上代码执行后,顺次在控制台输入:
a: 1
b: 2
c: 3
microtask1: 4
task1: 5
microtask2: 6
认真一看也没什么难的,然而这背地产生的细节,还是有必要探索下。咱们无妨先问本人几个问题,一起来看下吧。
Task 和 Microtask 都有哪些?
-
Tasks:
setTimeout
setInterval
MessageChannel
- I/0(文件,网络)相干 API
- DOM 事件监听:浏览器环境
setImmediate
:Node 环境,IE 如同也反对(见 caniuse 数据)
-
Microtasks:
requestAnimationFrame
:浏览器环境MutationObserver
:浏览器环境Promise.prototype.then
,Promise.prototype.catch
,Promise.prototype.finally
process.nextTick
:Node 环境queueMicrotask
requestAnimationFrame 是不是微工作?
requestAnimationFrame
简称 rAF
,常常被咱们用来做动画成果,因为其回调函数执行频率与浏览器屏幕刷新频率保持一致,也就是咱们通常说的 它能实现 60FPS 的成果 。在rAF
被大范畴利用前,咱们常常应用 setTimeout
来解决动画。然而 setTimeout
在主线程忙碌时,不肯定能及时地被调度,从而呈现卡顿景象。
那么 rAF
属于宏工作或者微工作吗?其实很多网站都没有给出定义,包含 MDN 上也形容得非常简单。
咱们无妨本人问问本人,rAF
是宏工作吗?我想了一下,显然不是,rAF
能够用来代替定时器动画,怎么能和定时器工作一样被 Event Loop 调度呢?
我又问了问本人,rAF
是微工作吗?rAF
的调用机会是在下一次浏览器重绘之前,这看起来和微工作的调用机会差不多,曾让我一度认为 rAF
是微工作,而实际上 rAF
也不是微工作。为什么这么说呢?请运行下这段代码。
function recursionRaf() {
requestAnimationFrame(() => {
console.log(‘raf 回调 ’)
recursionRaf()
})
}
recursionRaf();
你会发现,在有限递归的状况下,rAF
回调失常执行,浏览器也可失常交互,没有呈现阻塞的景象。
而如果 rAF
是微工作的话,则不会有这种待遇。不信你能够翻到前面一节内容「如果 Microtask 执行时又创立了 Microtask,怎么解决?」。
所以,rAF
的工作级别是很高的,领有独自的队列保护。在浏览器 1 帧的周期内,rAF
与 Javascript 执行,浏览器重绘是同一个 Level 的。(其实,大家在后面那张「解剖 1 帧」的图中也能看进去了。)
Task 和 Microtask 各有 1 个队列?
最后,我认为既然浏览器辨别了 Task 和 Microtask,那就只有各自安顿一个队列存储工作即可。事实上,Task 依据 task source 的不同,安顿了独立的队列。比方 Dom 事件属于 Task,然而 Dom 事件有很多种类型,为了不便 user agent 细分 Task 并精细化地安顿各种不同类型 Task 的解决优先级,甚至做一些优化工作,必须有一个 task source 来辨别。同理,Microtask 也有本人的 microtask task source。
具体解释见 HTML 规范中的一段话:
Essentially, task sources are used within standards to separate logically-different types of tasks, which a user agent might wish to distinguish between. Task queues *are used by user agents to coalesce task sources within a given event loop。
Task 和 Microtask 的生产机制是怎么的?
An event loop has one or more task queues. A task queue is a set of tasks.
javascript 是 事件驱动 的,所以 Event Loop 是 异步任务调度 的外围。尽管咱们始终说 工作队列 ,然而 Tasks 在数据结构上不是队列(Queue),而是 汇合(Set)。在每一轮 Event Loop 中,会取出第一个 runnable 的 Task(第一个可执行的 Task,并不一定是程序上的第一个 Task)进入 Main Thread 执行,而后再查看 Microtask 队列并执行队列中所有 Microtask。
说再多,都不如一张图直观,请看!
Task 和 Microtask 什么时候进入相应队列?
回过头来看,咱们始终在提这个概念“异步工作进入队列 ”,那么就有个疑难,Task 和 Microtask 到底是什么时候进入相应的队列?咱们从新来捋捋。异步工作有 注册 , 进队列 , 回调被执行 这三个要害行为。注册很好了解,代表这个工作被创立了;而回调被执行则代表着这个工作曾经被主线程捞起并执行了。然而,在 进队列 这一行为上,宏工作和微工作的体现是不一样的。
宏工作进队列
对于 Task 而言,工作注册时就会进入队列,只是工作的状态还不是runnable,不具备被 Event Loop 捞起的条件。
咱们先用 Dom 事件为例举个例子。
document.body.addEventListener(‘click’, function(e) {
console.log('被点击了', e)
})
当 addEventListener
这行代码被执行时,工作就注册了,代表有一个用户点击事件相干的 Task 进入工作队列。那么这个宏工作什么时候才变成 runnable 呢?当然是用户点击产生并且信号传递到浏览器 Render Process 的 Main Thread 后,此时宏工作变成 runnable 状态,才能够被 Event Loop 捞起,进入 Main Thread 执行。
这里再举个例子,顺便解释下为什么 setTimeout 0
会有提早。
setTimeout(function() {
console.log('我是 setTimeout 注册的宏工作')
}, 0)
执行 setTimeout
这行代码时,相应的宏工作就被注册了,并且 Main Thread 会告知定时器线程,“你定时 0 毫秒后给我一个音讯”。定时器线程收到音讯,发现只有期待 0 毫秒,立马就给 Main Thread 一个音讯,“我这边曾经过了 0 毫秒了”。Main Thread 收到这个回复音讯后,就把相应宏工作的状态置为 runnable,这个宏工作就能够被 Event Loop 捞起了。
能够看到,通过这样一个线程间通信的过程,即使是延时 0 毫秒的定时器,其回调也并不是在真正意义上的 0 毫秒之后执行,因为通信过程就须要消耗工夫。网上有个观点说 setTimeout 0
的响应工夫起码是 4ms,其实也是有根据的,不过也是有条件的。
HTML Living Standard: If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.
对于这种说法,我感觉本人有个概念就行,不同浏览器在实现标准的细节上必定不一样,具体通信过程也不详,是不是 4ms 也不好说,要害是你有没有搞清楚这背地经验了什么。
微工作进队列
后面咱们提到一个观点,执行完一个 Task 后,如果 Microtask 队列不为空,会把 Microtask 队列中所有的 Microtask 都取出来执行 。我认为,Microtask 不是在注册时就进入 Microtask 队列,因为 Event Loop 解决 Microtask 队列时,并不会判断 Microtask 的状态。反过来想,如果 Microtask 在注册时就进入 Microtask 队列,就会存在 Microtask 还未变为runnable 状态就被执行的状况,这显然是不合理的。我的观点是,Microtask 在变为 runnable 状态时才进入 Microtask 队列。
那么咱们来剖析下 Microtask 什么时候变成 runnable 状态,首先来看看 Promise。
var promise1 = new Promise((resolve, reject) => {
resolve(1);
})
promise1.then(res => {
console.log(‘promise1 微工作被执行了 ’)
})
读者们,我的第一个问题是,Promise 的微工作什么时候被注册?new Promise
的时候?还是什么时候?无妨来猜一猜!
答案是 .then
被执行的时候。(当然,还有 .catch
的状况,这里只是就这个例子说)。
那么 Promise 微工作的状态什么时候变成 runnable 呢?置信不少读者曾经有了脉络了,没错,就是 Promise 状态产生转移 的时候,在本例中也就是 resolve(1)
被执行的时候,Promise 状态由 pending 转移为 fulfilled。在 resolve(1)
执行后,这个 Promise 微工作就进入 Microtask 队列了,并且将在本次 Event Loop 中被执行。
基于这个例子,咱们再来加深下难度。
var promise1 = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 0);
});
promise1.then(res => {
console.log(‘promise1 微工作被执行了 ’);
});
在这个例子中,Promise 微工作的 注册 和进队列 并不在同一次 Event Loop。怎么说呢?在第一个 Event Loop 中,通过 .then
注册了微工作,然而咱们能够发现,new Promise
时,执行了一个 setTimeout
,这是相当于注册了一个宏工作。而resolve(1)
必须在宏工作被执行时才会执行。很显著,两者中距离了 至多 一次 Event Loop。
如果能剖析 Promise 微工作的过程,你天然就晓得怎么剖析 ObserverMutation 微工作的过程了,这里不再赘述。
如果 Microtask 执行时又创立了 Microtask,怎么解决?
咱们晓得,一次 Event Loop 最多只执行一个 runnable 的 Task,然而会执行 Microtask 队列中的所有 Microtask。如果在执行 Microtask 时,又创立了新的 Microtask,这个新的 Microtask 是在下次 Event Loop 中被执行吗?答案是否定的。微工作能够增加新的微工作到队列中,并在下一个工作开始执行之前且以后 Event Loop 完结之前执行完所有的微工作。请留神不要递归地创立微工作,否则会陷入死循环。
上面就是一个蹩脚的示例。
// bad case
function recursionMicrotask() {
Promise.resolve().then(() => {recursionMicrotask()
})
}
recursionMicrotask();
请不要轻易尝试,否则页面会卡死哦!(因为 Microtask 占着 Main Thread 不开释,浏览器渲染都没方法进行了)
为什么要辨别 Task 和 Microtask?
这是一个十分重要的问题。为什么不在执行完 Task 后,间接进行浏览器渲染这一步骤,而要再加上执行 Microtask 这一步呢?其实在后面的问题中曾经解答过了。一次 Event Loop 只会生产一个宏工作,而微工作队列在被生产时有“持续上车”的机制,这就让开发者有了更多的想象力,对代码的控制力会更强。
做几道题热热身?
在冲击 Promise/A+ 标准前,无妨先用几个习题来测试下本人对 Promise 的了解水平。
基本操作
function mutationCallback(mutationRecords, observer) {
console.log(‘mt1’)
}
const observer = new MutationObserver(mutationCallback)
observer.observe(document.body, { attributes: true})
Promise.resolve().then(() => {
console.log(‘mt2’)
setTimeout(() => {
console.log(‘t1’)
}, 0)
document.body.setAttribute(‘test’, “a”)
}).then(() => {
console.log(‘mt3’)
})
setTimeout(() => {
console.log(‘t2’)
}, 0)
这道题就不剖析了,答案:mt2 mt1 mt3 t2 t1
浏览器不讲武德?
Promise.resolve().then(() => {
console.log(0);
return Promise.resolve(4);
}).then((res) => {
console.log(res)
})
Promise.resolve().then(() => {
console.log(1);
}).then(() => {
console.log(2);
}).then(() => {
console.log(3);
}).then(() => {
console.log(5);
}).then(() =>{
console.log(6);
})
这道题据说是字节外部流出的一道题,说实话我刚看到的时候也是一头雾水。通过我在 Chrome 测试,失去的答案的确很有法则,就是:0 1 2 3 4 5 6。
先输入 0,再输入 1,我还能了解,为什么输入 2 和 3 后又忽然跳到 4 呢,浏览器你不讲武德啊!
emm… 我被戴上了苦楚面具!
那么这背地的执行程序到底是怎么的呢?仔细分析下,你会发现还是有迹可循的。
老规矩,第一个问题,这道题的代码执行过程中,产生了多少个微工作?可能很多人认为是 7 个,但实际上应该是 8 个。
编号
注册机会
异步回调
mt1
.then()
console.log(0);return Promise.resolve(4);
mt2
.then(res)
console.log(res)
mt3
.then()
console.log(1);
mt4
.then()
console.log(2);
mt5
.then()
console.log(3);
mt6
.then()
console.log(5);
mt7
.then()
console.log(6);
mt8
return Promise.resolve(4)
执行并且 execution context stack 清空后,隐式注册
隐式回调(未体现在代码中),目标是让 mt2 变成 runnable 状态
- 同步工作执行,注册 mt1~mt7 七个微工作,此时 execution context stack 为空,并且 mt1 和 mt3 的状态变为 runnable。JS 引擎安顿 mt1 和 mt3 进入 Microtask 队列(通过 HostEnqueuePromiseJob 实现)。
- Perform a microtask checkpoint,因为 mt1 和 mt3 是在同一次 JS call 中变为 runnable 的,所以 mt1 和 mt3 的回调先后进入 execution context stack 执行。
- mt1 回调进入 execution context stack 执行,输入 0 ,返回
Promise.resolve(4)
。mt1 出队列。因为 mt1 回调返回的是一个状态为 fulfilled 的 Promise,所以之后 JS 引擎会安顿一个 job(job 是 ecma 中的概念,等同于微工作的概念,这里先给它编号 mt8),其回调目标是让 mt2 的状态变为 fulfilled( 前提是以后 execution context stack is empty)。所以紧接着还是先执行 mt3 的回调。 - mt3 回调进入 execution context stack 执行,输入 1 ,mt4 变为 runnable 状态,execution context stack is empty,mt3 出队列。
- 因为此时 mt4 曾经是 runnable 状态,JS 引擎安顿 mt4 进队列,接着 JS 引擎会安顿 mt8 进队列。
- 接着,mt4 回调进入 execution context stack 执行,输入 2 ,mt5 变为 runnable,mt4 出队列。JS 引擎安顿 mt5 进入 Microtask 队列。
- mt8 回调执行,目标是让 mt2 变成 runnable 状态,mt8 出队列。mt2 进队列。
- mt5 回调执行,输入 3 ,mt6 变为 runnable,mt5 出队列。mt6 进队列。
- mt2 回调执行,输入 4 ,mt4 出队列。
- mt6 回调执行,输入 5 ,mt7 变为 runnable,mt6 出队列。mt7 进队列。
- mt7 回调执行,输入 6 ,mt7 出队列。执行结束!总体来看,输入后果顺次为:0 1 2 3 4 5 6。
对这块执行过程尚有疑难的敌人,能够先往下看看 Promise/A+ 标准和 ECMAScript262 标准中对于 Promise 的约定,再回过头来思考,也欢送留言与我交换!
通过我在 Edge 浏览器测试,后果是:0 1 2 4 3 5 6。能够看到,不同浏览器在实现 Promise 的主流程上是吻合的,然而在一些细枝末节上还有不统一的中央。理论利用中,咱们只有留神躲避这种问题即可。
实现 Promise/A+
热身结束,接下来就是直面大 boss Promise/A+ 标准。Promise/A+ 标准列举了大大小小三十余条细则,一眼看过来还是挺晕的。
仔细阅读多遍标准之后,我有了一个根本意识,要实现 Promise/A+ 标准,要害是要理清其中几个外围点。
关系链路
原本写了大几千字有点感觉困倦了,于是想着最初这部分就用文字解说疾速收尾,然而最初这节写到一半时,我感觉我写不上来了,纯文字的货色太干了,干得没法排汇,这对那些对 Promise 把握水平不够的读者来说是相当不敌对的。所以,我感觉还是先用一张图来形容一下 Promise 的关系链路。
首先,Promise 它是一个对象,而 Promise/A+ 标准则是围绕着 Promise 的原型办法 .then()
开展的。
.then()
的特殊性在于,它会返回一个新的 Promise 实例,在这种间断调用.then()
的状况下,就会串起一个 Promise 链,这与原型链又有一些相似之处。“厚颜无耻”地再举荐一篇「思维导图学前端」6k 字一文搞懂 Javascript 对象,原型,继承,哈哈哈。- 另一个灵便的中央在于,
p1.then(onFulfilled, onRejected)
返回的新 Promise 实例 p2,其状态转移的产生是在 p1 的状态转移产生之后(这里的 之后 指的是异步的之后)。并且,p2 的状态转移为 Fulfilled 还是 Rejected,这一点取决于onFulfilled
或onRejected
的返回值,这里有一个较为简单的剖析过程,也就是前面所述的 Promise Resolution Procedure 算法。
我这里画了一个简略的时序图,画图程度很差,只是为了让读者们先有个根本印象。
其中还有很多细节是没提到的(因为细节真的太多了,全副画进去就相当简单,具体过程请看我文末附的源码)。
nextTick
看了后面内容,置信大家都有一个概念,微工作是一个异步工作,而咱们要实现 Promise 的整套异步机制,必然要具备模仿微工作异步回调的能力。在标准中也提到了这么一条信息:
This can be implemented with either a“macro-task” mechanism such as setTimeout or setImmediate, or with a“micro-task” mechanism such as MutationObserver or process.nextTick.
我这里抉择的是用微工作来实现异步回调,如果用宏工作来实现异步回调,那么在 Promise 微工作队列执行过程中就可能会交叉宏工作,这就不太合乎微工作队列的调度逻辑了。这里还对 Node 环境和浏览器环境做了兼容,Node 环境中能够应用 process.nextTick
回调来模仿微工作的执行,而在浏览器环境中咱们能够抉择MutationObserver
。
function nextTick(callback) {
if (typeof process !== ‘undefined’ && typeof process.nextTick === ‘function’) {
process.nextTick(callback)
} else {
const observer = new MutationObserver(callback)
const textNode = document.createTextNode(‘1’)
observer.observe(textNode, {
characterData: true
})
textNode.data = ‘2’
}
}
状态转移
-
Promise 实例一共有三种状态,别离是 Pending, Fulfilled, Rejected,初始状态是 Pending。
const PROMISE_STATES = {
PENDING: ‘pending’,
FULFILLED: ‘fulfilled’,
REJECTED: ‘rejected’
}class MyPromise {
constructor(executor) {this.state = PROMISE_STATES.PENDING;
}
// … 其余代码
} -
一旦 Promise 的状态产生转移,就不可再转移为其余状态。
/**
- 封装 Promise 状态转移的过程
- @param {MyPromise} promise 产生状态转移的 Promise 实例
- @param {*} targetState 指标状态
- @param {*} value 随同状态转移的值,可能是 fulfilled 的值,也可能是 rejected 的起因
*/
function transition(promise, targetState, value) {
if (promise.state === PROMISE_STATES.PENDING && targetState !== PROMISE_STATES.PENDING) {
// 2.1: state 只能由 pending 转为其余态,状态转移后,state 和 value 的值不再变动
Object.defineProperty(promise, ‘state’, {
configurable: false,
writable: false,
enumerable: true,
value: targetState
})
// … 其余代码
}
} - 触发状态转移是靠调用
resolve()
或reject()
实现的。当resolve()
被调用时,以后 Promise 也不肯定会立刻变为 Fulfilled 状态,因为传入resolve(value)
办法的 value 有可能也是一个 Promise,这个时候,以后 Promise 必须追踪传入的这个 Promise 的状态,整个确定 Promise 状态的过程是通过 Promise Resolution Procedure 算法 实现的,具体细节封装到了上面代码中的resolvePromiseWithValue
函数中。当reject()
被调用时,以后 Promise 的状态就是确定的,肯定是 Rejected,此时能够通过transition
函数(封装了状态转移的细节)将 Promise 的状态进行转移,并执行后续动作。// resolve 的执行,是一个触发信号,基于此进行下一步的操作
function resolve(value) {
resolvePromiseWithValue(this, value)
}
// reject 的执行,是状态能够变为 Rejected 的信号
function reject(reason) {
transition(this, PROMISE_STATES.REJECTED, reason)
}
class MyPromise {
constructor(executor) {
this.state = PROMISE_STATES.PENDING;
this.fulfillQueue = [];
this.rejectQueue = [];
// 结构 Promise 实例后,立即调用 executor
executor(resolve.bind(this), reject.bind(this))
}
}
链式追踪
假如当初有一个 Promise 实例,咱们称之为 p1。因为 promise1.then(onFulfilled, onRejected)
会返回一个新的 Promise(咱们称之为 p2),与此同时,也会注册一个微工作 mt1,这个新的 p2 会追踪其关联的 p1 的状态变动。
当 p1 的状态产生转移时,微工作 mt1 回调会在接下来被执行,如果状态是 Fulfilled,则 onFulfilled
会被执行,否则 onRejected
会被执行。微工作 mt 回调 1 执行的后果将作为决定 p2 状态的根据。以下是 Fulfilled 状况下的局部要害代码,其中 promise 指的是 p1,而 chainedPromise 指的是 p2。
// 回调应异步执行,所以用到了 nextTick
nextTick(() => {
// then 可能会被调用屡次,所以异步回调应该用数组来保护
promise.fulfillQueue.forEach(({handler, chainedPromise}) => {
try {if (typeof handler === 'function') {const adoptedValue = handler(value)
// 异步回调返回的值将决定衍生的 Promise 的状态
resolvePromiseWithValue(chainedPromise, adoptedValue)
} else {
// 存在调用了 then,然而没传回调作为参数的可能,此时衍生的 Promise 的状态间接驳回其关联的 Promise 的状态。transition(chainedPromise, PROMISE_STATES.FULFILLED, promise.value)
}
} catch (error) {
// 如果回调抛出了异样,此时间接将衍生的 Promise 的状态转移为 rejected,并用异样 error 作为 reason
transition(chainedPromise, PROMISE_STATES.REJECTED, error)
}
})
// 最初清空该 Promise 关联的回调队列
promise.fulfillQueue = [];
})
Promise Resolution Procedure 算法
Promise Resolution Procedure 算法是一种形象的执行过程,它的语法模式是[[Resolve]](promise, x "[Resolve]")
,承受的参数是一个 Promise 实例和一个值 x,通过值 x 的可能性,来决定这个 Promise 实例的状态走向。如果间接硬看标准,会有点吃力,这里间接说人话解释一些细节。
2.3.1
如果 promise 和值 x 援用同一个对象,应该间接将 promise 的状态置为 Rejected,并且用一个 TypeError 作为 reject 的起因。
If
promise
andx
refer to the same object, rejectpromise
with aTypeError
as the reason.
【说人话】举个例子,老板说只有往年业绩超过 10 亿,业绩就超过 10 亿。这显然是个病句,你不能拿预期自身作为条件。正确的玩法是,老板说只有往年业绩超过 10 亿,就发 1000 万奖金(嘿嘿,这种事期待一下就好了)。
代码实现:
if (promise === x) {
// 2.3.1 因为 Promise 驳回状态的机制,这里必须进行全等判断,防止出现死循环
transition(promise, PROMISE_STATES.REJECTED, new TypeError(‘promise and x cannot refer to a same object.’))
}
2.3.2
如果 x 是一个 Promise 实例,promise 应该驳回 x 的状态。
2.3.2 If
x
is a promise, adopt its state [3.4]:2.3.2.1 If
x
is pending,promise
must remain pending untilx
is fulfilled or rejected.
2.3.2.2 If/whenx
is fulfilled, fulfillpromise
with the same value.
2.3.2.3 If/whenx
is rejected, rejectpromise
with the same reason.
【说人话】小王问领导:“往年会发年终奖吗?发多少?”领导听了心里想,“这个事我之前也在打听,不过还没定下来,得看老板的意思。”,于是领导对小王说:“会发的,不过要等音讯!”。
留神,这个时候,领导对小王许下了承诺,然而这个承诺 p2 的状态还是 pending,须要看老板给的承诺 p1 的状态。
- 可能性 1 :过了几天,老板对领导说:“往年业务做得能够,年终奖发 1000 万”。这里相当于 p1 曾经是 fulfilled 状态了,value 是 1000 万。领导拿了这个准信了,天然能够跟小王兑现承诺 p2 了,于是对小王说:“年终奖能够下来了,是 1000 万!”。这时,承诺 p2 的状态就是 fulfilled 了,value 也是 1000 万。小王这个时候就“别墅靠海”了。
- 可能性 2 :过了几天,老板有点发愁,对领导说:“往年业绩不太行啊,年终奖就不发了吧,明年,咱们明年多发点。”显然,这里 p1 就是 rejected 了,领导一看这状况不对啊,但也没方法,只能对小王说:“小王啊,往年公司状况非凡,年终奖就不发了。”这 p2 也随之 rejected 了,小王心田有点炸裂 ……
留神,Promise A/+ 标准 2.3.2 大节这里有两个大的方向,一个是 x 的状态未定,一个是 x 的状态已定。在代码实现上,这里有个技巧,对于状态未定的状况,必须用订阅的形式来实现,而.then 就是订阅的绝佳路径。
else if (isPromise(x)) {
// 2.3.2 如果 x 是一个 Promise 实例,则追踪并驳回其状态
if (x.state !== PROMISE_STATES.PENDING) {
// 假如 x 的状态曾经产生转移,则间接驳回其状态
transition(promise, x.state, x.state === PROMISE_STATES.FULFILLED ? x.value : x.reason)
} else {
// 假如 x 的状态还是 pending,则只需期待 x 状态确定后再进行 promise 的状态转移
// 而 x 的状态转移后果是不定的,所以两种状况咱们都须要进行订阅
// 这里用一个.then 很奇妙地实现了订阅动作
x.then(value => {
// x 状态转移为 fulfilled,因为 callback 传过来的 value 是不确定的类型,所以须要持续利用 Promise Resolution Procedure 算法
resolvePromiseWithValue(promise, value, thenableValues)
}, reason => {
// x 状态转移为 rejected
transition(promise, PROMISE_STATES.REJECTED, reason)
})
}
}
多的细节咱这篇文章就不一一剖析了,写着写着快 1 万字了,就先完结掉吧,感兴趣的读者能够间接关上源码看(往下看)。
这是跑测试用例的效果图,能够看到,872 个 case 是全副通过的。
残缺代码
这里间接给出我写的 Promise/A+ 标准的 Javascript 实现,供大家参考。前面如果有工夫,会思考详细分析下。
- github 仓库:promises-aplus-robin(棘手点个 star 就更好了)
- 源码
- 源码正文版
缺点
我这个版本的 Promise/A+ 标准实现,不具备检测 execution context stack 为空的能力,所以在细节上会有一点问题(execution context stack 还未清空就插入了微工作),无奈适配下面那道「浏览器不讲武德?」的题目所述场景。
方法论
不论是手写实现 Promise/A+ 标准,还是实现其余 Native Code,其本质上绕不开以下几点:
- 精确了解 Native Code 实现的能力,就像你了解一个需要要实现哪些性能点一样,并确定实现上的优先级。
- 针对每个性能点或者性能形容,逐个用代码实现,优先买通骨干流程。
- 设计足够丰盛的测试用例,回归测试,一直迭代,保障场景的覆盖率,最终打造一段优质的代码。
总结
看到结尾,置信大家也累了,感激各位读者的浏览!心愿本文对宏工作和微工作的解读能给各位读者带来一点启发。Promise/A+ 标准总体来说还是比拟艰涩难懂的,这对老手来说是不太敌对的,因而我倡议有肯定水平的 Promise 理论应用教训后再深刻学习 Promise/A+ 标准。通过学习和了解 Promise/A+ 标准的实现机制,你会更懂 Promise 的一些外部细节,对于设计一些简单的异步过程会有极大的帮忙,再不济也能晋升你的异步调试和排错能力。
这里还有一些标准和文章能够参考:
- Promises/A+ 标准
- Event Loop Processing Model
- tasks-microtasks-queues-and-schedules
- Jobs and Host Operations to Enqueue Jobs
如果您感觉这篇文章还不错,欢送点个赞,加个关注(前端司南),真挚感谢您的反对。也欢送和我间接交换,我是 laobaife,期待与您共同进步!