JavaScript异步编程

Published on
Authors

任何可以用 JavaScript 写成的应用最终都会用 JavaScript 写。
https://blog.codinghorror.com/the-principle-of-least-power/

为什么要用异步编程

同步的代码, 在很多情况下, CPU其实是在等待中度过的, 比如等待一个网络请求,等待数据库。比如在Python中,发起HTTP请求会阻塞住,知道返回结果才继续进行。

import requests
res = requests.get(‘https://youtube.com’)
# 在国内,可能要卡住4、5秒,才能运行下一步
print(res) 

这里就有大量的CPU时间浪费掉了,因为我什么也没干,就是干等着请求数据了。所以,这个时间可以利用起来,我先执行下面的代码,等到这个网络IO完成了,我在反过头来执行callback,这就是所谓的异步了。
也就是为了充分利用CPU。对于有UI的程序,也是避免阻塞UI

JavaScript的异步

一般认为JavaScript是可以利用事件模型处理异步触发任务的单线程语言。当要处理很多事件,同时要求数据的状态能够从 一个事件传递到下一个事件,那么就会写出如下代码:

step1(result1 => {
  step2(result2 => {
      step3(result3 => {
      })
  })
})

JavaScript中的每个异步函数都构建在其他某个或某些异步函数之上,凡是异步函数,从上到下(一直到原声代码)都是异步的。

举一个具体的需求: 登录教室(比实际的要更复杂一些) 来依次介绍各种异步的写法:

  • Step1: 用户输入用户名密码,点击登录时,请求Web服务器校验用户名密码是否合法,函数名:validUserPassword,返回:{roomId: String}
  • Step2: 如果合法,则根据返回的roomId请求教室信息,函数名:getRoomInfo(roomId),返回值:{roomId: String, userlist: ['user1', 'user2', 'user'3], roomType: enum(PLAYBACK, LIVE)}。如果不合法,则抛出错误,提示用户。
  • Step3: 如果请求成功,则根据userlist拉去每个用户的详细信息getUserInfo(userId)。如果请求教室信息失败,则抛出错误,提示用户。
  • Step4: 如果所有用户信息拉取成功,则根据roomType加载不同的教室。对于回放教室,调用loadPlayback(roomId)。对于直播教室调用loadLivestatus(roomId)

其中每一步成功了才能进入下一步,失败了需要返回相应的错误信息。
每一个函数都是异步函数。最后一个参数都是回调函数。回调函数的第一个参数是err,第二个参数是response。函数名后面是Async的,是返回Promise的异步函数。
最好直接在 https://jsfiddle.net/0neSe7en/oxaouqz5/ 这里自己运行代码试试看。

1. Callback

function viaCallback(callback) {
  validUserPassword('user', 'pass', (err, res) => {
	if (err) {
	  return callback(new Error('用户名密码错误'));
	}
	insertText(res);
	return getRoomInfo(res.roomId, (err, roomInfo) => {
	  if (err) {
		return callback(new Error('获取教室信息失败'));
	  }
	  insertText(roomInfo);
	  let doneCount = 0;
	  let userInfoResults = [];
	  for (let i = 0; i < roomInfo.userlist.length; i++) {
		const userId = roomInfo.userlist[i];
		getUserInfo(userId, (err, userInfo) => {
		  if (err) {
			return callback(new Error('获取用户信息失败'));
		  }
		  userInfoResults[i] = userInfo;
		  doneCount ++;
		  if (doneCount === roomInfo.userlist.length) {
			insertText(userInfoResults);
			if (roomInfo.roomType === 'PLAYBACK') {
			  loadPlayback(res.roomId, (err, res) => {
				if (err) {
				  return callback(new Error('加载回放信息失败'));
				} else {
				  insertText(res) ;
				}
			  })
			} else if (roomInfo.roomType === 'LIVE') {
			  loadLivestatus(res.roomId, (err, res) => {
				if (err) {
				  return callback(new Error('加载直播信息失败'));
				} else {
				  insertText(res) ;
				}
			  })
			}
		  }
		})
	  }
	})
  })
}

可以看到,在不把逻辑抽出到函数中,就会一层层的嵌套。而处理一个数组的异步调用时,就会变得格外麻烦。即使优化之后,代码的可读性还是比较差。

function viaCallbackBetter(callback) {
  function loadRoom(roomInfo) {
    if (roomInfo.roomType === 'PLAYBACK') {
      loadPlayback(roomInfo.roomId, callback)
    } else if (roomInfo.roomType === 'LIVE') {
      loadLivestatus(roomInfo.roomId, callback)
    }
  }

  function handleRoomInfo(err, roomInfo) {
    if (err) {
      return callback(new Error('获取教室信息失败'));
    }
    insertText(roomInfo);
    let doneCount = 0;
    let userInfoResults = [];
    for (let i = 0; i < roomInfo.userlist.length; i++) {
      const userId = roomInfo.userlist[i];
      getUserInfo(userId, (err, userInfo) => {
        if (err) {
          return callback(new Error('获取用户信息失败'));
        }
        userInfoResults[i] = userInfo;
        doneCount++;
        if (doneCount === roomInfo.userlist.length) {
          insertText(userInfoResults);
          loadRoom(roomInfo);
        }
      })
    }
  }

  function handleLogin(err, res) {
    if (err) {
      return callback(new Error('用户名密码错误'));
    }
    insertText(res);
    return getRoomInfo(res.roomId, handleRoomInfo)
  }

  validUserPassword('user', 'pass', handleLogin);
}

2. Async.js

这个现在不常用了,先略过,之后再补充。

3. Promise

function viaPromise() {
  return validUserPasswordAsync('user', 'pass').then(res => {
    insertText(res);
    return getRoomInfoAsync(res.roomId).then(res => {
      return res;
    }).catch(() => Promise.reject('获取教室信息失败'))
  }, () => {
    return Promise.reject('用户名密码错误');
  }).then(roomInfo => {
    insertText(roomInfo);
    return Promise.all(roomInfo.userlist.map(uid => getUserInfoAsync(uid))).then(userInfoResults => {
      return { userInfoResults, roomInfo };
    }).catch(() => Promise.reject('获取用户信息失败'))
  }).then(({userInfoResults, roomInfo}) => {
    insertText(userInfoResults);
    if (roomInfo.roomType === 'PLAYBACK') {
      return loadPlaybackAsync(roomInfo.roomId).catch(() => Promise.reject('加载回放失败'));
    } else if (roomInfo.roomType === 'LIVE') {
      return loadLivestatusAsync(roomInfo.roomId).catch(() => Promise.reject('加载直播失败'));
    }
    return Promise.reject('加载教室失败')
  })
}

优化之后:

function viaPromiseBetter() {
  function handleLogin(res) {
    insertText(res);
    return getRoomInfoAsync(res.roomId).catch(() => Promise.reject('获取教室信息失败'))
  }

  function handleRoomInfo(roomInfo) {
    insertText(roomInfo);
    return Promise.all(roomInfo.userlist.map(uid => getUserInfoAsync(uid)))
      .then(userInfoResults => ({ userInfoResults, roomInfo }))
      .catch(() => Promise.reject('获取用户信息失败'))
  }

  function loadRoom({userInfoResults, roomInfo}) {
    insertText(userInfoResults);
    if (roomInfo.roomType === 'PLAYBACK') {
      return loadPlaybackAsync(roomInfo.roomId).catch(() => Promise.reject('加载回放失败'));
    } else if (roomInfo.roomType === 'LIVE') {
      return loadLivestatusAsync(roomInfo.roomId).catch(() => Promise.reject('加载直播失败'));
    }
    return Promise.reject('加载教室失败')
  }

  return validUserPasswordAsync('user', 'pass')
    .then(handleLogin, () => Promise.reject('用户名密码错误'))
    .then(handleRoomInfo)
    .then(loadRoom);
}

4. async, await

async function viaAsync() {
  let roomId, roomInfo;
  try {
    const res = await validUserPasswordAsync('user', 'pass');
    roomId = res.roomId;
    insertText(res);
  } catch (e) {
    throw new Error('用户名密码错误');
  }
  try {
    roomInfo = await getRoomInfoAsync(roomId);
    insertText(roomInfo);
  } catch (e) {
    throw new Error('获取教室信息失败');
  }
  try {
    const userInfoResults = await Promise.all(roomInfo.userlist.map(uid => getUserInfoAsync(uid)));
    insertText(userInfoResults);
  } catch (e) {
    throw new Error('获取用户信息失败');
  }
  if (roomInfo.roomType === 'PLAYBACK') {
    try {
      return await loadPlaybackAsync(roomInfo.roomId)
    } catch (e) {
      throw new Error('加载回放失败');
    }
  } else if (roomInfo.roomType === 'LIVE') {
    try {
      return await loadLivestatusAsync(roomInfo.roomId)
    } catch (e) {
      throw new Error('加载直播失败');
    }
  } else {
    throw new Error('加载教室失败');
  }
}

5. worker

这个之后补充

为什么要用Promise

以前的用法:

$.get('/getSomething', { success: onSuccess, failure: onFailure, always: onAlways});

// 采用Promise。注意:这是jQuery最早实现的Promise,和规范的接口不一样。但是Promise是由于jQuery1.5带火的。
const promise = $.get('/getSomething');
promise.done(onSuccess);
promise.fail('onFailure);
promise.always(onAlways);

乍一看这种变化没什么。为什么一定要在触发Ajax调用之后再附加回调呢?!!封装!!。如果Ajax调用需要实现很多效果(触发动画、插入HTML、锁定/解锁用户输入等等),那么仅由负责发出请求的那部分应用代码处理所有这些效果,就显得很蠢很拙劣。

只传递Promise 对象就会优雅得多。传递Promise 对象就相当于声明:“你感兴趣的某某事就要发生了。想知道什么时候完事吗?给这个Promise 对象一个回调就行啦!”Promise 对象也和EventEmitter 对象一样,允许向同一个事件绑定任意多的处理器(堆积技术)。对于多个Ajax 调用分享某个功能小片段(譬如“正加载”动画)的情况,堆积技术也会使降低代码重复度容易很多。

不过使用Promise 对象的最大优势仍然在于,它可以轻松从现有Promise 对象派生出新的Promise 对象。我们可以要求代表着并行任务的两个Promise 对象合并成一个Promise 对象,由后者负责通知前面那些任务都已完成。也可以要求代表着任务系列中首任务的Promise 对象派生出一个能代表任务系列中末任务的Promise 对象,这样后者就能知道这一系列任务是否均已完成。

Promise的坑

我自己是在2015年初准备学习Node.js的,那个时侯对 JavaScript 的异步、回调函数,都不是很理解。在使用Promise的过程中,各种常见的坑基本都踩过了。然后看到了这篇文章,基本上解释了我遇到的所有的问题... ,作为 Promise 常见的坑。

作者是PouchDB( https://github.com/pouchdb/pouchdb )的创始人和维护者,译者是FEX团队成员( http://fex.baidu.com/blog/2015/07/we-have-a-problem-with-promises/ )。文章写于2015年5月份,我也在差不多的时间,大量使用Promise。

参考: