异步流程控制
INFO
本文内容大量参考自 Mixu 的 Node.js 书籍。
JavaScript 的核心设计是在“主”线程上非阻塞的,这就是视图渲染的地方。可以想象这在浏览器中有多重要。当主线程被阻塞时,会导致臭名昭著的最终用户害怕的“冻结”,并且不会分发其他事件,从而导致例如数据采集的丢失。
这产生了一些独特的约束,只有函数式编程才能解决。这就是回调函数发挥作用的地方。
然而,在更复杂的程序中,回调函数可能难以处理。这通常会导致“回调地狱”,其中多个嵌套的带有回调函数的函数使代码更难以阅读、调试、组织等。
async1(function (input, result1) {
async2(function (result2) {
async3(function (result3) {
async4(function (result4) {
async5(function (output) {
// 使用 output
});
});
});
});
});
当然,在现实生活中,很可能会有额外的代码行来处理 result1
、result2
等,因此,这个问题的长度和复杂性通常会导致代码看起来比上面的例子更混乱。
这就是函数大显身手的地方。更复杂的操作是由许多函数组成的:
- 初始化器风格/输入
- 中间件
- 终止器
“初始化器风格/输入”是序列中的第一个函数。此函数将接受操作的原始输入(如果有)。操作是一系列可执行的函数,原始输入主要为:
- 全局环境中的变量
- 带有或不带参数的直接调用
- 通过文件系统或网络请求获得的值
网络请求可以是外部网络发起的传入请求,也可以是同一网络上另一个应用程序发起的请求,或者是由应用程序本身在同一网络或外部网络上发起的请求。
中间件函数将返回另一个函数,终止器函数将调用回调函数。以下是网络或文件系统请求的流程图示。这里延迟为 0,因为所有这些值都存在于内存中。
function final(someInput, callback) {
callback(`${someInput} and terminated by executing callback `);
}
function middleware(someInput, callback) {
return final(`${someInput} touched by middleware `, callback);
}
function initiate() {
const someInput = 'hello this is a function ';
middleware(someInput, function (result) {
console.log(result);
// 需要回调函数 `return` 结果
});
}
initiate();
状态管理
函数可能依赖于状态,也可能不依赖于状态。当函数的输入或其他变量依赖于外部函数时,就会产生状态依赖。
因此,有两种主要的状态管理策略:
- 将变量直接传递给函数,以及
- 从缓存、会话、文件、数据库、网络或其他外部来源获取变量值。
请注意,我没有提到全局变量。使用全局变量管理状态通常是一种粗略的反模式,它使得保证状态变得困难甚至不可能。在复杂的程序中,应尽可能避免使用全局变量。
控制流
如果对象存在于内存中,则可以进行迭代,并且控制流不会发生变化:
function getSong() {
let _song = '';
let i = 100;
for (i; i > 0; i -= 1) {
_song += `${i} beers on the wall, you take one down and pass it around, ${
i - 1
} bottles of beer on the wall\n`;
if (i === 1) {
_song += "Hey let's get some more beer";
}
}
return _song;
}
function singSong(_song) {
if (!_song) throw new Error("song is '' empty, FEED ME A SONG!");
console.log(_song);
}
const song = getSong();
// 这将有效
singSong(song);
但是,如果数据存在于内存之外,则迭代将不再有效:
function getSong() {
let _song = '';
let i = 100;
for (i; i > 0; i -= 1) {
/* eslint-disable no-loop-func */
setTimeout(function () {
_song += `${i} beers on the wall, you take one down and pass it around, ${
i - 1
} bottles of beer on the wall\n`;
if (i === 1) {
_song += "Hey let's get some more beer";
}
}, 0);
/* eslint-enable no-loop-func */
}
return _song;
}
function singSong(_song) {
if (!_song) throw new Error("song is '' empty, FEED ME A SONG!");
console.log(_song);
}
const song = getSong('beer');
// 这将无效
singSong(song);
// Uncaught Error: song is '' empty, FEED ME A SONG!
为什么会这样?setTimeout
指示 CPU 将指令存储在总线上的其他位置,并指示稍后提取数据。在函数再次在 0 毫秒标记处执行之前,会经过数千个 CPU 周期,CPU 从总线获取指令并执行它们。唯一的问题是 song ('') 在数千个周期之前就已返回。
处理文件系统和网络请求时也会出现同样的情况。主线程根本无法被阻止不确定的时间段——因此,我们使用回调来以受控的方式及时安排代码的执行。
您可以使用以下 3 种模式执行几乎所有操作:
- **串行:**函数将按严格的顺序执行,这与
for
循环最相似。
// 在其他地方定义并准备执行的操作
const operations = [
{ func: function1, args: args1 },
{ func: function2, args: args2 },
{ func: function3, args: args3 },
];
function executeFunctionWithArgs(operation, callback) {
// 执行函数
const { args, func } = operation;
func(args, callback);
}
function serialProcedure(operation) {
if (!operation) process.exit(0); // 完成
executeFunctionWithArgs(operation, function (result) {
// 回调后继续
serialProcedure(operations.shift());
});
}
serialProcedure(operations.shift());
完全并行
:当顺序无关紧要时,例如向 1,000,000 个电子邮件收件人发送电子邮件。
let count = 0;
let success = 0;
const failed = [];
const recipients = [
{ name: 'Bart', email: 'bart@tld' },
{ name: 'Marge', email: 'marge@tld' },
{ name: 'Homer', email: 'homer@tld' },
{ name: 'Lisa', email: 'lisa@tld' },
{ name: 'Maggie', email: 'maggie@tld' },
];
function dispatch(recipient, callback) {
// `sendMail` 是一个假设的 SMTP 客户端
sendMail(
{
subject: 'Dinner tonight',
message: 'We have lots of cabbage on the plate. You coming?',
smtp: recipient.email,
},
callback
);
}
function final(result) {
console.log(`Result: ${result.count} attempts \
& ${result.success} succeeded emails`);
if (result.failed.length)
console.log(`Failed to send to: \
\n${result.failed.join('\n')}\n`);
}
recipients.forEach(function (recipient) {
dispatch(recipient, function (err) {
if (!err) {
success += 1;
} else {
failed.push(recipient.name);
}
count += 1;
if (count === recipients.length) {
final({
count,
success,
failed,
});
}
});
});
- **有限并行:**受限的并行,例如从 1000 万用户列表中成功向 1,000,000 个收件人发送电子邮件。
let successCount = 0;
function final() {
console.log(`dispatched ${successCount} emails`);
console.log('finished');
}
function dispatch(recipient, callback) {
// `sendEmail` 是一个假设的 SMTP 客户端
sendMail(
{
subject: 'Dinner tonight',
message: 'We have lots of cabbage on the plate. You coming?',
smtp: recipient.email,
},
callback
);
}
function sendOneMillionEmailsOnly() {
getListOfTenMillionGreatEmails(function (err, bigList) {
if (err) throw err;
function serial(recipient) {
if (!recipient || successCount >= 1000000) return final();
dispatch(recipient, function (_err) {
if (!_err) successCount += 1;
serial(bigList.pop());
});
}
serial(bigList.pop());
});
}
sendOneMillionEmailsOnly();
每种方法都有其自身的用例、优点和问题,您可以进行实验并阅读更多详细信息。最重要的是,记住模块化您的操作并使用回调!如果您有任何疑问,请将所有内容都视为中间件!