异步编程

本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2021-07-03

什么是异步

异步就是一件不能连续的完成的任务,必须前置任务准备好之后,再进行最终操作。

比如烧水泡面,你必须等到水开了之后才去泡面,在等待的期间你可以做任何事,即多个任务同时进行;而同步的情况下,等待期间你什么都不能做,必须站在原地等水开之后泡面,然后再做其他的事,即一次只能做一件事。

为什么要使用异步

1、因为JavaScript是单线程的,如果没有异步编程,会在数据请求阶段阻塞用户与UI界面的交互;

2、如果一组不相关的任务需要完成,在同步编程的情况下,会让后面的任务进行等待,这样的话,耗时是M+N,而异步是Max(M, N),随着应用复杂性的增加,异步的优势将越来越大。

理解异步与事件循环机制

异步、事件循环机制

异步编程的难点

异常处理

异步方法则通常在第一个阶段提交请求后立即返回,因此异常并不一定发生在这个阶段,所以如果使用try/catch包裹提交请求这一阶段,那么对处理结果的callback执行时抛出的异常将无能为力。

因此通常需要将异常通过回调函数传回。例如Node中,大部分的API都是异步的,它的回调的第一个参数往往就是异常对象。如果为空,则表示没有异常抛出。

所以,在编写异步方法的时候,必须要执行调用者传入的回调并正确传递回异常供调用者判断。

嵌套过深

使用异步编程,回调必不可少,编写代码时不注意就很可能会造成过深的嵌套。

存在多个异步调用的场景比比皆是,如果操作存在依赖关系,那么函数嵌套行为情有可原,如果操作不存在依赖关系,那么在一个异步调用的回调中调用另外一个异步的操作也应该尽量避免。

阻塞代码

在JavaScript中没有sleep()这样的线程沉睡功能,所以在以前会写出下述这样的代码来实现sleep(1000)的效果。

var start = new Date();
while (new Date() - start < 1000) {
}
// TODO 需要阻塞的代码

但是事实是糟糕的,这段代码会持续占用CPU进行判断,与真正的线程沉睡相去甚远,完全破坏了事件循环的调度。

多线程编程

在浏览器中的单线程指的是JavaScript执行线程与UI渲染共用的一个线程,实质上是没有充分利用多核CPU的,随着业务的复杂化,浏览器提出了Web Workers,它将通过将JavaScript执行与UI渲染分离,可以很好的利用多核CPU为大量计算服务。同时前端Web Workers也是一个利用消息机制合理使用多核CPU的理想模型。

// main.js
var data = [23,4,7,9,2,14,6,651,87,41,7798,24];
var worker = new Worker("work.js");

// 消息内容可以是任何能够被序列化为JSON的值 发送到work.js的数据
worker.postMessage(data);

// 接受来自work.js传回的值
worker.onmessage = function(event){
  var data = event.data; 
  //对数据进行处理
}

worker.onerror = function(event){
  console.log(event);
  console.log("ERROR: " + event.filename + " (" + event.lineno + "): " +event.message);
  // filename、lineno 和message,分别表示发生错误的文件名、代码行号和完整的错误消息。
};

worker.terminate(); //立即停止Worker 的工作
// 后续的所有过程都不会再发生(包括error 和message 事件也不会再触发)。

//  work.js
// 接受来自主进程的数据 Web Worker 中的全局对象都是worker对象本身,this self都引用的是这个全局对象worker
self.onmessage = function(event){
  var data = event.data;
  data.sort(function(a, b){
      return a - b;
  });
  self.postMessage(data); 
  // 给主进程发送计算过的数据 
  // 会触发main.js中的onmessage事件
};

异步转同步

如果试图同步式编程:比如有多个操作都是异步的,并且后面的操作依赖于前面操作的完成才能执行。那么,必须要通过良好的流程控制,将逻辑梳理成顺序式的形式。

异步编程的相关概念

高阶函数

在JavaScript中,函数作为一等公民,使用上十分自由,高阶函数就是将函数作为参数或者将函数作为返回值的函数(出参和入参有一个是函数那么就是高阶函数)。函数式编程就是指这种高度抽象的编程范式,是JavaScript异步编程的基础。

偏函数用法

固定一个函数的一些参数,然后产生另一个更小元的新函数。

function addElement(ele, content) {
  var eleDom = document.createElement(ele);
  eleDom.innerHTML = txt;
  document.body.appendChild(eleDom);
}

addElement('div', 'div1');
addElement('div', 'div2');
addElement('p', 'p1');
addElement('p', 'p2');

// 偏函数
function wrap(ele) {
  var eleDom = document.createElement(ele);
  return function(content) {
    eleDom.innerHTML = txt;
    document.body.appendChild(eleDom);
  }
}

var addDiv = wrap('div');
var addP = wrap('p');

addDiv('div3');
addP('p3')

函数柯里化

将接受多个参数的函数变换成接受一个单一参数的函数,返回处理余下的参数且返回结果的新函数。

function add(a, b, c) {
  return a + b + c;
}

console.log(add(1, 2, 3));

var newAdd = curry(add);

console.log(newAdd(1)(2, 3));
console.log(newAdd(1)(2)(3));
console.log(newAdd(1,2)(3));

function curry(fn, args) {
  var length = fn.length; // 函数的参数个数
  args = args || [];

  return function(...innerArgs) {
    var _args = [...args, ...innerArgs];

    if (_args.length < length) {
      return curry.call(this, fn, _args);
    }
    return fn.apply(this, _args);
  }
}

Thunk函数

Thunk 函数将多参数函数替换成一个只接受回调函数作为参数的单参数函数。

// 正常版本的readFile(多参数版本)
fs.readFile(fileName, encoding, callback);

// Thunk版本的readFile(单参数版本)
var thunkifyRead = function (fileName, encoding) {
  return function (callback) {
    return fs.readFile(fileName, encoding, callback);
  };
};

var readThunk = thunkifyRead('a.txt', 'utf8');
readThunk((err, data) => {
  console.log(data);
})

任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。下面是一个简单的 Thunk 函数转换器。

const Thunk = function(fn) {
  return function (...args) {
    return function (callback) {
      return fn.call(this, ...args, callback);
    }
  };
};

var thunkifyRead = Thunk(fs.readFile);
var readThunk = thunkifyRead('a.txt', 'utf8');
readThunk((err, data) => {
  console.log(data);
})

异步编程的解决方案

事件发布/订阅模式(消息机制)

场景一:一个异步操作获取的数据提供给多个业务逻辑。

// 假设我们实现了一个事件发布/订阅模式 有on方法用来订阅  emit方法用来发布
var pubSub = new PubSub(); 

pubSub.on('userInfo', show);
pubSub.on('userInfo', doOther);

getUserInfo(params, function(data) => {
  pubSub.emit('userInfo', data)
})

场景二:一个业务逻辑依赖多个事件回调的结果,且事件之间互不依赖。

var count = 0;
var results = {};
var done = function(key, value) {
  results[key] = value;
  count++;
  if(count === 2) {
    // 处理数据、决定如何渲染页面
    render(results)
  }
}

getUserInfo(params, function(data) => {
  done('userInfo', data)
})

getShowData(params, function(data) => {
  done('showData', data)
})

// 封装done方法
var after = function(times, callback) {
  var count = 0;
  var results = {};
  return function(key, value){
    results[key] = value;
    count++;
    if(count === times) {
      callback(results)
    }
  }
}

var done = after(2, render);

场景三:互相依赖的异步操作,面对互相依赖的异步操作,传统的解决方案——回调函数和事件就不适合了,简单的业务可以直接回调嵌套,但是复杂的就很容易出现回调地狱的情况,这就是异步编程需要注意的了。

fs.readFile(path, 'utf8', (err, res) => {
  fs.writeFile(targetPath, res, (err, data) => {
    if(!err) {
      console.log('写入成功');
    }
  })
})

Promise

Promise被提出来解决异步编程,比之 回调函数和事件 更加合理和更强大。回调函数和事件都需要预先指定成功回调和失败回调,而Promise采用一种先执行异步调用,延迟传递处理的方式。

所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态。一旦状态改变,就不会再变,任何时候都可以得到这个结果。

有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数,即链式调用;而且更加方便异常的抛出。

// 针对上例的场景三 

new Promise((resolve, reject) => {
  fs.readFile(path, 'utf8', (err, res) => {
    if (err) return reject(err);
    return resolve(res);
    // console.log(111); // 如果上面没有return 直接 resolve 后面的代码依然会执行。
  }
})
.then(res => {
  fs.writeFile(targetPath, res, (err, data) => {
    if(!err) {
      console.log('写入成功');
    }
    // 如果这里不是读写操作,第二步操作的结果还会被第三步操作依赖
    // 可以直接将结果return出去 继续链式调用
    // return data;
  })
}, console.log)

// Promise的每一个实例或者静态方法返回的依然是一个Promise对象。
// 所以一般使用catch方法,捕获之前所有异步操作的错误。

使用Promise来实现:一个异步操作获取的数据提供给多个业务逻辑。

var getUserInfo = function(params) {
  return new Promise((resolve, reject) => {
    ajax('api/userInfo', params, (data) => {
      if(data.status === 200) {
        resolve(data);
      } else {
        reject(data);
      }
    })
  })
}

getUserInfo({a: 123}).then(data => {
  handleA(data);
  handleB(data);
}, console.log)

使用Promise来实现:一个业务逻辑依赖多个事件回调的结果,且事件之间互不依赖。

var promises = [getUserInfo({a: 123}), getShowData()];

// 通常接收一个由Promise实例组成的数组作为参数
// 只有当所有的Promise操作成功之后 状态才会变更为已成功。
// 只要有一个reject了,那么状态就会变更为已失败
Promise.all(promises)
.then([userInfo, showData] => {
  // 成功回调中 接收到的参数将会是由promises操作返回的结果组成的一个数组
  render(userInfo, showData)
})
.catch(console.log)

异步编程流程控制

Generator

尽管Promise方法能够解决JS异步方法带来的回调地狱问题,但其本质上只是回调函数的改进。

那么,有没有更好的写法呢?传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做"协程"(coroutine),意思是多个线程互相协作,完成异步任务。

于是Generator被提出来,降低异步编程的编写难度和阅读难度。Generator 函数最大特点就是可以交出函数的执行权(即暂停执行)。整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。

语法
function* gen() {
  yield 123;
  yield 456;
}

// 调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,即遍历器对象(Iterator Object)。
var it = gen(); 
it.next(); // {value: 123, done: false}
it.next(); // {value: 456, done: false}
it.next(); // {value: undefined, done: true}

for(const a of it) {
  console.log(a); // 123, 456
}

1、调用next方法后开始执行,遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值。

2、下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。

3、如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。

4、使用next方法交换执行权的同时传递数据

function* gen() {
  var a = yield 123;
  console.log(a); // 'msg'
  yield 456;
}

var it = gen(); 
it.next(); // {value: 123, done: false}
it.next('msg'); // {value: 456, done: false}
使用
function* gen() {
  // 假设都封装成了Promise
  yield getUserInfo(); 
  yield getShowData(); 
}

var it = gen();
// 一对多
var promiseUserInfo = it.next().value;
promiseUserInfo.then(userInfo => {
  handleA(userInfo);
  handleB(userInfo);
})

// 多对一
var promiseShowData = it.next().value;
Promise.all([promiseUserInfo, promiseShowData]).then([userInfo, showData] => {
  render(userInfo, showData);
})

// 相互依赖
function* gen() {
  var dataOne = yield step1();
  yield step2(dataOne)
}

var it = gen();
var p1 = it.next();
p1.then(dataOne => {
  it.next(dataOne);
})

yield后面如果跟的是Thunk函数:

function* gen() {
  // 将a.txt的文件读取并写成新的b.txt
  var data = yield Thunk(fs.readFile('./a.txt', 'utf8')); 
  yield Thunk(fs.writeFile('./b.txt', data));
}

var it = gen();
it.next().value((err, data) =>{
  it.next(data).value(() => {
    console.log('写入成功');
  });
})

async / await

语法

async 函数就是 Generator 函数的语法糖。它返回一个Promise对象;await只能用在async函数中。正常情况下,await命令后面是一个 Promise 对象,返回该对象的结果。如果不是 Promise 对象,就直接返回对应的值。

async function f() {
  var a = await 123;
  return a;
}

f().then(console.log) // 123

await会等待Promise的状态改变,如果Promise被resolve,那么await表达式的结果就是resolve出来的数据。如果Promise被reject,那么直接就会导致异常,所以通常在使用await异步编程时需要使用try/catch块来捕获错误,在await等待期间,会阻塞代码的运行。

async function f() {
  try {
    console.log(1);
    var data = await getUserInfo();
    console.log(2);
  } catch (err) {
    console.log(err);
  }
  // 也可以在封装getUserInfo的时候 就直接调用catch方法用来捕获错误。
}
f();

// 为了避免使用try/catch 可以这样写
async function f() {
  var [err, data] = await getUserInfo().then(data => [null, data]).catch(err => [err, null]);
}
f();

// 或者直接抽离成公共方法

function awaitTo(promise) {
  return promise.then(data => [null, data]).catch(err => [err, null]);
}

async function f() {
  var [err, data] = await awaitTo(getUserInfo());
}
使用
async f() {
  // 一对多
  var userInfo = await getUserInfo();
  handleA(userInfo);
  handleB(userInfo);
  // 多对一
  var showData = await getShowData();
  render(userInfo, showData);

  // 互相依赖
  var data2 = await step2(userInfo);
}

f();