JS核心理论之《异步API与编程解决方案》

656 阅读11分钟

异步请求API

XHR

XMLHttpRequest是一个浏览器接口,使得Javascript可以进行HTTP(S)通信。

XHR的重要属性

  • xhr.readyState: 从0~4,其中4是done,表示请求完成
  • xhr.status:服务器返回的状态码,等于200表示一切正常
  • xhr.responseText:服务器返回的文本数据
  • xhr.responseXML:服务器返回的XML格式的数据
  • xhr.statusText:服务器返回的状态文本

XHR的事件监听

  • progress 处理中
  • abort 取消,XHR.abort() 函数调用时触发该事件
  • error 错误
  • load 完成加载
  • timeout 事件超时

新版本XMLHttpRequest,还支持以下功能:

  • 可以设置HTTP请求的时限。
  • 可以使用FormData对象管理表单数据。
  • 可以上传文件。
  • 可以请求不同域名下的数据(跨域请求)。前提是浏览器必须支持且服务器端必须同意。
  • 可以获取服务器端的二进制数据。
  • 可以获得数据传输的进度信息。

Fetch

Fetch 的核心是HTTP Request/Response/Header/Body 的接口,以及 fetch() 方法,fetch() 方法返回的是一个Promise对象。

Fetch几个特征

  • 当接收到一个代表错误的 HTTP 状态码时,从 fetch() 返回的 Promise 不会被标记为 reject, 即使响应的 HTTP 状态码是 404 或 500。 相反,它会将 Promise 状态标记为 resolve (但是会将 resolve 的返回值的 ok 属性设置为 false ),仅当网络故障时或请求被阻止时,才会标记为 reject。
  • 默认情况下,fetch 不会从服务端发送或接收任何 cookies, 若要发送 cookies,必须设置 {credentials: "include"} 属性。

支持的请求参数

function postData(url, data) {
  // 默认值 加*开头
  return fetch(url, {
    body: JSON.stringify(data), // 必须和'Content-Type' header一致
    cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
    credentials: 'same-origin', // include, same-origin, *omit
    headers: {
      'user-agent': 'Mozilla/4.0 MDN Example',
      'content-type': 'application/json'
    },
    method: 'POST', // *GET, POST, PUT, DELETE, etc.
    mode: 'cors', // no-cors, cors, *same-origin
    redirect: 'follow', // manual, *follow, error
    referrer: 'no-referrer', // *client, no-referrer
  })
  .then(response => response.json()) // parses response to JSON
}

postData('http://example.com/answer', {answer: 42})
  .then(data => console.log(data)) // JSON from `response.json()` call
  .catch(error => console.error(error))

二者区别

XHR Fetch
所有不同性质的接口都放XHR对象上 将不同性质的接口分散在几个不同的对象上
基于事件的异步模型,必须通过实例化方式发起请求 返回Promise对象,可链式调用,避免了嵌套的回调函数
有timeout,有原生方式取消请求与监控进度 没有timeout,无法取消请求,没有办法原生监测请求的进度
通过HTTP 状态码时能判断出错误 错误的HTTP状态码(400/500)返回的Promise不会被标记为reject
// 用 XHR 发起一个GET请求
var xhr = new XHMHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'json';
xhr.onload = function() {
    console.log(xhr.response);
};
xhr.onerror = function() {
    console.log('something wrong');
};
xhr.send();

// 用 Fetch 完成同样的请求
fetch(url).then(function(response) {
    return response.json();
}).then(function(jsonData) {
    console.log(jsonData);
}).catch(function() {
    console.log('something wrong');
});

Axios

axios 是一个基于Promise,用于浏览器和nodejs的 HTTP 客户端,它本身具有以下特征:

  • 从浏览器中创建 XMLHttpRequest
  • 从 node.js 发出 http 请求
  • 支持 Promise API
  • 拦截请求和响应
  • 转换请求和响应数据
  • 取消请求
  • 自动转换JSON数据
  • 客户端支持防止 CSRF/XSRF
  • 帮助函数(all、spread)处理并发请求

JS异步编程

所谓“异步”,简单说就是一个任务分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。 异步编程的方法有:回调函数事件监听发布/订阅Generator函数, Promise, async/await

回调函数

所谓回调函数,就是把第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数,一个函数依赖于另一个函数的执行而执行。

示例:

ajax(url, () => {
    // 处理逻辑
    ajax(url1, () => {
        // 处理逻辑
        ajax(url2, () => {
            // 处理逻辑
        })
    })
})
  • 优点:回调函数的优点是简单、容易理解和实现。
  • 缺点:容易写出回调地狱(函数层层嵌套),而且不能用try catch捕获异常,不能直接return。

事件监听

异步任务的执行不取决于代码的顺序,而取决于某个事件是否发生。

下面是两个函数f1和f2,编程的意图是f2必须等到f1执行完成,才能执行。首先,为f1绑定一个事件(这里采用的jQuery的写法),当f1发生done事件,就执行f2。

示例:

f1.on('done', f2);

function f1() {
  setTimeout(function () {
    // ...
    f1.trigger('done');
  }, 1000);
}
  • 优点:比较容易理解,可以绑定多个事件每个事件可以指定多个回调函数,而且可以"去耦合",有利于实现模块化。
  • 缺点:整个程序都要变成事件驱动型,运行流程很不清晰

发布/订阅

我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执行。 这就叫做"发布/订阅模式"(publish-subscribe pattern),又称"观察者模式"(observer pattern)。

首先,f2向信号中心jQuery订阅done信号。jQuery.publish('done')的意思是,f1执行完成后,向信号中心jQuery发布done信号,从而引发f2的执行。f2完成执行后,可以取消订阅(unsubscribe)。

示例:

jQuery.subscribe('done', f2);

function f1() {
  setTimeout(function () {
    // ...
    jQuery.publish('done');
  }, 1000);
}

jQuery.unsubscribe('done', f2);
  • 优点:通过查看“消息中心”,了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行
  • 缺点过于解耦,订阅者和发布者之间动态关系,很难跟踪依赖更新,而且相互无法知道消息传送是成功的还是失败的。

Promise

Promise有三种状态,一旦从等待状态变成为其他状态就永远不能更改状态了,也就是说一旦状态变为 resolved 后,就不能再次改变

  • 等待中(pending)
  • 完成了 (resolved)
  • 拒绝了(rejected)
new Promise((resolve, reject) => {
  resolve('success')
  // 无效
  reject('reject')
})

image

当我们在构造 Promise 的时候,构造函数内部的代码是立即执行的。

new Promise((resolve, reject) => {
  console.log('new Promise')
  resolve('success')
})
console.log('end')
// new Promise => end

Promise 实现了链式调用,也就是说每次调用 then 之后返回的都是一个 Promise,并且是一个全新的 Promise,原因是因为状态不可变。 如果你在 then 中 使用了 return,那么 return 的值会被 Promise.resolve() 包装。

示例:

Promise.resolve(1)
  .then(res => {
    console.log(res) // => 1
    return 2 // 包装成 Promise.resolve(2)
  })
  .then(res => {
    console.log(res) // => 2
  })
  • 优点:Promise 也很好地解决了回调地狱的问题。
  • 缺点:比如无法取消 Promise,错误需要通过回调函数捕获。

Generator函数

Generator 函数是 ES6 提供的一种异步编程解决方案,最大特点就是可以交出函数的执行权(即暂停执行)。

function* gen(x){
  var y = yield x + 2;
  return y;
}

上面代码就是一个 Generator 函数。它不同于普通函数,是可以暂停执行的,所以函数名之前要加星号,以示区别。 整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用 yield 语句注明。

Generator 函数的执行方法如下:

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

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

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

Generator函数可以暂停执行和恢复执行,这是它能封装异步任务的根本原因。除此之外,它还有两个特性,使它可以作为异步编程的完整解决方案:函数体内外的数据交换和错误处理机制

next 方法返回值的 value 属性,是 Generator 函数向外输出数据;next 方法还可以接受参数,这是向 Generator 函数体内输入数据。

function* gen(x){
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }

上面代码中,第一个 next 方法的 value 属性,返回表达式 x + 2 的值(3)。第二个 next 方法带有参数2,这个参数可以传入 Generator 函数, 作为上个阶段异步任务的返回结果,被函数体内的变量 y 接收。因此,这一步的 value 属性,返回的就是2(变量 y 的值)。

Generator 函数内部还可以部署错误处理代码,捕获函数体外抛出的错误。

function* gen(x){
  try {
    var y = yield x + 2;
  } catch (e){ 
    console.log(e);
  }
  return y;
}

var g = gen(1);
g.next();
g.throw('出错了');

上面代码的最后一行,Generator 函数体外,使用指针对象的 throw 方法抛出的错误,可以被函数体内的 try ... catch 代码块捕获。 这意味着,出错的代码与处理错误的代码,实现了时间和空间上的分离,这对于异步编程无疑是很重要的。

  • 优点:可以控制函数的执行(暂停与恢复),返回的是指针g,通过g.next()执行下一步,可以解决回调地犾问题。
  • 缺点:需要手动流程控制。

async/await

使用async/await,你可以轻松地达成之前使用生成器和co函数所做到的工作,它有如下特点:

  • async/await是基于Promise实现的,它不能用于普通的回调函数。
  • async/await与Promise一样,是非阻塞的
  • async/await使得异步代码看起来像同步代码,这正是它的魔力所在。

一个函数如果加上 async ,那么该函数就会返回一个 Promise。

async function async1() {
  return "1"
}
console.log(async1()) // -> Promise {<resolved>: "1"}

await 将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低。 示例:

function f1() {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            resolve(1)
        }, 1000)
    })
}
function f2() {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            resolve(2)
        }, 1000)
    })
}

async function f() {
    let start = new Date().getTime()
    let val1 = await f1()
    let val2 = await f2()
    
    let val  = val1 + val2
    let end  = new Date().getTime()
    let time = end - start
    console.log(val)
    console.log(time)
}
f()
// 结果
// 3
// 2010

如果换一种写法:

function f1() {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            resolve(1)
        }, 1000)
    })
}
function f2() {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            resolve(2)
        }, 1000)
    })
}

async function f() {
    let start = new Date().getTime()
    let val1 = f1()
    let val2 = f2()
    
    let val  = await val1 + await val2
    let end  = new Date().getTime()
    let time = end - start
    console.log(val)
    console.log(time)
}
f()
// 结果
// 3
// 1005

这是因为第一种写法,必须等f1()运行结束才能运行f2(),所以所用的时间是两个异步Promise等待时间的和; 而第二种写法中,因为提前运行了这两个Promise,程序运行到await val1的时候两个Promsie都已经开始运行完,也就是它们是并行的;所以f()的运行时间主要就取决于用时更长的那个Promise而不是两者的相加。

并行执行的任务可以直接使用Promise.all()来实现:

function f1() {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            resolve(1)
        }, 1000)
    })
}
function f2() {
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            resolve(2)
        }, 1000)
    })
}

async function f() {
    let start = new Date().getTime()
    let vals = await Promise.all([f1(), f2()])
    let val  = vals[0] + vals[1]

    let end  = new Date().getTime()
    let time = end - start
    console.log(val)
    console.log(time)
}
f()
// 结果
// 3
// 1005
  • 优点:更清晰准确的写代码,并且也能优雅地解决回调地狱问题。
  • 缺点:await 将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低

总结

  1. JS 异步编程进化史:callback -> promise -> generator -> async/await
  2. await 内部实现了 generator,其实 await 就是 generator 加上 Promise 的语法糖,且内部实现了自动执行 generator
  3. async/await可以说是异步终极解决方案。