Javascript进阶——异步编程
理解JS异步
同步和异步
同步:调用之后得到结果,再依次执行其他的任务
异步:调用之后可以不等待结果,继续做其他的事
众所周知,Javascript是单线程的,代码只能一行一行通过JS引擎的主线程执行。但是这种模式存在一个问题:如果有一个任务耗时很长,后面的任务都必须排队等着,会导致整个程序卡在一个地方,其他任务无法执行,造成页面长时间无响应,甚至卡死,用户体验很糟糕。
如此,“异步”模式就显得很重要了,耗时很长的操作都使用异步执行,避免浏览器失去响应,让用户能够流畅的访问网页。那么单线程的JS是怎么实现异步的呢?
JS异步原理
JS引擎本身是单线程的,异步其实是借助浏览器内核多线程来实现的。现代浏览器使用的都是多进程架构(如下图),而一个进程可以包含一个或多个线程。JS引擎处于渲染进程,异步也是通过此进程中的各个线程的协调来完成的。
浏览器进程
GUI线程:主要用于渲染布局
JS引擎线程:用于解析、执行JS代码;它与GUI线程是互斥的,因为JS里可以操作DOM,如果与GUI同时执行,可能会引起页面渲染混乱
定时触发器线程:用于执行定时任务,setTimeout,setInterval
事件触发线程:将满足条件的事件加入任务队列
异步HTTP请求线程:XHR所在线程,用于处理AJAX请求
多线程之间的配合实现异步:
定时器,异步请求线程可以独立于JS引擎主线程同时运行
定时器任务定时完成后,会通知事件触发线程,将定时器的回调任务加入任务队列
异步HTTP请求完成时,如果有回调函数,就会通知事件触发线程往任务队里添加事件
通过Event Loop机制,浏览器依次执行完任务队列里的所有任务
理解 Event Loop机制
JS异步的实现,Event Loop是关键的一步。Event Loop其实是一个处理模型,在不同的地方有不同的实现。浏览器和NodeJS基于不同的技术实现了各自的Event Loop。这里我们主要来看看浏览器端的实现。
在Event Loop模型中,我们将任务分为宏任务(macrotask)和微任务(microtask)。
以下任务会加入宏任务队列:
script,执行主线程中的script,是全局任务,也可以看成是一个宏任务
setTimeout/setInterval
I/O
UI rendering
以下任务会加入微任务队列:
Promise
Object.observe
MutationObserver
postMessage, 主要用于window对象之间的通信
首先我们先来看看浏览器执行一个JavaScript代码的具体流程:
JS代码执行流程
首先执行全局script,script可以包含同步任务和异步任务,同步任务执行完就出栈,异步任务则通过异步处理机制加入任务队列
当所有同步任务执行完成,首先检查微任务队列,一次执行完所有微任务
当所有微任务执行完以后,从任务队列中取出最早的宏任务,宏任务执行前,再次检查微任务队列,如果有新的微任务,先清空微任务,再执行宏任务,执行完成后,依次从队列中取出后面的宏任务,重复此步骤,直到执行完所有宏任务,Stack清空
步骤3,可以解释为以下Event Loop模型:
Event Loop处理模型
我们看一段示例代码,进一步加深理解:
console.log("1");
setTimeout(function() {
console.log("2");
}, 0);
Promise.resolve().then(function() {
console.log("3");
});
console.log("4");
console.log("start");
setTimeout(() => {
console.log("setTimeout");
new Promise(resolve => {
console.log("promise inner1");
resolve();
}).then(() => {
console.log("promise then1");
});
}, 0);
new Promise(resolve => {
console.log("promise inner2");
resolve();
}).then(() => {
console.log("promise then2");
});
正确的打印结果如下:
1
4
start
promise inner2
3
promise then2
2
setTimeout
promise inner1
promise then1
我们来解析一下执行过程:
首先依次执行同步任务:console.log("1"),console.log("4"),console.log("start")
需要注意的是console.log("promise inner2")是在new Promise定义中中声明的,也是同步执行的,then方法里面的才是异步任务
同步任务执行完,Stack清空,依次执行定义在外层的Promise微任务
外层微任务清空,依次执行宏任务setTimeout
常用的异步编程方法
学习了异步编程的过程之后,我们接着整理一下常用的异步编程方式。
回调函数
回调函数是最简单的实现异步的方式,常用的就是定时器和ajax请求。这种方式的优点就是简单易于理解,缺点是阅读性差,多层调用时耦合性高。代码形式多数如下:
function f1(callback){
setTimeout(function () {
// f1的任务代码
callback();
}, 1000);
}
使用定时器的Tips:
定时任务可能不会按时执行,取决于同步任务的执行时间
定时器嵌套5次之后最小间隔时间不能低于4ms
定时器主要应用场景:防抖,节流,倒计时,动画
事件监听
事件监听的要点:任务的执行不取决代码的顺序,而取决于某一个事件是否发生。常用的就是对dom对象的事件绑定。例如:
$('.element1').on('click', function(){
console.log(1);
});
发布/订阅
发布---订阅模式又叫观察者模式,它定义了对象间的一种一对多的关系,让多个观察者对象同时监听某一个主题对象,当一个对象发生改变时,所有依赖于它的对象都将得到通知。
简单的发布/订阅模式实现如下:
class PubCenter{
constructor(){
this.events={};
}
subscribe(eventName, callback){
if(this.events[eventName]){
this.events[eventName].push(callback);
}
else{
this.events[eventName]=[callback];
}
}
publish(eventName,data){
if(this.events[eventName]){
this.events[eventName].forEach(cb=>{
cb.apply(this,data);
})
}
}
unsubscribe(eventName, callback){
if(this.events[eventName]){
this.events[eventName]=this.events[eventName].filter(cb=>{
cb!=callback;
});
}
}
}
// 使用
const pub = new Publisher();
ajax('/url', function(data){
pub.publish('ajaxSucess', data);
});
pub.subscribe('ajaxSucess', function(){
console.log('ajax success');
});
发布/订阅的优势:
松耦合
灵活
缺点:
无法确保消息被触发或被触发几次
Promise
Promise 对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功) 和 rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。 Promise 对象的状态改变,只有两种可能:从 pending 变为 fulfilled 和 从 pending 变为 rejected。
ES6 原生提供了 Promise 对象。它的构造函数接受一个名为executor 的函数,此执行函数接受两个参数 resolve 和 reject,它们都是函数。 Promise 通常用于处理异步操作或阻塞代码,其示例包括文件操作,API调用,DB调用,IO调用等。这些异步操作的启动发生在执行函数中。如果异步操作成功,则通过 promise 的创建者调用resolve 函数返回预期结果,同样,如果出现意外错误,则通过调用 reject 函数传递错误具体信息。
基本用法:
new Promise(function (resolve, reject) {
// 一段耗时的异步操作
if (success) {
resolve("成功"); // 数据处理完成
} else {
reject("错误信息"); // 数据处理出错
}
}).then(
(res) => {
console.log(res);
}, // 处理成功回调
(err) => {
console.log(err);
} // 处理失败回调
);
// 多个Promise可以通过then方法链式调用,实现顺序执行,除了转账其他事都不在需要一层一层写嵌套代码
// 错误处理,可以通过then方法的第二个参数函数处理,也可以通过throw new Error用catch捕获处理
new Promise((resolve) => {
setTimeout(() => {
if (success) {
resolve("第一步");
} else {
reject("第一个错误");
}
}, 2000);
})
.then(
(res) => {
console.log(res); // res= '第一步'
return new Promise((resolve) => {
setTimeout(() => {
if (success) {
resolve("第二步");
} else {
throw new Error("第二个错误");
}
}, 2000);
});
},
(err) => {
console.log(err); // err='第一个错误'
}
)
.then((res) => {
console.log(res); // res= '第二步'
})
.catch((err) => {
console.log(err); // err='第二个错误'
});
除此之外,ES6还提供了一些API,支持多个Promise并行执行,Promise.all和Promise.race
Promise.all(iterable) 方法返回一个 Promise 实例,此实例在 iterable 参数内所有的 promise
都“完成(resolved)”或参数中不包含 promise 时回调完成(resolve);如果参数中 promise
有一个失败(rejected),此实例回调失败(reject),失败原因的是第一个失败 promise 的结果。
Promise.race(iterable) 方法返回一个 promise,一旦迭代器中的某个promise解决或拒绝,返回的 promise就会解决或拒绝。
// 同时执行p1和p2,只有p1、p2的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2的返回值组成一个数组,传递给p的回调函数
// 只要p1、p2之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数
var p= Promise.all([p1, p2]).then(function (results) {
console.log(results); // 获得一个Array: ['P1', 'P2']
});
// Promise.race
var p1 = new Promise(function (resolve, reject) {
setTimeout(resolve, 500, 'P1');
});
var p2 = new Promise(function (resolve, reject) {
setTimeout(resolve, 600, 'P2');
});
// 只要p1、p2之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的Promise实例的返回值,就传递给p的返回值
// 由于p1执行较快,Promise的then()将获得结果'P1'。p2仍在继续执行,但执行结果将被丢弃。
var p= Promise.race([p1, p2]).then(function (result) {
console.log(result); // 'P1'
});
Promise优势:
可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise 对象提供统一的接口,使得控制异步操作更加容易
缺点:
Promise 一旦创建,不能中途取消
如果不设置回调函数,Promise 内部抛出的错误,不会反应到外部
当处于 Pending 状态时,无法得知目前进展到哪一个阶段
Generator函数(生成器函数)
Generator函数时ES6提供的一种异步编程解决方案,它通过function*来声明,返回一个符合可迭代协议和迭代器协议的生成器对象,它在执行时能暂停,又能从暂停处继续执行。
可迭代协议: 包含[Symbol.iterator]属性,ES6内置的可迭代对象包含String,Array,Map,Set等
迭代器协议: 具有next()方法,并且next方法返回一个包含属性done(bool),value(object)对象,done表示可迭代对象是否遍历完成,value表示可迭代对象当前的值,遍历完成后,done=true,value=undefined
Generator函数要点:
Generator函数通过yeild关键字来暂停和恢复,yeild关键字只能出现在generator函数里
next方法的执行:遇到yeild关键字则暂停,将yeild后面的值作为返回对象的value的值;没有yeild,则一直执行到return,将return的值作为返回对象的value;没有return,将undefined作为返回对象的value
next方法可以带一个参数,该参数会被当做上一个yeild表达式的返回值
代码示例:
function* generator() {
let first = yield 1;
let second = yield first + 2;
yield second + 3;
}
const g = generator();
g.next(); // {value:1, done:false}
g.next(3); // {value:5, done:false}, next方法有参数,将作为上一次yield返回值,此时first=3
g.next(4); // {value:7, done: false}, 同上,second=4
g.next(); // {value: undefined, done: true}
// 利用yeild* 可实现生成器复用
function* gen1() {
yield 1;
yield 2;
}
function* gen2() {
yield 5;
yield* gen1();
yield 6;
}
const g2 = gen2();
g2.next(); //{value:5, done:false}
g2.next(); //{value:1, done:false}
g2.next(); //{value:2, done:false}
g2.next(); //{value:6, done:false}
g2.next(); // {value: undefined, done: true}
// 利用return函数可以提前结束generator函数
function* gen3() {
yield 1;
yield 2;
yield 3;
}
const g3 = gen3();
g3.next(); // {value:1, done:false}
g3.return(); // {value: undefined, done: true}
g3.next(); // {value: undefined, done: true}
// throw应用
function* gen4() {
try {
let v2 = yield 1 + 2;
} catch {
v2 = 4;
}
yield v2 + 3;
}
const g4 = gen4();
g4.next(); // {value: 3, done: false}
g4.throw(new Error("e")); //{value: 7, done: false}, throw不会中断执行,直到遇到yield
g4.next(); // {value: undefined, done: true}
利用yield* 可以复用生成器
参考链接:
浏览器进程?线程?傻傻分不清楚!
[译]官方图解:Chrome 快是有原因的,现代浏览器的多进程架构!
Event Loops标准
JS中的栈内存堆内存
Promises/A+
菜鸟教程 -- JavaScript Promise 对象
发表评论 (审核通过后显示评论):