JavaScript中的异步操作-学习笔记

1 关于异步操作的一些概念

虽然JavaScript引擎拥有多个线程,但是单个脚本只能在一个线程上运行,也就是说,JavaScript只能同时执行一个任务,其他的任务则必须在当前任务后面排队等待,这被称之为单线程模型

在JavaScript中,程序里的任务可以被分为两类:同步任务异步任务

  • 同步任务(synchronous):即在主线程上执行的任务,并且之所以同步,是因为只有执行完前一个任务才能执行后一个任务。

  • 异步任务(asynchronous):说白了就是被JavaScript引擎放进任务队列的任务(等于被主线程挂起了),待任务返回了执行结果,主线程才会执行它的回调函数(即切回到主线程上执行)。

在JavaScript运行时,除了一个正在运行的主线程,引擎还提供了多个任务队列(task queue)用于处理当前程序的异步任务,也正因为如此,异步任务使得程序不会出现“阻塞”的现象。

总结一下JavaScript程序的具体执行过程:

  1. 首先,主线程会执行所有的同步任务;
  2. 待执行完所有的同步任务,然后会去执行任务队列中的异步任务;
  3. 当异步任务返回执行结果时,那么异步任务会重新进入到主线程(即在主线程执行它的回调函数);
  4. 待当前的异步任务执行完毕后,下一个异步任务也是如此,直到最终任务队列被清空,整个程序也就执行完毕。

那么问题来了,什么样的任务是异步任务?那么JavaScript引擎是如何知道异步任务返回结果了?

异步任务,可以简单理解为耗时任务,例如网络请求、IO操作(文件读写)、浏览器获取地理位置等等都是异步任务。除此之外,像定时器(timer)执行的定时任务,在执行时也会被添加到任务队列中,定时任务主要由setTimeout()setInterval()这两个函数来完成。

至于JavaScript引擎何时知道异步任务返回了结果(例如网络请求返回了消息,IO操作(文件读写)返回了结果),这就要用到JavaScript引擎内部的事件循环(Event Loop)机制了。具体地,引擎会不断地一遍又一遍地检查,直到同步任务执行完毕,引擎就会去检查那些挂起的异步任务,看它们是否返回了运行结果,从而可以判断其是否能进入主线程,这种循环检查的机制,就叫做事件循环

注:事件循环其实就是一个用于等待和发送消息、事件的一个程序。

2 实现异步操作的方法

2.1 回调函数

举个例子:有f1f2两个函数,一般情况下,显然会先执行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种状态:

  1. 异步操作未完成(pending)
  2. 异步操作成功(fulfilled)
  3. 异步操作失败(rejected)

以上三种状态中异步操作成功异步操作失败合在一起被称为已定型(resolved)。因而状态的变化只有两种情况:

  1. 异步操作成功:Promise实例传回一个值,状态变为fulfilled;
  2. 异步操作失败: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.jsondemo2.json作为一组,而demo3.jsondemo4.json也作为一组,它们分别都是被同时读写的,两个组之间又是按照顺序,先执行的第一组,再执行的第二组。
在这里插入图片描述

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值