JavaScript 事件循环与异步编程

248 阅读15分钟

参考资料:

1. 计算机基本概念

在了解 JavaScript 的事件循环与异步编程之前,我们需要了解一些计算机的基本概念。

(1) 冯·诺伊曼体系结构

美籍匈牙利数学家 冯·诺伊曼1946 年提出了存储程序原理,即计算机制造的三个基本原则:

  • 采用二进制数表示计算机处理的数据和指令

  • 顺序执行程序

  • 计算机硬件由 输入设备存储器控制器运算器输出设备 五部分组成。

中央处理器(central processing unit,简称 CPU)是计算机系统的控制和运算中心,也就是控制器和运算器的集合体,用于执行程序指令,也就是任务。

随着 CPU 的不断发展,目前市面上基本都是多核 CPU,可以在同一时刻执行多个任务,即 并行

(2) 并行、并发

并行和并发是很容易混淆的两个概念,这里做下区分:

  • 并行:依赖于多核 CPU 或者多个 CPU,在同一时刻能执行多个任务。

  • 并发:涉及单个单核 CPUCPU 在同一时刻只能执行一个任务。而通过给任务划分时间片段,CPU 在不同时间片段切换执行相应任务,能在宏观上感觉同一个时间段内能执行多个任务。

(3) 进程、线程、协程

多核 CPU 可以实现并行,而并行能使程序更快运行,效率更高,性能更好。

多线程 代表可以通过并行处理任务,而线程不能单独存在,需要由进程来启动和管理。

进程process)是程序的一个运行过程,是资源分配的基本单位。

当启动一个程序时,操作系统会为该程序分配一块内存(用于存储代码和数据)和一个主线程(用于执行任务),这样的一个运行过程称为 进程

线程thread)是一系列任务的线性执行过程,是 CPU 调度的基本单位。

程序的运行过程中有很多任务需要执行,我们可以选择单线程依次执行任务,也可以选择多线程并行执行任务,期间就涉及了 CPU 的调度,也就是让 CPU 去执行任务。

协程coroutines),又称为 纤程fiber)或 绿色线程green thread),不受操作系统管理,而由程序自身控制,是比线程更为轻量的存在,用于提升性能,避免线程切换的资源消耗。

简单理解为,线程里包含里一系列任务,协程把这些任务再进行了拆分执行。

2. 事件循环

JavaScript 是采用单线程运行的,即同时只能运行一个任务,其他任务必须排队等待,这称为 单线程模型

虽然 JavaScript 只在一个线程上运行(这个线程称为 主线程),但 JavaScript 引擎本身是支持多线程的,而之所以采取单线程,也是最初设计时,不想浏览器脚本太复杂,因为多个线程存在资源共享和访问冲突。

由于 JavaScript 是单线程的,那么如果遇到一个任务执行时间过长,则很容易导致阻塞。而通常情况下,耗时的是 IO 操作(输入输出),比如从服务器请求数据,这个时候 CPU 是空闲的,可以先挂起等待的任务,继续执行后续的任务,等 IO 操作结束再去执行挂起的任务。

(1) 同步任务、异步任务

JavaScript 中,任务分为两类:

  • 同步任务(synchronous):在主线程上排队执行的任务。

  • 异步任务(asynchronous):被引擎挂起,并放入到任务队列中的任务。

首先,主线程会执行所有的同步任务,等同步任务执行完,再去任务队列查看满足执行条件的异步任务,放入主线程作为同步任务执行。如果有其他事件(比如点击)需要执行对应的脚本任务,或者同步任务执行过程中又产生了异步任务,则放入到相应的任务队列,这样一直循环,直到任务队列清空,这种机制称为 事件循环

(2) 宏任务、微任务

通过事件循环可以从任务队列中依次取出异步任务执行,所以也有先后顺序,那么如果有优先级高的异步任务(比如 DOM 更新事件)该如何处理呢?

答案是 微任务microtask)。

JavaScript 中异步任务分为两种:

  • 宏任务(macrotask):又叫 Task,由宿主环境(浏览器、Node)发起。

  • 微任务(microtask):又叫 Jobs,由 JavaScript 自身发起。

JavaScript 把异步任务放入了任务队列中,我们这些任务称之为 宏任务macrotask),每个宏任务中都关联了一个 微任务队列。当宏任务执行完后,引擎先执行当前宏任务中的微任务,再执行下一个宏任务。

微任务的产生有以下几种方式:

  • MutationObserver:用于观察 DOM 节点的变化

  • Promise 实例的 then/catch/finally 方法:用于消灭嵌套调用(回调地狱)和多次错误处理

  • queueMicrotask 函数:用于将微任务添加到微任务队列

  • Node.js 服务端的 process.nextTick

其他情况下的异步任务都是宏任务。

注意,如果微任务执行过程中产生了新的微任务,则会直接添加到当前微任务队列中,继续执行。

(3) 延迟队列

当我们使用 setTimeout 定时器来指定回调函数在多少毫秒后执行时,如果把回调函数直接放入任务队列,那么无法保证在指定毫秒间隔后执行,这时候浏览器除了维护 任务队列,还会维护一个 延迟队列,用来存放需要延迟执行的任务。

所以任务的执行顺序是:

--> 执行同步任务
--> 执行任务队列中的宏任务 
--> 执行宏任务的微任务队列中的微任务
--> 执行延迟队列中的宏任务
--> 执行下一个宏任务,开始循环

注意,延迟队列实际上是一个 hashmap 的数据结构,等执行延迟队列中的任务时,会判断是否到期,到期了就执行。

3. 异步编程

通过任务队列和事件循环的机制,JavaScript 能在单线程上实现异步编程,下面是些主要概念:

(1) 回调函数

JavaScript 中,函数可以作为参数进行传递,而这种参数函数,则称为 回调函数

function fn1(){
    console.log('fn1');  
}
function fn2(fn){
    fn();
    console.log('fn2');
}
fn2(fn1);
// "fn1"
// "fn2"

我们把同步任务中的回调函数称为 同步回调,而异步任务的回调函数称为 异步回调

function callback(name){
    console.log(name);
}
setTimeout(callback,2000,'tom');

回调函数的优点是简单、容易理解和实现,缺点是各部分高度耦合,代码结构混乱,尤其是嵌套调用时,容易产生回调地狱。

除了在代码里写回调函数外,异步操作还有 事件监听发布/订阅 的形式,但本质上还是执行回调函数。

(2) 定时器

JavaScript 提供了定时执行代码的功能,叫做定时器(timer)。

定时器主要由 setTimeoutsetInterval 两个函数实现,向延迟队列里添加定时任务。

setTimeout 用于指定某个函数或代码,在多少毫秒后执行。通过返回的定时器唯一编号,可以用 clearTimeout 函数进行取消。

// 指定代码在 1000 毫秒后执行
setTimeout('console.log(1)',1000);

// 指定函数在 1000 毫秒后执行,并且可以给回调函数指定参数
setTimeout(function(name){
    console.log(name);
},1000,'tom')

// 不指定毫秒时,相当于毫秒为 0 
var timerId = setTimeout(function(){console.log('hello')});

// 取消定时器
clearTimeout(timerId);

setIntervalsetTimeout 类似,只是用于间隔一段时间就执行一次任务,相当于无限次的定时执行。通过 clearInterval 函数可以取消定时器。

// 每隔 1000 毫秒就执行一次
var timerId = setInterval(function(){
    console.log('hello');
},1000);

// 取消定时器
clearInterval(timerId);

注意,由于定时器产生的异步任务需要等待同步任务执行完才执行,所以时间上有时不是很准确。

(3) 防抖、节流

了解了定时器后,通常会用来实现函数防抖(debounce)和函数节流(throttle)。

函数防抖(debounce)是用于防止某个函数被频繁执行,如果被触发多次,只执行最后一次。

实现原理:在事件被触发 n 秒后再执行回调,如果在这 n 秒内又被触发,则重新计时。

// 函数防抖,通过返回一个新的函数,在里面用 setTimeout 和 clearTimeout 进行防抖
function debounce(fn, delay){
    var timerId = null; // timerId 得在这里,不能放到返回的函数中,否则每次执行函数时 timerId 就会初始化,无法取消定时器
    return function(){
        var that = this;
        var args = Array.prototype.slice.call(arguments);
        if(timerId){
            clearTimeout(timerId);
            timerId = null;
        }
        timerId = setTimeout(function(){
            fn.apply(that, args);
        },delay);
    }
}

// 拖动改变窗口大小的事件触发频繁,需要进行防抖
function onResize(){
    console.log('resize');
}
window.addEventListener('resize',debounce(onResize,1000));

函数节流(throttle)是用于保证在单位时间内只执行一次函数,既实现函数的均匀执行,又不会频繁执行。

// 函数节流实现方式一:通过返回一个新函数,在里面比较时间戳是否执行
function throttle(fn, gapTime){
    var lastTime = null;
    return function(){
        var that = this;
        var args = Array.prototype.slice.call(arguments);
        var nowTime = Date.now();
        if(!lastTime || nowTime - lastTime >= gapTime){
            fn.apply(that, args);
            lastTime = nowTime;
        }
    }
}

// 函数节流实现方式二:通过返回一个新函数,在里面用定时器进行处理
function throttle(fn, gapTime){
    var timerId = null;
    return function(){
        var that = this;
        var args = Array.prototype.slice.call(arguments);
        if(!timerId){
            timerId = setTimeout(function(){
                fn.apply(that, args);
                timerId = null;
            }, gapTime);
        }
    }
}

// 每隔一秒打印一次
let fn = ()=>{
  console.log('boom')
}
setInterval(throttle(fn,1000),10)

(4) Promise

Promise 是异步编程的一种解决方案,比回调函数和事件更为合理和强大。

JavaScript 根据 ES6 标准中提供了 Promise 对象,主要用来解决异步回调的嵌套调用,以及合并多个任务的错误处理。

Promise 对象也有缺点,由于传入的函数会立即执行,所以无法取消;另外如果没有指定回调函数,内部的错误就无法发现。

Promise 对象代表一个异步操作,有三种状态:

  • pending:进行中

  • fulfilled:已成功

  • rejected:已失败

承诺只有异步操作的结果能修改这个状态,且不会再变,这就是 Promise承诺)的命名由来。

Promise 对象的初始状态是 pending,且只能由下面两种变化:

  • 通过 resolve 函数将状态从 pending 变为 fulfilled

  • 通过 reject 函数将状态从 pending 变为 rejected

一旦状态变化,后续再添加回调函数,会立刻得到相应的结果。

Promise 通过 延迟绑定回调函数穿透回调函数返回值 的方式解决异步回调的嵌套问题,基本用法如下:

// Promise 也是一个构造函数,用来生成 Promise 实例
// --- 参数为一个会立即执行的函数,此函数有 resolve 和 reject 两个参数,属于内置的函数,名称可以变,但顺序不可变。
// --- 调用 resolve 或 reject 并不会终止函数的运行,只是状态变了后,后面再调 resolve 或 reject 不会有影响
var pro = new Promise(function(resolve,reject){
    if(true){
        // 异步操作成功时,用 resolve 函数把结果传出去,状态由 pending 变为 fulfilled
        resolve('成功');

        // 注意,当 resolve 的参数是一个 Promise 实例时,当前 Promise 实例的状态则由传入的这个 Promise 的状态决定
        resolve(new Promise(function(resolve2,reject2){
            reject2('失败2');
        }))
    }else{
        // 异步操作失败时,用 reject 函数把错误传出去,相当于 throw 抛出错误,状态由 pending 变为 rejected
        reject(new Error('失败'));

        // 当 reject 的参数是一个 Promise 实例时,会当作普通的值处理,不受其影响
        reject(new Promise(function(resolve2,reject2){
            resolve2('成功2');
        }))
    }
});

// 生成 Promise 实例后,可以通过其 then 方法延迟绑定回调函数
// --- then 方法有两个可选参数,一个是状态为 fulfilled 时的回调函数,一个是状态为 rejected 时的回调函数
// --- onResolved 回调函数可以接收 resolve 函数传出的结果
// --- onRejected 回调函数可以接收 reject 函数传出的错误
function onResolved(value){
    console.log(value); 
}
function onRejected(error){
    console.log(error);
}
pro.then(onResolved, onRejected);

// Promise 实例的 then 方法始终会返回一个新的 Promise 实例
var pro = new Promise(function(resolve,reject){
    resolve('成功');
});
// --- 如果 then 方法不指定回调函数或者回调函数没有执行,则新的 Promise 实例的状态与原 Promise 一致
var newPro = pro.then();
pro // Promise {<fulfilled>: "成功"}
newPro // Promise {<fulfilled>: "成功"}
newPro === pro // false
// --- 如果 then 方法指定了回调函数,并且回调函数运行了,则将回调函数的返回值作为新的 Promise 实例的结果,状态为 fulfilled
var newPro = pro.then(function(value){
    return '新返回值';
});
pro // Promise {<fulfilled>: "成功"}
newPro // Promise {<fulfilled>: "新返回值"}

// Promise 实例的 catch 方法用于处理异常
// --- Promise 内部的错误不会影响外部,但会一直传递,即“冒泡”
// --- 实际上 catch(function(){}) 方法是 then(null,function(){}) 的别名
pro2.catch(function(error){

});
// ---等同于
pro2.then(null, function(error){

});

// Promise 实例的 finally 方法用于执行始终要运行的任务
// --- finally 指定的回调函数,会在 onResolved 和 onRejected 回调函数执行 return 或者 throw 抛出错误时执行
// --- finally 方法返回的新的 Promise 实例始终与原 Promise 实例的状态一致,除非报错了
pro2.finally(function(){
    console.log('始终执行的代码');
    throw new Error('错误');
})

注意:reject 函数传出错误和 throw 抛出错误一致,两者可以看作相同的作用。

除了 Promise 实例的用法外,Promise 对象还内置了其他的方法:

// Promise.all() 用于将多个 Promise 实例包装成一个 Promise 实例
// --- 当所有的状态都是 fulfilled 时,pro 的状态才是 fulfilled
// --- 当有一个的状态是 rejected 时,pro 的状态就是 rejected
var pro = Promise.all([pro1, pro2, pro3]);

// 下面这几个方法的使用都类似 Promise.all(),只是状态的变化不同
var pro = Promise.race([pro1, pro2, pro3]); // 当有一个的状态改变时,pro 的状态就改变
var pro = Promise.allSettle([pro1, pro2, pro3]); // 当所有的状态都变化时,pro 状态会变为 fulfilled
var pro = Promise.any([pro1, pro2, pro3]); // 当有一个状态是 fulfilled 时,pro 的状态就是 fulfilled;当全部状态都是 rejected 时,pro 的状态才是 rejected

// Promise.resolve() 方法用于将参数转换生成 Promise 实例
// --- 需要注意的是,如果参数是 Promise 实例则原样返回它的结果;如果是带 then 方法的对象,则调用对象的 then 方法。
Promise.resolve(); // Promise {<fulfilled>: undefined}
Promise.resolve(1); // Promise {<fulfilled>: 1}
Promise.resolve(new Promise(function(resolve){resolve(1)})); // Promise {<fulfilled>: 1}
Promise.resolve({then:function(resolve){resolve(1)}}); // Promise {<fulfilled>: 1}
Promise.resolve({then:function(){}}); // Promise {<pending>}

// Promise.reject() 方法用于将参数转换生成 Promise 实例,且状态为 rejected
// --- 不同于 Promise.resolve() 方法,Promise.reject() 无论参数是什么类型,都作为错误返回。
Promise.reject(new Promise(function(resolve){resolve(1)})); // Promise {<rejected>: Promise}
Promise.reject({then:function(){}}); // Promise {<rejected>: {then:function(){}}}

// Promise.try() 主要是用来实现异步代码的回调函数异步调用,同步代码的回调函数同步调用,以及捕获错误,但原生 js 还不支持。
var f = () => console.log('now');
Promise.resolve().then(f);
console.log('next');
// "next"
// "now"
var f = () => console.log('now');
Promise.try(f);
console.log('next');
// "now"
// "next"

注:从上面 Promise.resolvePromise.reject 方法的作用可以看出,基本等同于 new Promise(function(resolve, reject){})

(5) 重写 Promise

Promise 的基本用法和涵盖的方法了解了,这里试着重写下 Promise,就叫 MyPromise 吧。

/**
* 重写 Promise
* @param fn 会立即执行的函数
*/
function MyPromise(fn){

    // (1) 基本数据
    var that = this;
    that.state = 'pending'; // 状态
    that.result = null; // 结果
    that.fulfilledCallbacks = []; // 状态为 fulfilled 时的回调函数
    that.rejectedCallbacks = []; // 状态为 rejected 时的回调函数

    // (2) resovle 函数:使状态由 pending 变为 fulfilled,得到结果并触发回调函数
    function resolve(result){
        if(that.state !== 'pending') return;

        // 如果结果是一个有 then 方法的对象,就调用其 then 方法
        if(result === Object(result) && typeof result.then === 'function'){
            result.then(resolve,reject);
            return;
        }

        that.state = 'fulfilled';
        that.result = result;
        while(that.fulfilledCallbacks.length>0){
            that.fulfilledCallbacks.shift()(result); // 执行回调函数
        }
    }

    // (3) reject 函数:使状态由 pending 变为 rejected,得到结果并触发回调函数
    function reject(result){
        if(that.state !== 'pending') return;
        that.state = 'rejected';
        that.result = result;
        while(that.rejectedCallbacks.length>0){
            that.rejectedCallbacks.shift()(result); // 执行回调函数
        }
    }

    // (4) 执行传入的函数,如果有异常,则 reject
    try{
        fn(resolve, reject);
    }catch(e){
        reject(e);
    }

}

// MyPromise 实例的 then 方法:用于绑定回调函数(如果状态已变,则直接执行回调函数),并返回新的 MyPromise 实例
MyPromise.prototype.then = function(onFulfilled, onRejected){
    var that = this;
    onFulfilled = typeof onFulfilled === 'function'? onFulfilled : function(result){return result};
    onRejected = typeof onRejected === 'function'? onRejected : function(result){throw result};
    var myPromise = new MyPromise(function(resolve,reject){

        // 通过微任务执行成功时的回调函数
        var fulfilledCallback = function(){
            queueMicrotask(function(){
                try{
                    var result = onFulfilled(that.result);
                    resolvePromise(myPromise, result, resolve, reject);
                }catch(error){
                    reject(error);
                }
            });
        }

        // 通过微任务执行失败时的回调函数
        var rejectedCallback = function(){
            queueMicrotask(function(){
                try{
                    var result = onRejected(that.result);
                    resolvePromise(myPromise, result, resolve, reject);
                }catch(error){
                    reject(error);
                }
            });
        }

        if(that.state === 'pending'){ // 等待中
            that.fulfilledCallbacks.push(fulfilledCallback);
            that.rejectedCallbacks.push(rejectedCallback);
        }else if(that.state === 'fulfilled'){ // 已完成
            fulfilledCallback();
        }else if(that.state === 'rejected'){ // 已失败
            rejectedCallback();
        }
    });
    return myPromise;
}

// resolvePromise 函数:用来判断返回结果该是什么
function resolvePromise(myPromise, result, resolve, reject){

    // 结果是新返回的 MyPromise 实例,会导致循环引用,所以直接报错
    if(result === myPromise){
        reject(new TypeError('循环引用错误'));
        return;
    }

    // 如果结果是一个有 then 方法的对象,则通过微任务调用其 then 方法,因为对象可能还没建好
    if(result === Object(result) && typeof result.then === 'function'){
        queueMicrotask(function(){
            var called = false; // 当两个回调函数都调用时,忽略另一个结果
            result.then(function(value){
                if(called) return;
                called = true;
                resolvePromise(myPromise,value,resolve,reject);
            },function(value){
                if(called) return;
                called = true;
                reject(value);
            });
        })
        return;
    }

    resolve(result);
}

// MyPromise.resolve 方法
MyPromise.resolve = function(value){
    return new MyPromise(function(resolve){
        resolve(value);
    })
}

需要注意的是,当 then 方法的回调函数返回的是一个有 then 方法的对象,则通过创建微任务执行其 then 方法,因为对象可能还没建好。

如果此对象是 MyPromise 实例,由于调用其 then 方法绑定的回调函数时,会通过创建微任务执行,所以要多一步微任务,这样会导致顺序上不同:

MyPromise.resolve().then(() => {
    console.log(0);
    return MyPromise.resolve(4);
}).then((res) => {
    console.log(res)
})

MyPromise.resolve().then(() => {
    console.log(1);
}).then(() => {
    console.log(2);
}).then(() => {
    console.log(3);
}).then(() => {
    console.log(5);
}).then(() =>{
    console.log(6);
})
// 0
// 1
// 2
// 3
// 4
// 5
// 6

注意,通过 then 方法只是绑定回调函数,存了起来,没有放入微任务队列,只有要执行的时候才放入微任务中执行。

(6) Generator 函数

(7) async、await