在JavaScript的实现中,代码执行机制分为同步执行和异步执行两种。这里总结下我对这两种执行机制的初步理解。
同步机制
同步执行最符合人类大脑的思维模式,通常是:“先...然后...然后...”。
示例1:
const a = 1; //step1
const b = 2; //step2
const c = a + b; //step3 3
const d = a * b; //step4 4
就像示例一样,代码是一步一步线性执行的,理解最是简单、方便。
那么同步机制执行代码会带来的主要问题是:性能问题、在js引擎单线程情况下可能导致线程阻塞。
而js对于这些问题的解决方案是引入异步机制。
异步机制
js引入异步机制,有效的解决了同步机制带来的性能和阻塞的问题。
异步概念
程序的一部分现在运行,另一部分将来运行。现在和将来直接有段间隙,在这段间隙中,程序没有活跃执行。
示例2:
//现在执行start
const a = 1; //step1
const b = 2; //step2
setTimeout(funciton(){
//将来执行start
const c = a + b; //step3 3
const d = a * b; //step4 4
//将来执行end
},1000);
dosomething();
//现在执行end
这段示例将同步执行的代码分成了两部分,第一部分声明a,b变量后,通知浏览器1000ms之后将第二部分推送给js引擎,之后执行dosomething函数。
1000ms后,浏览器推送第二部分给js引擎,js引擎执行代码
异步机制的实现
事件循环
js引擎实现异步机制的方案:事件循环。
事件循环一开始是宿主环境(浏览器、node)的实现,之后为了精细控制异步任务,js引入Promise,事件循环也被正式纳入js引擎的势力范围。
示例3:
let eventLoop=[];
let event;
while(true){
//一次tick
if(eventLoop.length>0){
event=eventLoop.shift();
try{
event();
}
catch(err){
reportError(err);
}
}
}
示例展示的伪代码有一个用while循环实现的持续运行的循环,一个消息队列eventLoop,每一次循环称为一个tick,每个tick都会执行消息队列中的一个任务。至于任务的添加,那是由事件驱动的。
宏任务macroTask
消息队列中的任务都是宏任务。宏任务的组成:
-
JavaScript脚本执行事件;
-
用户交互事件(如鼠标点击、滚动页面、放大缩小等);
-
网络请求完成、文件读写完成事件等等;
-
渲染事件(解析DOM、计算布局、绘制等等)
微任务microTask
宏任务中有微任务队列。
产生微任务方式:
- 使用MutationObserver监听某个DOM节点,之后通过js来进行DOM操作,当这个节点发生变化,会产生记录DOM变化的微任务(也就是相关的回调函数或者默认的操作)
- Promise,当Promise决议的时候,会产生微任务。可以理解为new promise是定义一个事件,then和catch是对事件的监听函数,then和catch的参数(回调函数)就是微任务。
微任务执行时机:当前宏任务执行完毕,会检查微任务队列,并依次执行。如果在执行微任务期间又产生微任务,则追加到当前微任务队列。
示例4:
console.log(1);
new Promise(function pr(resolve,reject){
console.log(2);
var time1 = setTimeout(function callback1(){
resolve(2);
},10000);
console.log(3);
}).then(function fulfilled(data){
console.log(4);
console.log("data",data);
});
console.log(5);
var time2=setTimeout(function callback2(){
console.log(6);
},1000);
console.log(7);
//执行结果
//1
//2
//3
//5
//7
//6
//4
//data 2
一些术语
异步:关于程序现在和将来的时间间隙。程序一部分现在运行,一部分将来运行。
并行:关于能够同时发生的事情,可以分为运算级并行和任务级(进程级)并行。
并发:两个或者多个任务(进程)同时执行。并发是任务(进程)级别的并行。
多线程并行的问题:允许对内存的并行访问和修改,容易导致程序执行结果不符合预期,增加程序复杂度,程序的不确定性发生在运算级别。
单线程并行:单线程不允许对内存的并行访问和修改,而是通过和其他线程或者进程配合,同时执行任务,但是对任务的响应是通过事件循环一个一个执行。
js函数具有原子性,不会像多线程一样被中断,能够完整运行。但是多个函数运行的顺序不确定,因此js的不确定性发生在函数级别。
异步交互
由于异步任务执行顺序的不确定性,如果多个异步任务之间是独立的,则不需要关心这种不确定性。如果多个异步任务需要对共同的内存进行访问和修改,那么这种函数级别的不确定性可能导致和预期不一致的结果。
竞态条件
js函数级别执行顺序的不确定性,就是竞态条件。
规避竞态条件的方法:根据异步任务响应的结果来协调对内存的访问和修改方式。
示例5:
var result=[];
function response(data){
if(data.url==='url1'){
result[0]=data.result;
}
if(data.url==='url2'){
result[1]=data.result;
}
}
ajax('url1',response);
ajax('url2',response);
并发协作
将长时间运行的单个异步任务分割成多个步骤或者多批任务,使得其他异步任务有机会将自己的运算插入到事件循环中交替执行。
异步模式
js实现的异步模式,从基础的事件回调,发展到Promise和生成器,再到进一步async...await。逐步的完善了js的异步机制。
回调
回调模式是js最基础的异步实现方式
示例6:
//A
setTimeout(funciton(){
//C
},1000);
//B
回调函数封装了程序的延续(将来执行的部分)。例如示例展示的:先执行A部分,设置1000ms定时器后执行B部分,定时器到时立刻执行C部分。
回调模式的问题:
- 回调函数执行的机制并非线性,执行步骤和代码顺序并不匹配,不符合思维习惯,增加了心智的负担。(正如示例展示的)
- 增加了任务链理解负担,也就是俗称的回调地狱。
- 信任问题:回调函数由第三方控制,容易产生异常。
回调地狱示例7:
doA(function(){
doC();
doD(function(){
doE();
});
doF();
});
doB();
由于不确定这些函数是同步执行还是异步执行,执行顺序是有多种可能的,这需要开发人员自己去排查。而在实际项目中,由于项目的复杂性,回调模式会导致排查的困难。这就是回调模式容易导致地狱回调的原因。
Promise
Promise出现的原因主要是为了更好的管理异步执行机制,解决回调模式缺乏顺序性和回调产生的信任问题。
对Promise决议的监听是异步的,js引擎将监听函数设置在微任务队列中,js引擎保证了Promise的异步执行。
Promise是什么
- Promise是一种封装和组合未来值(未来值:异步任务的结果值)的易于复用的机制。
- 另一种理解是将Promise当成一个事件,then中的回调函数和catch中的回调函数视作对这个事件的成功、拒绝、异常这三种情况的监听处理函数。
Promise解决时序性
Promise是一种形如this...then...that
的链式执行顺序,相比回调函数离散的分布导致的理解困难,链式流程控制机制已经是一种比较好的改进。
之所以Promise可以使用链式流程控制任务执行步骤,是因为Promise规范了异步机制,并封装了未来值,链式流程控制是Promise机制的附加好处,而不是主要目的。
then和catch都会返回一个Promise,then第一个回调函数返回值会被设置为then产生的Promise的resolve决议的值,通过这种关联可以链接Promise
示例8:
var p1 = new Promise(function(resolve,reject){
resolve('success');
});
p1.then(function fulfilled(data){
console.log('成功1',data);
return new Promise(function(resolve,reject){
setTimeout(function(){
resolve(100);
},100);
});
},function reject(error){
console.log('失败1',error)
}).then(function fulfilled(data){
//延迟100ms执行
console.log('成功2',data)
},function reject(error){
//除非第一个then的两个回调处理函数异常,否则不会执行
console.log('失败2',error)
})
Promise解决信任问题
Promise对于信任问题的解决,是通过第三方任务告知我们任务何时结束,返回值是什么,由我们自己的代码决定下一步的操作,是对回调函数的控制反转。
根据Promise的定义:
-
Promise如果决议的话,只会决议一次,能够避免类似回调函数可能过少执行或者过多执行。
-
Promise如果没有决议的话,可以通过race设置超时处理。
-
Promise的决议是无法同步观测的,也就是说对决议的监听和异常处理也是异步的,这样能避免类似回调函数可能同步处理,可能异步处理导致的问题。
Promise异常处理
JavaScript中的异常处理方式通常是try...catch
,但是这种方式无法在外部捕获异步的异常,只能在异步方法内部去捕获。
示例9:
function foo(){
setTimeout(function(){
throw new Error('setTimeout error')
},100);
}
try{
foo();
}catch(error){
//无法捕获到异常
console.log(error);
}
//正确方式
function bar(){
setTimeout(function(){
try{
throw new Error('setTimeout error')
}catch(error){
console.log(error);
}
},100);
}
Promise的异常处理方式主要是:
- then(fulfilledFn,rejectedFn)的第二个拒绝回调,promise内部显式的拒绝决议和异常都会执行then中的rejectedFn。如果忽略第二个回调,那么then会执行默认回调,向Promise执行链抛出异常,由下一个监听函数出发。但是不会被外部try...catch捕获
- catch(cb),给Promise链最后追加catch捕获,那么整个Promise链如果没有处理的异常,最终会在这里捕获
异常忽略
Promise的异常还是可能被忽略的(即不被处理)。例如没有给then注册拒绝监听函数,也没有在最终进行catch捕获,或者在catch捕获中又触发异常。
Promise API
构造函数
Promise(function(resolve,reject){})
构造函数第一个参数是一个函数,该函数立刻执行,Promise决议可以调用resolve(可能是成功决议,可能是拒绝决议)或者reject(拒绝决议)。
示例10:
//构造一个Promise
var p = new Promise(function(resolve,reject){
resolve(Promise.reject('异常')) //拒绝决议,返回拒绝理由
});
p.then(function fulfilled(){
//不会执行到这里
},function rejected(err){
console.log(err); // '异常'
})。
then(fulfilledFn,rejectedFn)
then函数返回一个Promise,fulfilledFn是处理then监听的Promise的成功回调,rejectedFn是对舰艇的Promise的拒绝回调。如果这两个回调函数参数内部拒绝决议或者异常报错,则then返回的Promise被拒绝;否则为成功决议,决议值是返回值,没有返回值是undefined;
catch(cb)
用于Promise异常捕获,给Promise链最后追加catch捕获,那么整个Promise链如果没有处理的异常,最终会在这里捕获
静态API
- Promise.race([Promise1,Promise2...]),Promise任务并发执行,第一个Promise决议则视为整体成功决议,全部决议失败否则视为决议失败。这个api可以用来实现Promise的超时处理
- Promise.all([Promise1,Promise2...]),Promise任务并发执行,所有Promise都决议成功则视为整体成功决议,否则视为决议失败
- Promise.resolve(param),返回一个已经决议的Promise,如果参数是一个Promsie或或者thenable,那么会异步展开这个对象,其决议值是最终的Promise的决议值;如果是一个普通变量,则决议值就是该值。
- Promise.reject(param),返回一个已经决议拒绝的Promise。决议值就是param,不会去做什么异步展开。
Promise的局限性
- 异常忽略,正如前文描述的异常处理的缺陷。
- 无法取消的Promise。一旦创建了一个Promise并为其注册了完成和拒绝处理函数,如果这个Promise没有决议的话,无法从外部停止这个Promise。
- 链式流程控制,依旧繁琐。
生成器
生成器构成的异步流程,是一种十分符合大脑思维模式的代码控制机制,很好的解决了回调模式导致的理解困难问题。相较于Promise的链式流程控制,生成器模式更加符合思维模式。
普通函数和生成器函数
JavaScript中普通函数的执行是无法打断的,会完整运行。但是生成器函数不同,它是可以中断一段时间再执行的,在这中间可以插入其他任务。不同于回调模式和Promise模式,这是异步模式的另一种实现。
示例11:
function *foo(){
const a = 1;
yield;
const b = yield 2;
const c = a + b;
}
const it = foo();
const step1 = it.next(); //step1 {value:undefined,done:false}
//doOtherthings();
const step2 = it.next(); //step2 {value:2,done:false}
const step3 = it.next(3); //step3 {value:4,done:true}
生成器函数前缀需要*
修饰,执行生成器函数会返回一个迭代器对象,执行next方法可以对迭代器进行迭代,yield关键字终止程序执行,并可以想外传递消息和接受外部消息。
生成器函数可以多次执行,每次执行都会生成一个独立的迭代器。
生成器双向通信
yield表达式可以将消息传递到,yield表达式相当于将消息通知给调用方,并等待调用方返回表达式的结果值。
n个yield语句将生成器拆分成多个n+1个部分,需要进行n+1次迭代才能消耗完整个迭代器。
next函数对迭代器进行迭代,直到遇到yield,暂停执行,并将yield表达式的值传递到外层。每个next函数等待yield语句的值,最后一个next函数的值由return返回。
迭代器和iterable(可迭代)
迭代器:实现包含next函数的接口的对象。迭代器对象中的next方法返回值保存在value属性,done属性值代表迭代器是否消耗完毕。
iterable:一个包含可以在值上进行迭代的迭代器的对象。在ES6中,从一个iterable中提取迭代器的方法:iterable实现Symbol.iterator
,调用这个函数会返回一个迭代器。
示例12:
function foo(){
var value=1;
var iterator = {
next: function(){
value = value * 2;
return { value: value, done: false };
}
};
return {
[Symbol.iterator]:function(){
return iterator;
}
};
};
var iterable = foo();
for(var i of iterable){
console.log(i);
//避免死循环
if(i>10){
break;
}
}
生成器委托
示例13:
function *foo(){
console.log('foo');
yield 1;
yield 2;
yield 3;
return 'foo end'
}
function *bar(){
console.log('bar');
yield 0;
const value = yield *foo();
console.log('value',value)
yield 4;
}
const it=bar();
for(let i of it){ //for...of会消耗iterable对象
console.log(i);
}
//bar
//0
//foo
//1
//2
//3
//value foo end
//4
所谓生成器委托,其实就是将当前生成器代码的控制转移给委托的生成器函数,直到消耗完委托的生成器。此时调用委托生成器函数的yield的结果值是委托生成器的返回值,而不是通过next()传递进来的。
生成器委托主要用于代码的组织。类似于普通函数,将部分代码结构封装起来,供其他生成器调用。
异常处理
生成器异常处理这种看似同步的模式,在可读性和可理解性上是很符合我们的思维的。
- 将异常抛入迭代器内部:可以通过可迭代对象的throw方法,将异常抛入到迭代器内部处理,内部使用try...catch捕获
- 外部捕获:迭代器内部异常未处理,可以通过外部try...catch捕获
- 当使用生成器委托的时候,被委托的生成器如果发生异常,其处理方式同1、2
情况1示例14:
function ajax(){
request('https://baidu.com',{
success:function(data){
console.log(data);
it.next();
},
error:function(err){
it.throw(err);// 异步任务发生异常,通过it.throw()方法将异常抛入到生成器中处理
}
})
}
function *foo(){
try{
const result = yield ajax(); //ajax是异步任务
}catch(err){
console.log(err);
}
}
var it=foo();
foo.next();
情况2示例15:
function *foo(){
console.log('foo');
yield 1;
throw new Error('foo err'); //生成器内部异常未捕获,抛出到外部。
yield 2;
yield 3;
return 'foo end';
}
const it=foo();
try{
for(let i of it){
console.log(i);
}
}catch(err){
console.log(err)
}
Promise+生成器
示例16:
function ajax(){
return request('https://baidu.com'); //request返回一个Promise
}
function *foo(){
try{
const result = yield ajax();
}catch(err){
console.log(err);
}
}
//依旧是较为繁琐的调用
var it = foo();
const p = foo.next();
p.then(function filfulled(data){
console.log(data);
it.next();
},function rejected(msg){
it.throw(msg);
})
Promise的可信任型和可组合型(链式调用),以及结合生成器的类似同步的执行机制。这是ES6之前的很好的异步模式解决方案。
当然,这里的问题是还需要自己去主动的调用生成器产生迭代器,手动控制迭代。而es6的async...await
模式是js引擎在语法层面的解决方案,相当于自动执行Promsie+生成器的异步流程。
而在async...await
方案还没有落地的时候,第三方库提供了一些自动执行生成器的方案(Thunk和co模块)。
自执行的生成器
示例17:
function run(gen){
var args=[].slice.call(arguments,1);
var it;
//在当前上下文中初始化生成器
it=gen.apply(this,args);
//返回一个promise用于生成器完成
return Promise.resolve().then(function handleNext(value){
//对下一个yield出的值运行
var next = it.next(value);
return (function handleResult(next){
//生成器运行完毕了吗?
if(next.done){
return next.value;
}
//否则继续运行
else{
return Promise.resolve(next.value).then(
//成功就回复异步执行,把决议的值返回给生成器
handleNext,
//如果value是被拒绝的promise,就把错误传回给生成器惊醒出错处理
function handleErr(err){
return Promsie.resolve(it.throw(err)).then(handleResult);
}
)
}
})(next);
});
}
run(foo); //foo即是示例16中定义的生成器函数
ES6异步模式async...await
可以理解为Promise+生成器+自执行机制的语法糖,在编译阶段就已经完成了对迭代器的消费方式。不必像生成器函数那样在外部编码控制迭代器的消耗。
类似生成器模式,实现了已同步方式编写异步代码。
async
async关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上。
async函数内部同Promise的执行器函数一样,内部是同步执行的。见示例18。
async函数返回值是Promise,如果显式返回非Promise的值,会被Promise.resolve()
包装成Promise,如果程序异常或者内部有一个await跟着一个拒绝的决议Promise.reject
,整个async函数返回拒绝决议的Promise。
示例18:
async function foo(){
console.log(1);
}
foo();
console.log(2);
//1
//2
await
await是一种类似yield的处理机制,可以暂停和恢复异步代码的执行。
await是一元操作符,其后跟着的操作数如果是非Promise对象,则会由Promise.prototype.resolve
包装成Promise。如果是Promsie,则会等待Promise决议完成。
重要的是,await操作总是异步执行的,哪怕操作数是基本对象。
await只能出现在async函数中。
示例19:
async function foo(){
console.log(1);
const a=await 2;
console.log(2);
}
console.log(3);
foo();
console.log(4);
//3
//1
//4
//2
异常处理
async函数内部异常,处理方式和生成器函数不同,不会被外部捕获,而是和Promise的异常处理机制一样,需要由Promise.prototype.then
或者Promise.prototype.catch
监听函数处理,原因是async内部的实现已经捕获了异常,并且以Promise.reject方式处理了,所以不会向外部抛出异常。
当然,也可以在async函数内部使用try...catch
捕获异常。
示例20:
async function foo(){
throw new Error(1);
}
async function bar(){
const p = await new Promise((resolve,reject)=>{
reject(2)
});
console.log(p)
}
//错误的异常监听 => 同步的异常处理无法捕获
try{
foo()
}catch(err){
console.log('foo',err)
}
try{
bar()
}catch(err){
console.log('bar',err)
}
//正确的异常监听
var p1=foo();
var p2=bar();
p1.catch(data=>{
console.log('foo err',data)
});
//输出 foo err + 调用栈信息
p2.catch(data=>{
console.log('bar err',data)
});
//输出bar err 2
//或者在async内部捕获,推荐使用这种异常处理机制
async function bar(){
try{
const p=await new Promise((resolve,reject)=>{
reject(2)
});
console.log(3,p); //不会执行
}catch(err){
console.log('catch',err); //异常捕获,输出catch 2
}
}
bar();