看了就会,保姆级带你一步步实现Promise的核心功能(一)

简介: 众所周知, promise 是前端面试中雷打不动的面试题了,面试官都很爱考。周一之前也是知识比较浮于表面,在一些面经上看到了 promise 的实现方式,就只停留在那个层面上。但实际上我发现,如果没有深入其原理去理解,面试官稍微变个法子来考,这道题很容易就把我给问倒了。所以呀,还是老老实实从头到尾研究一遍,这样等遇到了,不管怎么考,万宗不变其一,把原理理解了,就没有那么容易被问倒了。下面开始进入本文的讲解~🏷️

45.png

📚序言


众所周知, promise 是前端面试中雷打不动的面试题了,面试官都很爱考。周一之前也是知识比较浮于表面,在一些面经上看到了 promise 的实现方式,就只停留在那个层面上。但实际上我发现,如果没有深入其原理去理解,面试官稍微变个法子来考,这道题很容易就把我给问倒了。所以呀,还是老老实实从头到尾研究一遍,这样等遇到了,不管怎么考,万宗不变其一,把原理理解了,就没有那么容易被问倒了。

下面开始进入本文的讲解~🏷️


📰一、js的同步模式和异步模式


1. 单线程💡


大家都知道, js 的设计是基于单线程进行开发的,它原先的目的在于只参与浏览器中DOM节点的操作。

而对于单线程来说,其意味着只能执行一个任务,且所有的任务都会按照队列的模式进行排队。

所以,单线程的缺点就在于,当 js 运行的时候, html 是不会进行渲染的。因此,如果一个任务特别耗时,那么将会很容易造成页面阻塞的局面。

为了解决这个问题, js 提出了同步模式异步模式的解决方案。


2. 同步模式💡



(1)定义

所谓同步模式,指的就是函数中的调用堆栈,按照代码实现的顺序,一步步进行。


(2)图例

接下来我们来用一段代码,演示 js函数调用堆栈的执行情况。具体代码如下:

const func1 = () => {
    func2();
    console.log(3);
}
const func2 = () => {
    func3();
    console.log(4);
}
const func3 = () => {
  console.log(5);
}
func1(); //5 4 3
复制代码

看到这里,相信很多小伙伴已经在构思其具体的执行顺序。下面用一张图来展示执行效果:

46.png

对于这个数据结构来说,它遵循后进先出的原则。因此,当 func1func2func3 依次放进调用栈后, 遵循后进先出原则 ,那么 func3 函数的内容会先被执行,之后是 func2 ,最后是 func1

因此,对于 js同步模式来说,就是类似于上述的函数调用堆栈。


3. 异步模式💡


(1)举例

当程序遇到网络请求或定时任务等问题时,这个时候会有一个等待时间。

假设一个定时器设置 10s ,如果放在同步任务里,同步任务会阻塞代码执行,我们会等待 10s 后才能看到我们想要的结果。1个定时器的等待时间可能还好,如果这个时候是100个定时器呢?我们总不能等待着 1000s 的时间就为了看到我们想要的结果吧,这几乎不太现实。

那么这个时候就需要异步,通过异步来让程序不阻塞代码执行灵活执行程序


(2)定义

对于同步模式来说,它只能自上而下地一行一行执行,一行一行进行解析。那与同步模式不同的是,异步模式是按照我们想要的结果进行输出,不会像同步模式一样产生阻塞,以达到让程序可控的效果。


(3)js如何实现异步

相对于同步模式来说,异步模式的结构更为复杂。除了调用栈之外, 它还有消息队列事件循环这两个额外的机制。所谓事件循环,也称为 event loop事件轮询。因为 js 是单线程的,且异步需要基于回调来实现,所以, event loop 就是异步回调的实现原理。

JS在程序中的执行遵循以下规则:

  • 从前到后,一行一行执行;
  • 如果某一行执行报错,则停止下面代码的执行;
  • 先把同步代码执行完,再执行异步。


一起来看一个实例:

console.log('Hi');
setTimeout(function cb1(){
    console.log('cb1'); //cb1 即callback回调函数
}, 5000);
console.log('Bye');
//打印顺序:
//Hi
//Bye
//cb1
复制代码

从上例代码中可以看到, JS 是先执行同步代码,所以先打印 HiBye ,之后执行异步代码,打印出 cb1

以此代码为例,下面开始讲解 event loop 的过程。


(4)event loop过程

对于上面这段代码,执行过程如下图所示:

1.png

从上图中可以分析出这段代码的运行轨迹。首先 console.log('Hi') 是同步代码,直接执行并打印出 Hi 。接下来继续执行定时器 setTimeout定时器是异步代码,所以这个时候浏览器会将它交给 Web APIs 来处理这件事情,因此先把它放到 Web APIs 中,之后继续执行 console.log('Bye')console.log('Bye') 是同步代码,在调用堆栈 Call Stack 中执行,打印出 Bye

到这里,调用堆栈 Call Stack 里面的内容全部执行完毕,当调用堆栈的内容为空时,浏览器就会开始去 消息队列 Callback Queue 寻找下一个任务,此时消息队列就会去 Web API 里面寻找任务,遵循先进先出原则,找到了定时器,且定时器里面是回调函数 cb1 ,于是把回调函数 cb1 传入任务队列中,此时 Web API 也空了,任务队列里面的任务就会传入到调用堆栈里Call Stack 里执行,最终打印出 cb1


4. 回调函数💡


早期我们在解决异步问题的时候,基本上都是使用callback回调函数的形式 来调用的。形式如下:

//获取第一份数据
$.get(url1, (data1) => {
    console.log(data1);
    //获取第二份数据
    $.get(url2, (data2) => {
        console.log(data2);
        //获取第三份数据
        $.get(url3, (data3) => {
            console.log(data3);
            //还可以获取更多数据
        });
    });
});
复制代码

从上述代码中可以看到,早期在调用数据的时候,都是一层套一层, callback 调用 callback ,仿佛深陷调用地狱一样,数据也被调用的非常乱七八糟的。所以,因为 callback 对开发如此不友好,也就有了后来的 promise 产生。

promiseCommonJS 社区最早提出,之后在2015年的时候, ES6 将其写进语言标准中,统一了它的用法,原生提供了 Promise 对象。 promise 的出现,告别了回调地狱时代,解决了回调地狱callback hell 的问题。

那下面我们就来看看 Promise 的各种神奇用法~


📃二、Promise异步方案


1. Promise的三种状态📂


(1)Promise的三种状态


状态 含义
pending 等待状态,即在过程中,还没有结果。比如正在网络请求,或定时器没有到时间。
fulfilled 满足状态,即事件已经解决了,并且成功了;当我们主动回调了 fulfilled 时,就处于该状态,并且会回调 then 函数。
rejected 拒绝状态,即事件已经被拒绝了,也就是失败了;当我们主动回调了 reject 时,就处于该状态,并且会回调 catch 函数。


(2)状态解释

对于 Promise 来说,它是一个对象,用来表示一个异步任务在执行结束之后返回的结果,它有 3 种状态pendingfulfilledrejected其执行流程如下:

47.png

如果一个异步任务处于 pending 状态时,那么表示这个 promise 中的异步函数还未执行完毕,此时处于等待状态。相反,如果 promise 中的异步函数执行完毕之后,那么它只会走向两个结果:

  • fulfilled ,表示成功
  • rejected ,表示失败

一旦最终状态从 pending 变化为 fulfilled 或者 rejected 后,状态就再也不可逆

所以,总结来讲,Promise 对象有以下两个特点:

  • promise 对象的状态不受外界影响,一旦状态被唤起之后,函数就交由 web API 去处理,这个时候在函数主体中再执行任何操作都是没有用的
  • 只会出现 pendingfulfilled,或者 pendingrejected 状态,即要么成功要么失败。即使再对 promise 对象添加回调函数,也只会得到同样的结果,即它的状态都不会再发生被改变


2. 三种状态的变化和表现📂


(1)状态的变化

promise 主要有以上三种状态, pendingfulfilledrejected 。当返回一个 pending 状态的 promise 时,不会触发 thencatch 。当返回一个 fulfilled 状态时,会触发 then 回调函数。当返回一个 rejected 状态时,会触发 catch 回调函数。那在这几个状态之间,他们是怎么变化的呢?

1)演示1

先来看一段代码:

const p1 = new Promise((resolved, rejected) => {
});
console.log('p1', p1); //pending
复制代码

在以上的这段代码中,控制台打印结果如下:

48.png

在这段代码中, p1 函数里面没有内容可以执行,所以一直在等待状态,因此是 pending

2)演示2

const p2 = new Promise((resolved, rejected) => {
    setTimeout(() => {
        resolved();
    });
});
console.log('p2', p2); //pending 一开始打印时
setTimeout(() => console.log('p2-setTimeout', p2)); //fulfilled
复制代码

在以上的这段代码中,控制台打印结果如下:

49.png

在这段代码中, p2 一开始打印的是 pending 状态,因为它没有执行到 setTimeout 里面。等到后续执行 setTimeout 时,才会触发到 resolved 函数,触发后返回一个 fulfilled 状态 promise

3)演示3

const p3 = new Promise((resolved, rejected) => {
    setTimeout(() => {
        rejected();
    });
});
console.log('p3', p3);
setTimeout(() => console.log('p3-setTimeout', p3)); //rejected
复制代码

在以上的这段代码中,控制台打印结果如下。

50.png

在这段代码中, p3 一开始打印的是 pending 状态,因为它没有执行到 setTimeout 里面。等到后续执行 setTimeout 时,同样地,会触发到 rejected 函数,触发后返回一个 rejected 状态的 promise

看完 promise 状态的变化后,相信大家对 promise 的三种状态分别在什么时候触发会有一定的了解。那么我们接下来继续看 promise 状态的表现。


(2)状态的表现

  • pending 状态,不会触发 thencatch
  • fulfilled 状态,会触发后续的 then 回调函数。
  • rejected 状态,会触发后续的 catch 回调函数。

我们来演示一下。

1)演示1

const p1 = Promise.resolve(100); //fulfilled
console.log('p1', p1);
p1.then(data => {
    console.log('data', data);
}).catch(err => {
    console.error('err', err);
});
复制代码

在以上的这段代码中,控制台打印结果如下:

51.png

在这段代码中, p1 调用 promise 中的 resolved 回调函数,此时执行时, p1 属于 fulfilled 状态, fulfilled 状态下,只会触发 .then 回调函数,不会触发 .catch ,所以最终打印出 data 100

2)演示2

const p2 = Promise.reject('404'); //rejected
console.log('p2', p2);
p2.then(data => {
    console.log('data2', data);
}).catch(err => {
    console.log('err2', err);
})
复制代码

在以上的这段代码中,控制台打印结果如下:

52.png

在这段代码中, p2 调用 promise 中的 reject 回调函数,此时执行时, p1 属于 reject 状态, reject 状态下,只会触发 .catch 回调函数,不会触发 .then ,所以最终打印出 err2 404


3. Promise的使用案例📂


对三种状态有了基础了解之后,我们用一个案例来精进对 Promise 的使用。现在,我们想要实现的功能是,通过 fs 模块,异步地调用本地的文件。如果文件存在,那么在控制台上输出文件的内容;如果文件不存在,则将抛出异常。实现代码如下:

const fs = require('fs');
const readFile = (filename) => {
    // 返回一个 promise 实例,以供 then 调用
    const promise = new Promise(function(resolve, reject){
        // 使用 readFile 去异步地读取文件,异步调用也是 promise 函数的意义
        // 注意:下面这个函数的逻辑是错误优先,也就是先err,再data
        fs.readFile(filename, (err, data) => {
            // 如果文件读取失败,就调取 reject ,并抛出异常
            if(err){
                reject(err);
            }else{
                // 如果成功,就调取 resolve ,并返回调用成功的数据
                resolve(data);
            }
        });
    });
    return promise;
}
// 测试代码
// 文件存在逻辑
const existedFile = readFile('./test.txt');
existedFile.then(
    (data) => {
        // Buffer.from()方法用于创建包含指定字符串,数组或缓冲区的新缓冲区。
        // Buffer.from(data).toString()读出文件里面的内容。文件里面记得写内容!!
        console.log('content: ', Buffer.from(data).toString());
    },
    (error) => {
        console.log(error);
    }
)
// 文件不存在逻辑
const failFile = readFile('./fail.txt');
failFile.then(
    (data) => {
        console.log(Buffer.from(data).toString());
    },
    (err) => {
        console.log(err);
    }
);
复制代码

最终控制台的打印结果如下:

[Error: ENOENT: no such file or directory, open 'C:\\promise\\fail.txt'] {
  errno: -4058,
  code: 'ENOENT',
  syscall: 'open',
  path: 'C:\\promise\\fail.txt'
}
content:  这是一个测试文件!
复制代码

大家可以看到,当 ./test.txt 文件存在时,那么 existedFile 会去调用后续的 .then 回调函数,因此最终返回调用成功的结果。注意,这是一个测试文件! 这行字就是 test 文件里面的内容。

同时, ./fail.txt 文件不存在,因此 failFile 会调用后续的 .catch 文件,同时将异常抛出。

现在,大家应该对 promise 的使用有了一定的了解,下面我们继续看 promisethencatch 对状态的影响。


4. then和catch对状态的影响📂


  • then 正常返回 fulfilled ,里面有报错则返回 rejected
  • catch 正常返回 fulfilled ,里面有报错则返回 rejected

我们先来看第一条规则:then 正常返回 fulfilled ,里面有报错则返回 rejected

1)演示1

const p1 = Promise.resolve().then(() => {
    return 100;
})
console.log('p1', p1); //fulfilled状态,会触发后续的.then回调
p1.then(() => {
    console.log('123');
});
复制代码

在以上的这段代码中,控制台打印结果如下。

53.png

在这段代码中, p1 调用 promise 中的 resolve 回调函数,此时执行时, p1 正常返回 fulfilled , 不报错,所以最终打印出 123

2)演示2

const p2 = Promise.resolve().then(() => {
    throw new Error('then error');
});
console.log('p2', p2); //rejected状态,触发后续.catch回调
p2.then(() => {
    console.log('456');
}).catch(err => {
    console.error('err404', err);
});
复制代码

在以上的这段代码中,控制台打印结果如下。

54.png

在这段代码中, p2 调用 promise 中的 resolve 回调函数,此时执行时, p2 在执行过程中,抛出了一个 Error ,所以,里面有报错则返回 rejected 状态 , 所以最终打印出 err404 Error: then error 的结果。

我们再来看第二条规则catch 正常返回 fulfilled ,里面有报错则返回 rejected

1)演示1(需特别谨慎! !)

const p3 = Promise.reject('my error').catch(err => {
    console.error(err);
});
console.log('p3', p3); //fulfilled状态,注意!触发后续.then回调
p3.then(() => {
    console.log(100);
});
复制代码

在以上的这段代码中,控制台打印结果如下。

55.png

在这段代码中, p3 调用 promise 中的 rejected 回调函数,此时执行时, p3 在执行过程中,正常返回了一个 Error这个点需要特别谨慎!!这看起来似乎有点违背常理,但对于 promise 来说,不管时调用 resolved 还是 rejected只要是正常返回而没有抛出异常,都是返回 fulfilled 状态。所以,最终 p3 的状态是 fulfilled 状态,且因为是 fulfilled 状态,之后还可以继续调用 .then 函数。

2)演示2

const p4 = Promise.reject('my error').catch(err => {
    throw new Error('catch err');
});
console.log('p4', p4); //rejected状态,触发.catch回调函数
p4.then(() => {
    console.log(200);
}).catch(() => {
    console.log('some err');
});
复制代码

在以上的这段代码中,控制台打印结果如下。

56.png

在这段代码中, p4 依然调用 promise 中的 reject 回调函数,此时执行时, p4 在执行过程中,抛出了一个 Error ,所以,里面有报错则返回 rejected 状态 , 此时 p4 的状态为 rejected ,之后触发后续的 .catch 回调函数。所以最终打印出 some err 的结果。


5. Promise的并行执行📂


(1)Promise.all

Promise.all 方法用于将多个 Promise 实例包装成一个新的 Promise 实例。比如:

var p = Promise.all([p1, p2, p3]);
复制代码

p的状态由 p1p2p3 决定,分成两种情况:

  • 只有 p1p2p3 的状态都变为 fulfilled ,最终 p 的状态才会变为 fulfilled 。此时 p1p2p3 的返回值组成一个数组,并返回给 p 回调函数。
  • 只要 p1p2p3 这三个参数中有任何一个被 rejected , 那么 p 的状态就会变成 rejected 。此时第一个被 rejected 的实例的返回值将会返回给 p 的回调函数。

下面用一个实例来展示 Promise.all 的使用方式。具体代码如下:

//生成一个Promise对象的数组
var promises = [4, 8, 16, 74, 25].map(function (id) {
    return getJSON('/post/' + id + ".json");
});
Promise.all(promises).then(fucntion (posts) {
  // ...
}).catch(function (reason) {
  // ...       
}}
复制代码

大家可以看到,对于以上代码来说, promises 是包含5个Promise实例的数组,只有这5个实例的状态都变成 fulfilled ,或者其中有一个变为 rejected ,那么才会调用 Promise.all 方法后面的回调函数。


这里有一种值得注意的特殊情况是,如果作为参数的 Promise 实例自身定义了 catch 方法,那么它被 rejected 时并不会触发 Promise.all()catch 方法。这样说可能比较抽象,我们用一个实例来展示一下,具体代码如下:

const p1 = new Promise((resolve, reject) => {
    resolve('hello');
}).then(result => {
    return result;
}).catch(e => {
    return e;
});
const p2 = new Promise((resolve, reject) => {
    throw new Error('报错了');
}).then(result => {
    return result;
}).catch(e => {
    return e;
});
Promise.all([p1, p2]).then(result => {
    console.log(result);
}).catch(e => {
    console.log(e);
})
复制代码

在上面的代码中, p1resolve ,之后调用后续的 .then 回调函数。而 p2reject ,因此之后会调用后续的 .catch 回调函数。注意,这里的 p2 有自己的 catch 方法,且该方法返回的时一个新的 Promise 实例,而 p2 实际上指向的就是这个实例。

所以呢,这个实例执行完 catch 方法后也会变成 resolved 。因此, 在 Promise.all() 这个方法中,其参数里面的两个实例就都会 resolved ,所以之后会调用 then 方法指定的回调函数,而不会调用 catch 方法指定的回调函数。


(2)Promise.race

Promise.race 方法同样是将多个 Promise 实例包装成一个新的 Promise 实例。比如:

var p = Promise.race([p1, p2, p3]);
复制代码

我们同样用以上这段代码来进行分析。与 Promise.all() 不同的是,只要 p1p2p3 中有一个实例率先改变状态,那么** p 的状态就会跟着改变**,且那个率先改变的 Promise 实例的返回值就会传递给 p 的回调函数。

所以呀,为什么它叫 racerace ,顾名思义就是竞赛的意思。在赛场上,第一名永远只有一个。而我们可以把第一名视为第一个 resolve 状态的 promise ,只要第一名出现了,那么结果就是第一名赢了,所以返回的值就是第一个为 resolve 的值。其他人再怎么赛跑都逃不过拿不到第一的现实。


6. 两个有用的附加方法📂


ES6Promise API 并没有提供很多方法,但是我们可以自己来部署一些有用的方法。接下来,我们将来部署两个不在 ES6 中但是却很有用的方法。


(1)done()

无论 Promise 对象的回调链以 then 方法还是 catch 方法结尾,只要最后一个方法抛出错误,那么都有可能出现无法捕捉到的情况。这是为什么呢?原因在于 promise 内部的错误并不会冒泡到全局。因此,我们提供了一个 done 方法。done 方法总是处于回调链的尾端,保证抛出任何可能出现的错误。我们来看下它的使用方式,具体代码如下:

asyncFunc()
  .then(f1)
  .catch(r1)
  .then(f2)
  .done();
复制代码

同时呢,它的实现代码也比较简单,我们来看一下。具体代码如下:

Promise.prototype.done = function (onFulfilled, onRejected) {
    this.then(onFulfilled, onRejected) 
        .catch(function (reason) {
            //抛出一个全局错误
            setTimeout(() => {
                throw reason;
            }, 0);
        })
}
复制代码

由以上代码可知, done 方法可以像 then 方法那样使用,提供 fulfilledrejected 状态的回调函数,也可以不提供任何参数。但是不管如何, done 方法都会捕捉到任何可能出现的错误,并向全局抛出。


(2)finally()

finally 方法用于指定不管 Promise 对象最后状态如何都会执行的操作。它与 done 方法最大的区别在于,它接受一个普通的回调函数作为参数,该函数不管怎样都必须执行。

下面来展示一个例子。假设我们现在有一台服务器,现在让这台服务器使用 Promise 来处理请求,然后使用 finally 方法关掉服务器。具体实现代码如下:

server.listen(0)
  .then(function () {
    // run test
}).finally(server.stop);
复制代码

同样地,它的实现代码也比较简单,我们来看一下。具体代码如下:

Promise.prototype.finally = function (callback) {
    let p = this.constructor;
    return this.then(
        value => p.resolve(callback()).then(() => {
            return value;
        }),
        reason => p.resolve(callback()).then(() => {
            throw reason;
        })
    );
};
复制代码

通过以上代码我们可以了解到,不管前面的 promisefulfilled 还是 rejected ,最终都会执行回调函数 callback

相关文章
|
2月前
|
vr&ar 图形学
2D丨3D元宇宙游戏系统开发详细规则/需求步骤/逻辑方案/源码步骤
Developing a 2D/3D metaverse game system involves multiple aspects, including game design, graphics engines, virtual world construction, social interaction, and economic systems. The following is a summary of a development plan:
|
7月前
|
存储 前端开发 JavaScript
潮玩宇宙大逃杀无聊猿卷轴模式系统开发详细规则丨步骤需求丨方案项目丨技术架构丨源码功能
确定游戏类型和规则:明确无聊猿卷轴模式游戏类型和游戏规则,包括敌人类型、地图设计、任务类型、战斗机制等。
|
2月前
|
前端开发 JavaScript 小程序
系统刷JavaScripit 构建前端体系(语法篇)
系统刷JavaScripit 构建前端体系(语法篇)
18 1
|
2月前
|
自然语言处理 iOS开发
海外短剧系统开发功能指南/案例设计/步骤方案/源码程序
The development of overseas short drama systems needs to consider the following main requirements
什么是元宇宙游戏系统开发案例介绍/方案步骤/需求功能/源码指南
Metaverse game system development refers to the creation of a complex system that combines virtual reality, blockchain technology, and game design, allowing players to immerse themselves in a virtual world and interact with other players, trade virtual assets, and more. This type of gaming system ty
|
2月前
|
安全 区块链
区块链游戏系统开发步骤需求丨功能逻辑丨规则玩法丨指南教程丨源码详细
Developing blockchain game systems has been a highly anticipated field in recent years. By combining blockchain technology and game mechanics, players can enjoy a brand new gaming experience and higher game credibility.
|
5月前
|
机器人 TensorFlow 算法框架/工具
量化交易机器人系统开发详细策略/需求步骤/逻辑方案/源码设计
auto nhwc_data = nhwc_Tensor->host<float>(); auto nhwc_size = nhwc_Tensor->size(); ::memcpy(nhwc_data, image.data, nhwc_size);
|
7月前
|
敏捷开发 存储 测试技术
链动2+1系统开发项目案例丨指南教程丨需求方案丨功能设计丨成熟技术丨步骤逻辑丨源码程序
用户需求导向:系统开发应以用户需求为中心,从用户的角度思考,了解用户的真实需求和期望,以提供优质的用户体验。
|
7月前
|
监控 安全 数据挖掘
泰山众筹系统开发详细指南丨设计方案丨规则玩法丨逻辑功能丨步骤需求丨源码程序
泰山众筹系统是一个基于区块链技术的众筹平台,旨在为用户提供一个安全、透明和高效的众筹环境。