我一度认为本人很懂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的起因。
Ifpromise
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 Ifx
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,期待与您共同进步!