JS异步解决方案

216 阅读10分钟

本文是我个人学习总结,有写的不对的地方,还望大家指正,会不断的更新完善

本文大量参考了阮一峰老师的ECMAScript 6 入门的书

一、实际需求

有如下前端需求:

​ 前端从后端的用户接口获取到用户信息,再根据用户信息中用户已经参加的抽奖次数去调对应的抽奖接口

1、通过异步函数的回调去解决

如果我们用jquery的ajax去实现第一个需求

$.ajax({
    url: 'http://localhost:3000/user',
    type: 'GET',
    dataType: 'json',
    success: function (res) {
        if (res.errcode == 0) {
            $.ajax({
                url: 'http://localhost:3000/lottery',
                type: 'GET',
                dataType: 'json',
                data: { name: res.result.name },
                success: function (res) {
                    console.log('异步', res)
                }
            })
        }
    }
})

需求实现了,也没啥问题。但实际需求中可能会在对抽奖接口返回的数据再进行处理,这时候我们只能在lottery接口请求回调中处理,这就导致后续的代码全部写在了第一个ajax中,如果夸张点,后续可能有100个请求都是接着上一个请求返回的数据来进行相应操作的,这就导致了一个问题,无尽的回调——回调地狱

$.ajax({
    url: '1',
    success: function (res1) {
        $.ajax({
            url: '2',
            data: { name: res1.name },
            success: function (res2) {
                $.ajax({
                    url: '3',
                    data: { name: res2.name },
                    success: function (res3) {
                        $.ajax({
                            url: '4',
                            data: { name: res3.name },
                            success: function (res4) {
                                // ......
                            }
                        })
                    }
                })
            }
        })
    }
})

回调地狱后期会造成调试和阅读困难,代码之间的耦合度也非常高,不利于后续开发和他人接手。

那么有没有一种方法,能够像下面来写呢?

var res1 = $.ajax({ url: '1' })
var res2 = $.ajax({ url: '2', data: { name: res1.name } })
var res3 = $.ajax({ url: '3', data: { name: res2.name } })
var res4 = $.ajax({ url: '4', data: { name: res3.name } })

答案是可以的,jquery的ajax默认是异步请求,参数asynctrue,如果想发送同步请求,改为false即可

var res = $.ajax({ url: 'http://localhost:3000/user', type: 'GET', async: false, dataType: 'json' }).responseJSON
if (res.errcode == 0) {
    var res1 = $.ajax({ url: 'http://localhost:3000/lottery', type: 'GET', async: false, dataType: 'json', data: { name: res.result.name } }).responseJSON
    console.log('同步', res1)
}

到这里,我们通过把ajax改为同步请求,即解决了业务需求,也解决了回调地狱,但是,万事万物,有利有弊,既然我们用同步解决了异步,获得了同步上的好处,那必然失去了异步的好处。

一般在请求接口前,会加上一个加载页面,防止因为网络问题,接口返回慢导致页面毫无反应,用户不知道应用进行到了哪里,等接口返回,则把加载页面移走,增加用户体验。

// 异步请求的加载页
$('#loadingPage').fadeIn(function () { console.log('加载页淡入') })	// 淡入加载页面,优化用户体验
$.ajax({
    url: '',
    async: true,
    complete: function () { $('#loadingPage').fadeOut(function () { console.log('加载页淡出') }) }	// 请求完成,淡出加载页面
})

但是如果你这个时候用同步来请求,你就会发现,加载页面只在ajax返回之后瞬间淡入淡出,并没有起到,开始请求则淡入,返回请求则淡出,和异步请求的体验相差甚远

// 同步请求的加载页
$('#loadingPage').fadeIn(function () { console.log('加载页淡入') })	// 淡入加载页面,优化用户体验
var res = (function () { return $.ajax({ url: 'http://localhost:3000/user', async: false }).responseJSON })()
$('#loadingPage').fadeOut(function () { console.log('加载页淡出') })	// 淡出加载页面

这是因为同步请求的时候,浏览器异步相关的操作都会卡在那里(jquery内部fadeIn是用定时器setTimeout实现的),直到ajax返回,这时候浏览器才会执行其他的操作。

那么问题来了,能不能够在使用异步的情况下,尽可能的使代码看起来像同步,更有利于阅读代码和开发呢?

这时候大部分人估计会提到Promise?

好,试试

2、通过Promise包装一下异步函数

暂时不用关心Promise具体是啥,怎么用。看个形式即可

// 使用Promise包装ajax
var ajaxUserPromise = function () {
    return new Promise((resolve, reject) => {
        $.ajax({
            url: 'http://localhost:3000/user',
            success: function (res) {
                resolve(res)
            },
            error: function (err) {
                reject(err)
            }
        })
    })
}
var ajaxLotteryPromise = function (name) {
    return new Promise((resolve, reject) => {
        $.ajax({
            url: 'http://localhost:3000/lottery',
            data: { name: name },
            success: function (res) {
                resolve(res)
            },
            error: function (err) {
                reject(err)
            }
        })
    })
}
ajaxUserPromise().then(res=>{
    return ajaxLotteryPromise(res.name)
}).then(res=>{
    console.log(res)
})

可以,有内味了,通过then的链式调用,大幅度减少了回调的代码,但如果你极端一哈哈,就会发现

p1().then(()=>{
    return p2()
}).then(()=>{
    return p3()
}.then(()=>{
    return p4()
}).then(()=>{
    // ...
}

和我们希望的

var res1 = $.ajax({ url: '1' })
var res2 = $.ajax({ url: '2', data: { name: res1.name } })
var res3 = $.ajax({ url: '3', data: { name: res2.name } })
var res4 = $.ajax({ url: '4', data: { name: res3.name } })

诚然,Promise的确通过then的方式简化了我们的回调,也可以通过then来进行链式写法,依次调取多个接口

看起来似乎还是有点距离,稍微可以称之为链式地狱?哈哈哈

能不能更进一步呢?答案是可以的。

3、通过async函数包装ajax请求

暂时不用关心async具体是啥,怎么用。看个形式即可

let ajaxAwait = async function () {
    let res = await $.ajax({ url: 'http://localhost:3000/user', type: 'GET', async: true, dataType: 'json' })
    if (res.errcode == 0) {
        var res2 = await $.ajax({ url: 'http://localhost:3000/lottery', type: 'GET', async: false, dataType: 'json', data: { name: res.result.name } })
        return res2
    }
}

ajaxAwait()

// 下面是ajax同步代码
var res = $.ajax({ url: 'http://localhost:3000/user', type: 'GET', async: false, dataType: 'json' }).responseJSON
if (res.errcode == 0) {
    var res1 = $.ajax({ url: 'http://localhost:3000/lottery', type: 'GET', async: false, dataType: 'json', data: { name: res.result.name } }).responseJSON
    console.log('同步', res1)
}

看,是不是像极了?!并且await看起来像同步,其实是异步操作,加载页面效果不会和同步一样打折

但...其实吧...还是做不到和同步一样直接了当,除非await可以写在async外面,但语法规定只能卸载async里面,毕竟世间难两全嘛。

不过现在有一个语法提案,允许在模块的顶层独立使用await命令。这个提案的目的,是借用await解决模块异步加载的问题。

可以从Promise和async的写法可以看出,async更适合处理一些顺序异步的问题,相对于Promise来说,更直观点

async function() {
    var res1 = await $.ajax({ url: '1' })
    var res2 = await $.ajax({ url: '2', data: { name: res1.name } })
    var res3 = await $.ajax({ url: '3', data: { name: res2.name } })
    var res4 = await $.ajax({ url: '4', data: { name: res3.name } })
}

二、Promise

Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。

具体可以看下阮一峰的ECMAScript6入门Promised对象

摘出几个重要的方法:

(1)Promise.all()

const p = Promise.all([p1, p2, p3]).then().catch()

p的状态由p1p2p3决定,类似于逻辑与

​ 1、只有p1p2p3的状态都变成fulfilledp的状态才会变成fulfilled,此时p1p2p3的返回值组成一个数组,传递给p的回调函数。

​ 2、只要p1p2p3之中有一个被rejectedp的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。

(2)Promise.race()

const p = Promise.race([p1, p2, p3]).then().catch()

上面代码中,只要p1p2p3之中有一个实例率先改变状态,p的状态就跟着改变,类似于逻辑或。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数。

(3)Promise.allSettled()

只有等到所有这些参数实例都返回结果,不管是fulfilled还是rejected,包装实例才会结束。该方法由 ES2020 引入。

const p = Promise.allSettled([p1, p2, p3]).then().catch()

// 返回的格式
// [
//    { status: 'fulfilled', value:  },
//    { status: 'rejected', reason:  }
// ]

(4)Promise.any()

const p = Promise.any([p1, p2, p3]).then().catch()

只要参数实例有一个变成fulfilled状态,包装实例就会变成fulfilled状态;如果所有参数实例都变成rejected状态,包装实例就会变成rejected状态。

该方法目前是一个第三阶段的提案

(5)Promise.try()

不管函数f是同步函数还是异步操作,都用 Promise 来处理它。这样就可以用thencatch来管理

const p = Promise.try(f).then().catch()

//
const f = () => console.log('now');
Promise.try(f);
console.log('next');
// now
// next

不过这个现在也是提案阶段,不过它已经存在一些Promise 库BluebirdQwhen

三、生成器(Generator)函数

1 含义

Generator 函数是 ES6 提供的一种异步编程解决方案。详情可以看下阮一峰的ECMAScript6入门Generator函数的语法

通俗一点理解就是生成器里面有多个状态,通过yield标明为某个状态的停止,后续主动用next()方法手动执行下一步。

function* helloWorldGenerator() {
  yield 'hello';	// 停止1位
  yield 'world';	// 停止2位
  return 'ending';	// 停止3位
  yield 'notend';	// return 也会返回done为true
}

var hw = helloWorldGenerator();

hw.next()	// 手动执行1位
// { value: 'hello', done: false }
hw.next()	// 手动执行2位
// { value: 'world', done: false }
hw.next()	// 手动执行3位  返回done为true,,说明已经执行完所有的停止状态
// { value: 'ending', done: true }
hw.next()	//上面已经执行完所有的状态,后续都输出 { value: undefined, done: true }
// { value: undefined, done: true }

2 yiled

yield书写注意点:

(1)yield必须写在生成器内

(2)yield表达式如果用在另一个表达式之中,必须放在圆括号里面。

function* demo() {
  console.log('Hello' + yield); // SyntaxError
  console.log('Hello' + yield 123); // SyntaxError

  console.log('Hello' + (yield)); // OK
  console.log('Hello' + (yield 123)); // OK
}

(3)yield表达式用作函数参数或放在赋值表达式的右边,可以不加括号。

function* demo() {
  foo(yield 'a', yield 'b'); // OK
  let input = yield; // OK
}

3 next()

yield表达式本身没有返回值,或者说总是返回undefinednext方法可以带一个参数,该参数就会被当作上一个yield表达式的返回值。

function* foo(x) {
  var y = 2 * (yield (x + 1));
  var z = yield (y / 3);
  return (x + y + z);
}

var a = foo(5);
a.next() // Object{value:6, done:false}

// 此时y = 2 * undefiend  y=NaN
a.next() // Object{value:NaN, done:false}

// 此时z = undefiend;  value = undefiend/3
a.next() // Object{value:NaN, done:true}

var b = foo(5);
b.next() // { value:6, done:false }

// 此时y = 2 * 12
b.next(12) // { value:8, done:false }

// 此时z = 13
b.next(13) // { value:42, done:true }

注意,由于next方法的参数表示上一个yield表达式的返回值,所以在第一次使用next方法时,传递参数是无效的。V8 引擎直接忽略第一次使用next方法时的参数,只有从第二次使用next方法开始,参数才是有效的。从语义上讲,第一个next方法用来启动遍历器对象,所以不用带有参数。

4 yield*表达式

如果在 Generator 函数内部,调用另一个 Generator 函数。需要在前者的函数体内部,自己手动完成遍历。

function* foo() {
  yield 'a';
  yield 'b';
}

function* bar() {
  yield 'x';
  // 手动遍历 foo()
  for (let i of foo()) {
    console.log(i);
  }
  yield 'y';
}

for (let v of bar()){
  console.log(v);
}
// x
// a
// b
// y
// --------------------------
// 使用yield*

function* bar() {
  yield 'x';
  yield* foo();
  yield 'y';
}

// 等同于
function* bar() {
  yield 'x';
  yield 'a';
  yield 'b';
  yield 'y';
}

5 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
// 遍历又执行了next()方法,但是done为true就停止循环了,return就不执行

这里需要注意,一旦next方法的返回对象的**done属性为truefor...of循环就会中止**,且不包含该返回对象,所以上面代码的return语句返回的6,不包括在for...of循环之中。

四、async

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。

async 函数是什么?一句话,它就是生成器函数的语法糖。

// 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());
};

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

//async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await,仅此而已。

虽然是语法糖,但也对生成器进行了一些改进:

(1)内置执行器。

​ Generator 函数的执行必须靠执行器(next),所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。

(2)更好的语义。

(3)更广的适用性。

co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。

(4)返回值是Promise

async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。

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