剖析Node.js异步编程中的回调与代码设计模式


Posted in Javascript onFebruary 16, 2016

NodeJS 最大的卖点——事件机制和异步 IO,对开发者并不是透明的。开发者需要按异步方式编写代码才用得上这个卖点,而这一点也遭到了一些 NodeJS 反对者的抨击。但不管怎样,异步编程确实是 NodeJS 最大的特点,没有掌握异步编程就不能说是真正学会了 NodeJS。本章将介绍与异步编程相关的各种知识。

在代码中,异步编程的直接体现就是回调。异步编程依托于回调来实现,但不能说使用了回调后程序就异步化了。我们首先可以看看以下代码。

function heavyCompute(n, callback) {
 var count = 0,
  i, j;

 for (i = n; i > 0; --i) {
  for (j = n; j > 0; --j) {
   count += 1;
  }
 }

 callback(count);
}

heavyCompute(10000, function (count) {
 console.log(count);
});

console.log('hello');
100000000
hello

可以看到,以上代码中的回调函数仍然先于后续代码执行。JS 本身是单线程运行的,不可能在一段代码还未结束运行时去运行别的代码,因此也就不存在异步执行的概念。

但是,如果某个函数做的事情是创建一个别的线程或进程,并与JS主线程并行地做一些事情,并在事情做完后通知 JS 主线程,那情况又不一样了。我们接着看看以下代码。

setTimeout(function () {
 console.log('world');
}, 1000);

console.log('hello');
hello
world

这次可以看到,回调函数后于后续代码执行了。如同上边所说,JS 本身是单线程的,无法异步执行,因此我们可以认为 setTimeout 这类 JS 规范之外的由运行环境提供的特殊函数做的事情是创建一个平行线程后立即返回,让 JS 主进程可以接着执行后续代码,并在收到平行进程的通知后再执行回调函数。除了 setTimeout、setInterval 这些常见的,这类函数还包括 NodeJS 提供的诸如 fs.readFile 之类的异步 API。

另外,我们仍然回到 JS 是单线程运行的这个事实上,这决定了 JS 在执行完一段代码之前无法执行包括回调函数在内的别的代码。也就是说,即使平行线程完成工作了,通知 JS 主线程执行回调函数了,回调函数也要等到 JS 主线程空闲时才能开始执行。以下就是这么一个例子。

function heavyCompute(n) {
 var count = 0,
  i, j;

 for (i = n; i > 0; --i) {
  for (j = n; j > 0; --j) {
   count += 1;
  }
 }
}

var t = new Date();

setTimeout(function () {
 console.log(new Date() - t);
}, 1000);

heavyCompute(50000);
8520

可以看到,本来应该在1秒后被调用的回调函数因为 JS 主线程忙于运行其它代码,实际执行时间被大幅延迟。

代码设计模式
异步编程有很多特有的代码设计模式,为了实现同样的功能,使用同步方式和异步方式编写的代码会有很大差异。以下分别介绍一些常见的模式。

函数返回值
使用一个函数的输出作为另一个函数的输入是很常见的需求,在同步方式下一般按以下方式编写代码:

var output = fn1(fn2('input'));
// Do something.

而在异步方式下,由于函数执行结果不是通过返回值,而是通过回调函数传递,因此一般按以下方式编写代码:

fn2('input', function (output2) {
 fn1(output2, function (output1) {
  // Do something.
 });
});

可以看到,这种方式就是一个回调函数套一个回调函多,套得太多了很容易写出>形状的代码。

遍历数组
在遍历数组时,使用某个函数依次对数据成员做一些处理也是常见的需求。如果函数是同步执行的,一般就会写出以下代码:

var len = arr.length,
 i = 0;

for (; i < len; ++i) {
 arr[i] = sync(arr[i]);
}

// All array items have processed.

如果函数是异步执行的,以上代码就无法保证循环结束后所有数组成员都处理完毕了。如果数组成员必须一个接一个串行处理,则一般按照以下方式编写异步代码:

(function next(i, len, callback) {
 if (i < len) {
  async(arr[i], function (value) {
   arr[i] = value;
   next(i + 1, len, callback);
  });
 } else {
  callback();
 }
}(0, arr.length, function () {
 // All array items have processed.
}));

可以看到,以上代码在异步函数执行一次并返回执行结果后才传入下一个数组成员并开始下一轮执行,直到所有数组成员处理完毕后,通过回调的方式触发后续代码的执行。

如果数组成员可以并行处理,但后续代码仍然需要所有数组成员处理完毕后才能执行的话,则异步代码会调整成以下形式:

(function (i, len, count, callback) {
 for (; i < len; ++i) {
  (function (i) {
   async(arr[i], function (value) {
    arr[i] = value;
    if (++count === len) {
     callback();
    }
   });
  }(i));
 }
}(0, arr.length, 0, function () {
 // All array items have processed.
}));

可以看到,与异步串行遍历的版本相比,以上代码并行处理所有数组成员,并通过计数器变量来判断什么时候所有数组成员都处理完毕了。

异常处理
JS 自身提供的异常捕获和处理机制——try..catch..,只能用于同步执行的代码。以下是一个例子。

function sync(fn) {
 return fn();
}

try {
 sync(null);
 // Do something.
} catch (err) {
 console.log('Error: %s', err.message);
}
Error: object is not a function

可以看到,异常会沿着代码执行路径一直冒泡,直到遇到第一个 try 语句时被捕获住。但由于异步函数会打断代码执行路径,异步函数执行过程中以及执行之后产生的异常冒泡到执行路径被打断的位置时,如果一直没有遇到 try 语句,就作为一个全局异常抛出。以下是一个例子。

function async(fn, callback) {
 // Code execution path breaks here.
 setTimeout(function () {
  callback(fn());
 }, 0);
}

try {
 async(null, function (data) {
  // Do something.
 });
} catch (err) {
 console.log('Error: %s', err.message);
}
/home/user/test.js:4
  callback(fn());
     ^
TypeError: object is not a function
 at null._onTimeout (/home/user/test.js:4:13)
 at Timer.listOnTimeout [as ontimeout] (timers.js:110:15)

因为代码执行路径被打断了,我们就需要在异常冒泡到断点之前用 try 语句把异常捕获住,并通过回调函数传递被捕获的异常。于是我们可以像下边这样改造上边的例子。

function async(fn, callback) {
 // Code execution path breaks here.
 setTimeout(function () {
  try {
   callback(null, fn());
  } catch (err) {
   callback(err);
  }
 }, 0);
}

async(null, function (err, data) {
 if (err) {
  console.log('Error: %s', err.message);
 } else {
  // Do something.
 }
});
Error: object is not a function

可以看到,异常再次被捕获住了。在 NodeJS 中,几乎所有异步 API 都按照以上方式设计,回调函数中第一个参数都是 err。因此我们在编写自己的异步函数时,也可以按照这种方式来处理异常,与 NodeJS 的设计风格保持一致。

有了异常处理方式后,我们接着可以想一想一般我们是怎么写代码的。基本上,我们的代码都是做一些事情,然后调用一个函数,然后再做一些事情,然后再调用一个函数,如此循环。如果我们写的是同步代码,只需要在代码入口点写一个 try 语句就能捕获所有冒泡上来的异常,示例如下。

function main() {
 // Do something.
 syncA();
 // Do something.
 syncB();
 // Do something.
 syncC();
}

try {
 main();
} catch (err) {
 // Deal with exception.
}

但是,如果我们写的是异步代码,就只有呵呵了。由于每次异步函数调用都会打断代码执行路径,只能通过回调函数来传递异常,于是我们就需要在每个回调函数里判断是否有异常发生,于是只用三次异步函数调用,就会产生下边这种代码。

function main(callback) {
 // Do something.
 asyncA(function (err, data) {
  if (err) {
   callback(err);
  } else {
   // Do something
   asyncB(function (err, data) {
    if (err) {
     callback(err);
    } else {
     // Do something
     asyncC(function (err, data) {
      if (err) {
       callback(err);
      } else {
       // Do something
       callback(null);
      }
     });
    }
   });
  }
 });
}

main(function (err) {
 if (err) {
  // Deal with exception.
 }
});

可以看到,回调函数已经让代码变得复杂了,而异步方式下对异常的处理更加剧了代码的复杂度。

Javascript 相关文章推荐
JQuery.uploadify 上传文件插件的使用详解 for ASP.NET
Jan 22 Javascript
JS使用oumousemove和oumouseout动态改变图片显示的方法
Mar 31 Javascript
JavaScript动态修改背景颜色的方法
Apr 16 Javascript
js实现表单Radio切换效果的方法
Aug 17 Javascript
js解决movebox移动问题
Mar 29 Javascript
详解js的事件代理(委托)
Dec 22 Javascript
bootstrap表格分页实例讲解
Dec 30 Javascript
jQuery实现select下拉框获取当前选中文本、值、索引
May 08 jQuery
Node.JS段点续传:Nginx配置文件分段下载功能的实现方法
Mar 12 Javascript
记录一次开发微信网页分享的步骤
May 07 Javascript
解决vue打包后vendor.js文件过大问题
Jul 03 Javascript
微信小程序吸底区域适配iPhoneX的实现
Apr 09 Javascript
使用Node.js处理前端代码文件的编码问题
Feb 16 #Javascript
让图片跳跃起来  javascript图片轮播特效
Feb 16 #Javascript
Node.js本地文件操作之文件拷贝与目录遍历的方法
Feb 16 #Javascript
详解Node.js包的工程目录与NPM包管理器的使用
Feb 16 #Javascript
javascript每日必学之运算符
Feb 16 #Javascript
解析Node.js基于模块和包的代码部署方式
Feb 16 #Javascript
javascript每日必学之基础入门
Feb 16 #Javascript
You might like
php中的观察者模式
2010/03/24 PHP
一个简单的php加密解密函数(动态加密)
2013/06/19 PHP
getimagesize获取图片尺寸实例
2014/11/15 PHP
浅析Laravel5中队列的配置及使用
2016/08/04 PHP
PHP 7.1新特性的汇总介绍
2016/12/16 PHP
thinkPHP框架实现图像裁剪、缩放、加水印的方法
2017/03/14 PHP
PHP新特性之字节码缓存和内置服务器
2017/08/11 PHP
PHP生成随机数的方法总结
2018/03/01 PHP
Yii框架实现对数据库的CURD操作示例
2019/09/03 PHP
JavaScript入门教程(7) History历史对象
2009/01/31 Javascript
关于编写性能高效的javascript事件的技术
2014/11/28 Javascript
jQuery中outerHeight()方法用法实例
2015/01/19 Javascript
javascript白色简洁计算器
2015/05/04 Javascript
返回函数的JavaScript函数
2016/06/14 Javascript
移动端使用localStorage缓存Js和css文的方法(web开发)
2016/09/20 Javascript
angular ng-model 无法获取值的处理方法
2018/10/02 Javascript
Vant的安装和配合引入Vue.js项目里的方法步骤
2018/12/05 Javascript
Vue 实现输入框新增搜索历史记录功能
2019/10/15 Javascript
nest.js 使用express需要提供多个静态目录的操作方法
2019/10/24 Javascript
layui table表格数据的新增,修改,删除,查询,双击获取行数据方式
2019/11/14 Javascript
Python处理RSS、ATOM模块FEEDPARSER介绍
2015/02/18 Python
django传值给模板, 再用JS接收并进行操作的实例
2018/05/28 Python
不知道这5种下划线的含义,你就不算真的会Python!
2018/10/09 Python
pytorch 准备、训练和测试自己的图片数据的方法
2020/01/10 Python
Django url 路由匹配过程详解
2021/01/22 Python
生物有机护肤品:Aurelia Probiotic Skincare
2018/01/31 全球购物
Ruby如何创建一个线程
2013/03/10 面试题
巧克力蛋糕店创业计划书
2014/01/14 职场文书
商场总经理岗位职责
2014/02/03 职场文书
环保倡议书范文
2014/05/12 职场文书
入党积极分子学习党的纲领思想汇报
2014/09/13 职场文书
离婚协议书怎么写的
2014/12/14 职场文书
《蜜蜂引路》教学反思
2016/02/22 职场文书
php远程请求CURL案例(爬虫、保存登录状态)
2021/04/01 PHP
Go Gin实现文件上传下载的示例代码
2021/04/02 Golang
MySQL 8.0 之不可见列的基本操作
2021/05/20 MySQL