javascript之前端系列,知其然,知其所以然(二)

728 阅读1小时+

整理这份js知识体系的起因是受神三元灵魂之问系列的启发

面对着那么多不断迭代更替的新技术,总是感觉学习时间不够,效果不好而焦虑,是不是自己一开始自己的关注点就错了,关注点不应该在于眼花缭乱的技术,而在于自身知识体系的建设。

虽然每天都在写代码,自己写的到底是什么,很多概念听着好像很熟悉,但是又说不上来。为了弄清楚这些困惑在自己心中的问题,所以开始了这份知识体系的建设。

js系列总共分两篇,这是本系列的第二篇,主要内容是基于EventLoop把知识延伸到宏任务,微任务和异步。接着又梳理了一些常见api的实现的方法。

正如灵魂之问对我的启发,也希望知其然系列的内容对你有所启发。另外,由于个人知识水平有限,如有理解不对的地方,请大家批评指正。

js知识体系梳理思路框架的思维导图如下

javascript知识体系思维导图

image.png

11 EventLoop

每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是我们今天要讲的消息队列和事件循环系统

1. 消息队列和事件循环-浏览器页面主线程是如何运作的

为了能加深刻地理解事件循环机制,我们就从最简单的场景来分析,然后一步步了解浏览器页面主线程是如何运作的。

使用单线程处理安排好的任务

我们先从最简单的场景讲起,比如有如下一系列的任务:

  • 任务 1:1+2
  • 任务 2:20/5
  • 任务 3:7*8
  • 任务 4:打印出任务 1、任务 2、任务 3 的运算结果

我们把所有任务代码按照顺序写进主线程里,等线程执行时,这些任务会按照顺序在线程中依次被执行;等所有任务执行完成之后,线程会自动退出。可以参考下图来直观地理解下其执行过程:

image.png

这就是我们主线程模型第一版:线程的一次执行

在线程运行过程中处理新任务

但并不是所有的任务都是在执行之前统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的。比如在线程执行过程中,又接收到了一个新的任务要求计算“10+2”,那上面那种方式就无法处理这种情况了。

要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制。我们可以通过一个 for 循环语句来监听是否有新的任务。

相较于第一版的线程,这一版的线程做了两点改进。

  • 第一点引入了循环机制,具体实现方式是在线程语句最后添加了一个 for 循环语句,线程会一直循环执行。
  • 第二点是引入了事件,可以在线程运行过程中,等待用户输入的数字,等待过程中线程处于暂停状态,一旦接收到用户输入的信息,那么线程会被激活,然后执行相加运算,最后输出结果。

通过引入事件循环机制,就可以让该线程“活”起来了,我们每次输入两个数字,都会打印出两数字相加的结果,你可以结合下图来参考下这个改进版的线程:

image.png

这就是我们主线程模型第二版:在线程中引入事件循环

处理其他线程发送过来的任务

在第二版的线程模型中,所有的任务都是来自于线程内部的,如果另外一个线程想让主线程执行一个任务,利用第二版的线程模型是无法做到的。

那么如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?

一个通用模式是使用消息队列。那什么是消息队列,可以参考下图:

image.png

从图中可以看出,消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取

有了队列之后,我们就可以继续改造线程模型了,改造方案如下图所示:

image.png

这就是我们主线程模型第三版:队列 + 循环

从上图可以看出,我们的改造如下:

  • 添加一个消息队列;
  • IO 线程中产生的新任务添加进消息队列尾部;
  • 渲染主线程会循环地从消息队列头部中读取任务,执行任务。

处理其他进程发送过来的任务

通过使用消息队列,我们实现了线程之间的消息通信。在 Chrome 中,跨进程之间的任务也是频繁发生的,那么如何处理其他进程发送过来的任务?可以参考下图:

image.png

从图中可以看出,渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就和前面讲解的“处理其他线程发送的任务”一样了。

消息队列和事件循环

通过上面一步步分析,我们终于了解了浏览器页面主线程是如何运作的。浏览器页面是通过事件循环机制来驱动的,每个渲染进程都有一个消息队列,页面主线程按照顺序来执行消息队列中的事件,如执行 JavaScript 事件、解析 DOM 事件、计算布局事件、用户输入事件等等,如果页面有新的事件产生,那新的事件将会追加到事件队列的尾部。所以可以说是消息队列和主线程循环机制保证了页面有条不紊地运行。

2. 消息队列中的任务类型

那接下来我们再来看看消息队列中的任务类型有哪些。你可以参考下Chromium 的官方源码,这里面包含了很多内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。

除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。

以上这些事件都是在主线程中执行的,所以在编写 Web 应用时,还需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题

3. 如何安全退出

当页面主线程执行完成之后,又该如何保证页面主线程能够安全退出呢?Chrome 是这样解决的,确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。

4. 页面使用单线程的缺点

宏任务和微任务:如何处理高优先级的任务。

比如一个典型的场景是监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用 JavaScript 设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。

不过这个模式有个问题,因为 DOM 变化非常频繁,如果每次发生变化的时候,都直接调用相应的 JavaScript 接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降

如果将这些 DOM 变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。

这也就是说,如果 DOM 发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用宏任务方式,又会影响到监控的实时性

那该如何权衡效率和实时性呢?

针对这种情况,微任务就应用而生了,下面我们来看看微任务是如何权衡效率和实时性的。

通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。

等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。

异步:如何解决单个任务执行时长过久的问题。

因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。可以参考下图:

image.png

从图中你可以看到,如果在执行动画过程中,其中有个 JavaScript 任务因执行时间过久,占用了动画单帧的时间,这样会给用户制造了卡顿的感觉,这当然是极不好的用户体验。针对这种情况,JavaScript 可以通过异步来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。

12 宏任务和微任务

前面讲到单线程为了处理高优先级的任务引入了微任务,那微任务和宏任务到底有什么区别呢?它们又是如何相互取长补短的呢?

1. 宏任务

页面中的大部分任务都是在主线程上执行的,这些任务包括了:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件,定时器。

为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列(定时器)和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务。

宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了,下面我们就来分析下为什么宏任务难以满足对时间精度要求较高的任务。

页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间

所以说宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如后面要介绍的监听 DOM 变化的需求。

2. 微任务

异步回调

要了解微任务我们先了解下异步回调,异步回调主要有以下两种方式。

  • 第一种是把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数。这种比较好理解,我们前面介绍的 setTimeout 和 XMLHttpRequest 的回调函数都是通过这种方式来实现的。
  • 第二种方式的执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的。

微任务是什么

那这里说的微任务到底是什么呢?

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

不过要搞清楚微任务系统是怎么运转起来的,就得站在 V8 引擎的层面来分析下。

我们知道当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。顾名思义,这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给 V8 引擎内部使用的,所以你是无法通过 JavaScript 直接访问的。

微任务产生时机

也就是说每个宏任务都关联了一个微任务队列。那么接下来,我们就需要分析两个重要的时间点——微任务产生的时机执行微任务队列的时机

我们先来看看微任务是怎么产生的?在现代浏览器里面,产生微任务有两种方式。

  • 第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  • 第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

微任务执行时机

现在微任务队列中有了微任务了,那接下来就要看看微任务队列是何时被执行的。

通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

3. 监听DOM变化方法演变

我们来看看微任务是如何应用在 MutationObserver 中的。MutationObserver 是用来监听 DOM 变化的一套方法,监听 DOM 变化一直是前端工程师一项非常核心的需求.

比如许多 Web 应用都利用 HTML 与 JavaScript 构建其自定义控件,与一些内置控件不同,这些控件不是固有的。为了与内置控件一起良好地工作,这些控件必须能够适应内容更改、响应事件和用户交互。因此,Web 应用需要监视 DOM 变化并及时地做出响应

轮询检测

早期页面并没有提供对监听的支持,所以那时要观察 DOM 是否变化,唯一能做的就是轮询检测,比如使用 setTimeout 或者 setInterval 来定时检测 DOM 是否有改变。这种方式简单粗暴,但是会遇到两个问题:如果时间间隔设置过长,DOM 变化响应不够及时;反过来如果时间间隔设置过短,又会浪费很多无用的工作量去检查 DOM,会让页面变得低效。

Mutation Event

直到 2000 年的时候引入了 Mutation Event,Mutation Event 采用了观察者的设计模式,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调

采用 Mutation Event 解决了实时性的问题,因为 DOM 一旦发生变化,就会立即调用 JavaScript 接口。但也正是这种实时性造成了严重的性能问题,因为每次 DOM 变动,渲染引擎都会去调用 JavaScript,这样会产生较大的性能开销。也正是因为使用 Mutation Event 会导致页面性能问题,所以 Mutation Event 被反对使用,并逐步从 Web 标准事件中删除了。

MutationObserver

为了解决了 Mutation Event 由于同步调用 JavaScript 而造成的性能问题,从 DOM4 开始,推荐使用 MutationObserver 来代替 Mutation Event。MutationObserver API 可以用来监视 DOM 的变化,包括属性的变化、节点的增减、内容的变化等。

那么相比较 Mutation Event,MutationObserver 到底做了哪些改进呢?

首先,MutationObserver 将响应函数改成异步调用,可以不用在每次 DOM 变化都触发异步调用,而是等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。这样即使频繁地操纵 DOM,也不会对性能造成太大的影响。

我们通过异步调用和减少触发次数来缓解了性能问题,那么如何保持消息通知的及时性呢?这时候,微任务就可以上场了,在每次 DOM 节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8 引擎就会按照顺序执行微任务了。

综上所述, MutationObserver 采用了“异步 + 微任务”的策略。

  • 通过异步操作解决了同步操作的性能问题
  • 通过微任务解决了实时性的问题

4. 再谈EventLoop

前面讲了那么多理论,我们再从实战的角度理解下EventLoop,宏任务和微任务的执行顺序到底是怎样的。分析下面这段代码输出什么?

function bar(){
  console.log('bar')
  Promise.resolve().then(
    (str) =>console.log('micro-bar')
  ) 
  setTimeout((str) =>console.log('macro-bar'),0)
}


function foo() {
  console.log('foo')
  Promise.resolve().then(
    (str) =>console.log('micro-foo')
  ) 
  setTimeout((str) =>console.log('macro-foo'),0)
  
  bar()
}
foo()
console.log('global')
Promise.resolve().then(
  (str) =>console.log('micro-global')
) 
setTimeout((str) =>console.log('macro-global'),0)

我们就来详细分析下 V8 是怎么执行这段 JavaScript 代码的。

(1) 当 V8 执行这段代码时,会将全局执行上下文压入调用栈中,并在执行上下文中创建一个空的微任务队列。

此时的消息队列、主线程和调用栈的状态图如下所示: image.png

(2) 执行 foo 函数的调用,V8 会先创建 foo 函数的执行上下文,并将其压入到栈中。接着执行 Promise.resolve,这会触发一个 micro-foo1 微任务,V8 会将该微任务添加进微任务队列。然后执行 setTimeout 方法。该方法会触发了一个 macro-foo1 宏任务,V8 会将该宏任务添加进消息队列。
(3) foo 函数调用了 bar 函数,那么 V8 需要再创建 bar 函数的执行上下文,并将其压入栈中,接着执行 Promise.resolve,这会触发一个 micro-bar 微任务,该微任务会被添加进微任务队列。然后执行 setTimeout 方法,这也会触发一个 macro-bar 宏任务,宏任务同样也会被添加进消息队列。

此时的消息队列、主线程和调用栈的状态图如下所示:

image.png

(4) bar 函数执行结束并退出,bar 函数的执行上下文也会从栈中弹出,紧接着 foo 函数执行结束并退出,foo 函数的执行上下文也随之从栈中被弹出。
(5) 主线程执行完了 foo 函数,紧接着就要执行全局环境中的代码 Promise.resolve 了,这会触发一个 micro-global 微任务,V8 会将该微任务添加进微任务队列。接着又执行 setTimeout 方法,该方法会触发了一个 macro-global 宏任务,V8 会将该宏任务添加进消息队列。

此时的消息队列、主线程和调用栈的状态图如下所示:

image.png

(6) 等到这段代码即将执行完成时,V8 便要销毁这段代码的环境对象,此时环境对象的析构函数被调用(注意,这里的析构函数是 C++ 中的概念),这里就是 V8 执行微任务的一个检查点,这时候 V8 会检查微任务队列,如果微任务队列中存在微任务,那么 V8 会依次取出微任务,并按照顺行执行。因为微任务队列中的任务分别是:micro-foo、micro-bar、micro-global,所以执行的顺序也是如此。
(7) 等微任务队列中的所有微任务都执行完成之后,当前的宏任务也就执行结束了,接下来主线程会继续重复执行取出任务、执行任务的过程。由于正常情况下,取出宏任务的顺序是按照先进先出的顺序,所有最后打印出来的顺序是:macro-foo、macro-bar、macro-global。

此时的消息队列、主线程和调用栈的状态图如下所示:

image.png

通过以上分析,执行这段代码,我们发现最终打印出来的顺序是:

foo
bar
global
micro-foo
micro-bar
micro-global
macro-foo
macro-bar
macro-global

来让我们看下我们是否真的理解了EventLoop,分析下这段代码打印出来的结果?

async function foo() {
    console.log('foo')
}
async function bar() {
    console.log('bar start')
    await foo()
    console.log('bar end')
}
console.log('script start')
setTimeout(function () {
    console.log('setTimeout')
}, 0)
bar();
new Promise(function (resolve) {
    console.log('promise executor')
    resolve();
}).then(function () {
    console.log('promise then')
})
console.log('script end')

13 JS异步解决方案

1. 异步要解决什么问题以及异步的解决方案

关于异步我们要先清楚的是,无论什么异步解决方案,异步的本质依然是异步任务。各种异步解决方案,主要是为了以一种更加同步的的方式去表达异步流,因为我们的大脑对于事情的计划方式的理解是线性的,阻塞的,单线程的同步的方式,但是回调直接表达异步流的方式是非线性的,非顺序的,这使得我们正确的理解这样的代码难度很大。

什么是异步

所谓"异步",简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。

比如,有一个任务是读取文件进行处理,任务的第一段是向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段(处理文件)。这种不连续的执行,就叫做异步。

相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着。

js为什么会有异步

js是单线程的语言,一次只能执行一个任务,如果有多个任务,就必须排队,任务按顺序执行,只有前一个任务执行完毕才会执行后一个任务。

这种模式的好处是实现起来比较简单,执行环境相对单纯;坏处是只要有一个任务耗时很长,后面的任务都必须排队等着,会延迟整个程序的执行。

比如在浏览器端运行的 js ,可能会有大量的网络请求,而一个网络资源啥时候返回,这个时间是不可预估的。这种情况也要傻傻的等着、卡顿着、啥都不做吗? ———— 那肯定不行。

因此,JS 对于这种场景就设计了异步 ———— 即,发起一个网络请求,就先不管这边了,先干其他事儿,网络请求啥时候返回结果,到时候再说。这样就能保证一个网页的流程运行。

异步实现的核心原理

先看下下面一段代码

var ajax = $.ajax({
    url: '/data/data1.json',
    success: function () {
        console.log('success')
    }
})

上面代码中$.ajax()需要传入两个参数进去,urlsuccess,其中url是请求的路由,success是一个函数。这个函数传递过去不会立即执行,而是等着请求成功之后才能执行。对于这种传递过去不立即执行,等出来结果之后再执行的函数,叫做callback,即回调函数

JavaScript 可以通过回调功能来实现异步,也就是让要执行的 JavaScript 任务滞后执行。实现异步的最核心原理,就是将callback作为参数传递给异步执行函数,当有结果返回之后再触发 callback执行

常用的异步操作

开发中比较常用的异步操作有:

  • 网络请求,如ajax
  • IO 操作,如readFile readdir
  • 定时函数,如setTimeout setInterval

异步解决方案的发展历程

可以通过回调函数Promise生成器Async/Await来实现异步。异步解决方案发展史如下图:

image.png

2. 回调函数

异步编程的问题:代码逻辑不连续

Web 页面的单线程架构决定了异步回调,而异步回调影响到了我们的编码方式,到底是如何影响的呢?

假设有一个下载的需求,使用 XMLHttpRequest 来实现,具体的实现方式你可以参考下面这段代码:


//执行状态
function onResolve(response){console.log(response) }
function onReject(error){console.log(error) }

let xhr = new XMLHttpRequest()
xhr.ontimeout = function(e) { onReject(e)}
xhr.onerror = function(e) { onReject(e) }
xhr.onreadystatechange = function () { onResolve(xhr.response) }

//设置请求类型,请求URL,是否同步信息
let URL = 'https://web.cn'
xhr.open('Get', URL, true);

//设置参数
xhr.timeout = 3000 //设置xhr请求的超时时间
xhr.responseType = "text" //设置响应返回的数据格式
xhr.setRequestHeader("X_TEST","web.cn")

//发出请求
xhr.send();

我们执行上面这段代码,可以正常输出结果的。但是,这短短的一段代码里面竟然出现了五次回调,这么多的回调会导致代码的逻辑不连贯、不线性,非常不符合人的直觉,这就是异步回调影响到我们的编码方式。那有什么方法可以解决这个问题吗?

当然有,我们可以封装这堆凌乱的代码,降低处理异步回调的次数。

封装异步代码,让处理流程变得线性

我们可以把 XMLHttpRequest 请求过程的代码封装起来,重点关注输入数据和输出结果。

那我们就按照这个思路来改造代码。首先,我们把输入的 HTTP 请求信息全部保存到一个 request 的结构中,包括请求地址、请求头、请求方式、引用地址、同步请求还是异步请求、安全设置等信息。request 结构如下所示:

//makeRequest用来构造request对象
function makeRequest(request_url) {
    let request = {
        method: 'Get',
        url: request_url,
        headers: '',
        body: '',
        credentials: false,
        sync: true,
        responseType: 'text',
        referrer: ''
    }
    return request
}

然后就可以封装请求过程了,这里我们将所有的请求细节封装进 XFetch 函数,XFetch 代码如下所示:

//[in] request,请求信息,请求头,延时值,返回类型等
//[out] resolve, 执行成功,回调该函数
//[out] reject  执行失败,回调该函数
function XFetch(request, resolve, reject) {
    let xhr = new XMLHttpRequest()
    xhr.ontimeout = function (e) { reject(e) }
    xhr.onerror = function (e) { reject(e) }
    xhr.onreadystatechange = function () {
        if (xhr.status = 200)
            resolve(xhr.response)
    }
    xhr.open(request.method, URL, request.sync);
    xhr.timeout = request.timeout;
    xhr.responseType = request.responseType;
    //补充其他请求信息
    //...
    xhr.send();
}

这个 XFetch 函数需要一个 request 作为输入,然后还需要两个回调函数 resolve 和 reject,当请求成功时回调 resolve 函数,当请求出现问题时回调 reject 函数。

有了这些后,我们就可以来实现业务代码了,具体的实现方式如下所示:

XFetch(makeRequest('https://web.cn'),
    function resolve(data) {
        console.log(data)
    }, function reject(e) {
        console.log(e)
    })

新的问题:回调地狱

上面的示例代码已经比较符合人的线性思维了,在一些简单的场景下运行效果也是非常好的,不过一旦接触到稍微复杂点的项目时,你就会发现,如果嵌套了太多的回调函数就很容易使得自己陷入了回调地狱,不能自拔。你可以参考下面这段让人凌乱的代码:

XFetch(makeRequest('https://web.cn/?category'),
      function resolve(response) {
          console.log(response)
          XFetch(makeRequest('https://web.cn/column'),
              function resolve(response) {
                  console.log(response)
                  XFetch(makeRequest('https://web.cn')
                      function resolve(response) {
                          console.log(response)
                      }, function reject(e) {
                          console.log(e)
                      })
              }, function reject(e) {
                  console.log(e)
              })
      }, function reject(e) {
          console.log(e)
      })

这段代码之所以看上去很乱,归结其原因有两点:

  • 第一是嵌套调用,下面的任务依赖上个任务的请求结果,并在上个任务的回调函数内部执行新的业务逻辑,这样当嵌套层次多了之后,代码的可读性就变得非常差了。
  • 第二是任务的不确定性,执行每个任务都有两种可能的结果(成功或者失败),所以体现在代码中就需要对每个任务的执行结果做两次判断,这种对每个任务都要进行一次额外的错误处理的方式,明显增加了代码的混乱程度。

原因分析出来后,那么问题的解决思路就很清晰了:

第一是消灭嵌套调用
第二是合并多个任务的错误处理

Promise 已经帮助我们解决了这两个问题。那么接下来我们就来看看 Promise 是怎么消灭嵌套调用和合并多个任务的错误处理的。

3. Promise

Promise怎么解决回调地狱

首先,我们使用 Promise 来重构 XFetch 的代码,示例代码如下所示:

function XFetch(request) {
  function executor(resolve, reject) {
      let xhr = new XMLHttpRequest()
      xhr.open('GET', request.url, true)
      xhr.ontimeout = function (e) { reject(e) }
      xhr.onerror = function (e) { reject(e) }
      xhr.onreadystatechange = function () {
          if (this.readyState === 4) {
              if (this.status === 200) {
                  resolve(this.responseText, this)
              } else {
                  let error = {
                      code: this.status,
                      response: this.response
                  }
                  reject(error, this)
              }
          }
      }
      xhr.send()
  }
  return new Promise(executor)
}

接下来,我们再利用 XFetch 来构造请求流程,代码如下:

var x1 = XFetch(makeRequest('https://web.cn/?category'))
var x2 = x1.then(value => {
    console.log(value)
    return XFetch(makeRequest('https://web.cn/column'))
})
var x3 = x2.then(value => {
    console.log(value)
    return XFetch(makeRequest('https://web.cn/'))
})
x3.catch(error => {
    console.log(error)
})

Promise 利用了三大技术手段来解决回调地狱:

  • 回调函数的延迟绑定
  • 回调函数返回值穿透
  • 错误冒泡
回调函数的延时绑定。
//创建Promise对象x1,并在executor函数中执行业务逻辑
let x1 = new Promise(function executor(resolve, reject){
    resolve(100)
})
//x1延迟绑定回调函数onResolve
x1.then(function onResolve(value){
    console.log(value)
})

回调函数的延时绑定在代码上体现就是先创建 Promise 对象 x1,通过 Promise 的构造函数 executor 来执行业务逻辑;创建好 Promise 对象 x1 之后,再使用 x1.then 来设置回调函数。也就是说回调函数不是直接声明的,而是通过后面的的then方法传入的。

返回值穿透

let x2 = new Promise(function executor(resolve, reject){
    return new Promise(function executor(resolve, reject){
        resolve(100)
    })//内部返回值Promise 穿透到最外层就是x2
})
x2.then(function onResolve(value){
    console.log(value)
})

我们会根据回调函数(onResolve)的传入值来决定创建什么类型的 Promise 任务,创建好的 Promise 对象需要返回到最外层,这样就可以摆脱嵌套循环了。这里的 x 指的就是内部返回的 Promise,然后在 x 后面可以依次完成链式调用。

通过回调函数延迟绑定返回值穿透技术就可以解决回调函数多层嵌套的问题,形式如下:

new Promise(function(resolve, reject){
    return new Promise(function(resolve, reject){
        resolve(100)
    })
}).then(function(value){
    return new Promise(function(resolve, reject){
        resolve(200)
    })
}).then(function(value){
    return new Promise(function(resolve, reject){
        resolve(300)
    })
})

这两种技术结合产生了链式调用的效果。

错误“冒泡”技术
new Promise(function(resolve, reject){
    return new Promise(function(resolve, reject){
        resolve(100)
    })
}).then(function(value){
    return new Promise(function(resolve, reject){
        resolve(200)
    })
}).then(function(value){
    return new Promise(function(resolve, reject){
        resolve(300)
    })
}).catch(err=>{
//错误处理
)

Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被 Reject 函数处理或 catch 语句捕获为止。具备了这样“冒泡”的特性后,就不需要在每个 Promise 对象中单独捕获异常了。

*总结

通过这链式调用(回调函数延迟绑定,返回值穿透)和错误冒泡技术方式,我们就消灭了嵌套调用和频繁的错误处理,这样使得我们写出来的代码更加优雅,更加符合人的线性思维。

Promise为什么引入微任务

Promise 中的执行函数是同步进行的,但是里面存在着异步操作,在异步操作结束后会调用 resolve 方法,或者中途遇到错误调用 reject 方法,这两者都是作为微任务进入到 EventLoop 中。但是你有没有想过,Promise 为什么要引入微任务的方式来进行回调操作?

解决方式

回到问题本身,其实就是如何处理回调的问题。总结起来有三种方式:

  1. 使用同步回调(同步任务),直到异步任务进行完,再进行后面的任务。
  2. 使用异步回调(宏任务),将回调函数放在进行宏任务队列的队尾。
  3. 使用异步回调(微任务),将回调函数放到当前宏任务中的最后面。
优劣对比

第一种方式显然不可取,因为同步的问题非常明显,会让整个脚本阻塞住,当前任务等待,后面的任务都无法得到执行,而这部分等待的时间是可以拿来完成其他事情的,导致 CPU 的利用率非常低,而且还有另外一个致命的问题,就是无法实现延迟绑定的效果。

如果采用第二种方式,那么执行回调(resolve/reject)的时机应该是在前面所有的宏任务完成之后,倘若现在的任务队列非常长,那么回调迟迟得不到执行,造成应用卡顿

为了解决上述方案的问题,另外也考虑到延迟绑定的需求,Promise 采取第三种方式, 即引入微任务, 即把 resolve(reject) 回调的执行放在当前宏任务的末尾。

*总结

这样,利用微任务解决了两大痛点: 执行效率和实时性问题

    1. 采用异步回调替代同步回调解决了浪费 CPU 性能的问题。
    1. 放到当前宏任务最后执行,解决了回调执行的实时性问题。

Promise 的基本实现思想已经讲清楚了,相信大家已经知道了它为什么这么设计,接下来就让我们一步步弄清楚它内部到底是怎么设计的

Promise用法

基本用法
function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('数据加载完成');
      // 或 reject(new Error('加载失败'));
    }, 1000);
  });
}

fetchData()
  .then(data => console.log(data))
  .catch(error => console.error(error));
进阶用法
1. Promise.resolve() / Promise.reject()
// 快速创建已解决的Promise
const resolvedPromise = Promise.resolve('立即解决的值');

// 快速创建已拒绝的Promise
const rejectedPromise = Promise.reject(new Error('立即拒绝'));
2. Promise.all() - 并行执行

所有操作成功,才返回成功,一个失败返回失败

const promise1 = Promise.resolve(1);
const promise2 = new Promise(resolve => setTimeout(() => resolve(2), 1000));
const promise3 = fetch('https://api.example.com/data');

Promise.all([promise1, promise2, promise3])
  .then(values => {
    console.log(values); // [1, 2, response]
  })
  .catch(error => {
    // 任何一个Promise被拒绝就会进入这里
    console.error(error);
  });
3. Promise.allSettled() - 不短路版本

所有操作处理完成 返回成功,

// 异步操作成功时
{status: 'fulfilled', value: value}

// 异步操作失败时
{status: 'rejected', reason: reason}
Promise.allSettled([
  Promise.resolve(1),
  Promise.reject(new Error('失败')),
  Promise.resolve(3)
]).then(results => {
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('成功:', result.value);
    } else {
      console.log('失败:', result.reason);
    }
  });
});
4. Promise.race() - 竞速

返回最先操作处理完成的结果

const timeoutPromise = new Promise((_, reject) => 
  setTimeout(() => reject(new Error('超时')), 5000)
);

const fetchPromise = fetch('https://api.example.com/data');

Promise.race([fetchPromise, timeoutPromise])
  .then(response => {
    console.log('成功获取数据');
  })
  .catch(error => {
    console.error('请求失败或超时', error);
  });
5. Promise.any() - 首个成功

有一个操作成功就成功

Promise.any([
  Promise.reject(new Error('失败1')),
  Promise.reject(new Error('失败2')),
  Promise.resolve('成功')
]).then(result => {
  console.log(result); // "成功"
}).catch(errors => {
  // 所有Promise都失败时进入
  console.error(errors.errors); // [Error: 失败1, Error: 失败2]
});
*核心特点
  • 三种状态:pending、fulfilled、rejected
  • 链式调用(.then().catch()
  • 错误冒泡机制
优缺点
  • 优点

    • 解决回调地狱
    • 更好的错误处理
    • 支持链式调用
  • 缺点

    • 无法取消
    • 中间状态不可控

Promise 如何实现链式调用

从现在开始,我们就来动手实现一个功能完整的Promise,一步步深挖其中的细节。我们先从链式调用开始。

简易版实现

首先写出第一版的代码:

//定义三种状态
const PENDING = "pending";
const FULFILLED = "fulfilled";
const REJECTED = "rejected";

function MyPromise(executor) {
  let self = this; // 缓存当前promise实例
  self.value = null;
  self.error = null; 
  self.status = PENDING;
  self.onFulfilled = null; //成功的回调函数
  self.onRejected = null; //失败的回调函数

  const resolve = (value) => {
    if(self.status !== PENDING) return;
    setTimeout(() => {
      self.status = FULFILLED;
      self.value = value;
      self.onFulfilled(self.value);//resolve时执行成功回调
    });
  };

  const reject = (error) => {
    if(self.status !== PENDING) return;
    setTimeout(() => {
      self.status = REJECTED;
      self.error = error;
      self.onRejected(self.error);//resolve时执行成功回调
    });
  };
  executor(resolve, reject);
}
MyPromise.prototype.then = function(onFulfilled, onRejected) {
  if (this.status === PENDING) {
    this.onFulfilled = onFulfilled;
    this.onRejected = onRejected;
  } else if (this.status === FULFILLED) {
    //如果状态是fulfilled,直接执行成功回调,并将成功值传入
    onFulfilled(this.value)
  } else {
    //如果状态是rejected,直接执行失败回调,并将失败原因传入
    onRejected(this.error)
  }
  return this;
}

可以看到,Promise 的本质是一个有限状态机,存在三种状态:

  • PENDING(等待)
  • FULFILLED(成功)
  • REJECTED(失败)

对于 Promise 而言,状态的改变不可逆,即由等待态变为其他的状态后,就无法再改变了。

不过,回到目前这一版的 Promise, 还是存在一些问题的。

设置回调数组

首先只能执行一个回调函数,对于多个回调的绑定就无能为力,比如下面这样:

let promise1 = new MyPromise((resolve, reject) => {
  fs.readFile('./foo.js','utf-8', (err, data) => {
    if(!err){
      resolve(data);
    }else {
      reject(err);
    }
  })
});

let x1 = promise1.then(data => {
  console.log("第一次展示", data);    
});

let x2 = promise1.then(data => {
  console.log("第二次展示", data);    
});

let x3 = promise1.then(data => {
  console.log("第三次展示", data);    
});

这里我绑定了三个回调,想要在 resolve() 之后一起执行,那怎么办呢?

需要将 onFulfilled onRejected 改为数组,调用 resolve 时将其中的方法拿出来一一执行即可。

self.onFulfilledCallbacks = [];
self.onRejectedCallbacks = [];
MyPromise.prototype.then = function(onFulfilled, onRejected) {
  if (this.status === PENDING) {
    this.onFulfilledCallbacks.push(onFulfilled);
    this.onRejectedCallbacks.push(onRejected);
  } else if (this.status === FULFILLED) {
    onFulfilled(this.value);
  } else {
    onRejected(this.error);
  }
  return this;
}

接下来将 resolve 和 reject 方法中执行回调的部分进行修改:

// resolve 中
self.onFulfilledCallbacks.forEach((callback) => callback(self.value));
//reject 中
self.onRejectedCallbacks.forEach((callback) => callback(self.error));
链式调用完成

我们采用目前的代码来进行测试:

let fs = require('fs');
//封装一个返回promise的函数
let readFilePromise = (filename) => {
  return new MyPromise((resolve, reject) => {
    fs.readFile(filename,'utf-8', (err, data) => {
      if(!err){
        resolve(data);
      }else {
        reject(err);
      }
    })
  })
}
readFilePromise('./foo.js').then(data => {
  console.log(data);    
  return readFilePromise('./bar.js');
}).then(data => {
  console.log(data);
})

//foo.js文件
//foo.js文件

咦?怎么打印了两个 foo.js文件,第二次不是读的 bar.js 文件 文件吗?

问题出在这里:

MyPromise.prototype.then = function(onFulfilled, onRejected) {
  //...
  return this;
}

这么写每次返回的都是第一个 Promise。then 函数当中返回的第二个 Promise 直接被无视了!

说明 then 当中的实现还需要改进, 我们现在需要对 then 中返回值重视起来。

MyPromise.prototype.then = function (onFulfilled, onRejected) {
  let bridgePromise;
  let self = this;//this指向当前的promise
  if (self.status === PENDING) {
    return bridgePromise = new MyPromise((resolve, reject) => {
      self.onFulfilledCallbacks.push((value) => {
        try {
          // 看到了吗?要拿到 then 中回调返回的结果。
          let x = onFulfilled(value);//一个要执行then的里面的回调函数
          resolve(x);//一个要拿到then回调的返回值 还需要resolvePromise处理
        } catch (e) {
          reject(e);
        }
      });
      self.onRejectedCallbacks.push((error) => {
        try {
          let x = onRejected(error);
          resolve(x);
        } catch (e) {
          reject(e);
        }
      });
    });
  }
  //...
}

假若当前状态为 PENDING,将回调数组中添加如上的函数,当 Promise 状态变化后,会遍历相应回调数组并执行回调

但是这段程度还是存在一些问题:

  1. 首先 then 中的两个参数不传的情况并没有处理,
  2. 假如 then 中的回调执行后返回的结果(也就是上面的x)是一个 Promise, 直接给 resolve 了,这是我们不希望看到的。

怎么来解决这两个问题呢?

先对参数不传的情况做判断:

// 成功回调不传给它一个默认函数
onFulfilled = typeof onFulfilled === "function" ? onFulfilled : value => value;
// 对于失败回调直接抛错
onRejected = typeof onRejected === "function" ? onRejected : error => { throw error };

然后对返回Promise的情况进行处理:

function resolvePromise(bridgePromise, x, resolve, reject) {
  //如果x是一个promise
  if (x instanceof MyPromise) {
    // 拆解这个 promise ,直到返回值不为 promise 为止
    
    //可以通过.then链式调用的核心 在.then里面又调用了return的prommise的.then方法
    //错误冒泡也是借用.then(null,reject)方法链式调用一直冒泡到最后的.then方法
    if (x.status === PENDING) {
      x.then(y => {
        resolvePromise(bridgePromise, y, resolve, reject);
      }, error => {
        reject(error);
      });
    } else {
        x.then(resolve, reject);
    }
  } else {
    // 非 Promise 的话直接 resolve 即可
    resolve(x);//回调函数的返回值穿透
  }
}

注意
调用开始的promise.then时候从前到后把所有then中的回调放到成功的或失败的回调函数队列里面

  • 连续多个 then 里的回调方法是同步注册的,但注册到了不同的 callbacks 数组中,因为每次 then 都返回新的 promise 实例(参考上面的例子和图)
  • 注册完成后开始执行构造函数中的异步事件,异步完成之后依次调用 callbacks 数组中提前注册的回调

然后在 then 的方法实现中作如下修改:

resolve(x)  ->  resolvePromise(bridgePromise, x, resolve, reject);

在这里大家好好体会一下拆解 Promise 的过程,其实不难理解,我要强调的是其中的递归调用始终传入的resolvereject这两个参数是什么含义(下一个promise的resove和reject),其实他们控制的是最开始传入的bridgePromise的状态,这一点非常重要

紧接着,我们实现一下当 Promise 状态不为 PENDING 时的逻辑。

成功状态下调用then:

if (self.status === FULFILLED) {
  return bridgePromise = new MyPromise((resolve, reject) => {
    try {
      // 状态变为成功,会有相应的 self.value
      let x = onFulfilled(self.value);
      // 暂时可以理解为 resolve(x),后面具体实现中有拆解的过程
      resolvePromise(bridgePromise, x, resolve, reject);
    } catch (e) {
      reject(e);
    }
  })
}

失败状态下调用then:

if (self.status === REJECTED) {
  return bridgePromise = new MyPromise((resolve, reject) => {
    try {
      // 状态变为失败,会有相应的 self.error
      let x = onRejected(self.error);
      resolvePromise(bridgePromise, x, resolve, reject);
    } catch (e) {
      reject(e);
    }
  });
}

Promise A+中规定成功和失败的回调都是微任务,由于浏览器中 JS 触碰不到底层微任务的分配,可以直接拿 setTimeout(属于宏任务的范畴) 来模拟,用 setTimeout将需要执行的任务包裹 ,当然,上面的 resolve 实现也是同理, 大家注意一下即可,其实并不是真正的微任务。

if (self.status === FULFILLED) {
  return bridgePromise = new MyPromise((resolve, reject) => {
    setTimeout(() => {
      //...
    })
}
if (self.status === REJECTED) {
  return bridgePromise = new MyPromise((resolve, reject) => {
    setTimeout(() => {
      //...
    })
}

好了,到这里, 我们基本实现了 then 方法,现在我们拿刚刚的测试代码做一下测试, 依次打印如下:

001.txt的内容
002.txt的内容

可以看到,已经可以顺利地完成链式调用。

错误捕获及冒泡机制分析

现在来实现 catch 方法:

Promise.prototype.catch = function (onRejected) {
  return this.then(null, onRejected);
}

对,就是这么几行,catch 原本就是 then 方法的语法糖。

相比于实现来讲,更重要的是理解其中错误冒泡的机制,即中途一旦发生错误,可以在最后用 catch 捕获错误。

我们回顾一下 Promise 的运作流程也不难理解,贴上一行关键的代码:

// then 的实现中
onRejected = typeof onRejected === "function" ? onRejected : error => { throw error };

一旦其中有一个PENDING状态的 Promise 出现错误后状态必然会变为失败, 然后执行 onRejected函数,而这个 onRejected 执行又会抛错,把新的 Promise 状态变为失败,新的 Promise 状态变为失败后又会执行onRejected......就这样一直抛下去,直到用catch 捕获到这个错误,才停止往下抛。

这就是 Promise 的错误冒泡机制

至此,Promise 三大法宝: 回调函数延迟绑定回调返回值穿透错误冒泡

实现Promise的resolve、reject、finally

实现 Promise.resolve

有时需要将现有对象转为 Promise 对象,Promise.resolve()方法就起到这个作用。

实现 resolve 静态方法有三个要点:

    1. 传参为一个 Promise, 则直接返回它。
    1. 传参为一个 thenable 对象,返回的 Promise 会跟随这个对象,采用它的最终状态作为自己的状态
    1. 其他情况,直接返回以该值为成功状态的promise对象。

具体实现如下:

Promise.resolve = (param) => {
  if(param instanceof Promise) return param;
  return new Promise((resolve, reject) => {
    if(param && param.then && typeof param.then === 'function') {
      // param 状态变为成功会调用resolve,将新 Promise 的状态变为成功,反之亦然
      param.then(resolve, reject);
    }else {
      resolve(param);
    }
  })
}
实现 Promise.reject

Promise.reject(reason)方法也会返回一个新的 Promise 实例,该实例的状态为rejected

Promise.reject 中传入的参数会作为一个 reason 原封不动地往下传, 实现如下:

Promise.reject = function (reason) {
    return new Promise((resolve, reject) => {
        reject(reason);
    });
}
实现 Promise.prototype.finally

无论当前 Promise 是成功还是失败,调用finally之后都会执行 finally 中传入的函数,并且将值原封不动的往下传。

Promise.prototype.finally = function(callback) {
  this.then(value => {
    return Promise.resolve(callback()).then(() => {
      return value;
    })
  }, error => {
    return Promise.resolve(callback()).then(() => {
      throw error;
    })
  })
}

实现Promise的all和race

实现 Promise.all

Promise.all()方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。

  • Promise.all():等待所有 Promise 都 fulfilled 后才 resolve。

  • Promise.allSettled():等待所有 Promise 都 settled(fulfilled 或 rejected)后才 resolve,返回一个包含每个 Promise 结果的对象。

对于 all 方法而言,需要完成下面的核心功能:

  1. 传入参数为一个空的可迭代对象,则直接进行resolve
  2. 如果参数中有一个promise失败,那么Promise.all返回的promise对象失败。
  3. 在任何情况下,Promise.all 返回的 promise 的完成状态的结果都是一个数组

具体实现如下:

Promise.all = function(promises) {
  return new Promise((resolve, reject) => {
    let result = [];
    let index = 0;
    let len = promises.length;
    if(len === 0) {
      resolve(result);
      return;
    }
   
    for(let i = 0; i < len; i++) {
      // 为什么不直接 promise[i].then, 因为promise[i]可能不是一个promise
      Promise.resolve(promise[i]).then(data => {
        result[i] = data;
        index++;
        if(index === len) resolve(result);
      }).catch(err => {
        reject(err);
      })
    }
  })
}
实现 Promise.race

Promise.race()方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。

race 的实现相比之下就简单一些,只要有一个 promise 执行完,状态发生改变,直接 resolve或则reject 并停止执行。

Promise.race = function(promises) {
  return new Promise((resolve, reject) => {
    let len = promises.length;
    if(len === 0) return;
    for(let i = 0; i < len; i++) {
      Promise.resolve(promise[i]).then(data => {
        resolve(data);
        return;
      }).catch(err => {
        reject(err);
        return;
      })
    }
  })
}
  • Promise.race() 只关心第一个完成的 Promise,不管它是 fulfilled 还是 rejected。

  • 如果所有的 Promise 都 rejected,那么 race 返回的 Promise 也会 rejected,并且会传递第一个 rejected 的原因。
    应用场景
    Promise.race() 在处理多个异步操作时非常有用,它提供了一种竞态机制,可以让我们关注第一个完成的 Promise。通过合理运用 Promise.race(),可以实现超时处理、取消操作等功能,提高程序的健壮性。

  • 竞态条件: 当有多个异步操作同时进行,你只关心第一个完成的操作的结果时,就可以使用 Promise.race()

const promise1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('one');
  }, 3000);
});

const promise2 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('two');
  }, 2000);
});

Promise.race([promise1, promise2])
  .then(value => {
    console.log(value); // 输出:two
  })
  .catch(error => {
    console.error(error);
  });
  • 超时处理: 可以将一个 Promise 与一个超时 Promise 一起传入 Promise.race(),如果在指定时间内没有完成,则执行超时操作。
function timeout(ms) {
  return new Promise((resolve, reject) => {
    setTimeout(reject, ms, 'timeout');
  });
}

Promise.race([
  fetchData(), // 你的数据获取函数
  timeout(2000) // 2秒超时
])
.then(result => {
  console.log(result); // 如果 fetchData 在 2 秒内完成,则输出结果
})
.catch(error => {
  console.error(error); // 如果超时,则输出错误信息
});
  • 取消操作: 可以创建一个 Promise,当需要取消操作时,立即 resolve 或 reject 这个 Promise,从而中断其他正在进行的操作。
function cancellableRace(promises) {
    let cancel;

    const cancelPromise = new Promise((_, reject) => {
        cancel = () => reject(new Error('Operation cancelled'));
    });

    const racePromise = Promise.race([...promises, cancelPromise]);

    return {
        promise: racePromise,
        cancel
    };
}

// 示例使用
const promise1 = new Promise((resolve) => setTimeout(() => resolve('First resolved'), 1000));
const promise2 = new Promise((resolve) => setTimeout(() => resolve('Second resolved'), 2000));

const { promise, cancel } = cancellableRace([promise1, promise2]);

promise
    .then((result) => console.log(result))
    .catch((error) => console.log(error.message));

// 在 500 毫秒后取消
setTimeout(cancel, 500);

// 运行结果: "Operation cancelled"

到此为止,一个完整的 Promise 就被我们实现完啦。从原理到细节,我们一步步拆解和实现,希望大家在知道 Promise 设计上的几大亮点之后,也能自己手动实现一个Promise,让自己的思维层次和动手能力更上一层楼!

Promise的问题

Prmise实现了链式调用,但是每次都需要return一个值出去,再then的方式调用,如果调用非常多,这个调用链也会非常长。有没有更线性,更顺序的方式编码方式去解决这个问题,当然有,Generator

4. Generator

什么是生成器

一般所有的函数都运行直到完毕,换句话说,一旦一个函数开始运行,在它结束之前不会被任何事情打断。

而Generator 函数是 ES6 提供的一种异步编程解决方案,叫生成器,语法行为与传统函数完全不同。

生成器是一个带星号的"函数"(注意:它并不是真正的函数),可以通过yield关键字暂停执行恢复执行

还有,在执行当中的每次暂停/恢复循环都提供了一个双向信息传递的机会。

生成器的执行流程

function* genDemo() {
    console.log("开始执行第一段")
    const yield1 = yield 'generator 1'

    console.log("开始执行第二段")
    console.log(yield1, 'yield1')
    const yield2 = yield 'generator 2'

    console.log("执行结束")
    console.log(yield2, 'yield2')
    return 'generator 3'
}

console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')

// main 0
// 开始执行第一段
// {value: "generator 1", done: false}
// main 1
// 开始执行第二段
//next 1 yield1
// {value: "generator 2", done: false}
// main 2
// 执行结束
//next 2 yield2
// {value: "generator 3", done: true}
// main 3

由此可以看到,生成器的执行有这样几个关键点:

  1. 调用 gen() 后,程序会阻塞住,不会执行任何语句。
  2. 调用 g.next() 后,程序继续执行,直到遇到 yield 程序暂停。
  3. 实际上next 方法返回一个对象, 有两个属性: valuedone。value 为当前 yield 后面的结果,done 表示是否执行完,遇到了return 后,done 会由false变为true
  4. 注意调用next恢复gen的执行,next中的参数可以传递给yield前面的值

生成器函数的具体使用方式:

  1. 在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行。
  2. 外部函数可以通过 next 方法恢复函数的执行。

生成器为何能暂停和恢复?首先要了解协程的概念

生成器的实现机制-协程

什么是协程

协程是一种比线程更加轻量级的存在,协程处在线程的环境中,一个线程可以存在多个协程,可以将协程理解为线程中的一个个任务。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统控制,而完全是由程序代码所控制。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

协程的执行流程

那你可能要问了,JS 不是单线程执行的吗,开这么多协程难道可以一起执行吗?

答案是:并不能。那多个协程的执行流程是怎样的?一个线程一次只能执行一个协程。比如当前执行 A 协程,另外还有一个 B 协程,如果想要执行 B 的任务,就必须在 A 协程中将 JS 线程的控制权转交给 B协程,那么现在 B 执行,A 就相当于处于暂停的状态。同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。

为了让更好地理解协程是怎么执行的,结合上面那段代码的执行过程,可以画出下面的“协程执行流程图”,可以对照着代码来分析:

image.png

从图中可以看出来协程的四点规则:

  1. 通过调用生成器函数 genDemo 来创建一个协程 gen,创建之后,gen 协程并没有立即执行。
  2. 要让 gen 协程执行,需要通过调用 gen.next。
  3. 当协程正在执行的时候,可以通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程。
  4. 如果协程在执行期间,遇到了 return 关键字,那么 JavaScript 引擎会结束当前协程,并将 return 后面的内容返回给父协程。

不过,对于上面这段代码,你可能又有这样疑问:父协程有自己的调用栈,gen 协程时也有自己的调用栈,当 gen 协程通过 yield 把控制权交给父协程时,V8 是如何切换到父协程的调用栈?当父协程通过 gen.next 恢复 gen 协程时,又是如何切换 gen 协程的调用栈?

要搞清楚上面的问题,你需要关注以下两点内容。

  1. 第一点:gen 协程和父协程是在主线程上交互执行的,并不是并发执行的,它们之前的切换是通过 yield 和 gen.next 来配合完成的。
  2. 第二点:当在 gen 协程中调用了 yield 方法时,JavaScript 引擎会保存 gen 协程当前的调用栈信息,并恢复父协程的调用栈信息。同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息。

为了直观理解父协程和 gen 协程是如何切换调用栈的,你可以参考下图:

image.png

到这里相信你已经弄清楚了协程是怎么工作的,其实在 JavaScript 中,生成器就是协程的一种实现方式,这样相信你也就理解什么是生成器了。

可能你还会有疑问: 这个生成器不就暂停-恢复、暂停-恢复这样执行的吗?它和异步有什么关系?而且,每次执行都要调用next,能不能让它一次性执行完毕呢?下一节我们就来仔细拆解这些问题。

Generator的异步应用

这里面其实有两个问题:

  1. Generator 如何跟异步产生关系?
  2. 怎么把 Generator 按顺序执行完毕?
thunk函数

Thunk 函数是自动执行 Generator 函数的一种方法。

在 JavaScript 语言中,Thunk 函数通过替换多参数函数,将其替换成一个只接受回调函数作为参数的单参数函数。 我们看下面的代码:

//定义参数a,参数callback为回调函数,
function foo(a,callback){
    callback(a)
}
//我们可以这样调用foo
foo(1,res=>{
    console.log(res,'res1')
})

我们foo函数改写如下

function thunkFoo(a){
    return function(callback){
        callback(a)
    }
}

let foo2=thunkFoo(2)
foo2(res => console.log(2,'res2'))

我们通过调用thunkFoo生成foo2,再调用foo2,我们把两个参数的foo函数,改成只有一个回调函数作为参数的单参数函数,这就是thunk函数。这样改写貌似变得更复杂了,你看到的复杂仅仅是表面的,这一点东西变的复杂,是为了让以后更加复杂的东西变得简单

Generator+Thunk版异步

文件操作(在node.js运行环境中,下同 )为例,我们来看看 异步操作 如何应用于Generator

// 正常版本的readFile(多参数版本)
// fs.readFile(fileName, options,callback);

// Thunk版本的readFile(单参数版本)
var readFileThunk = function (fileName) {
    return function (callback) {
        return fs.readFile(fileName,'utf-8',callback);
    };
};

readFileThunk就是一个thunk函数。异步操作核心的一环就是绑定回调函数,而thunk函数可以帮我们做到。首先传入文件名,然后生成一个针对某个文件的定制化函数。这个函数中传入回调,这个回调就会成为异步操作的回调。这样就让 Generator异步关联起来了。

紧接者我们做如下的操作:

const gen = function* () {
    const foo = yield readFileThunk('foo.js')
    console.log(foo)
    const bar = yield readFileThunk('bar.js')
    console.log(bar)
}

接着我们让它执行完:

let g = gen();
// 第一步: 由于进场是暂停的,我们调用next,让它开始执行。
// next返回值中有一个value值,这个value是yield后面的结果,放在这里也就是是thunk函数生成的定制化函数,里面需要传一个回调函数作为参数
g.next().value((err, data1) => {
  // 第二步: 拿到上一次得到的结果(foo.js),调用next, 将结果foo.js作为参数传入,程序继续执行。
  // 同理,value传入回调
  g.next(data1).value((err, data2) => {
  //当在父协程中调用g.next(foo.js),相当于把foo.js传给g协程中的foo了,所以这里要高明白foo的值怎么来的
    g.next(data2);
  })
})

//打印结果
//foo.js文件
//bar.js文件

上面嵌套的情况还算简单,如果任务多起来,就会产生很多层的嵌套,可操作性不强,有必要把执行的代码封装一下:

function run(gen){
  const next = (err, data) => {
    let res = gen.next(data);
    if(res.done) return;
    res.value(next);
  }
  next();
}
run(g);
//打印结果
//foo.js文件
//bar.js文件

再次执行,依然打印正确的结果。上面代码的run函数,就是一个 Generator 函数的自动执行器。内部的next函数就是 Thunk 的回调函数。next函数先将指针移到 Generator 函数的下一步(gen.next方法),然后判断 Generator 函数是否结束(result.done属性),如果没结束,就将next函数再传入 Thunk 函数(result.value属性),否则就直接退出。

Thunk 函数是自动按顺序执行 Generator 函数,这是通过thunk完成异步操作的情况。

Generator+Promise版异步

看下Generator+Promise版异步 还是上面例子

const readFilePromise = (filename) => {
    return new Promise((resole, reject) => {
        fs.readFile(filename, 'utf-8', (err, data) => {
            if (err) {
                reject(err)
            } else {
                resole(data)
            }
        })
    })
}

const gen = function* () {
    const foo = yield readFilePromise('foo.js')
    console.log(foo)
    const bar = yield readFilePromise('bar.js')
    console.log(bar)
}

执行的代码如下:

let g = gen()
function getGenPromise(gen,data){
    return gen.next(data).value
}
getGenPromise(g).then(data1=>{
    return getGenPromise(g,data1)
}).then(data2=>{
    return getGenPromise(g,data2)
})
//打印结果
//foo.js文件
//bar.js文件

同样,我们可以对执行Generator的代码加以封装:

function run(g) {
    const next = (data) => {
        let res = g.next(data);
        if (res.done) return;
        res.value.then(data => {
            next(data);
        })
    }
    next();
}

run(g)
//打印结果
//foo.js文件
//bar.js文件

同样能输出正确的结果。代码非常精炼,希望能参照刚刚链式调用的例子,仔细体会一下递归调用的过程。

co库

以上我们针对 thunk 函数Promise两种Generator异步操作的一次性执行完毕做了封装,但实际场景中已经存在成熟的工具包了,如果大名鼎鼎的co库, 其实核心原理就是我们已经手写过了(就是刚刚封装的Promise情况下的执行代码),只不过源码会各种边界情况做了处理。使用起来非常简单:

const co = require('co');
let g = gen();
co(g).then(res =>{
  console.log(res);
})

coGenerator结合使用,简单几行代码就完成了Generator所有的操作。

Generator的问题

使用Generator解决方式看着更线性,更顺序了,类似于同步的书写代码模式,但是有个问题,就是我们需要执行器,去执行这个同步编写的代码。有没有不用执行器,也可以用同步的方式去编写异步的代码,也有async+await

5. Async+await

什么是async函数

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。

async 函数是什么?一句话,它就是 Generator 函数的语法糖。

前文有一个 Generator 函数,依次读取两个文件。

const readFilePromise = (filename) => {
    return new Promise((resole, reject) => {
        fs.readFile(filename, 'utf-8', (err, data) => {
            if (err) {
                reject(err)
            } else {
                resole(data)
            }
        })
    })
}

const gen = function* () {
    const foo = yield readFilePromise('foo.js')
    console.log(foo)
    const bar = yield readFilePromise('bar.js')
    console.log(bar)
}

上面代码的函数gen可以写成async函数,就是下面这样。

const asyncReadFile = async function () {
    const foo = await readFilePromise('foo.js')
    console.log(foo)
    const bar = await readFilePromise('bar.js')
    console.log(bar)
}

一比较就会发现,async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await,仅此而已。

async函数对 Generator 函数的改进,体现在以下四点。

(1)内置执行器。

Generator 函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。

asyncReadFile();

上面的代码调用了asyncReadFile函数,然后它就会自动执行,输出最后结果。这完全不像 Generator 函数,需要调用next方法,或者用co模块,才能真正执行,得到最后结果。

(2)更好的语义。

asyncawait,比起星号和yield,语义更清楚了。async表示函数里有异步操作await表示紧跟在后面的表达式需要等待结果

(3)更广的适用性。

co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)

(4)返回值是 Promise。

async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。

进一步说,async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖(多个异步操作串行之行)

ES7 中引入了 async/await,这种方式能够彻底告别执行器和生成器,实现更加直观简洁的代码。其实 async/await 技术背后的秘密就是 Promise 和生成器应用,往低层说就是微任务和协程应用。要搞清楚 async 和 await 的工作原理,我们就得对 async 和 await 分开分析。

Async

根据 MDN 定义,async 是一个通过异步执行并隐式返回 Promise 作为结果的函数

对 async 函数的理解,这里需要重点关注两个词:异步执行和隐式返回 Promise
async 关键字

  • 将一个函数声明为异步函数。
  • 异步函数总是返回一个 Promise 对象。
  • 在异步函数内部,可以使用 await 关键字。

这里我们先来看看是如何隐式返回 Promise 的,你可以参考下面的代码:

async function foo() {
    return 'foo'
}
console.log(foo())  // Promise {<resolved>: foo}

执行这段代码,我们可以看到调用 async 声明的 foo 函数返回了一个 Promise 对象,状态是 resolved,这就是隐式返回Promise的效果。

Await

await 关键字

  • 只能用在 async 函数内部。
  • 后面跟一个 Promise。
  • 会暂停异步函数的执行,直到 Promise resolve,然后返回 resolve 的值。 下面我们再看看 await 到底做了什么事情。

async function foo() {
    console.log(1)
    let a = await 100
    console.log(a)
    console.log(2)
}
console.log(0)
foo()
console.log(3)

我们先站在协程的视角来看看这段代码的整体执行流程图:

image.png

结合上图,我们来一起分析下 async/await 的执行流程。

首先,执行console.log(0)这个语句,打印出来 0。

紧接着就是执行 foo 函数,由于 foo 函数是被 async 标记过的,所以当进入该函数的时候,JavaScript 引擎会保存当前的调用栈等信息,然后执行 foo 函数中的console.log(1)语句,并打印出 1。

接下来就执行到 foo 函数中的await 100这个语句了,这里是我们分析的重点,因为在执行await 100这个语句时,JavaScript 引擎在背后为我们默默做了太多的事情,那么下面我们就把这个语句拆开,来看看 JavaScript 到底都做了哪些事情。

当执行到await 100时,会默认创建一个 Promise 对象,代码如下所示:

let promise_ = new Promise((resolve,reject){
  resolve(100)
})

在这个 promise_ 对象创建的过程中,我们可以看到在 executor 函数中调用了 resolve 函数,当调用resolve函数的时候,JavaScript 引擎会将该任务提交给微任务队列

然后 JavaScript 引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将 promise_ 对象返回给父协程。

主线程的控制权已经交给父协程了,这时候父协程要做的一件事是调用 promise_.then 来监控 promise 状态的改变。当promise的状态变为fulfilled的时候会调用resolve(100),把promise_.then 中的回调函数添加到微任务队列。

接下来继续执行父协程的流程,这里我们执行console.log(3),并打印出来 3。随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列,微任务队列中有resolve(100)的任务等待执行,执行到这里的时候,会触发 promise_.then 中的回调函数,如下所示:

promise_.then((value)=>{
   //回调函数被激活后
  //将主线程控制权交给foo协程,并将vaule值传给协程
})

该回调函数被激活以后,会将主线程的控制权交给 foo 函数的协程,并同时将 value 值传给该协程。foo 协程激活之后,会把刚才的 value 值赋给了变量 a,然后 foo 协程继续执行后续语句,执行完成之后,将控制权归还给父协程。

最终打印结果如下

//  0
//  1
//  3
//  100
//  2

总结一下,async/await利用协程Promise实现了同步方式编写异步代码的效果,其中Generator是对协程的一种实现,虽然语法简单,但引擎在背后做了大量的工作,我们也对这些工作做了一一的拆解。用async/await写出的代码也更加优雅、美观,相比于之前的Promise不断调用then的方式,语义化更加明显,相比于co + Generator性能更高,上手成本也更低,不愧是JS异步终极解决方案!

async + await的问题

需要try catch 捕获错误,适用于串行执行,前后请求有依赖关系,如果是并行请求没必要使用

forEach 中用 await 会产生什么问题?怎么解决这个问题?

问题

问题:对于异步代码,forEach 并不能保证按顺序执行。

举个例子:

async function test() {
	let arr = [4, 2, 1]
	arr.forEach(async item => {
		const res = await handle(item)
		console.log(res)
	})
	console.log('结束')
}

function handle(x) {
	return new Promise((resolve, reject) => {
		setTimeout(() => {
			resolve(x)
		}, 1000 * x)
	})
}

test()

我们期望的结果是:

4 
2 
1
结束

但是实际上会输出:

结束
1
2
4
问题原因

这是为什么呢?我想我们有必要看看forEach底层怎么实现的。

// 核心逻辑 注意是let声明的块级作用域
for (let i = 0; i < length; i++) {
    if (i in array) {
      var element = array[i];
      callback(element, i, array);
    }
}

可以看到,forEach 拿过来直接执行了,这就导致它无法保证异步任务的执行顺序。比如后面的任务用时短,就先resove 把回调函数先添加到微任务中,那么就又可能抢在前面的任务之前执行。

解决方案

如何来解决这个问题呢?

其实也很简单, 我们利用for...of就能轻松解决。

async function test() {
  let arr = [4, 2, 1]
  for(const item of arr) {
	const res = await handle(item)
	console.log(res)
  }
	console.log('结束')
}
解决原理-Iterator

好了,这个问题看起来好像很简单就能搞定,你有想过这么做为什么可以成功吗?

其实,for...of并不像forEach那么简单粗暴的方式去遍历执行,而是采用一种特别的手段——迭代器去遍历。

首先,对于数组来讲,它是一种可迭代数据类型。那什么是可迭代数据类型呢?

原生具有[Symbol.iterator]属性数据类型为可迭代数据类型。如数组、类数组(如arguments、NodeList)、Set和Map。

迭代器是一种有序的,连续的,基于拉取的用于消耗数据的组织方式。

可迭代对象可以通过迭代器进行遍历。

let arr = [4, 2, 1];
// 这就是迭代器
let iterator = arr[Symbol.iterator]();
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());


// {value: 4, done: false}
// {value: 2, done: false}
// {value: 1, done: false}
// {value: undefined, done: true}

因此,我们的代码可以这样来组织:

async function test() {
  let arr = [4, 2, 1]
  let iterator = arr[Symbol.iterator]();
  let res = iterator.next();
  while(!res.done) {
    let value = res.value;
    await handle(value);
    console.log(value);
    res = iterator.next();
  }
	console.log('结束')
}
// 4
// 2
// 1
// 结束

多个任务成功地按顺序执行!其实刚刚的for...of循环代码就是这段代码的语法糖。

重新认识生成器

回头再看看用iterator遍历[4,2,1]这个数组的代码。

let arr = [4, 2, 1];
// 迭代器
let iterator = arr[Symbol.iterator]();
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());
console.log(iterator.next());

// {value: 4, done: false}
// {value: 2, done: false}
// {value: 1, done: false}
// {value: undefined, done: true}

返回值有valuedone属性,生成器也可以调用 next,返回的也是这样的数据结构,这么巧?!

没错,生成器本身就是一个迭代器生成器是通过迭代器控制的

其实这个也解释了我们为何可以用生成器按顺序的方式执行异步任务了

既然属于迭代器,那它就可以用for...of遍历了吧?

当然没错,不信来写一个简单的斐波那契数列(50以内):

function* fibonacci(){
  let [prev, cur] = [0, 1];
  console.log(cur);
  while(true) {
    [prev, cur] = [cur, prev + cur];
    yield cur;
  }
}

for(let item of fibonacci()) {
  if(item > 50) break;
  console.log(item);
}
// 1
// 1
// 2
// 3
// 5
// 8
// 13
// 21
// 34

这就是迭代器的魅力,同时又对生成器有了更深入的理解,没想到我们的老熟人Generator还可以这样。

14 手写call,apply和bind

1. call、apply、bind的区别及用途

区别

方法调用时机参数传递方式返回值
call立即调用参数列表函数执行结果
apply立即调用参数数组函数执行结果
bind不调用参数列表绑定后的新函数

apply 第一个参数指定函数体内this对象的指向,第二个参数为一个带下标的集合,这个集合可以是数组,也可以是类数组,apply方法把这些元素作为参数传递给被调用的函数;

call 第一个参数指定函数体内this对象的指向,从第二个参数开始往后,每个参数被依次传入函数,call方法把这些元素作为参数传递给被调用的函数;

bind 第一个参数指定函数体内this对象的指向,参数可以同call一样传入,也可以分多次传入,返回一个函数

  • 都可以改变函数体内 this 指向,并执行这个函数,借用其他对象的方法
  • call 和 apply 会立即执行,bind 不会,而是返回一个函数
  • call 和 bind 可以接收多个参数apply 只能接受两个,第二个是数组
  • bind 参数可以分多次传入

用途

改变函数体内this指向

let obj = {
    c: 'c'
}

function foo(a, b) {
    return a + b + this.c
}

console.log(foo.call(obj, 'a', 'b')) //abc 依次传入多个参数,立即执行
console.log(foo.apply(obj, ['a', 'b'])) //abc 第二个参数为数组,立即执行
console.log(foo.bind(obj, 'a')('b')) //abc bind可以分多次传入参数,返回一个函数,再执行
console.log(foo.bind(obj, 'a', 'b')()) //abc

借用其他对象的方法

//借用Array的concat 方法把类数组arguments 转化为数组
function bar(a, b) {
    let arr = Array.prototype.concat.apply([], arguments)
    console.log(arr.pop()) //2
}
bar(1, 2)

2. 手写call

Function.prototype.myCall=function(context){

    // 首先 context 为可选参数,如果不传的话默认上下文为 window
    context = context || window
    const fn=Symbol('fn')

    //更改this指向 接下来给 context 创建一个 fn 属性,并将值设置为需要调用的函数
    context.fn=this//this指向调用call的函数 fn中的this自然指向context

    //传参 因为 call 可以传入多个参数作为调用函数的参数,截取第二位后面的参数
    const args = [...arguments].slice(1)

    //执行函数 然后调用函数并将对象上的函数删除
    const result = context.fn(...args)
    delete context.fn
    return result
}

Function.prototype.myCall = function(context, ...args) {
    // 处理 context
    context = context || (typeof window !== 'undefined' ? window : globalThis);
    
    // 创建唯一属性名
    const fnKey = '__fn_' + Date.now();
    
    // 绑定函数
    context[fnKey] = this;
    
    // 执行函数
    const result = context[fnKey](...args);
    
    // 清理
    delete context[fnKey];
    
    return result;
}

3. 手写apply

Function.prototype.myApply=function(context){

    context = context ||window
    const fn =Symbol('fn')
    //更改this指向
    context.fn=this
    
    //传参 并执行函数
    let result 
    //判断有没有数组参数
    if(arguments[1]){
        result = context.fn(...arguments[1])
    }else{
        result = context.fn()
    }
    delete context.fn
    return result
}
Function.prototype.myApply = function(context, argsArray) {
    // 1. 处理 context 参数
    context = context || (typeof window !== 'undefined' ? window : globalThis);
    
    // 2. 创建唯一属性名避免冲突
    const fnKey = '__applyFn_' + Date.now();
    
    // 3. 将当前函数绑定到 context 上
    context[fnKey] = this;
    
    // 4. 处理参数数组(确保是数组或类数组对象)
    argsArray = argsArray || [];
    if (!Array.isArray(argsArray) && !isArrayLike(argsArray)) {
        throw new TypeError('CreateListFromArrayLike called on non-object');
    }
    
    // 5. 执行函数并保存结果
    const result = context[fnKey](...argsArray);
    
    // 6. 清理添加的属性
    delete context[fnKey];
    
    // 7. 返回结果
    return result;
};

// 辅助函数:判断是否为类数组对象
function isArrayLike(obj) {
    return obj && typeof obj.length === 'number' && obj.length >= 0;
}

4. 手写bind

Function.prototype.myBind = function (context) {
    if (typeof this !== 'function') {
        throw new TypeError('Error')
    }
    const _this = this
    //bind中的参数
    const args = [...arguments].slice(1)
    // 返回一个函数
    return function F() {
        // 因为返回了一个函数,我们可以 new F(),所以需要判断
        if (this instanceof F) {
            //合并bind中的参数args和调用返回函数中的参数arguments
            return new _this(...args, ...arguments)
        }
        return _this.apply(context, args.concat(...arguments))
    }
}

以下是对实现的分析:

  • 前几步和之前的实现差不多,就不赘述了
  • bind 返回了一个函数,对于函数来说有两种方式调用,一种是直接调用,一种是通过 new 的方式,我们先来说直接调用的方式
  • 对于直接调用来说,这里选择了 apply 的方式实现,但是对于参数需要注意以下情况:因为 bind 可以实现类似这样的代码 f.bind(obj, 1)(2),所以我们需要将两边的参数拼接起来,于是就有了这样的实现 args.concat(...arguments)
  • 最后来说通过 new 的方式,在之前的章节中我们学习过如何判断 this,对于 new 的情况来说,不会被任何方式改变 this,所以对于这种情况我们需要忽略传入的 this

15 如何模拟实现new的效果

new被调用后达到了什么效果:

  1. 让实例可以访问构造函数原型(constructor.prototype)所在原型链上的属性
  2. 让实例可以访问到私有属性。
  3. 如果构造函数返回的结果不是引用数据类型

那怎么才能达到new的效果呢?new一个对象的时候这中间发生了什么,以这种方式调用构造函数实际上会经历以下 4 个步骤:

  1. 创建一个新对象,这个对象将会作为执行 new 构造函数() 之后,返回的对象实例。

  2. 将新对象的原型(proto),指向构造函数的 prototype 属性。

  3. 将构造函数内部的this指向这个对象,并执行构造函数逻辑(为这个新对象添加属性)。

  4. 返回新对象,根据构造函数执行逻辑,返回第一步创建的对象或者构造函数的显式返回值。

    function myNew(fn, ...args) {
        // 不是函数不能 new
        if (typeof fn !== "function") {
            throw new Error('TypeError')
        }
        // 继承原型 第(1)(2)步创建一个继承 fn 原型的对象 原型继承 
        // 使用现有的对象来作为新创建对象的原型(prototype)
        const newObj = Object.create(fn.prototype);
        //继承属性 构造函数继承 
        //第(3) 将 fn 的 this 绑定给新对象,并继承其属性,然后获取返回结果
        const result = fn.apply(newObj, args);
        // 根据 result 对象的类型决定返回结果
        return result && (typeof result === "object" || typeof result == "function") ? result : newObj;
    }

大家看这个是不是有点眼熟,通过Object.create继承原型,fn.apply实现构造函数继承属性,像不像寄生组合继承,其实实现new的核心就是寄生组合继承

16 函数的arguments为什么不是数组?如何转化成数组?

类数组对象是指具有 length 属性和索引元素的对象

因为arguments本身并不能调用数组方法,它是一个另外一种对象类型,只不过属性从0开始排,依次为0,1,2...最后还有callee和length属性。我们也把这样的对象称为类数组。

常见的类数组还有:

    1. 用getElementsByTagName/ClassName()获得的HTMLCollection
    1. 用querySelector获得的nodeList

那这导致很多数组的方法就不能用了,必要时需要我们将它们转换成数组,有哪些方法呢?

1. 扩展运算符

//[...arrayLike]
function foo(a,b) {
    let arr=[...arguments]
    console.log(arr.pop())//2
}
foo(1,2)

2. Array.from

Array.from()  方法从一个类似数组或可迭代对象创建一个新的,浅拷贝的数组实例。

function foo(a,b) {
    let arr=Array.from(arguments)
    console.log(arr.pop())//2
}
foo(1,2)

3. Array.prototype.slice

slice()  方法返回一个新的数组对象,这一对象是一个由 begin 和 end 决定的原数组的浅拷贝(包括 begin,不包括end)。原始数组不会被改变。

function foo(a,b) {
    let arr=Array.prototype.slice.call(arguments)
    console.log(arr.pop())//2
}
foo(1,2)

4. Array.apply

function foo(a,b) {
    let arr=Array.apply(null,arguments)
    console.log(arr.pop())//2
}
foo(1,2)

5. Array.prototype.concat

concat()  方法用于合并两个或多个数组。此方法不会更改现有数组,而是返回一个新数组。

function foo(a,b) {
    let arr=Array.prototype.concat.apply([],arguments)
    console.log(arr.pop())//2
}
foo(1,2)

17 数组扁平化

多维数组转为一维数组 如下

let ar = [1, [2, [3, [4, 5]]], 6];// -> [1, 2, 3, 4, 5, 6]
let str = JSON.stringify(arr);

1. es6中的flat方法

// falt() 参数几拉平基层数组 不管有多少层嵌套,都要转成一维数组,可以用Infinity关键字作为参数
let flatArr = arr.flat(1)
let flatArr = arr.flat(Infinity)

2. replace+split

let flatArr = str.replace(/(\[|\])/g, '').split(',')

3. replace+JSON.parse

//转成字符串,再去掉字符串里的 “[” 和 “]”,再把字符串转回数组
str = str.replace(/(\[|\])/g, '')
str = '[' + str + ']'
let flatArr = JSON.parse(str)

4. for循环+递归

let flat = function (arr) {
    let result = []
    let arrFlat = function (arr) {
        for (var i = 0; i < arr.length; i++) {
            let item = arr[i]
            if (Array.isArray(arr[i])) {
                arrFlat(item)
            } else {
                result.push(item)
            }
        }
    }
     arrFlat(arr)
    return result
}

5. reduce函数迭代+递归

// 用递归,用 for 循环加递归也可以,这里用 reduce 
// reduce 累计器,本质上也是循环, 
// cur 是循环的当前一个值,相当于 for循环里的arr[i], pre 是前一个值,相当于for循环里的arr[i-1]
function arrFlat(arr) {
    return arr.reduce((pre, cur) => {
        return pre.concat(Array.isArray(cur) ? arrFlat(cur) : cur)
    }, [])
}
let flatArr = arrFlat(arr)

6. reduce + concat 非递归

function flatten(arr) {
  return arr.reduce((pre, cur) => pre.concat(cur), []);
}

const nested = [1, [2, [3, [4]], 5]];
console.log(flatten(nested)); // [1, 2, [3, [4]], 5] (仅扁平化一层)

7. 扩展运算符

//只要有一个元素有数组,那么循环继续
while (arr.some(Array.isArray)) {
    arr = [].concat(...arr)
}
console.log(arr)

注意事项

  1. 空位处理

    const arr = [1, , 3, [4, , 6]];
    console.log(arr.flat()); // [1, 3, 4, 6] - 空位会被移除
    
  2. 非数组元素

    const arr = [1, { a: 2 }, [3, 4]];
    console.log(arr.flat()); // [1, { a: 2 }, 3, 4] - 对象不会被展开
    
  3. 深度控制

    • 递归方法可以精确控制扁平化深度
    • flat() 方法通过参数控制深度

18 数组排序

选择排序

//选择排序:拿其中一个和后面所有的进行比较,如果存在比前面更小,交换位置 筛选出最小的放最前面
let arrSort=[2,3,1,4,1,5,6]
function sort(arr) {
    for (var i = 0; i < arr.length; i++) {
        for (var j = i + 1; j < arr.length; j++) {
            if (arr[i] > arr[j]) {
                var temp = arr[i];
                arr[i] = arr[j];
                arr[j] = temp;
            }
        }
    }
    return arr
}

冒泡排序

//冒泡排序:相邻的两个进行比较 筛选出最大的值放到最后面或最前面
function sort(arr) {
    for (var i = 0; i < arr.length; i++) {
        for (var j = 0; j < arr.length - i; j++) {
            if (arr[j] > arr[j + 1]) { //arr[j]>arr[j+1]
                var temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
    return arr
}

sort

function sort(arr) {
    arr.sort((a, b) => a - b)
    return arr
}

19 数组去重

set

function unique(arr){
    return Array.from(new Set(arr))
}
function unique(arr){
    return [...new Set(arr)]
}

forEach+indexOf

function unique(arr){
    let result=[]
    arr.forEach(item=>{
        if(result.indexOf(item)===-1){
            result.push(item)
        }
    })
    return result
}

forEach+includes

function unique(arr){
    let result=[]
    arr.forEach(item=>{
        if(!result.includes(item)){
            result.push(item)
        }
    })
    return result
}

filter+indexOf

function unique(arr){
    return arr.filter((item,index)=>{
        return arr.indexOf(item)===index
    })
}

双重循环

拿其中一个和后面所有的进行比较,跳出当前循环相等进行下一轮循环

function unique(arr){
    let result=[]
    for(let i=0;i<arr.length;i++){
        for(let j=i+1;j<arr.length;j++){
            if(arr[i]===arr[j]){
                j=++i
                contitue
            }
        }
        result.push(arr[i])
    }
    return result
}

20 浅拷贝的实现方式

理解深浅拷贝的区别能帮助你在 JavaScript 开发中更好地管理数据状态,避免意外的数据修改

浅拷贝只复制对象的第一层属性,如果属性是引用类型,则复制的是引用地址

浅拷贝的特点

  • 修改第一层属性不会影响原对象
  • 修改嵌套对象会影响原对象

我们知道基本类型复制的是值的拷贝,引用类型复制的是值的引用。引用类型也就是对象,我们怎么才能实现对象的拷贝呢

先看下面示例代码

let arr = [1,2,{val:3}]
let copyArr=arr.slice()

copyArr[1]=3//原数组值 2 不变化
copyArr[2].val=4//原数组值val变化
console.log(JSON.stringify(arr),'arr')//[1,2,{"val":4}]
console.log(JSON.stringify(copyArr),'copyArr')//[1,3,{"val":4}]

我们更改copyArr数组中2值的时候,原数组arr中的值不发生变化,更改copyArr数组中val值的时候,原数组arr中的值发生变化。这其实就是浅拷贝。它只能拷贝一层对象。如果有对象的嵌套,那么浅拷贝将无能为力。但幸运的是,深拷贝就是为了解决这个问题而生的,它能 解决无限极的对象嵌套问题,实现彻底的拷贝。 我们先了解下实现浅拷贝的方式。

1. 数组浅拷贝

let arr = [1,2,{val:3}]
//1、concat
//let copyArr= arr.concat()

//2、扩展运算符
//let copyArr = [...arr]

//3、slice 
let copyArr=arr.slice()

copyArr[1]=3
copyArr[2].val=4

console.log(JSON.stringify(arr),'arr')//[1,2,{"val":4}]
console.log(JSON.stringify(copyArr),'copyArr')//[1,3,{"val":4}]

2. 对象浅拷贝

let obj={name:'foo',atr:{age:18,sex:'man'}}

//1、Object.assign
//let copyObj= Object.assign({},obj,{name:'bar'})

//Object.assign(target, ...sources)
// `target`目标对象,接收源对象属性的对象,也是修改后的返回值。
// `sources`源对象,包含将被合并的属性。

//2、扩展运算符
let copyObj = {...obj}

copyObj.name='foo2'
copyObj.atr.age=28

console.log(JSON.stringify(obj),'obj')//{"name":"foo","atr":{"age":28,"sex":"man"}}
console.log(JSON.stringify(copyObj),'copyObj')//{"name":"foo2","atr":{"age":28,"sex":"man"}}

3. 手动实现

const shallowClone = (target) => {
  if (typeof target === 'object' && target !== null) {
    const cloneTarget = Array.isArray(target) ? []: {};
    for (let prop in target) {
    //只拷贝自身属性
      if (target.hasOwnProperty(prop)) {
          cloneTarget[prop] = target[prop];
      }
    }
    return cloneTarget;
  } else {
    return target;
  }
}

21 深拷贝的实现方式

深拷贝会递归复制对象的所有层级,创建一个完全独立的副本

深拷贝的特点:

  • 完全独立于原对象
  • 修改任何层级都不会影响原对象
  • 实现成本较高,特别是处理特殊对象和循环引用

1. 简易版本及存在的问题

JSON.parse(JSON.stringify());

估计这个api能覆盖大多数的应用场景,没错,谈到深拷贝,我第一个想到的也是它。但是实际上,对于某些严格的场景来说,这个方法是有巨大的坑的。问题如下

    1. 无法解决循环引用的问题。举个例子:
let obj = {
    foo: 'foo',
    bar: function () {
        return this.foo
    }
}
//obj.target = obj

拷贝a会出现系统栈溢出,因为出现了无限递归的情况。

    1. 无法拷贝一些特殊的对象,诸如 RegExp, Date, Set, Map等。
const set = new Set([1, 2, 3, 4, 5, 5, 5])
const map = new Map([
    ['name', '张三'],
    ['title', 'Author']
])

let copySet = JSON.parse(JSON.stringify(set))
let copyMap = JSON.parse(JSON.stringify(map))
console.log(copySet, 'copySet') //{}
console.log(copyMap, 'copyMap') //{}
    1. 无法拷贝函数(划重点)。
console.log(copyObj, 'copyObj') //{foo: "foo"}

我们先写一个简易版的深拷贝,然后以此为基础一步步解决上面三个问题

const deepClone = (target) => {
  if (typeof target === 'object' && target !== null) {
    const cloneTarget = Array.isArray(target) ? []: {};
    for (let key in target) {
      if (target.hasOwnProperty(key)) {
          cloneTarget[key] = deepClone(target[key]);
      }
    }
    return cloneTarget;
  } else {
    return target;
  }
}

2. 解决循环引用

现在问题如下:

let obj = {val : 2};
obj.target = obj;

deepClone(obj);//报错: RangeError: Maximum call stack size exceeded

这就是循环引用。我们怎么来解决这个问题呢?

创建一个WeakMap。记录下已经拷贝过的对象,如果说已经拷贝过,那直接返回它行了。

关于为什么用WeakMap而不用Map
是map 上的 key 和 map 构成了强引用关系WeakMap,它是一种特殊的Map, 其中的键是弱引用的。其键必须是对象,而值可以是任意的。被弱引用的对象可以在任何时候被回收,而对于强引用来说,只要这个强引用还在,那么对象无法被回收。拿上面的例子说,map 和 a一直是强引用的关系, 在程序结束之前,a 所占的内存空间一直不会被释放

weakMap 是 ES6 引入的一种特殊类型的集合,与普通 Map 相比有一些独特特性和用途。

基本特性

  1. 键必须是对象(不能是原始值)

    const weakMap = new WeakMap();
    const obj = {};
    
    weakMap.set(obj, 'value');  // 正确
    weakMap.set('key', 'value'); // 错误,TypeError
    
  2. 弱引用持有键

    • 如果没有其他引用指向键对象,该键值对会被自动回收
    • 这使得 WeakMap 特别适合作为"元数据存储"
  3. 不可枚举/不可迭代

    • 没有 size 属性
    • 没有 clear() 方法
    • 不能使用 for...of 遍历
const isObject = (target) => (typeof target === 'object' || typeof target === 'function') && target !== null;

const deepClone = (target, map = new WeakMap()) => { 
  if(map.get(target))  
    return target; 
 
  if (isObject(target)) { 
    map.set(target, true); 
    const cloneTarget = Array.isArray(target) ? []: {}; 
    for (let prop in target) { 
      if (target.hasOwnProperty(prop)) { 
          cloneTarget[prop] = deepClone(target[prop],map); 
      } 
    } 
    return cloneTarget; 
  } else { 
    return target; 
  } 
}

现在来试一试:

const a = {val:2};
a.target = a;
let newA = deepClone(a);
console.log(newA)//{ val: 2, target: { val: 2, target: [Circular] } }

截屏2022-07-27 13.20.45.png

3. 拷贝特殊对象

可遍历对象

对于特殊的对象,我们使用以下方式来鉴别:

Object.prototype.toString.call(obj);

梳理一下对于可遍历对象会有什么结果:

'Map'
'Set'
'Array'
'Object'
'Arguments'

好,以这些不同的字符串为依据,我们就可以成功地鉴别这些对象。

const getType = Object.prototype.toString.call(obj).slice(8, -1);;

const canTraverse = {
    'Map': true,
    'Set': true,
    'Array': true,
    'Object': true,
    'Arguments': true,
};

const deepClone = (target, map = new Map()) => {
  if(!isObject(target)) 
    return target;
  let type = getType(target);
  let cloneTarget;
  if(!canTraverse[type]) {
    // 处理不能遍历的对象
    return;
  }else {
    // 这波操作相当关键,可以保证对象的原型不丢失!
    let ctor = target.constructor;
    cloneTarget = new ctor();
  }

  if(map.get(target)) 
    return target;
  map.put(target, true);

  if(type === mapTag) {
    //处理Map
    target.forEach((item, key) => {
      cloneTarget.set(key, deepClone(item, map));
    })
  }
  
  if(type === setTag) {
    //处理Set
    target.forEach(item => {
      target.add(deepClone(item, map));
    })
  }

  // 处理数组和对象
  for (let prop in target) {
    if (target.hasOwnProperty(prop)) {
        cloneTarget[prop] = deepClone(target[prop], map);
    }
  }
  return cloneTarget;
}

不可遍历对象

const boolTag = 'Boolean';
const numberTag = 'Number';
const stringTag = 'String';
const symbolTag = 'Symbol';
const dateTag = 'Date';
const errorTag = 'Error';
const regexpTag = 'RegExp';
const funcTag = 'Function';

对于不可遍历的对象,不同的对象有不同的处理。

const handleRegExp = (target) => {
  const { source, flags } = target;
  return new target.constructor(source, flags);
}

const handleFunc = (target) => {
  // 待会的重点部分
}

const handleNotTraverse = (target, tag) => {
  const Ctor = targe.constructor;
    switch (tag) {
        case boolTag:
            return new Object(Boolean.prototype.valueOf.call(target));
        case numberTag:
            return new Object(Number.prototype.valueOf.call(target));
        case stringTag:
            return new Object(String.prototype.valueOf.call(target));
        case symbolTag:
            return new Object(Symbol.prototype.valueOf.call(target));
        case errorTag:
        case dateTag:
            return new Ctor(target);
        case regexpTag:
            return handleRegExp(target);
        case funcTag:
            return handleFunc(target);
        default:
            return new Ctor(target);
    }
}

4. 拷贝函数

虽然函数也是对象,但是它过于特殊,我们单独把它拿出来拆解。

提到函数,在JS种有两种函数,一种是普通函数,另一种是箭头函数。每个普通函数都是 Function的实例,而箭头函数不是任何类的实例,每次调用都是不一样的引用。那我们只需要 处理普通函数的情况,箭头函数直接返回它本身就好了。

那么如何来区分两者呢?

答案是: 利用原型。箭头函数是不存在原型的。

代码如下:

const handleFunc = (func) => {
  // 箭头函数直接返回自身
  if(!func.prototype) return func;
  const bodyReg = /(?<={)(.|\n)+(?=})/m;
  const paramReg = /(?<=().+(?=)\s+{)/;
  const funcString = func.toString();
  // 分别匹配 函数参数 和 函数体s
  const param = paramReg.exec(funcString);
  const body = bodyReg.exec(funcString);
  if(!body) return null;
  if (param) {
    const paramArr = param[0].split(',');
    return new Function(...paramArr, body[0]);
  } else {
    return new Function(body[0]);
  }
}

到现在,我们的深拷贝就实现地比较完善了。

5. 完整代码

完整版的深拷贝

const getType = obj => Object.prototype.toString.call(obj).slice(8, -1);

const isObject = (target) => (typeof target === 'object' || typeof target === 'function') && target !== null;

const canTraverse = {
    'Map': true,
    'Set': true,
    'Array': true,
    'Object': true,
    'Arguments': true,
};
const mapTag = 'Map';
const setTag = 'Set';
const boolTag = 'Boolean';
const numberTag = 'Number';
const stringTag = 'String';
const symbolTag = 'Symbol';
const dateTag = 'Date';
const errorTag = 'Error';
const regexpTag = 'RegExp';
const funcTag = 'Function';

const handleRegExp = (target) => {
    const {
        source,
        flags
    } = target;
    return new target.constructor(source, flags);
}

const handleFunc = (func) => {
    // 箭头函数直接返回自身
    if (!func.prototype) return func;
    const bodyReg = /(?<={)(.|\n)+(?=})/m;
    const paramReg = /(?<=\().+(?=\)\s+{)/;
    const funcString = func.toString();
    // 分别匹配 函数参数 和 函数体
    const param = paramReg.exec(funcString);
    const body = bodyReg.exec(funcString);
    if (!body) return null;
    if (param) {
        const paramArr = param[0].split(',');
        return new Function(...paramArr, body[0]);
    } else {
        return new Function(body[0]);
    }
}

const handleNotTraverse = (target, tag) => {
    const Ctor = target.constructor;
    switch (tag) {
        case boolTag:
            return new Object(Boolean.prototype.valueOf.call(target));
        case numberTag:
            return new Object(Number.prototype.valueOf.call(target));
        case stringTag:
            return new Object(String.prototype.valueOf.call(target));
        case symbolTag:
            return new Object(Symbol.prototype.valueOf.call(target));
        case errorTag:
        case dateTag:
            return new Ctor(target);
        case regexpTag:
            return handleRegExp(target);
        case funcTag:
            return handleFunc(target);
        default:
            return new Ctor(target);
    }
}

const deepClone = (target, map = new WeakMap()) => {
    if (!isObject(target))
        return target;
    let type = getType(target);
    let cloneTarget;
    if (!canTraverse[type]) {
        // 处理不能遍历的对象
        return handleNotTraverse(target, type);
    } else {
        // 这波操作相当关键,可以保证对象的原型不丢失!
        let ctor = target.constructor;
        cloneTarget = new ctor();
    }

    if (map.get(target))
        return target;
    map.set(target, true);

    if (type === mapTag) {
        //处理Map
        target.forEach((item, key) => {
            cloneTarget.set(key,deepClone(item, map));
        })
    }

    if (type === setTag) {
        //处理Set
        target.forEach(item => {
            cloneTarget.add(deepClone(item, map));
        })
    }

    // 处理数组和对象
    for (let prop in target) {
        if (target.hasOwnProperty(prop)) {
            cloneTarget[prop] = deepClone(target[prop], map);
        }
    }
    return cloneTarget;
}

知其然后记

当然js相关的内容远不止这些,自己只是梳理了其中的一部分。梳理这份js系列,自己最大的感受就是知识是相通的,很多知识不是孤立存在的,而是一环套一环的关联在一起的,就拿本篇的Eventloop来说,宏任务,微任务,异步这些技术都是在事件循环的基础上发展起来的。另外想了解某项技术,一定要了解它的发展历程,技术是怎么演进的。技术不断迭代的过程,也就是不断的去解决现有技术存在的问题的过程,搞清了这些技术的来龙去脉,相信我们也就明白了我们手中使用的语言了。

知其然,知其所以然,技术提升的过程并不是一路平坦,前路漫漫,不忘初心,砥砺前行,与君共勉,希望知其然js系列对你有所启发。

参考资料

《浏览器工作原理与实践》 极客时间
《图解 Google V8》极客时间
《JavaScript高级程序设计(第三版)》
《你不知道的JavaScript(中卷)》

(2.4w字,建议收藏)😇原生JS灵魂之问(下), 冲刺🚀进阶最后一公里
基础很好?22个高频JavaScript手写代码总结了解一下
最全的手写JS面试题