「大概也许是」目前最好的 JavaScript 异步方案 async/await

2015-11-23 11:00:40 +08:00
 LeanCloudRRY

构建一个应用程序总是会面对异步调用,不论是在 Web 前端界面,还是 Node.js 服务端都是如此, JavaScript 里面处理异步调用一直是非常恶心的一件事情。以前只能通过回调函数,后来渐渐又演化出来很多方案,最后 Promise 以简单、易用、兼容性好取胜,但是仍然有非常多的问题。其实 JavaScript 一直想在语言层面彻底解决这个问题,在 ES6 中就已经支持原生的 Promise ,还引入了 Generator 函数,终于在 ES7 中决定支持 async 和 await 。

基本语法

async/await 究竟是怎么解决异步调用的写法呢?简单来说,就是将异步操作用同步的写法来写。先来看下最基本的语法( ES7 代码片段):

const f = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
resolve(123);
    }, 2000);
  });
};

const testAsync = async () => {
  const t = await f();
  console.log(t);
};

testAsync();

首先定义了一个函数 f,这个函数返回一个 Promise ,并且会延时 2 秒,resolve 并且传入值 123 。testAsync 函数在定义时使用了关键字 async,然后函数体中配合使用了 await,最后执行 testAsync。整个程序会在 2 秒后输出 123 ,也就是说 testAsync 中常量 t 取得了 fresolve 的值,并且通过 await 阻塞了后面代码的执行,直到 f 这个异步函数执行完。

对比 Promise

仅仅是一个简单的调用,就已经能够看出来 async/await 的强大,写码时可以非常优雅地处理异步函数,彻底告别回调恶梦和无数的 then 方法。我们再来看下与 Promise 的对比,同样的代码,如果完全使用 Promise 会有什么问题呢?

const f = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(123);
    }, 2000);
  });
};

const testAsync = () => {
  f().then((t) => {
    console.log(t);
  });
};

testAsync();

从代码片段中不难看出 Promise 没有解决好的事情,比如要有很多的 then 方法,整块代码会充满 Promise 的方法,而不是业务逻辑本身,而且每一个 then 方法内部是一个独立的作用域,要是想共享数据,就要将部分数据暴露在最外层,在 then 内部赋值一次。虽然如此, Promise 对于异步操作的封装还是非常不错的,所以 async/await 是基于 Promise 的,await 后面是要接收一个 Promise 实例。

对比 RxJS

RxJS 也是非常有意思的东西,用来处理异步操作,它更能处理基于流的数据操作。举个例子,比如在 Angular2 中 http 请求返回的就是一个 RxJS 构造的 Observable Object ,我们就可以这样做:

$http.get(url)
  .map(function(value) {
    return value + 1;
  })
  .filter(function(value) {
    return value !== null;
  })
  .forEach(function(value) {
    console.log(value);
  })
  .subscribe(function(value) {
    console.log('do something.');
  }, function(err) {
    console.log(err);
  });

如果是 ES6 代码可以进一步简洁:

$http.get(url) 
  .map(value => value + 1) 
  .filter(value => value !== null) 
  .forEach(value => console.log(value)) 
  .subscribe((value) => { 
    console.log('do something.'); 
  }, (err) => { 
    console.log(err); 
  });

可以看出 RxJS 对于这类数据可以做一种类似流式的处理,也是非常优雅,而且 RxJS 强大之处在于你还可以对数据做取消、监听、节流等等的操作,这里不一一举例了,感兴趣的话可以去看下 RxJS 的 API 。

这里要说明一下的就是 RxJS 和 async/await 一起用也是可以的, Observable Object 中有 toPromise 方法,可以返回一个 Promise Object ,同样可以结合 await 使用。当然你也可以只使用 async/await 配合 underscore 或者其他库,也能实现很优雅的效果。总之, RxJS 与 async/await 不冲突。

异常处理

通过使用 async/await ,我们就可以配合 try/catch 来捕获异步操作过程中的问题,包括 Promise 中 reject 的数据。

const f = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(234);
    }, 2000);
  });
};

const testAsync = () => {
  try {
    const t = await f();
    console.log(t);
  } catch (err) {
    console.log(err);
  }
};

testAsync();

代码片段中将 f 方法中的 resolve 改为 reject,在 testAsync 中,通过 catch 可以捕获到 reject 的数据,输出 err 的值为 234 。try/catch 使用时也要注意范围和层级。如果 try 范围内包含多个 await,那么 catch 会返回第一个 reject 的值或错误。

const f1 = () => {
return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(111);
    }, 2000);
  });
};

const f2 = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(222);
    }, 3000);
  });
};

const testAsync = () => {
  try {
    const t1 = await f1();
    console.log(t1);
    const t2 = await f2();
    console.log(t2);
  } catch (err) {
    console.log(err);
  }
};

testAsync();

如代码片段所示,testAsync 函数体中 try 有两个 await 函数,而且都分别 reject,那么 catch 中仅会触发 f1reject,输出的 err 值是 111 。

开始使用

无论是 Web 前端还是 Node.js 服务端,都可以通过预编译的手段实现使用 ES6 和 ES7 来写代码,目前最流行的方案是通过 Babel 将使用 ES7 、 ES6 写的代码编译为 E6 或 ES5 的代码来执行。

Node.js 服务端配置

服务端使用 Babel ,最简单的方式是通过 require hook 。

首先安装 Babel :

$ npm install babel-core --save

安装 async/await 支持:

$ npm install babel-preset-stage-3 --save

在服务端代码的根目录中配置 .babelrc 文件,内容为:

{
  "presets": ["stage-3"]
}

在顶层代码文件( server.js 或 app.js 等)中引入 Babel 模块:

require("babel-core/register");

在这句后面引入的模块,都将会自动通过 babel 编译,但当前文件不会被 babel 编译。另外,需要注意 Node.js 的版本,如果是 4.0 以上的版本则默认支持绝大部分 ES6 ,可以直接启动。但是如果是 0.12 左右的版本,就需要通过 node — harmory 来启动才能够支持。因为 stage-3 模式, Babel 不会编译基本的 ES6 代码,环境既然支持又何必要编译为 ES5 ?这样做也是为了提高性能和编译效率。

配置 Web 前端构建

可以通过增加 Gulp 的预编译 task 来支持。

首先安装 gulp-babel 插件:

$ npm install gulp-babel --save-dev

然后编写配置:

var gulp = require('gulp');
var babel = require('gulp-babel');
gulp.task('babel', function() {
  return gulp.src('src/app.js')
    .pipe(babel())
    .pipe(gulp.dest('dist'));
});

除了 Gulp-babel 插件,也可以使用官方的 Babel-loader 结合 Webpack 或 Browserify 使用。

要注意的是,虽然官方也有纯浏览器版本的 Babel.js ,但是浏览器限制非常多,而且对客户端性能影响也较大,不推荐使用。

LeanEngine Full Stack

LeanEngine (云引擎)是 LeanCloud 推出的服务器端运行环境,支持 Node.js 和 Python 环境,功能强大而且目前免费,结合 LeanCloud JavaScript SDK ,使原本复杂的开发工作变得简单高效。目前也支持 Redis 和海外节点,轻松满足你的业务需求。


LeanCloud 使用自已的服务编写出了很多的应用和 Web 产品。为了方便各位开发者基于 LeanEngine 来开发应用, LeanCloud 整理了目前开发 Web 端产品的技术栈,并结合 LeanEngine 特点,推出了一套完整实用的技术解决方案:LeanEngine-Full-Stack,它已经配置了 Babel ,可以在 LeanEngine 中结合 JavaScript SDK 使用 async/await 处理异步操作。所以,还等什么?快来下载编写新项目吧。

Enjoy!

结语

LeanCloud 希望能够通过构建最简单易用的技术产品,帮助各位开发者和创业者加速产品开发,尽可能地节约资源成本、时间成本和机会成本,希望本文能够帮助到你。有什么问题,可以在 LeanEngine-Full-Stack @GitHub 仓库 中提交 issue ,或者直接去 LeanCloud 社区 提问。

[参考资料] EcmaScript async/await 详细规范

9314 次点击
所在节点    JavaScript
15 条回复
shuson
2015-11-23 11:05:31 +08:00
消灭 callback hell ,然后消灭 then chain hell 。
当我们谈论 javascript 的时候,我在各种 hell ,你却在 heaven 。
serenader
2015-11-23 11:22:44 +08:00
> 从代码片段中不难看出 Promise 没有解决好的事情,比如要有很多的 then 方法,整块代码会充满 Promise 的方法,而不是业务逻辑本身,而且每一个 then 方法内部是一个独立的作用域,要是想共享数据,就要将部分数据暴露在最外层,在 then 内部赋值一次。

确实如此。每次写 Promise 的时候感觉还是不太优雅。

感谢楼主的分享,又学到了新知识。
cheneydog
2015-11-23 11:41:41 +08:00
leancloud 能用来做普通的网站么?有 80 端口么?能绑定域名么?
iamppz
2015-11-23 12:55:31 +08:00
Rx 主要做的还是对事件流的过滤吧,感觉 subscribe 和 then 区别不是太大
RoshanWu
2015-11-23 13:02:24 +08:00
昨天刚用 async/await 重构了一个项目,不过我用的不是 Babel ,而是 TypeScript ,感觉更方便一点: https://github.com/roshanca/gitlab-autochangelog
oott123
2015-11-23 13:23:05 +08:00
@serenader 其实这个问题可以看 /t/198533 的讨论,有几种方法还是很顺手的。
minsheng
2015-11-23 14:09:05 +08:00
最好的方法当然是用 PureScript 的 do-notation 。这个问题二十年前就被解决了,简单的 Continuation Monad 而已。

PureScript 的特性多了去了,比如说强类型系统,比 TypeScript 的还方便。比如说 algebraic data types ,可以方便的定义枚举:
data Color = Red | Green | Blue

也可以定义 higher kinded types ,也就是 Swift 里枚举的那个 associated value :
data Maybe a = Just a | Nothing
定义了一个 optional 的类型。

方便的 pattern matching ,无论是定义简单的函数:
fac :: Number -> Number
fac 1 = 1
fac n = fac (n - 1) * n
或者处理上面的 Maybe :
nextStep :: Maybe String -> Maybe String
nextStep (Just x) = performOperation x
nextStep _ = Nothing

当然,几乎所有的类型都可以省略:
let fib 0 = 1; fib 1 = 1; fib n = fib (n-1) + fib (n-2) in fib 10

类似泛型的 type class :
class ToJson a where
toJson :: a -> String

然后只要针对每一种类型定义相关的函数就可以直接使用 toJson 函数了,多么模块化!当然,可以专门针对数组 [a] 针对一个 toJson 函数,只要 SomeType 定义了 toJson ,数组 [SomeType] 也就都可以用 toJson 了。

还支持两个参数的 type class
class Convertible a b where
convert :: a -> b

然后就可以类型安全的转换数据了。

担心 Maybe 穿起来特别复杂?有一串操作,每一步都可能失败?没关系,我们有 do notation
operations :: Number -> Maybe Number
operations x = do x1 <- op1 x; x2 <- op2 x1; x3 <- op3 x2; return x3

对比原来的写法:
operations x = case op1 x of
Nothing -> Nothing
Just x1 -> case op2 x1 of
Nothing -> Nothing
Just x2 -> case op3 x2 of
Nothing -> Nothing
Just x3 -> Just x3

有没有感觉这个看起来很像 callback hell ?对了!这就是我说的 do notation 如何轻松的解决 callback hell ,只需要定义一个专门的 Continuation Monad 就可以用 do notation 解决 callback hell 。而且这玩意具有可扩展性,而不像楼主提到的 await async 处理的问题比较死。 Node.js 一个常见的 pattern 就是用第一个参数传 error ,而在这里稍微修改一下 Continuation Monad 的定义就可以写出:
operations x = do x1 <- mayFailOp1 x; …

只要其中挂了一个整个操作自动中断。

Do-notation 和 monad 的各种奇技淫巧各大社区已经研究了很多年,无限的可能性。

然而需要和 JSON 打交道的人可能要吐槽强类型系统的不方便,但不用担心 PureScript 原生支持 extensible records !换句话说可以定义这样的函数:
fullName :: forall t. { firstName :: String, lastName :: String | t } -> String
然后任何类型!任何类型!只要包含了 firstName 和 lastName !就可以传给 fullName ,返回的就是 String 。这个是多么方便啊!这可是有静态检查的!你只要确保项目里那几个小小的与外界打交道的函数包上了 PureScript 的皮就好了。

最后再来一个重磅的, generics ,泛型编程。这可不是 Java 里的泛型,上面的 type class 已经支持那个特性了。这里的泛型是根据数据类型的形状自动生成代码。比如说,自动生成等于函数 eq ,或者 toJson 。配合起 type class 简直爽爆了,再也不用写 boilerplate/冗余代码了。

然后, PureScript 的 FFI ——也就是跟普通 JavaScript 交互的接口——也是特别简单,我就不说了,具体可以自行参考 purescript.org

这,才是 GHCJS 之外最好的, callback hell 解决方案。
zhujinliang
2015-11-23 14:23:11 +08:00
之前看到有个 wind.js ,觉得思路很好,但现在没再听说了

整体思路就是写还是按照同步的写,但通过一个“编译器”变成异步的代码,编译器自动处理异步调用过程
malcolmyu
2015-11-23 15:31:40 +08:00
就是给 tj 的那套 co 弄个一个很甜的语法糖
ChefIsAwesome
2015-11-23 15:40:40 +08:00
rx 不是语法糖,目的不是让你把异步写的跟同步一样。我一直觉得“把异步写的跟同步一样”没什么意义,能充分利用异步才是关键。
举个例子:
同时发五个请求去取一篇文章的五个章节。因为网络的原因,得到相应的顺序可能是 1,3,2,5,4 。
现在要求得到 1 的时候立即把 1 渲染出来,得到 3 的时候不渲染,等到得到 2 之后再把渲染 2 和 3 一起渲染了。

这种复杂的情况用 rx 就很容易实现。
bramblex
2015-11-23 15:44:45 +08:00
@minsheng

Haskell 大法好 /w\

就一个简单的 continuation 的问题嘛~
zhouyg
2015-11-23 16:21:21 +08:00
服务端环境下用 babel 作大死啊,如果出现莫名其妙的问题,排查起来就是个灾难。
iwege
2015-11-23 22:24:06 +08:00
@zhujinliang 和那个差不多,只是上升到语法标准了, wind.js 我记得是用 eval 来做的。
kamushin
2015-11-24 14:05:38 +08:00
和 yield 的区别是?
edward1992
2015-11-24 14:15:27 +08:00
赞一个。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/238191

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX