JavaScript: 从 Event Loop 到 Promise (常见问题分析)

2,530 阅读8分钟

写在最前面

  • promise 作为前端常用的工具,今天从底了解一下 promise 的使用和基础知识。
    • 其中有出入或者错误的地方希望朋友们指出。

导航

  • 一、同步和异步
  • 二、单线程和多线程
  • 三、evet loop
  • 四、实战,promise 题目分析

Promise

  • 什么是 promise?

    • 我们先明确:Promise 对象用于表示一个异步操作的最终完成 (或失败), 及其结果值.
  • 什么是 async 和 await

    • async/await 使得异步代码看起来像同步代码(实际是阻塞了代码),一句话总结,async 函数就是 Generator 函数的语法糖,返回了一个 promise.resolve() 的结果。阮一峰老师的 async 教程
  • 上面提到了一个异步的问题,我们前端er都知道 JavaScript - 是单线程的,如果存在多个任务的时候,就会有任务队列进行排队,然后一一执行任务。

不着急介绍 promise 的详情,首先我们从最开始的同步和异步讲起:

一、同步和异步

1.1 同步

简单的理解

  • 如果函数在返回结果的时候,调用者能够拿到预期的结果(即使会等待但是依然能拿到预期的结果),那么这个函数就是同步的。
console.log('synchronous'); //我们能立即得到 synchronous

1.2 异步

简单的理解

  • 如果函数返回的时候,不能立即得到预期的结果,而是通过一定的手段得到的(比如回调函数 callback()), 这就是异步,比如常用的 promise 和 ajax 操作等。

来看一个图

image

二、单线程和多线程

  • 简单的了解了同步和异步的概念后,我们看看什么是单线程和多线程?

2.1 浏览器常驻线程

一个浏览器通常由以下几个常驻的线程:

  1. 渲染引擎线程,负责页面的渲染
  2. js引擎线程,负责js的解析和执行
  3. 定时触发器线程,处理setInterval和setTimeout
  4. 事件触发线程,处理DOM事件
  5. 异步http请求线程,处理http请求
  • 要注意其中渲染引擎js引擎线程是不能同时进行的,渲染线程在执行任务的时候,js引擎线程会被挂起。因为若是在渲染页面的时候,js处理了DOM,浏览器就不知道该听谁的了

2.2 JS 引擎

  1. 渲染引擎:Chrome/Safari/Opera用的是Webkit引擎,IE用的是Trdent引擎,FireFox用的是Gecko引擎。不同的引擎对同一个样式的实现不一致,就导致浏览器的兼容性问题。
  2. JS引擎:js引擎可以说是js虚拟机,负责解析js代码的解析和执行。通常有以下步骤:
    • 词法解析:将源代码分解位有意义的分词
    • 语法分析:用语法分析器将分词解析成语法树
    • 代码生成:生成机器能运行的代码
    • 代码执行
  • 当然不同浏览器的JS引擎也是不同的:Chrome用的是V8,FireFox用的是SpiderMonkey,Safari用的是JavaScriptCore,IE用的是Chakra。

总结一点:JavaScript是单线程的,但是浏览器不是单线程的。一些I/O操作,定时器的计时和事件监听是由其他线程完成的。

三、消息队列和事件循环

开局一张图

image

导图要表达的内容用文字来表述的话: 1.同步和异步任务分别进入不同的执行"场所" 2.同步的进入主线程,异步的进入Event Table并注册回调函数到 Event Queue 中。 3.当主线程执行完毕以后,然后会去 Event Queue 查询,时候如果存在的函数,放进主线程中继续执行。 4.上述就是event loop的执行

说了这么多文字,不如直接一段代码更直白:

console.log('script start');

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function cb() {
    console.log('promise2');
});

console.log('script end');

// script start
// promise1
// script end
// promise2

分析这段代码:

首先执行,打印 script start

然后进入 promise 函数打印 promise1,执行 resolve()
在 then 执行的时候我们把异步回调放进了 event table 中注册相关的回调函数。
new promise 执行完毕,回调函数cb() 进入Event Queue。

执行 打印 script end;

主线程从Event Queue读取回调函数 cb 并执行。

3.1 宏任务和微任务

  • 记住一点,当同一个 event queue 中有 微任务 的时候,优先执行 微任务

macro-task(宏任务):包括整体代码script,setTimeout,setInterval micro-task(微任务):Promise,process.nextTick

image

看一个栗子

3.2 思考一下代码执行顺序

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}

async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0);

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});

console.log('script end');

// 思考上述代码打印结果

注意几个点

1、js是单线程的。
2、promise被定义后是立即执行的,但是他的resolve是异步的。
3、promise的异步优先级高于setTimeout。
4、async会返回一个promise对象,await关键字会让出线程。
  • 分析
- 定义异步函数 async1, 异步函数 async2
1. console.log('script start'); 执行 (1)`script start`

2. setTimeout 执行,异步放入异步队列中,注意这是一个宏任务(我们标记为 macro1)

3. 执行 async1(), 打印 (2)`async1 start`, 执行 async1() 中的 await async2(): 打印 (3)`async2`;
遇到 await 后面的函数进入任务队列,这里又注册一个微任务(我们标记为 mico1);到这里 async1() 就执行完了

4. 执行 new Promise:打印 (4)`promise1`,执行 resolve();
然后在 then 中注册回调函数,console.log('promise2') 函数进入任务队列;
注册 event queue(我们标记为 mico2).这里 new Promise 就执行完了。

5. 执行 console.log('script end');, 打印 (5) `script end`;

6. 上面👆五步把主线程都执行完毕了,然后去event queue 查找有没有注册的函数;
我们发现了(macro 1, mico1, mico2),按照优先执行微任务的原则,我们按照这样的顺序执行 mico1 > mico2 > macro1。
 打印:(6) `async1 end` (7) `promise2` (8) `setTimeout`


 /**
 * script start
 * async1 start
 * async2
 * promise1
 * script end
 * async1 end
 * promsise2
 * setTimerout
 */

[!warning]可能你会在不同浏览器发现不同结果,这是因为不同浏览器和版本的不同遵循的 promise 规则不同。这里是按照较新版本的 chrome(68+) 执行的结果,具体参考(www.w3.org/2001/tag/do…

四、回到 promise

  • 这里直接看几道常见的题目来认识 promise 具体是什么?

4.1 理解常见的状态变化

  • 理解 resolve
const promise = new Promise((resolve, reject) => {
    console.log(1);
    resolve();
    console.log(2);
})
promise.then(() => {
    console.log(3);
})
console.log(4);

分析

首先Promise新建后立即执行,所以会先输出1,2,而Promise.then()内部的代码在当次事件循环的结尾立即执行,所以会先输出4,最后输出3.

QA:1 2 4 3

  • 理解状态变化
const promise = new Promise((resolve, reject) => {
    resolve('success1');
    reject('error');
    resolve('success2');
});
promise.then((res) => {
    console.log('then:', res);
}).catch((err) => {
    console.log('catch:', err);
})

分析

resolve函数Promise对象的状态从“未完成”变为“成功”(即从pending变为resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去; reject函数将Promise对象的状态从“未完成”变为“失败”(即从pending变为rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。 而一旦状态改变,就不会有再变

所以代码中的reject('error');不会有作用。 Promise只能resolve一次,剩下的调用都会被忽略。 所以第二次resolve('success');也不会有作用。

QA:then:success1

4.2 手写 promise

(1) promise 对象初始化状态为 pending

(2) 当调用resolve(成功),会由pending => fulfilled

(3) 当调用reject(失败),会由pending => rejected

  • 基础版
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function MyPromise(executor) {
    this.state = PENDING;
    this.value = null;
    this.reason = null;

    const resolve = value => {
        if (this.state === PENDING) {
            this.state = FULFILLED;
            this.value = value;
        }
    };

    const reject = reason => {
        if (this.state === PENDING) {
            this.state = REJECTED;
            this.reason = reason;
        }
    };

    try {
        executor(resolve, reject);
    } catch (reason) {
        reject(reason);
    }
}
  • 添加 then 手法
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

function MyPromise(executor) {
    this.state = PENDING;
    this.value = null;
    this.reason = null;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = value => {
        if (this.state === PENDING) {
            this.state === FULFILLED;
            this.value === value;
            this.onFulfilledCallbacks.forEach(fuc =>{
                fuc();
            });
        }
    };

    const reject = reason => {
        if (this.state === PENDING) {
            this.state = REJECTED;
            this.reason === reason;
            this.onRejectedCallbacks.forEach(fuc =>{
                fuc();
            })
        }
    };

    try {
        executor(resolve, reject);
    } catch (reason) {
        reject(reason);
    }
}

/*
- then方法接受两个参数onFulfilled、onRejected,它们分别在状态由PENDING改变为FULFILLED、REJECTED后调用
- 一个promise可绑定多个then方法
- then方法可以同步调用也可以异步调用
- 同步调用:状态已经改变,直接调用onFulfilled方法
- 异步调用:状态还是PENDING,将onFulfilled、onRejected分别加入两个函数- 
- 数组onFulfilledCallbacks、onRejectedCallbacks,
- 当异步调用resolve和reject时,将两个数组中绑定的事件循环执行。
*/

MyPromise.prototype.then = function(onFulfilled,onRejected){
    switch(this.state){
        case FULFILLED:
            onFulfilled(this.value);
            break;
        case REJECTED:
            onRejected(this.reason);
            break;
        case PENDING:
            this.onFulfilledCallbacks.push(()=>{
                onFulfilled(this.value);
            });
            this.onRejectedCallbacks.push(() => {
                onRejected(this.reason);
            })
            break;
            
    }
}

// 由于catch方法是then(null, onRejected)的语法糖,所以这里也很好实现
MyPromise.prototype.catch = function(onRejected){
    return this.then(null, onRejected);
}

4.3 promsie.all

  • 全部成功,返回一个成功res的数组,如果有失败就返回那个失败的err
Promise.all = function(promises) {
    return new Promise(function(resolve, reject) {
      var resolvedCounter = 0
      var promiseNum = promises.length
      var resolvedValues = new Array(promiseNum)
      for (var i = 0; i < promiseNum; i++) {
        (function(i) {
          Promise.resolve(promises[i]).then(function(value) {
            resolvedCounter++
            resolvedValues[i] = value
            if (resolvedCounter == promiseNum) {
              return resolve(resolvedValues)
            }
          }, function(reason) {
            return reject(reason)
          })
        })(i)
      }
    })

4.4 图片异步加载封装

  • 实现一个图片异步的封装,成功就返回图片,失败返回错误提示,有一个属性值 src
function loadImageAsync(url) {
    return new Promise(function(resolve,reject) {
        var image = new Image();
        image.onload = function() {
            resolve(image) 
        };
        image.onerror = function() {
            reject(new Error('Could not load image at' + url));
        };
        image.src = url;
     });
}   

4.5 待续,promise 有很多有趣的实现,后续会继续补充

参考