理解JavaScript概念系列--异步任务

4,044 阅读9分钟

思考一下

  1. 什么是JavaScript异步?
  2. 为什么要实现JavaScript异步?
  3. 怎么实现JavaScript异步?
  4. JavaScript异步原理是什么?

最近权利的游戏第八季已经开播两集了,权游迷们看完第二集的时候不知道有没有这样一种体会,想象一下如果你是剧中的一位人物,在与异鬼大军大战前夜你会想什么或者你会做些什么事?不得不说导演把剧中人物在大战前夜他们的心理活动以及表现描述的恰到好处,每一帧画面都值得细细体味。

回到写文章上来,如何在一篇文章中把想要总结的知识点深入浅出的罗列出来是我一直在思考的问题,欢迎同学们给出宝贵意见。

JavaScript异步

所谓 "异步",简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。

Javascript语言的执行环境是 "单线程" (single thread)。所谓“单线程”执行也可以理解为JavaScript代码从上到下顺序解释(编译)执行。在程序执行过程中遇到需要控制在未来某个时间点(比如setTimeout,callback等)才能执行的程序则将其放到一边(消息队列),继续执行其下面的程序。那些程序被控制在未来某个时间点执行就形成了JavaScript的 异步执行机制

function add(a, b) {
    return a + b;
}
function sub(c, d) {
    return c - d;
}
function multiply(e, f) {
    return e * f;
}

console.log(add(1, 2));
setTimeout(function() {
    console.log(sub(2, 1));
}, 1000);
console.log(multiply(1, 2));
// 3 console.log(add(1, 2))
// 2 console.log(multiply(1, 2))
// 1 console.log(sub(2, 1))

如果没有这种异步执行机制,当遇到一个任务耗时很长,后面的任务都必须排队等着,会拖延整个程序的执行。常见的浏览器无响应(假死),往往就是因为某一段Javascript代码长时间运行(比如死循环),导致整个页面卡在这个地方,其他任务无法执行。

JavaScript 单线程与任务

1. 运行在浏览器中的JavaScript程序是单线程执行的

  • Js程序始终在一个线程上执行,这个线程就是Js引擎线程。
  • 每个浏览器只有一个Js引擎线程。
  • 单线程,即Js引擎在同一时刻只能执行一个任务,其他任务要执行,需要排队。
  • Js引擎线程和UI线程互斥,因为Js操作DOM导致Js执行时影响页面的渲染。
  • HTML5提测Web Worker标准,运行Js脚本创建多个线程,但是子线程完全受主线程的控制且不能操作DOM元素,所以并没有改变Js单线程的本质。

后续会有文章介绍Web Worker

2. 浏览器是多线程的

  • Js引擎线程:执行JavaScript程序
  • UI渲染线程:渲染页面
  • 浏览器事件触发线程:控制交互,相应用户触发事件
  • 网络请求线程:处理网络请求,ajax是委托给浏览器新开的一个http线程
  • EventLoop处理线程:处理轮询消息队列

后续会有文章介绍浏览器运行机制

JavaScript任务实际上是程序中的一个个代码块(执行单元)或者一行代码,由于JavaScript引擎的单线程执行机制,程序中凡是在JavaScript引擎中顺序执行的代码则为 同步任务,在未来某个时间点执行的代码则为 异步任务

同步任务有哪些?异步任务有哪些?

除了异步任务以外的都是同步任务(有点废话),同步任务太常见了,比如add(a, b)数学运算,document.getElementById('main').style.fontSize = '12px dom运算,quickSort(arr)数组快速排序等。然而JavaScript中提供实现异步任务的代码方式相对来说是有限的,主要是下文中的几种。

JavaScript 创建异步任务

1、JavaScript原生事件处理函数

由Js事件触发的函数本身就是异步任务。

JavaScript原生事件有哪些?

DOM元素上可以触发的有onclickonmouseoveronmouseoutonmousedownonmouseupondblclickonkeydownonkeypressonkeyup等,窗口onresize ,滚动条onscroll等,资源加载onload等,网络请求onreadystatechange ···

// DOM0事件模型
var btn = document.getElementById("myBtn");
btn.onclick = function() {
    alert(this.id);
}
// DOM2事件模型
btn.addEventListener('click', function() {
    alert('hello '+this.id);
}, true)

2、定时器

setImmediate(),setTimeout()setInterval()三种JavaScript中设置定时执行某些程序的函数属于异步任务。

setImmediate(fn)是将事件插入到事件队列尾部,主线程和事件队列的函数执行完成之后立即执行setImmediate指定的回调函数,和setTimeout(fn,0)的效果差不多,但是当他们同时在同一个事件循环中时,执行顺序是 不确定 的。

// note: setImmediate() 是node环境下的函数
setImmediate(function() {
    console.log('run setImmediate');
});
setTimeout(function() {
    alert('run setTimeout');
}, 0);

// run setImmediate -> run setTimeout
// 也有可能是
// run setTimeout -> run setImmediate

异步任务的执行顺会在后面文章《理解JavaScript概念系列--Event Loop》中详细介绍

3、MessageChannel

后续补充相关内容

4、Promise

Promise 对象用于表示一个异步操作的最终状态(完成或失败),以及该异步操作的结果值。

下面看一下Promise的两个例子,一个是无条件触发异步函数,另一个是简单模拟实现axios中的get方法。

// 无条件触发异步结果函数resolve和reject
console.log('before promise run');
var promise = new Promise(function(resolve, reject) {
    console.log('promise is running');
    resolve('promise is resolved');
});
promise.then(result => console.log(result));
console.log('I am a line of reference');
// before promise run
// promise is running
// I am a line of reference
// promise is resolved

// 在一定条件下触发异步结果函数resolve和reject
var axios = {};
axios.get = function(url) {
    return new Promise(function(resolve, reject) {
        var xhr = new XMLHttpRequest();
        xhr.open('GET', url, true);
        xhr.send();
        // 异步事件
        xhr.onreadystatechange = function() {
            if(xhr.readyState == 4) {
                if(xhr.status == 200) {
                    try {
                        var response = JSON.parse(xhr.responseText);
                        resolve(response);
                    }catch(e) {
                        reject(e);
                    }
                }else {
                    reject(new Error(xhr.statusText));
                }
            }
        }
    });
}

axios.get('/userInfo').then(res => res.data)

Promise是JavaScript中提供的原生解决异步编程的一种方案,一般程序中最终异步阶段的调用是需要 外部事件 或者 定时器 等额外异步单元来触发,如果无条件触发异步结果函数,结果响应函数也要等JS引擎主线程所有同步任务执行结束后再执行。

5、Generator

生成器对象 是由一个 generator function 返回的,并且它符合可迭代协议和迭代器协议。

function* gen() {
    yield 1;
    yield 2;
    yield 3;
}
let g = gen();
console.log(g); // Generator {  }
console.log(g.next()); // Object { value: 1, done: false }
console.log(g.next()); // Object { value: 2, done: false }
console.log(g.next()); // Object { value: 3, done: false }
console.log(g.next()); // Object { value: undefined, done: true }

上面代码中,调用 Generator 函数,会返回一个内部指针(即遍历器)g。这是 Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针gnext方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield语句,上例是执行到yield 3为止。

换言之,next方法的作用是分阶段执行Generator函数。每次调用next方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段。

// generator函数异步任务封装
var fetch = require('node-fetch');
function* gen() {
    var url = 'https: //api.xxx.users';
    var result = yield fetch(url);
    console.log(result);
}

var g = gen(); // g为遍历器对象
var result = g.next(); // fetch(url)返回一个promise对象

result.value.then(
    data => data.json;
).then(
    data => g.next(data); // 本次next,跳出当前遍历器
)

上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用next方法(第二行),执行异步任务的第一阶段。由于Fetch模块返回的是一个 Promise 对象,因此要用then方法调用下一个next方法。

可以看到,虽然 Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。

Generator创建的异步任务,必须通过触发next才能执行完成异步阶段的任务,要不然将一直不返回异步结果。

6、async函数

async函数是Generator函数的一个语法糖。

const fs = require('fs');

const readFile = function(fileName) {
    return new Promise(function(resolve, reject) {
        fs.readFile(fileName, function(error, data) {
            if(error) {
                return reject(error);
            }
            resolve(data);
        });
    });
}
// Generator函数读取文件过程
const gen = function* () {
    const f1 = yield readFile('../etc/text');
    const f2 = yield readFile('../etc/xml');
    console.log(f1.toString());
    console.log(f2.toString());
}

// 将上面函数gen改写成async函数
const asyncReadFile = async function() {
    const f1 = await readFile('../etc/text');
    const f1 = await readFile('../etc/xml');
    console.log(f1.toString());
    console.log(f2.toString());
}

async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成awaitasync函数对 Generator 函数的改进,体现在以下四点。

  1. 内置执行器
  2. 更好的语义
  3. 更广的适用性
  4. 返回值是Promise

更加详细的介绍请阅读阮一峰老师的总结 《async 函数》。下面看一下async函数的 核心—返回Promise对象

async function fn() {
    return 'hello async'; // 等同于 return await 'hello async'
}
var p = fn();
console.log(p); // Promise { <state>: "fulfilled", <value>: "hello async" }
p.then(value => console.log(value)); // hello async

正常情况下,await命令后面是一个Promise对象,返回该对象的结果。如果不是Promise对象,就直接返回对应的值。

process.nextTick(node环境)

Node.js服务端环境也是单线程的,除了系统I/O之外,在它的时间轮询过程中同一时间(点)只会处理一个事件。在I/O型应用中,给每一个输入输出定义回调函数,他们会自动添加到时间轮询的处理队列中。当I/O操作完成后,这些回调函数会被触发并执行。process.nextTick的意思就是定义一个(异步)动作,并且这个动作在下一个时间轮询的时间点上执行。

function f() {
    console.log('foo')
}

process.nextTick(f);

setTimeout(function() {
    console.log('bar');
}, 0);

process.nextTick(f);
console.log('I am the first, although at the end of the code');

// I am the first, although at the end of the code
// foo
// foo
// bar

从上面程序的执行过程可以看出,process.nextTick创建的是一个异步任务,但是它优先于setTimeout执行。

现在再回顾一下文章开头那几个问题,你心中有答案了吗?

总结

本篇文章总结了一些使用原生Js创建异步任务的方法(也就是最小的异步任务创建单元),注意不是异步编程的方法,异步编程有回调函数,发布/订阅等方式,后续会有相关文章介绍。

参考文章

这一次,彻底弄懂 JavaScript 执行机制
JS 演变、单线程、异步任务
对浏览器端javaScript运行机制的理解
Generator 函数的异步应用
async 函数
理解Node.js里的process.nextTick()