JavaScript基础——异步

303 阅读8分钟

回调函数

    f1();

    f2();

如果f1是一个很耗时的任务,可以考虑改写f1,把f2写成f1的回调函数。

    function f1(callback){

        setTimeout(function () {

            // f1的任务代码

            callback();

        }, 1000);

    }

执行代码就变成下面这样:

  f1(f2);

如果有更多的异步处理任务,那么整段代码充满了回调嵌套,代码不仅在纵向扩展,横向也在扩展。我相信,对于任何人来说,调试起来都会很困难,我们不得不从一个函数跳到下一个,再跳到下一个,在整个代码中跳来跳去以查看流程,而最终的结果藏在整段代码的中间位置。真实的JavaScript程序代码可能要混乱的多,使得这种追踪难度会成倍增加。这就是我们常说的回调地狱(Callback Hell)。

控制反转就是把自己程序一部分的执行控制交给某个第三方,在你的代码和第三方工具直接有一份并没有明确表达的契约。 既然是无法控制的第三方在执行你的回调函数,那么就有可能存在以下问题,当然通常情况下是不会发生的:

调用回调过早 调用回调过晚 调用回调次数太多或者太少 未能把所需的参数成功传给你的回调函数 吞掉可能出现的错误或异常 ...... 这种控制反转会导致信任链的完全断裂,如果你没有采取行动来解决这些控制反转导致的信任问题,那么你的代码已经有了隐藏的Bug,尽管我们大多数人都没有这样做。 这里,我们引出了回调函数处理异步的第二个问题:控制反转。

综上,回调函数处理异步流程存在2个问题:

  1. 缺乏顺序性: 回调地狱导致的调试困难,和大脑的思维方式不符
  2. 缺乏可信任性: 控制反转导致的一系列信任问题


Promise

Promise解决的是回调函数处理异步的第2个问题:控制反转。

简单说,它的思想是,每一个异步任务返回一个Promise对象,该对象有一个then方法,允许指定回调函数。比如,f1的回调函数f2,可以写成:

    f1().then(f2);

这样写的优点在于,回调函数变成了链式写法,无论有再多的业务依赖,通过多个then(...)来获取数据,让代码只在纵向进行扩展,另外一点就是逻辑性更明显了,将异步业务提取成单个函数,整个流程可以看到是一步步向下执行的,依赖层级也很清晰,最后需要的数据是在整个代码的最后一步获得。

Promise是如何解决控制反转带来的信任缺失问题

首先明确一点,Promise可以保证以下情况,引用自JavaScript | MDN:

  1. 在JavaScript事件队列的当前运行完成之前,回调函数永远不会被调用
  2. 通过 .then 形式添加的回调函数,甚至都在异步操作完成之后才被添加的函数,都会被调用
  3. 通过多次调用 .then,可以添加多个回调函数,它们会按照插入顺序并且独立运行


Generator

Generator 介绍

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

形式上,Generator 函数是一个普通函数,但是有两个特征。一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)。

function* helloWorldGenerator() {
    yield 'hello';
    yield 'world';
    return 'ending';
}

var hw = helloWorldGenerator();

上面代码定义了一个 Generator 函数helloWorldGenerator,它内部有两个yield表达式(helloworld),即该函数有三个状态:hello,world 和 return 语句(结束执行)。

调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是上一章介绍的遍历器对象(Iterator Object)。

下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。也就是说,每次调用next方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个yield表达式(或return语句)为止。换言之,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而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 }

上面代码一共调用了四次next方法。

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

yield 与 return
      yield表达式与return语句既有相似之处,也有区别。相似之处在于,都能返回紧跟在语句后面的那个表达式的值。区别在于每次遇到yield,函数暂停执行,下一次再从该位置继续向后执行,而return语句不具备位置记忆的功能。一个函数里面,只能执行一次(或者说一个)return语句,但是可以执行多次(或者说多个)yield表达式。正常函数只能返回一个值,因为只能执行一次return;Generator 函数可以返回一系列的值,因为可以有任意多个yield。从另一个角度看,也可以说Generator生成了一系列的值,这也就是它的名称的来历(英语中,generator 这个词是“生成器”的意思)。

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

function* f() {
  console.log('执行了!')
}

var generator = f();
    
setTimeout(function () {
  generator.next()
}, 2000);

上面代码中,函数f如果是普通函数,在为变量generator赋值时就会执行。但是,函数f是一个 Generator 函数,就变成只有调用next方法时,函数f才会执行。

Generator 实现异步

function getUserId () {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/api/userId',
        success: function (data) {
            userId = data;
            it.next(userId);
        }
        error: function (err) {
            console.log(err);
        }
    });
}

function newBlog (userId) {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/api/blog/new',
        data: {
            userId: userId
        },
        success: function (data) {
            blogData = data;
            it.next(newBlog);
        }
        error: function (err) {
            console.log(err);
        }
    });
}

function getBlogData (blogData) {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/api/new/getData',
        data: {
            blogId: newBlog.blogId,
        },
        success: function (blogId) {
            it.next(blogId);
        }
        error: function (err) {
            console.log(err);
        }
    });
}

function *main () {
    let userId = yield getuserId();
    let newBlog = yield newBlog(userId);
    let blogData = yield getBlogData(blogData);
    console.log('新建博客数据:', blogData);
}

// 生成迭代器实例
var it = main();

// 运行第一步
it.next();
console.log('不影响主线程执行');

我们注意*main()生成器内部的代码,不看yield关键字的话,是完全符合大脑思维习惯的同步书写形式,把异步的流程封装到外面,在成功的回调函数里面调用it.next(),将传回的数据放到任务队列里进行排队,当JavaScript主线程空闲的时候会从任务队列里依次取出回调任务执行。

综上,生成器Generator解决了回调函数处理异步流程的第一个问题:不符合大脑顺序、线性的思维方式。



async/await

上面我们介绍了Promise和Generator,把这两者结合起来,就是Async/Await。

Generator的缺点是还需要我们手动控制next()执行,使用Async/Await的时候,只要await后面跟着一个Promise,它会自动等到Promise决议以后的返回值,resolve(...)或者reject(...)都可以。

什么是Async/Await?

Async - 定义异步函数(async function someName(){...})

  • 自动把函数转换为 Promise
  • 当调用异步函数时,函数返回值会被 resolve 处理
  • 异步函数内部可以使用 await

Await - 暂停异步函数的执行 (var result = await someAsyncCall();)

  • 当使用在 Promise 前面时,await 等待 Promise 完成,并返回 Promise 的结果
  • await 只能和 Promise 一起使用,不能和 callback 一起使用
  • await 只能用在 async 函数中

Async/Await 底层依然使用了 Promise。

多个异步函数同时执行时,需要借助 Promise.all

async function getABC() {
  let A = await getValueA(); // getValueA 花费 2 秒
  let B = await getValueB(); // getValueA 花费 4 秒
  let C = await getValueC(); // getValueA 花费 3 秒

  return A*B*C;
}

每次遇到 await 关键字时,Promise 都会停下在,一直到运行结束,所以总共花费是 2+4+3 = 9 秒。await 把异步变成了同步。

Async/Await是Generator和Promise的组合,完全解决了基于回调的异步流程存在的两个问题,可能是现在最好的JavaScript处理异步的方式了。



总结

本文通过四个阶段来讲述JavaScript异步编程的发展历程:

  1. 第一个阶段 - 回调函数,但会导致两个问题:

    • 缺乏顺序性: 回调地狱导致的调试困难,和大脑的思维方式不符
    • 缺乏可信任性: 控制反转导致的一系列信任问题
  2. 第二个阶段 - Promise,Promise是基于PromiseA+规范的实现,它很好的解决了控制反转导致的信任问题,将代码执行的主动权重新拿了回来。

  3. 第三个阶段 - 生成器函数Generator,使用Generator,可以让我们用同步的方式来书写代码,解决了顺序性的问题,但是需要手动去控制next(...),将回调成功返回的数据送回JavaScript主流程中。

  4. 第四个阶段 - Async/Await,Async/Await结合了Promise和Generator,在await后面跟一个Promise,它会自动等待Promise的决议值,解决了Generator需要手动控制next(...)执行的问题,真正实现了用同步的方式书写异步代码。

参考:

  1. segmentfault.com/a/119000001…
  2. www.jianshu.com/p/fe0159f8b…
  3. www.ruanyifeng.com/blog/2012/1…