本文深入解析 Node.js 异步编程的核心机制,阐明单线程模型下回调的执行时机。重点介绍函数返回值、数组串行与并行遍历、以及基于错误优先回调的错误处理等常见代码设计模式,帮助开发者避免回调地狱并编写更健壮的异步代码。
Node.js 异步编程基础
Node.js 的核心优势在于其事件驱动模型与异步 I/O 能力,但这并非对开发者完全透明。开发者必须采用异步思维编写代码才能发挥这一优势,这也曾引发部分争议。尽管如此,掌握异步编程仍是精通 Node.js 的必经之路。本章将系统介绍异步编程的关键知识。
在代码层面,异步编程最直观的表现形式即为回调。异步逻辑依赖于回调实现,但使用回调并不等同于程序自动变为异步。我们先通过以下代码示例进行观察。
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
从上述代码可见,回调函数依然优先于后续代码执行。由于 JavaScript 是单线程运行环境,在当前代码段执行完毕前,无法并行执行其他代码,因此这里并不存在真正的异步执行概念。
然而,若某个函数负责创建独立线程或进程,并与 JavaScript 主线程并行执行任务,待任务完成后通知主线程,情况则截然不同。我们继续分析以下代码。
setTimeout(function () {
console.log('world');
}, 1000);
console.log('hello');
hello
world
此时可以看到,回调函数在后续代码执行之后才被调用。正如前述,JavaScript 本身是单线程的,无法直接异步执行。我们可以将 setTimeout 这类非 JavaScript 规范内置、由运行环境提供的特殊函数理解为:它们创建了一个并行线程后立即返回,使 JavaScript 主进程得以继续执行后续代码,并在收到并行进程的通知后再执行回调。除了 setTimeout 和 setInterval 等常见 API,Node.js 提供的如 fs.readFile 等异步 API 也遵循此机制。
此外,回到 JavaScript 单线程运行的事实:这决定了在一段代码执行完毕前,JavaScript 无法执行包括回调在内的其他代码。即便并行线程已完成工作并通知主线程执行回调,该回调也必须等待 JavaScript 主线程空闲时才能启动。以下示例展示了这一现象。
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 秒后触发的回调函数,因 JavaScript 主线程忙于执行其他代码,其实际执行时间被显著推迟。
异步代码设计模式
代码设计模式 异步编程衍生出多种特有的代码设计模式。为实现相同功能,同步与异步代码的编写方式存在巨大差异。以下将介绍几种常见模式。
函数返回值处理
函数返回值 将一个函数的输出作为另一个函数的输入是常见需求。在同步模式下,代码通常如下编写:
var output = fn1(fn2('input'));
// Do something.
而在异步模式下,由于函数结果并非通过返回值传递,而是通过回调函数传递,代码通常如下编写:
fn2('input', function (output2) {
fn1(output2, function (output1) {
// Do something.
});
});
可以看出,这种方式形成了回调套回调的结构,嵌套过深极易形成所谓的“回调地狱”(Pyramid of Doom)。
数组遍历策略
遍历数组 在遍历数组时,使用特定函数依次处理数据成员也是常见需求。若函数为同步执行,代码通常如下:
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.
}));
可以看出,与串行遍历版本相比,上述代码并行处理所有数组成员,并通过计数器变量判断何时所有成员处理完毕。
异常处理机制
异常处理 JavaScript 自身提供的 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
可以看出,异常再次被成功捕获。在 Node.js 中,几乎所有异步 API 均按此方式设计,回调函数的第一个参数均为 err。因此,在编写自定义异步函数时,也可遵循此方式处理异常,以保持与 Node.js 设计风格的一致性。
有了异常处理方式后,我们进一步思考常规代码编写逻辑。基本上,代码执行流程为:执行一些操作,调用一个函数,再执行一些操作,再调用一个函数,如此循环。若编写同步代码,只需在入口点添加一个 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.
}
});
可以看出,回调函数已使代码复杂度增加,而异步模式下的异常处理进一步加剧了这种复杂性。