参考资料:
1. 计算机基本概念
在了解 JavaScript
的事件循环与异步编程之前,我们需要了解一些计算机的基本概念。
(1) 冯·诺伊曼体系结构
美籍匈牙利数学家 冯·诺伊曼
于 1946
年提出了存储程序原理,即计算机制造的三个基本原则:
-
采用二进制数表示计算机处理的数据和指令
-
顺序执行程序
-
计算机硬件由
输入设备
、存储器
、控制器
、运算器
、输出设备
五部分组成。
中央处理器(central processing unit
,简称 CPU
)是计算机系统的控制和运算中心,也就是控制器和运算器的集合体,用于执行程序指令,也就是任务。
随着 CPU
的不断发展,目前市面上基本都是多核 CPU
,可以在同一时刻执行多个任务,即 并行
。
(2) 并行、并发
并行和并发是很容易混淆的两个概念,这里做下区分:
-
并行
:依赖于多核CPU
或者多个CPU
,在同一时刻能执行多个任务。 -
并发
:涉及单个单核CPU
,CPU
在同一时刻只能执行一个任务。而通过给任务划分时间片段,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
)。
定时器主要由 setTimeout
和 setInterval
两个函数实现,向延迟队列里添加定时任务。
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);
setInterval
与 setTimeout
类似,只是用于间隔一段时间就执行一次任务,相当于无限次的定时执行。通过 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.resolve
和Promise.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
方法只是绑定回调函数,存了起来,没有放入微任务队列,只有要执行的时候才放入微任务中执行。