[博客][已更新正文内容] Node.js 从回调地狱到 ES7
发布于 13 天前 作者 sharejishu 471 次浏览 最后一次编辑是 12 天前 来自 分享

大家好,我来 CNode 灌水啦,希望大家捧场。

最近在整理自己在工作中使用到的 Nodejs 的知识点,都是抽自己早起和晚上的休息时间写,每次写一点。以前没写过博客,现在发现写博客好累,自己在写的过程中也发现了自己的不足,很多东西只会用,并未深究。在写的时候也是一个查漏补缺、深入了解的过程,也算有所成长。希望和大家分享一下。写的不好不要骂哦~

Node.js 从回调地狱到 ES7

Node.js 是异步 IO,在遇到 IO 操作时,Node.js 会在底层再开一个线程去处理 IO,等 IO 执行完后再执行回调函数,导致 Node.js 里回调函数泛滥。比如

const fs = require('fs');

fs.readFile('/etc/passwd', 'UTF-8', (err, data) => {
  if (err) {
    console.error(err);
  } else {
    console.log(data);
  }
});

读写文件、数据库、网络请求处处都是回调,当业务逻辑越来越复杂时,回调彼此嵌套,难以区分,代码就非常难看和难以维护。

以一个实例说明。

一个用 callback 方式实现的用户注册实例

假设现在要实现一个注册方法,要先来判断用户名是否存在,如果不存在就创建用户,创建成功后给用户发一封邮件。并有以下 API

  • User.findByUsername(username, callback) 根据用户名查找用户并返回
  • User.create(attributes, callback) 创建用户并返回用户
  • sendEmail(email, subject, content, callback) 发送邮件,不返回任何值
// 用 callback 的方式实现注册
function register(attributes, callback) {
  User.findByUsername(attributes.username, (user) => {
    // callback 1
    if (!user) {
      User.create(attributes, (newUser) => {
        // callback 2
        if (newUser) {
          sendEmail(newUser.email, '注册成功', '恭喜您注册成功', () => {
            // callback 3
            console.log('注册成功,邮件已发送');
            callback(true);
          });
        } else {
          callback('注册失败');
        }
      });
    } else {
      callback('用户名已被使用');
    }
  });
}

上面一共用了 3 个回调,每个回调都会增加一层缩进,并多一层嵌套,这让代码的层级结构很复杂,很难阅读和维护。

callback 的两个问题,太长的缩进和看不清开始和结束的嵌套

为了解决这个问题,后来有了Promise

Promise/A+

使用 Promise 可以让你的异步代码进行链式调用,关于 Promise 有好几个规范,这里讲 Promise/A+ 规范。抽取最重要的几个约定整理如下

  • Promise 即一个拥有 then 方法的对象(这个 then 方法必须符合下面的约定)
  • then 方法有两个参数 then(onFulfilled, onRejected)
  • 这两个参数都是可选的
  • 当方法执行按预期完成时,将方法输出的值传入 onFulfilled 并调用(这里的值可以是你想到的任何值,包括 undefined、promise)
  • 当方法抛出异常时,将调用 onRejected 方法并传入该异常
  • onFulfilled 和 onRejected 都只能调用一次
  • then 方法必须返回一个 promise(链式调用的基础)

尝试根据 fs.readFile 写一个 Promise 风格的 readFile 函数

const fs = require('fs');

function readFile(path, options) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, options, (err, data) => {
      if (err) {
        return reject(err); // err 会被传入 onRejected 函数
      }
      resolve(data); // data 将会作为 onFulfilled 的第一个参数
    });
  });
}

事实上已经有一些库提供了将 callback 转为 Promise 的方法,比如 bluebird

const fs = require('fs');
const Promise = require('bluebird');

const readFile = Promise.promisify(fs.readFile);

Promise 的使用方法

readFile('/etc/passwd', 'UTF-8')
  .then((file) => {
    console.log(file);
  })
  .catch((err) => {
    console.error(err);
  });

catch 会捕获前面 promise 链式调用中未捕获的异常

方法不用再传入回调函数,而是在 then 里传入函数处理结果。这样做的好处是让代码始终保持在平级(callback 是逐级加深缩进)、不嵌套,易读性好了很多。并且可以统一在后面处理异常。

a()
  .then(function () {

  }).then(functino() {

  }).then(function() {

  }).catch(function () {

  });

从 a 方法到后面的任何一个 then 里如果抛出异常都会进入 catch。

Promise 的还有一个好处是可以让多个方法并行统一处理结果。对于 a、b、c 都返回 promise 的情况,若要让 a、b、c 并行执行并且等待全部执行完毕

Promise.all([a(), b(), c()]).then((results) => {
  // results 是三个 promise 的结果
});

用 Promise 改进后的注册代码

假设已用将注册所用到的三个方法改为了 Promise 风格,然后再重写注册方法

function register(attributes) {
  let user;
  User
    .findByUsername(attributes.username)
    .then((_user) => {
      if (!_user) {
        return User.create(attributes);
      } else {
        throw new Error('用户名已存在');
      }
    })
    .then((_user) => {
      user = _user;
      return sendEmail(user.email, '注册成功', '恭喜您注册成功');
    })
    .then(() => {
      doSomethingElse();
    })
    .then(() => {
      resolve(user);
    })
}

可以看到用 Promise 的代码结构比 callback 清晰得多,已经避免了代码嵌套,缩进也得到了解决。

Promise 比 callback 有了很大改进,但是仍然不够完美。需要写大量的括号和方法申明,代码格式上相比同步代码调用仍然不够精简。而且在逻辑流程控制上不自然,比如

a().then((ret) => {
  // 1
  if(ret) {
    return b(ret);
  }
}).then((ret) => {
  // 2
  if(ret) {
    doSomething();
  }
}).then((ret) => {
  // 3
  doXXX();
});

在 1 里当没有 ret 的时候希望直接跳到 3 是不行的,只能什么都不返回然后在 2 里再判断一次。

你可能觉得可以在 1 的 if 里调用 2,但是这样就让所有的 then 不在同一级上,代码退回了 callback 一样的风格,难以阅读和维护。

而且真实的逻辑往往比这更加复杂。

另外 Promise 的这种做法在变量赋值和传递上也不自然,比如上面的 user 变量,为了后面的步骤可以访问,不得不提前在代码外层申明。

后来 ES6 引入了 Generator 函数,解决了以上所有这些问题。

Generator 函数

想了解 Generator 函数的这有一篇博客写得很详细,这里只简单说一下。申明 generator 和申明一个方法类似,只是在 function 后面加了一个 *

function* nums() {
  yield 1;
  yield 2;
  yield 3;
}

// generator 在被调用时不会执行方法体内的代码,只是返回一个对象
const numsGenerator = nums();
// 调用 next() 方法,方法体内的代码开始执行,并在第一个 yield 处停止
let ret = numsGenerator.next();
console.log(ret); // { value: 1, done: false }
// 再次调用,接着从刚才停止的地方执行,到第二个 yield 结束
ret = numsGenerator.next(); 
console.log(ret); // { value: 2, done: false }
// 第三个 yield
ret = numsGenerator.next();
console.log(ret); // { value: 3, done: false }
// 结束
ret = numsGenerator.next();
console.log(ret); // { value: undefined, done: true }

这里要注意的是,当有 n 个 yield 要执行时,实际上要调用 n + 1 次才会真正跑完整个函数。

next() 方法是可以传参的,其传入的值会作为上一次 yield 语句的结果,比如

function* nums() {
  const n = yield 1;
  const m = n * 2;
}

const numsGenerator = nums();
let ret = numsGenerator.next();
console.log(ret.value); // 1

// 将上一次 yield 的值代入,即 n = 1
// 如果这里传入的不是 ret.value,而是 4,那么代入后值将为 4 * 2 = 8
ret = numsGenerator.next(ret.value);
console.log(ret.value); // 1 * 2 = 2

如果调用 next 时没有把上一次的值传入,则视上一个 yield 表达式的值为 undefined,即 n = undefined;也可以传入其他值,无论传入什么值,都会替换掉上一个 yield 的结果。

自动执行的 generator

基于 generator 的机制,可以写一个方法自动去执行 generator

function co(generator) {
  const gen = generator();
  let ret = gen.next();
  while (ret && !ret.done) {
    ret = gen.next(ret.value);
  }
}

function* nums() {
  const n = yield 1;
  console.log(n);
  const m = n * 2;
  console.log(m);
}

co(nums); // 输出 1 2

原理是通过一个 while 循环,检测 ret.done 是否为 true,否则就自动调用 next 方法,并将上一次调用的结果作为参数传入。

yield 后可接方法

function sum(a, b) {
  return a + b;
}

function* nums() {
  const n = yield 1;
  console.log(n);
  const m = yield sum(n, 5);
  console.log(m);
}

co(nums); // 输出 1 6

gen.next() 会返回方法调用后的结果

当 yield 的方法返回 Promise

比如

function* nums() {
  const n = yield readFile('/etc/passwd');
  console.log(n);
  const m = yield readFile('/etc/bashrc');
  console.log(m);
}

对于 yield readFile('/etc/passwd'),next() 得到的值就是 Promise,然后调用 promise.then,在 onFulfilled 里再次调用 next 即可。

function co(generator) {
  const gen = generator();

  function goon(ret) {
    if (!ret || ret.done) {
      return;
    }

    if (ret.value instanceof Promise) {
      const promise = ret.value;
      promise.then((value) => {
        ret = gen.next(value);
        goon(ret);
      }).catch(console.error);
    } else {
      ret = gen.next(ret.value);
      goon(ret);
    }
  }

  const ret = gen.next();
  goon(ret);
}

co(nums); // 输出文件的内容(先确认你是否有这两个文件)

因为设计到异步代码,不能直接用 while 来实现迭代,所以这里改为用一个递归调用的函数来实现迭代。代码很简陋,但是说明原理已经足够。

在生产环境中,一般用 cobluebird 来处理 generator。

用 generator 改写注册用户

function* register(attributes) {
  let user = yield User.findByUsername(attributes.username);
  if (user) {
    throw new Error('用户名已存在');
  }

  user = yield User.create(attributes);
  yield sendEmail(user.email, '注册成功', '恭喜您注册成功');
  yield doSomethingElse();
  return user;
}

Amazon! 这才是该有的写法。Promise 版的注册代码有 22 行,而 generator 版包括一个空行也才 11 行,少了一半代码。而且代码看起来更自然,写起来跟写同步代码几乎没有区别,只是在异步方法前需要加一个 yield。这感觉棒极了!

ES7 async/await

ES7 提供了新的规范专门处理异步问题,规则很简单

  • 在 function 关键字前加一个 async 来申明此方法为异步方法
  • 在异步方法内用 await 等待一个 Promise 返回结果

相比 generator,只是把 function* 换成了 async function, yield 换成了 await

再来改写注册代码

async function register(attributes) {
  let user = await User.findByUsername(attributes.username);
  if (user) {
    throw new Error('用户名已存在');
  }

  user = await User.create(attributes);
  await sendEmail(user.email, '注册成功', '恭喜您注册成功');
  await doSomethingElse();
  return user;
}

说实话并没有太大变化,但是从语意上要更清晰。generator 的设计意图是为了提供迭代功能,但是无意间解决了异步问题,还是要借助一些第三方库来执行,而使用 ES7 的语法原生就能得到支持。

async/await 目前在 NodeJS v7.1.0+ 已经得到支持。

5 回复

头像为啥改了 gravatar 和 github 都不行呢?

@sharejishu 建议把正文发出来,然后留下博客地址效果更好。如果大家还要点一下才能看,估计很多就不会做的,你说呢?

@i5ting 恩,下次一定把正文贴出来

@sharejishu 棒棒哒~坚持总结写博客,以后真的会受益无穷,加油

马克加索尔

回到顶部