首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JavaScript 中如何进行异步编程

JavaScript 中如何进行异步编程

作者头像
江米小枣
发布2020-06-16 16:51:01
7360
发布2020-06-16 16:51:01
举报
文章被收录于专栏:云前端云前端

JavaScript是单线程的

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。(引用阮一峰老师)

既然是单线程,那么涉及到网络请求这种耗时的事情怎么办呢,只能傻等着吗?对于灵活的Javascript来说,这不科学啊。所以,为了使浏览器非阻塞的运行任务,JS就设计了异步。

于是,所有的任务就分为两种,同步任务(synchronous)和异步任务(asynchronous)。

同步任务指,在主线程上排队执行的任务,即前一个任务执行完成,才能执行下一个任务;异步任务指的是,不进入主线程,而进入“任务队列”(task queue)的任务,只有“任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

事件循环(Event Loop)

要完全理解异步,就需要了解 JS 的运行核心——事件循环(Event Loop)和任务队列(Task Queue)。

JS中所有的同步任务都在主线程上执行,形成一个执行栈;此外还有一个任务队列,用来存放异步任务的相关回调;一旦执行栈中的同步任务执行完毕,系统就会读取“任务队列”,检查有哪些事件待处理,并取出相关事件及回调函数放入执行栈中由主线程执行。主线程从"任务队列"中读取事件,这个过程是不断循环的,整个的这种运行机制又称为Event Loop(事件循环)。可以根据下图来加深理解:

上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

Javascript异步编程方法

回调函数

回调函数是javascript中最基础的异步编程方法了。

 step1(function(res1){
   step2(function(res2){
       step3(function(res3){            //...
       });
   });
});

正如上面所写的那样,把函数作为参数传入,然后回调,这种方式的弊端也显而易见,各个部分之间高度耦合(Coupling);一旦函数嵌套层次变多的话,代码维护将会十分困难,并形成所谓的“回调地狱(callback hell)”。

事件监听

事件监听是javascript中非常常见的异步编程模式;

element.addEventListener("click",function(){
   alert("clicked");
})

这种方式的耦合程度低,但是整个程序变成事件驱动,得不到流程控制。

发布/订阅

发布/订阅模式通俗理解就是,订阅者把自己想订阅的事件注册到调度中心,当该事件触发时候,发布者发布该事件到调度中心(顺带上下文),调度中心把这一信号传输给订阅者,那么订阅者就知道自己何时开始执行任务。

function f2() {
 console.log("I'm done");
};center.subscribe("done", f2);function f1(){
   setTimeout(function () {
       // f的任务代码      center.publish("done");
   }, 1000);
}

首先,f2向"信号中心"center"订阅"done"信号。f1执行完成后向信号中心发布“done”信号,f2收到信号之后,便开始执行。发布/订阅模式类似于事件监听,但是比事件监听更加灵活一些,我们把信号交给调度中心统一管理,可以掌握事件被订阅的次数,以及订阅者的信息,管理起来很方便。

Promise 对象

Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。

首先,来创建一个Promise实例:

var promise = new Promise(function(resolve, reject) {  // ... some code if (/* 异步操作成功 */){
   resolve(value);
 } else {
   reject(error);
 }
});promise.then(function(value) {
   // success
}, function(error) {
   // failure
});
  • 参数传递 Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。resolve会在异步操作成功时调用,reject会在异步操作失败时调用。Promise实例生成以后,可以用then方法分别指定resolved状态和rejected状态的回调函数。
  • 有限状态机 Promise(中文:承诺)其实为一个有限状态机,共有三种状态:pending(执行中)、fulfilled(执行成功)和rejected(执行失败)。其中pending为初始状态,fulfilled和rejected为结束状态(结束状态表示promise的生命周期已结束)。状态转换关系为:pending->fulfilled,pending->rejected。 随着状态的转换将触发各种事件(如执行成功事件、执行失败事件等)。
  • then方法 Promise实例生成以后,可以用then方法分别指定resolved状态和rejected状态的回调函数。值得注意的是,then方法的返回值是一个新的Promise实例,因此可以采用链式写法,即then方法后面再调用另一个then方法。
  • 异常捕获 上文已经提到then方法会接受两个参数,其中第二个参数会在执行reject之后触发,对于Promise中的异常处理,建议用catch方法,而不是then的第二个参数。.catch方法实际是.then(null, rejection)的别名,用于指定发生错误时的回调函数。请看下面的代码:
const fullFileName = path.resolve(__dirname, '../data/data2.json')const result = readFilePromise(fullFileName)
result.then(data => {
   console.log(data)    return JSON.parse(data).a
}).then(a => {
   console.log(a)
}).catch(err => {
   console.log(err.stack)  // 这里的 catch 就能捕获 readFilePromise 中触发的 reject ,而且能接收 reject 传递的参数
})

在若干个then连续调用之后,一般会在最后跟一个.catch来捕获异常,而且执行reject时传递的参数也会在catch中获取到。这样做的好处是:

让程序看起来更加简洁,是一个串联的关系,没有分支(如果用then的两个参数,就会出现分支,影响阅读)

看起来更像是try - catch的样子,更易理解。

  • 类方法 Promise.resolve() 有时候需将现有对象转换成Promise对象,可以使用Promise.resolve()。 Promise.reject() Promise.reject(reason)方法也会返回一个新的Promise实例,该实例的状态为rejected。Promise.reject方法的参数reason,会被传递给实例的回调函数。 Promise.all() Promise.all方法用于将多个Promise实例,包装成一个新的Promise实例。 Promise.race() Promise.race()也是将多个Promise实例包装成一个新的Promise实例。
  • 应用 Promise的诞生就是为了解决"回调函数地狱"的,它将回调函数的嵌套,改成链式调用。下面是采用Promise来读取多个文件:
var readFile = require('fs-readfile-promise');readFile(fileA)
.then(function (data) {
 console.log(data.toString());
})
.then(function () {
 return readFile(fileB);
})
.then(function (data) {
 console.log(data.toString());
})
.catch(function (err) {
 console.log(err);
});

Generator函数

Generator 函数是 ES6 提供的一种异步编程解决方案,语法行为与传统函数完全不同。

  • 语法 function* helloWorldGenerator() { yield 'hello'; yield 'world'; return 'ending'; } var hw = helloWorldGenerator(); 形式上,Generator 函数是一个普通函数,但是有两个特征。一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)。 从语法上,首先可以把它理解成,Generator 函数是一个状态机,封装了多个内部状态。执行 Generator 函数会返回一个遍历器对象,可以依次遍历 Generator 函数内部的每一个状态。

Generator 函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,然后通过调用遍历器对象的next方法,让指针指向下一个状态;通俗来讲,Generator函数是分段执行的,遇到yeild会暂停执行,而调用next方法会回复执行。

hw.next()// { value: 'hello', done: false }

hw.next()// { value: 'world', done: false }

hw.next()// { value: 'ending', done: true }

hw.next()// { value: undefined, done: true }

这段代码中,value表示yeild表达式后边的那个表达式的值,done表示遍历是否结束;

总结一下,调用 Generator 函数,返回一个遍历器对象,代表 Generator 函数的内部指针。以后,每次调用遍历器对象的next方法,就会返回一个有着value和done两个属性的对象。value属性表示当前的内部状态的值,是yield表达式后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束。

Generator 函数可以不用yield表达式,这时就变成了一个单纯的暂缓执行函数。

function* f() {
 console.log('执行了!')
}var generator = f();//此处不会执行setTimeout(function () {
 generator.next();//执行函数
}, 2000);

需要注意的是,一个Generator函数中,可以执行多次yeild语句,暂停之后会记住指针的位置,即下一次从哪个位置继续执行。而return语句只能执行一次,并且不具备位置记忆功能。 yield表达式只能用在 Generator 函数里面,用在其他地方都会报错。

  • for...of循环 for...of循环可以自动遍历 Generator 函数时生成的Iterator对象(不了解的可以看看),且此时不再需要调用next方法。
function *foo() {
 yield 1;  yield 2;  yield 3;  yield 4;  yield 5;  return 6;
}for (let v of foo()) {
 console.log(v);
}// 1 2 3 4 5
  • 应用 Generato函数最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样:
var fetch = require('node-fetch');
function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
 console.log(result.bio);
}

这段代码中,首先读取一个接口,然后从返回的结构中读取信息,这简直就像是同步操作!

执行方法如下:

var g = gen();var result = g.next();result.value.then(function(data){  
    return data.json();
}).then(function(data){
 g.next(data);
});

上面代码中,首先执行 Generator 函数,获取遍历器对象,然后使用next方法(第二行),执行异步任务的第一阶段。由于Fetch模块返回的是一个 Promise 对象,因此要用then方法调用下一个next方法。 对比之前的Promise, Generator 函数将异步操作表示得很简洁,但是流程管理却不方便(即何时执行第一阶段、何时执行第二阶段)。

async 函数

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。那么async 函数是什么呢?它就是 Generator 函数的语法糖。

还是读取文件的例子,这次用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);
   });
 });
};

const gen = function* () {
    const f1 = yield readFile('/etc/fstab');
    const f2 = yield readFile('/etc/shells');
 console.log(f1.toString());
 console.log(f2.toString());
};

而用async函数,就写成这样:

const asyncReadFile = async function () {
 const f1 = await readFile('/etc/fstab');
  const f2 = await readFile('/etc/shells');
 console.log(f1.toString());
 console.log(f2.toString());
};

对比发现,async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await,经验告诉我们。肯定不会这么简单啊。 其实,async函数确实对Generator函数做了一些改进:

  • 内置执行器 Generator 函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。
  • 返回值是 Promise async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。

进一步说,async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。

基本用法
  • 声明方式
// 函数声明
async function foo() {}// 函数表达式
const foo = async function () {};// 对象的方法
let obj = { async foo() {} };
obj.foo().then(...)// Class 的方法
class Storage {
   constructor() {
        this.cachePromise = caches.open('avatars');
 } async getAvatar(name) {
       const cache = await this.cachePromise;    return cache.match(`/avatars/${name}.jpg`);
 }
}

const storage = new Storage();
storage.getAvatar('jake').then(…);// 箭头函数
const foo = async () => {};

async函数返回一个 Promise 对象,可以使用then方法添加回调函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

  • 错误处理 只要一个await语句后面的 Promise 变为reject,那么整个async函数都会中断执行。
async function f() {
 await Promise.reject('出错了');
 await Promise.resolve('hello world'); // 不会执行
}

因为await命令后面的Promise对象,运行结果可能是rejected,所以最好把await命令放在try...catch代码块中,这样不管异步操作是否成功,后面的await都会执行。

async function main() {
 try {
    const val1 = await firstStep();
    const val2 = await secondStep(val1);
    const val3 = await thirdStep(val1, val2);   console.log('Final: ', val3);
 }  catch (err) {
   console.error(err);
 }
}
  • 注意 await命令只能用在async函数之中,如果用在普通函数,就会报错。

好啦,以上就是对日常用到的一些异步编程方法的总结,蒽,学而时习之,不亦说乎。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2017-11-14,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云前端 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • JavaScript是单线程的
    • 事件循环(Event Loop)
      • Javascript异步编程方法
        • 回调函数
        • 事件监听
        • 发布/订阅
        • Promise 对象
        • Generator函数
        • async 函数
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档