1 关于异步操作的一些概念
虽然JavaScript引擎拥有多个线程,但是单个脚本只能在一个线程上运行,也就是说,JavaScript只能同时执行一个任务,其他的任务则必须在当前任务后面排队等待,这被称之为单线程模型。
在JavaScript中,程序里的任务可以被分为两类:同步任务和异步任务。
-
同步任务(synchronous):即在主线程上执行的任务,并且之所以同步,是因为只有执行完前一个任务才能执行后一个任务。
-
异步任务(asynchronous):说白了就是被JavaScript引擎放进任务队列的任务(等于被主线程挂起了),待任务返回了执行结果,主线程才会执行它的回调函数(即切回到主线程上执行)。
在JavaScript运行时,除了一个正在运行的主线程,引擎还提供了多个任务队列(task queue)用于处理当前程序的异步任务,也正因为如此,异步任务使得程序不会出现“阻塞”的现象。
总结一下JavaScript程序的具体执行过程:
- 首先,主线程会执行所有的同步任务;
- 待执行完所有的同步任务,然后会去执行任务队列中的异步任务;
- 当异步任务返回执行结果时,那么异步任务会重新进入到主线程(即在主线程执行它的回调函数);
- 待当前的异步任务执行完毕后,下一个异步任务也是如此,直到最终任务队列被清空,整个程序也就执行完毕。
那么问题来了,什么样的任务是异步任务?那么JavaScript引擎是如何知道异步任务返回结果了?
异步任务,可以简单理解为耗时任务,例如网络请求、IO操作(文件读写)、浏览器获取地理位置等等都是异步任务。除此之外,像定时器(timer)执行的定时任务,在执行时也会被添加到任务队列中,定时任务主要由setTimeout()
和setInterval()
这两个函数来完成。
至于JavaScript引擎何时知道异步任务返回了结果(例如网络请求返回了消息,IO操作(文件读写)返回了结果),这就要用到JavaScript引擎内部的事件循环(Event Loop)机制了。具体地,引擎会不断地一遍又一遍地检查,直到同步任务执行完毕,引擎就会去检查那些挂起的异步任务,看它们是否返回了运行结果,从而可以判断其是否能进入主线程,这种循环检查的机制,就叫做事件循环。
注:事件循环其实就是一个用于等待和发送消息、事件的一个程序。
2 实现异步操作的方法
2.1 回调函数
举个例子:有f1
和f2
两个函数,一般情况下,显然会先执行f1
,待f1
执行完毕后才会执行f2
。
function f1() {
console.log('f1函数被执行了');
}
function f2() {
console.log('f2函数被执行了');
}
f1();
f2();
执行程序后控制台输出结果和预期一样:
f1函数被执行了
f2函数被执行了
如下代码所示:我们在f1
函数中进行了读取文件的操作(显然为异步任务),然后在f2
函数中将读取的文件对象输出。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Callback Test</title>
</head>
<body>
<input type="file" id="inputFile">
<input type="button" id="btn" value="点击">
<script type="text/javascript" src="jquery-3.6.0.min.js"></script>
<script type="text/javascript">
var jsonObj;
function f1() {
var inputFile = $('#inputFile')[0].files[0];
var reader = new FileReader();
reader.readAsText(inputFile, 'UTF-8');
reader.onload = function (evt) {
var fileString = evt.target.result;
jsonObj = JSON.parse(fileString);
console.log(jsonObj);
console.log('f1函数被执行了');
};
}
function f2() {
console.log(jsonObj);
console.log('f2函数被执行了');
}
$('#btn').click(function () {
f1();
f2()
});
</script>
</body>
</html>
测试用json示例数据:
{
"sites": [
{ "name":"菜鸟教程" , "url":"www.runoob.com" },
{ "name":"google" , "url":"www.google.com" },
{ "name":"微博" , "url":"www.weibo.com" }
]
}
执行程序后控制台输出结果如下:显然,优先执行了同步任务f2,但此时jsonObj还没有被读取,因此返回一个undefined,然后才执行异步任务f1,最终也输出了文件对象。
undefined
f2函数被执行了
{sites: Array(3)}
f1函数被执行了
我们可以利用回调函数来控制执行顺序,代码修改如下:
var jsonObj;
function f1(callback) {
var inputFile = $('#inputFile')[0].files[0];
var reader = new FileReader();
reader.readAsText(inputFile, 'UTF-8');
reader.onload = function (evt) {
var fileString = evt.target.result;
jsonObj = JSON.parse(fileString);
console.log(jsonObj);
console.log('f1函数被执行了');
callback();
};
}
function f2(jsonObj) {
console.log(jsonObj);
console.log('f2函数被执行了');
}
$('#btn').click(function () {
f1(f2);
});
执行程序后控制台输出结果如下:
{type: "FeatureCollection", features: Array(215)}
f1函数被执行了
{type: "FeatureCollection", features: Array(215)}
f2函数被执行了
2.2 事件监听
另一种思路是采用事件驱动方法,即异步任务的执行取决于某个事件是否发生。
我们将上一节的示例进行改写:
var jsonObj;
function f1() {
var inputFile = $('#inputFile')[0].files[0];
var reader = new FileReader();
reader.readAsText(inputFile, 'UTF-8');
reader.onload = function (evt) {
var fileString = evt.target.result;
jsonObj = JSON.parse(fileString);
console.log(jsonObj);
console.log('f1函数被执行了');
//触发done事件,从而开始执行f2函数
$(document).trigger('done');
};
}
function f2() {
console.log(jsonObj);
console.log('f2函数被执行了');
}
$('#btn').on('click', function () {
// 绑定一个done事件,在该事件被触发后执行f2函数
$(document).on('done', f2);
f1();
});
执行程序后控制台输出结果如下:
{sites: Array(3)}
f1函数被执行了
{sites: Array(3)}
f2函数被执行了
2.3 发布/订阅模式
发布/订阅模式与事件监听模式很相似,关于发布中心的实现我使用前端异步解决方案-2(发布/订阅模式)一文中的代码,我这里也贴一下实现代码:
//实现发布中心
/*
* log=[{
* index: Number, 日志编号(自增)
* type: String, 日志类型('subscribe','unsubscribe','publish')
* eventName: String, 事件名
* time: new Date(), 时间
* fun:Function 订阅/取订的方法(只有日志类型为'subscribe'或'unsubscribe'的才有)
* param:Object 触发事件时的参数(只有日志类型为'publish'的才有)
* }]
* eventCenter = {
* eventName:[Function,Function] //eventName即为事件名,其值为订阅方法列表
* }
* .subscribe(eventName, fun) 订阅 eventName:事件名 fun:订阅方法
* .unsubscribe(eventName, fun) 取订 eventName:事件名 fun:订阅方法
* .publish(eventName[,param]) 发布 eventName:事件名 param:事件参数
* .showLog([filter]) 日志展示 filter 过滤器,同数组的过滤器用法 返回过滤后的log
* .showEventCenter([eventName]) 事件中心 eventName 事件名 返回事件绑定的方法
* */
let subscribeCenter = function () {
//事件中心
let eventCenter = {};
//日志
let log = [];
//添加日志函数
function pushLog(type, eventName, fun, param) {
let info = {
index: log.length,
type: type,
eventName: eventName,
time: new Date()
};
if (fun) {
info.fun = fun;
}
if (param) {
info.param = param;
}
log.push(info)
}
return {
//订阅
subscribe(eventName, fun) {
pushLog("subscribe", eventName, fun);
eventCenter[eventName] = eventCenter[eventName] || [];
eventCenter[eventName].push(fun);
},
//取消订阅
unsubscribe(eventName, fun) {
pushLog("unsubscribe", eventName, fun);
let onList = eventCenter[eventName];
if (onList) {
for (let i = 0; i < onList.length; i++) {
if (onList[i] === fun) {
onList.splice(i, 1);
return
}
}
}
},
//发布
publish(eventName, param) {
pushLog("publish", eventName, null, param)
let onList = eventCenter[eventName];
if (onList) {
for (let i = 0; i < onList.length; i++) {
onList[i](param)
}
}
},
//显示日志
showLog(filter) {
filter = filter || (() => true);
let returnLog = log.filter(filter);
returnLog.forEach(x => {
let y = {};
for (let key in x) {
y[key] = x[key]
}
return y
});
return returnLog;
},
//显示事件中心
showEventCenter(eventName) {
let selectEM = eventName ? eventCenter[eventName] : eventCenter, returnEM = {};
for (let key in selectEM) {
returnEM[key] = [];
selectEM[key].forEach(x => {
returnEM[key].push(x)
});
}
return returnEM
}
}
}();
我们继续改写上面的例子:
var jsonObj;
function f1() {
var inputFile = $('#inputFile')[0].files[0];
var reader = new FileReader();
reader.readAsText(inputFile, 'UTF-8');
reader.onload = function (evt) {
var fileString = evt.target.result;
jsonObj = JSON.parse(fileString);
console.log(jsonObj);
console.log('f1函数被执行了');
subscribeCenter.publish('done');
};
}
function f2() {
console.log(jsonObj);
console.log('f2函数被执行了');
}
$('#btn').on('click', function () {
subscribeCenter.subscribe('done', f2);
f1();
});
执行程序后控制台输出结果如下:
{sites: Array(3)}
f1函数被执行了
{sites: Array(3)}
f2函数被执行了
2.4 借助Promise对象
Promise对象为异步操作提供了统一的接口,它实际上起到了代理(proxy)作用,即充当异步操作和回调函数之间的中介。
Promise对象通过自身的状态来控制异步操作,Promise实例具有3种状态:
- 异步操作未完成(pending)
- 异步操作成功(fulfilled)
- 异步操作失败(rejected)
以上三种状态中异步操作成功和异步操作失败合在一起被称为已定型(resolved)。因而状态的变化只有两种情况:
- 异步操作成功:Promise实例传回一个值,状态变为fulfilled;
- 异步操作失败:Promise实例抛出一个错误,状态变为rejected;
同样地,继续修改上述示例:
var jsonObj;
function f1(resolve) {
var inputFile = $('#inputFile')[0].files[0];
var reader = new FileReader();
reader.readAsText(inputFile, 'UTF-8');
reader.onload = function (evt) {
var fileString = evt.target.result;
jsonObj = JSON.parse(fileString);
console.log(jsonObj);
console.log('f1函数被执行了');
resolve();
};
}
function f2() {
console.log(jsonObj);
console.log('f2函数被执行了');
}
$('#btn').click(function () {
var promise = new Promise(
function (resolve) {
f1(resolve);
}
);
promise.then(f2);
});
执行程序后控制台输出结果如下:
{sites: Array(3)}
f1函数被执行了
{sites: Array(3)}
f2函数被执行了
这里我们也可以改写为:
function f1(resolve) {
var inputFile = $('#inputFile')[0].files[0];
var reader = new FileReader();
reader.readAsText(inputFile, 'UTF-8');
reader.onload = function (evt) {
var jsonObj;
var fileString = evt.target.result;
jsonObj = JSON.parse(fileString);
console.log(jsonObj);
console.log('f1函数被执行了');
resolve(jsonObj);
};
}
function f2(jsonObj) {
console.log(jsonObj);
console.log('f2函数被执行了');
}
$('#btn').click(function () {
var promise = new Promise(
function (resolve) {
f1(resolve);
}
);
promise.then(f2);
});
2.5 本章小结
对比一下以上4种实现异步的方法
方法 | 优点 | 缺点 |
---|---|---|
回调函数 | 简单、容易理解和实现 | 不利于代码的阅读和维护,各个部分之间高度耦合(coupling) |
事件监听模式 | 比较容易理解,可以绑定多个事件,每个事件可以指定多个回调函数,而且可以去耦合(decoupling),有利于实现模块化 | 阅读代码的时候,很难看出主流程 |
发布/订阅模式 | 由于可以监控程序的运行,如通过查看“消息中心”,了解存在多少信号、每个信号有多少订阅者,因此要优于事件监听模式 | 同上 |
Promise对象 | 采用 Promises 以后,程序流程变得非常清楚,十分易读。 | - |
3 控制多个异步任务的执行
3.1 串行执行
我们按顺序串行读取4个文件,代码实现如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>控制多个异步操作的执行</title>
</head>
<body>
<input type="file" id="inputFile1"><br><br>
<input type="file" id="inputFile2"><br><br>
<input type="file" id="inputFile3"><br><br>
<input type="file" id="inputFile4"><br><br>
<input type="button" id="btn" value="点击">
<script type="text/javascript" src="jquery-3.6.0.min.js"></script>
<script type="text/javascript">
var items = ['#inputFile1', '#inputFile2', '#inputFile3', '#inputFile4'];
var results = [];
function read(arg, callback) {
var reader = new FileReader();
var inputFile = $(arg)[0].files[0];
reader.readAsText(inputFile, 'UTF-8');
reader.onload = function (evt) {
var jsonObj;
var fileString = evt.target.result;
jsonObj = JSON.parse(fileString);
callback(jsonObj);
};
}
//串行
function series(item) {
if (item) {
read(item, function (result) {
console.log(result);
results.push(result);
return series(items.shift());
});
} else {
console.log('串行读取的文件如下:');
console.log(results);
}
}
$('#btn').click(function () {
series(items.shift());
});
</script>
</body>
</html>
我们将下面4个json分别保存本地文件:
demo1.json
内容如下:
{
"sites1": [
{ "name":"菜鸟教程" , "url":"www.runoob.com" },
{ "name":"google" , "url":"www.google.com" },
{ "name":"微博" , "url":"www.weibo.com" }
]
}
demo2.json
内容如下:
{
"sites2": [
{ "name":"菜鸟教程" , "url":"www.runoob.com" },
{ "name":"google" , "url":"www.google.com" },
{ "name":"微博" , "url":"www.weibo.com" }
]
}
demo3.json
内容如下:
{
"sites3": [
{ "name":"菜鸟教程" , "url":"www.runoob.com" },
{ "name":"google" , "url":"www.google.com" },
{ "name":"微博" , "url":"www.weibo.com" }
]
}
demo4.json
内容如下:
{
"sites4": [
{ "name":"菜鸟教程" , "url":"www.runoob.com" },
{ "name":"google" , "url":"www.google.com" },
{ "name":"微博" , "url":"www.weibo.com" }
]
}
在控制台的运行结果如下:按顺序依次读取了各个文件。
3.2 并行执行
我们利用forEach
方法同时发起6个读文件的异步任务,具体实现如下:
var items = ['#inputFile1', '#inputFile2', '#inputFile3', '#inputFile4'];
var results = [];
function read(arg, callback) {
var reader = new FileReader();
var inputFile = $(arg)[0].files[0];
reader.readAsText(inputFile, 'UTF-8');
reader.onload = function (evt) {
var jsonObj;
var fileString = evt.target.result;
jsonObj = JSON.parse(fileString);
callback(jsonObj);
};
}
// 并行
function parallel(items) {
items.forEach(function (item) {
read(item, function (readResult) {
console.log(readResult);
results.push(readResult);
if (results.length === items.length) {
console.log('并行读取的文件有:');
console.log(results);
}
});
});
}
$('#btn').click(function () {
parallel(items);
});
在控制台的运行结果如下:根据结果容易看出,各个文件是同时开始被读取的,最先读完了demo3.json
,而demo1.json
在第3个位置,并没有像串行那样依次按顺序去读取。
3.3 并行与串行结合使用
我们加一个限制条件,每次最多执行limit个异步任务,这样避免了过度占用系统资源。
var items = ['#inputFile1', '#inputFile2', '#inputFile3', '#inputFile4'];
var results = [];
var running = 0;
var limit = 2;
function read(arg, callback) {
var reader = new FileReader();
var inputFile = $(arg)[0].files[0];
reader.readAsText(inputFile, 'UTF-8');
reader.onload = function (evt) {
var jsonObj;
var fileString = evt.target.result;
jsonObj = JSON.parse(fileString);
callback(jsonObj);
};
}
// 并行与串行结合
function mixed() {
while (running < limit && items.length > 0) {
var item = items.shift();
read(item, function (readResult) {
console.log(readResult);
results.push(readResult);
running--; //每读取完一个文件则减1
if (items.length > 0) {
mixed();
} else if (running === 0) { //所有文件都读取完毕
console.log('并行与串行结合读取的文件有:');
console.log(results);
}
});
running++;//每运行一个异步任务,则加1
}
}
$('#btn').click(function () {
mixed();
});
在控制台的运行结果如下:demo1.json
和demo2.json
作为一组,而demo3.json
和demo4.json
也作为一组,它们分别都是被同时读写的,两个组之间又是按照顺序,先执行的第一组,再执行的第二组。