j.s异步编程1

(原文出处https://eloquentjavascript.net/11_async.html)本文是关于 JavaScript 中的异步编程,涵盖了异步性、回调函数、Promise 以及错误处理等关键概念:

1. 异步性

  • 同步与异步:在同步编程模型中,操作按顺序依次发生。执行耗时操作的函数会使程序暂停,直至操作完成并返回结果。相比之下,异步模型允许多个操作同时进行。当一个操作启动时,程序会继续运行,并在操作完成时收到通知以获取结果。例如,在网络请求中,同步请求会使程序等待,而异步请求则允许程序继续执行其他任务。
  • 同步系统中的线程:在同步系统中,为了在不等待一个任务完成就启动另一个任务的情况下执行多个任务,可以使用额外的控制线程。线程可以由操作系统进行交错调度,并且在多处理器计算机上,它们可以在不同处理器上同时运行。然而,使用线程进行编程较为复杂。

2. 回调函数

  • 概念:实现异步编程的一种方式是通过回调函数。异步函数会接受一个额外的参数,即回调函数。它启动一个进程,安排在进程完成时调用回调函数,然后返回。例如,Node.js 和浏览器中的 setTimeout 函数会等待指定的毫秒数,然后调用给定的函数。
  • 回调函数链式调用:当依次执行多个异步操作时,需要对回调函数进行链式调用。但这可能会导致“回调地狱”,随着每个异步操作嵌套在另一个函数中,缩进级别会不断增加,使得像对异步操作进行循环这样的复杂操作变得繁琐。

3. Promise

  • 基本概念:Promise 为回调函数提供了另一种选择。异步函数可以返回一个 Promise 对象,该对象表示其未来的结果。Promise 就像是一个尚未可用值的收据。它有一个 then 方法,用于注册一个函数,当它所等待的操作完成时,该函数将被调用。
  • 创建 Promise:
    • Promise.resolve 可用于将一个值包装在 Promise 中。如果输入已经是一个 Promise ,则直接返回它;否则,会创建一个新的 Promise ,并立即使用给定的值进行解析。
    • Promise 构造函数可用于创建一个不会立即解析的 Promise 。它接受一个函数作为参数,会立即调用该函数,并传入一个 resolve 函数,当异步操作完成时,可使用该函数来解析 Promise 。
  • Promise 链式调用:then 方法会返回另一个 Promise 。这允许将多个 then 调用链接起来,以设置一系列异步操作。每个 then 调用可以执行一些数据转换(同步或异步 ),并将结果传递到链中的下一步。
  • Promise 的错误处理:Promise 可以处于已解析(成功 )或已拒绝(失败 )状态。拒绝状态会在 then 调用链中传播。catch 方法用于处理拒绝状态,类似于 then 处理解析状态的方式。它会返回一个新的 Promise ,可用于在异步管道中优雅地处理错误。此外,then 可以接受一个拒绝处理函数作为第二个参数,以便更简洁地进行错误处理。

4. 错误处理

  • 回调风格的问题:在基于回调的异步编程中,很难确保将错误正确报告给回调函数。一种常见的约定是使用回调函数的第一个参数表示失败,第二个参数表示成功结果,但这需要在回调函数中仔细处理错误和异常。
  • 基于 Promise 的解决方案:Promise 简化了错误处理。当异步操作失败时,Promise 会被拒绝,拒绝原因(通常是一个错误对象 )会沿着链传递。可以通过 catch 或作为 then 的第二个参数来注册处理函数,以处理这些拒绝情况,与基于回调的方法相比,异步值和错误的流动更容易管理。

异步编程

谁能在泥污沉淀时静静等待?
谁能在行动时刻来临前保持沉稳?

——老子,《道德经》

(插图展示了树枝上的两只乌鸦 )

计算机的核心部分,也就是执行构成我们程序的各个步骤的部件,被称为处理器。到目前为止我们所见到的程序,会让处理器一直处于忙碌状态,直至它们完成工作。像处理数字的循环这类操作的执行速度,几乎完全取决于计算机处理器和内存的速度。

但许多程序会与处理器之外的事物进行交互。例如,它们可能通过计算机网络进行通信,或者从硬盘请求数据 —— 而从硬盘获取数据要比从内存获取数据慢得多。

当这种情况发生时,如果让处理器闲置着,那就太可惜了 —— 在此期间它或许还能做些其他工作。在一定程度上,这由操作系统来处理,操作系统会在多个正在运行的程序之间切换处理器。但当我们希望单个程序在等待网络请求时仍能取得进展时,这就无济于事了。

异步性

在同步编程模型中,事情是依次发生的。当你调用一个执行耗时操作的函数时,它只有在操作完成且能返回结果时才会返回。在操作进行的这段时间里,你的程序会停止运行。

异步模型则允许多个事情同时发生。当你启动一个操作时,你的程序会继续运行。当操作完成时,程序会收到通知并获取结果(例如,从磁盘读取的数据 )。

我们可以用一个小例子来比较同步编程和异步编程:一个通过网络发出两个请求,然后合并结果的程序。

在同步环境中,请求函数只有在完成工作后才会返回,执行此任务最简便的方法是依次发出请求。这样做的缺点是,第二个请求要在第一个请求完成后才会启动。总耗时至少是两次响应时间之和。

在同步系统中,解决这个问题的方法是启动额外的控制线程。线程是另一个正在运行的程序,其执行过程可能会与其他程序被操作系统交错执行 —— 由于大多数现代计算机都包含多个处理器,多个线程甚至可以在不同处理器上同时运行。第二个线程可以启动第二个请求,然后两个线程都等待它们的结果返回,之后再重新同步以合并结果。

(展示同步和异步程序中控制流的图表。第一部分展示了一个同步程序,其中程序的活动阶段和等待阶段都在一条连续的单线上发生。第二部分展示了一个多线程同步程序,有两条平行线,等待部分在两条线上同时发生,从而使程序更快完成。最后一部分展示了一个异步程序,其中多个异步操作从主程序分支出去,主程序在某个时刻停止,然后在它等待的第一个操作完成时继续运行 )

描述这种差异的另一种方式是,在同步模型中,等待操作完成是隐含的,而在异步模型中,等待是显式的 —— 由我们来控制。

异步性有利有弊。它使表达那些不适合直线式控制模型的程序变得更容易,但也会让表达遵循直线式流程的程序变得更加棘手。在本章后面的内容中,我们会介绍一些减少这种棘手情况的方法。

两个重要的 JavaScript 编程平台 —— 浏览器和 Node.js —— 都将可能耗时的操作设为异步,而不是依赖线程。由于使用线程进行编程出了名地困难(当一个程序同时做很多事情时,理解它的行为要困难得多 ),所以这通常被认为是一件好事。

回调函数

异步编程的一种方法是,让需要等待某些操作完成的函数接受一个额外的参数,即回调函数。异步函数启动一个进程,进行相关设置,以便在进程完成时调用回调函数,然后返回。

例如,在 Node.js 和浏览器中都可用的 setTimeout 函数,会等待指定的毫秒数,然后调用一个函数。

setTimeout(() => console.log("Tick"), 500);

等待通常并非重要的工作,但当你需要安排在特定时间发生某事,或者检查某个操作是否比预期耗时更长时,它会非常有用。

另一个常见的异步操作示例是从设备存储中读取文件。假设你有一个 readTextFile 函数,它将文件内容读取为字符串,并将其传递给一个回调函数。

readTextFile("shopping_list.txt", content => {
  console.log(`Shopping List:\n${content}`);
});
// → Shopping List:
// → Peanut butter
// → Bananas

代码功能说明

这段代码展示了一个典型的回调风格异步文件读取操作,通过调用 readTextFile 函数异步读取 shopping_list.txt 文件的内容,并在读取完成后将内容打印到控制台。

关键逻辑解析

  1. 异步函数调用
    readTextFile 是一个异步函数,接收两个参数:

    • filename:要读取的文件名(此处为 "shopping_list.txt")。
    • callback:读取完成后的回调函数,接收文件内容 content 作为参数。
  2. 回调函数处理结果
    当文件读取完成后,callback 被触发,打印格式化后的购物清单:

    Shopping List:
    Peanut butter
    Bananas
    

潜在问题与改进建议

1. 错误处理缺失

原代码未处理可能的错误情况(如文件不存在、权限不足等)。若读取失败,回调函数可能不会被调用,或传入错误信息但未被处理。

改进方案:在回调中检查错误参数(假设 readTextFile 遵循 Node.js 回调风格,即 callback(error, data)):

readTextFile("shopping_list.txt", (content, error) => {
  if (error) {
    console.error("读取文件失败:", error);
    return;
  }
  console.log(`Shopping List:\n${content}`);
});

2. 兼容性问题

readTextFile 并非 JavaScript 内置函数,需确保其存在且参数顺序正确(部分库可能将错误参数作为第一个参数)。

替代方案:若在 Node.js 环境中,推荐使用内置的 fs 模块:

const fs = require('fs');

fs.readFile("shopping_list.txt", 'utf8', (error, content) => {
  if (error) {
    console.error("读取文件失败:", error);
    return;
  }
  console.log(`Shopping List:\n${content}`);
});

3. 现代 Promise 风格

若需更简洁的错误处理和链式调用,可将回调函数转换为 Promise:

// 使用 util.promisify (Node.js)
const { readFile } = require('fs').promises;

readFile("shopping_list.txt", 'utf8')
  .then(content => console.log(`Shopping List:\n${content}`))
  .catch(error => console.error("读取文件失败:", error));

// 或手动封装为 Promise
function readTextFileAsync(filename) {
  return new Promise((resolve, reject) => {
    readTextFile(filename, (content, error) => {
      if (error) reject(error);
      else resolve(content);
    });
  });
}

执行流程示例

假设 shopping_list.txt 内容为:

Peanut butter
Bananas
  1. 调用 readTextFile:发起异步读取请求。
  2. 主线程继续执行:不会等待文件读取完成。
  3. 文件读取完成后:回调函数被触发,打印结果:
    Shopping List:
    Peanut butter
    Bananas
    

最佳实践建议

  1. 始终处理错误
    在异步回调中检查错误参数,避免程序静默失败。

  2. 优先使用 Promise/async-await
    现代 JavaScript 推荐使用 Promise 或 async-await 替代传统回调,以简化错误处理和异步流程控制。

  3. 注意编码格式
    读取文本文件时,明确指定编码(如 'utf8'),避免中文等非 ASCII 字符乱码。

这种回调风格是 JavaScript 早期处理异步操作的基础,但在复杂场景下容易导致“回调地狱”。建议结合项目需求,逐步迁移至更现代的异步编程范式。

readTextFile 函数并非标准 JavaScript 的一部分。我们将在后续章节中介绍如何在浏览器和 Node.js 中读取文件。

使用回调函数依次执行多个异步操作,意味着你必须不断传递新函数,以处理操作完成后的后续计算。一个比较两个文件并返回一个布尔值(指示它们的内容是否相同 )的异步函数可能如下所示:

function compareFiles(fileA, fileB, callback) {
  readTextFile(fileA, contentA => {
    readTextFile(fileB, contentB => {
      callback(contentA == contentB);
    });
  });
}

代码功能说明

这段代码定义了一个异步函数 compareFiles,用于比较两个文件的内容是否相同。它采用回调风格的异步编程模式,通过嵌套调用 readTextFile 依次读取两个文件的内容,并在回调中比较它们是否相等,最终通过回调函数返回比较结果。

关键逻辑解析

  1. 读取第一个文件
    readTextFile(fileA, contentA => {...}) 异步读取第一个文件的内容,在回调中获取 contentA。

  2. 读取第二个文件
    在第一个文件读取成功的回调中,嵌套调用 readTextFile(fileB, contentB => {...}) 异步读取第二个文件的内容。

  3. 比较内容并返回结果
    在第二个文件读取成功的回调中,比较 contentA 和 contentB 是否相等,结果通过 callback 传递给调用者。

存在的问题

  1. 错误处理缺失

    • 若第一个文件读取失败(如文件不存在),不会触发任何错误回调,直接忽略错误。
    • 若第二个文件读取失败,同样不会通知调用者,导致调用者无法处理异常情况。
  2. 嵌套回调导致的问题

    • 代码结构为两层嵌套回调,若后续需增加更多异步操作(如读取第三个文件),会导致回调地狱,代码可读性和可维护性变差。
    • 错误处理分散在各个回调中,难以统一管理。
  3. 顺序依赖
    必须等待第一个文件读取完成后才开始读取第二个文件,即使两个文件的读取操作可以并行进行,导致不必要的性能开销。

改进方案

1. 添加错误处理

function compareFiles(fileA, fileB, callback) {
  readTextFile(fileA, (contentA, errorA) => {
    if (errorA) {
      callback(null, errorA); // 处理第一个文件读取失败的情况
      return;
    }
    readTextFile(fileB, (contentB, errorB) => {
      if (errorB) {
        callback(null, errorB); // 处理第二个文件读取失败的情况
        return;
      }
      callback(contentA === contentB); // 比较内容并返回结果
    });
  });
}

2. 使用 Promise 改进

function compareFiles(fileA, fileB) {
  return Promise.all([
    textFile(fileA), // 假设 textFile 是基于 Promise 的文件读取函数
    textFile(fileB)
  ]).then(([contentA, contentB]) => {
    return contentA === contentB;
  });
}

// 使用示例
compareFiles("file1.txt", "file2.txt")
  .then(areEqual => console.log("文件内容是否相同:", areEqual))
  .catch(error => console.error("比较失败:", error));

3. 使用 async/await(更现代的写法)

async function compareFiles(fileA, fileB) {
  const [contentA, contentB] = await Promise.all([
    textFile(fileA),
    textFile(fileB)
  ]);
  return contentA === contentB;
}

改进后的优势

  1. 错误处理统一
    使用 Promise 或 async/await 时,任何一个文件读取失败都会导致整个 Promise 链被拒绝,可通过 .catch 统一捕获错误。

  2. 并行执行
    Promise.all 允许两个文件读取操作并行进行,提高效率(相比原代码的串行执行)。

  3. 代码可读性
    避免了嵌套回调,代码结构更清晰,逻辑流程更直观。

最佳实践建议

  1. 优先使用 Promise/async/await
    在现代 JavaScript 中,回调风格的异步编程已逐渐被 Promise 和 async/await 取代,推荐使用更简洁、更易维护的方式。

  2. 尽早处理错误
    在每个异步操作的回调中检查错误(如示例中的 if (error) 检查),确保错误能被及时传递给调用者。

  3. 避免不必要的顺序依赖
    若多个异步操作之间没有依赖关系(如本例中的两个文件读取),应使用并行方式(如 Promise.all)提高性能。
    这种编程风格是可行的,但随着每个异步操作的进行,缩进级别会不断增加,因为你会不断进入新的函数中。进行更复杂的操作,比如在循环中封装异步操作,可能会变得很麻烦。

在某种程度上,异步性具有传染性。任何调用异步函数的函数本身也必须是异步的,需要使用回调函数或类似机制来返回结果。调用回调函数比简单地返回一个值要复杂一些,也更容易出错,所以如果需要以这种方式构建程序的大部分内容,并不是一件好事。

承诺(Promise)

构建异步程序的另一种略有不同的方法是,让异步函数返回一个表示其(未来 )结果的对象,而不是传递回调函数。这样一来,这些函数实际上返回了有意义的东西,而且程序的结构更接近同步程序。

这就是标准的 Promise 类的用途。Promise 是一张收据,代表一个可能尚未可用的值。它提供了一个 then 方法,允许你注册一个函数,当它所等待的操作完成时,该函数将被调用。当 Promise 被解决(即其值可用 )时,这样的函数(可以有多个 )会被传入结果值进行调用。在已经解决的 Promise 上调用 then 是可行的 —— 你的函数仍然会被调用。

创建 Promise 最简单的方法是调用 Promise.resolve 。这个函数确保你传入的值被包装在一个 Promise 中。如果传入的值已经是一个 Promise ,则直接返回它。否则,你会得到一个新的 Promise ,它会立即以你传入的值作为结果进行解决。

let fifteen = Promise.resolve(15);
fifteen.then(value => console.log(`Got ${value}`));
// → Got 15

要创建一个不会立即解决的 Promise ,你可以将 Promise 用作构造函数。它的接口有点奇特:构造函数期望接收一个函数作为参数,它会立即调用这个函数,并传入一个可用于解决 Promise 的函数。

例如,这是如何为 readTextFile 函数创建一个基于 Promise 的接口:

function textFile(filename) {
  return new Promise(resolve => {
    readTextFile(filename, text => resolve(text));
  });
}

textFile("plans.txt").then(console.log);

代码功能说明

这段代码定义了一个基于 Promise 的文件读取函数 textFile,用于将传统的 回调风格异步函数 readTextFile 转换为 Promise 风格。但存在一个关键问题:未处理读取失败的情况。

问题分析

  1. 错误处理缺失
    原代码仅在 readTextFile 的回调中调用 resolve(text),但未检查可能的错误参数。若文件读取失败(如文件不存在),回调函数可能会传入错误对象,但 Promise 不会进入拒绝状态,而是永远挂起(既不 resolve 也不 reject)。

  2. 正确的错误处理方式
    应在回调中检查错误参数,并通过 reject 传递错误:

    function textFile(filename) {
      return new Promise((resolve, reject) => {
        readTextFile(filename, (text, error) => {
          if (error) reject(error); // 处理失败情况
          else resolve(text);       // 处理成功情况
        });
      });
    }
    

执行流程对比

原代码(错误处理缺失)

// 假设 readTextFile 因文件不存在返回错误
textFile("plans.txt").then(
  content => console.log("成功:", content), // 不会执行
  error => console.error("失败:", error)    // 不会执行,Promise 永远不拒绝
);

修正后代码(正确处理错误)

textFile("plans.txt").then(
  content => console.log("成功:", content), // 读取成功时执行
  error => console.error("失败:", error)    // 读取失败时执行(如文件不存在)
);

其他潜在问题

  1. 参数顺序不确定
    若 readTextFile 的回调参数顺序为 (error, text)(如 Node.js 标准回调风格),原代码会错误地将错误对象作为内容传递给 resolve。需确认 readTextFile 的实际参数顺序。

  2. 类型不匹配
    若 readTextFile 返回二进制数据(如 Buffer)而非字符串,直接传递给 resolve 可能导致后续处理异常。

最佳实践建议

  1. 始终处理错误
    在 Promise 封装中检查错误参数,并通过 reject 传播错误。

  2. 添加类型注释(TypeScript)
    若使用 TypeScript,明确标注参数类型,避免参数顺序混淆:

    function textFile(filename: string): Promise<string> {
      return new Promise((resolve, reject) => {
        readTextFile(filename, (text: string, error: Error) => {
          error ? reject(error) : resolve(text);
        });
      });
    }
    
  3. 使用 util.promisify(Node.js)
    若 readTextFile 是 Node.js 风格的回调函数(如 fs.readFile),可直接使用 util.promisify 转换:

    const { readFile } = require('fs').promises; // 内置 Promise 版本
    
    // 等价于手动封装的 textFile 函数
    readFile("plans.txt", "utf8").then(console.log);
    

总结

原代码的核心问题是 未处理异步操作的失败情况,导致 Promise 可能陷入永久等待状态。修正方法是在回调中检查错误参数,并通过 reject 明确拒绝 Promise。这是将回调风格转换为 Promise 时的常见陷阱,需特别注意。
请注意,与回调风格的函数不同,这个异步函数返回了一个有意义的值 —— 一个在未来某个时刻为你提供文件内容的 Promise 。

then 方法的一个有用特性是,它本身会返回另一个 Promise 。这个 Promise 会解析为回调函数返回的值,或者,如果回调函数返回的值是一个 Promise ,则解析为该 Promise 所解析的值。因此,你可以将多个对 then 的调用 “链” 在一起,以设置一系列异步操作。

下面这个函数读取一个包含文件名的文件,并返回该列表中随机一个文件的内容,展示了这种异步 Promise 管道:

function randomFile(listFile) {
  return textFile(listFile)
   .then(content => content.trim().split("\n"))
   .then(ls => ls[Math.floor(Math.random() * ls.length)])
   .then(filename => textFile(filename));
}

该函数返回这个 then 调用链的结果。初始的 Promise 将文件列表作为字符串获取。第一个 then 调用将该字符串转换为行数组,生成一个新的 Promise 。第二个 then 调用从该数组中随机选择一行,生成第三个 Promise ,得到单个文件名。最后的 then 调用读取这个文件,所以整个函数的结果是一个 Promise ,它返回随机一个文件的内容。

在这段代码中,前两个 then 调用中使用的函数返回一个常规值,当函数返回时,该值会立即被传入 then 返回的 Promise 中。最后一个 then 调用返回一个 Promise (textFile(filename) ),使其成为一个真正的异步步骤。

也可以将所有这些步骤都放在单个 then 回调中完成,因为实际上只有最后一步是异步的。但是,像这样只进行一些同步数据转换的 then 包装器通常很有用,比如当你想要返回一个 Promise ,该 Promise 产生某个异步结果的已处理版本时。

代码功能说明

该函数 randomFile 用于从指定文件中读取文件名列表,随机选择一个文件名并读取其内容,返回一个 Promise 对象,最终解析为随机文件的内容。核心逻辑通过 Promise 链式调用 实现异步操作的流程控制。

关键逻辑解析

  1. 读取文件名列表文件
    textFile(listFile) 调用已有的文件读取函数(返回 Promise),读取包含文件名列表的文件内容(假设文件内容为每行一个文件名,如 file1.txt\nfile2.txt)。

  2. 处理文件内容为数组
    .then(content => content.trim().split("\n"))

    • content.trim():去除字符串首尾的空白符(如换行符)。
    • split("\n"):按换行符分割字符串,生成文件名数组 ls(如 ["file1.txt", "file2.txt"])。
  3. 随机选择文件名
    .then(ls => ls[Math.floor(Math.random() * ls.length)])

    • Math.random():生成 [0, 1) 区间的随机数。
    • Math.floor(...):将随机数取整为数组索引(如数组长度为 3 时,索引为 0、1、2)。
    • 最终从数组中随机选取一个文件名(如 "file2.txt")。
  4. 读取随机选择的文件内容
    .then(filename => textFile(filename))
    使用随机得到的文件名再次调用 textFile,返回该文件内容的 Promise。

执行流程示例

假设 listFile 为 files.txt,内容为:

image.jpg
data.json
notes.md
  1. 第一步:textFile("files.txt") 读取文件内容为字符串 image.jpg\ndata.json\nnotes.md。
  2. 第二步:处理为数组 ["image.jpg", "data.json", "notes.md"]。
  3. 第三步:随机选择索引(假设为 1),得到文件名 "data.json"。
  4. 第四步:textFile("data.json") 读取该文件内容,最终 Promise 解析为文件内容字符串。

潜在问题与异常处理

可能的错误场景

  1. 列表文件读取失败:如文件不存在,textFile(listFile) 会拒绝 Promise,错误类型为 Error。
  2. 列表文件内容为空:split("\n") 生成空数组,访问 ls.length 会导致 Math.random() * 0 为 0,ls[0] 为 undefined,后续 textFile(undefined) 会触发文件名无效错误。
  3. 随机文件读取失败:如随机选中的文件不存在,最后一步 textFile(filename) 会拒绝 Promise。

改进后的错误处理

function randomFile(listFile) {
  return textFile(listFile)
    .then(content => content.trim().split("\n"))
    .then(ls => {
      if (ls.length === 0) {
        throw new Error("文件名列表为空"); // 主动抛出有意义的错误
      }
      return ls[Math.floor(Math.random() * ls.length)];
    })
    .then(filename => textFile(filename))
    .catch(error => { // 统一处理链条中的所有错误
      console.error("随机文件读取失败:", error.message);
      return null; // 可返回默认值或重新抛出错误
    });
}

与同步逻辑的对比

同步伪代码(仅示意逻辑,非实际可执行代码)

function syncRandomFile(listFile) {
  const content = fs.readFileSync(listFile, "utf8"); // 同步读取列表文件
  const ls = content.trim().split("\n");
  const filename = ls[Math.floor(Math.random() * ls.length)];
  return fs.readFileSync(filename, "utf8"); // 同步读取随机文件
}

Promise 方式优势

  • 非阻塞 I/O:适用于浏览器或 Node.js 环境的异步文件操作,不会阻塞主线程。
  • 错误处理模块化:通过 catch 统一处理异步链中的错误,避免同步代码中繁琐的 try/catch 嵌套。

最佳实践建议

  1. 校验文件名有效性
    在随机选择文件名后,添加合法性校验(如检查文件名是否包含非法字符):

    .then(ls => {
      const filename = ls[Math.floor(Math.random() * ls.length)];
      if (!/^[a-zA-Z0-9._-]+$/.test(filename)) { // 简单校验文件名格式
        throw new Error(`非法文件名: ${filename}`);
      }
      return filename;
    })
    
  2. 限制随机范围
    若需要排除特定文件(如隐藏文件),可在分割数组后过滤无效项:

    .then(content => content.trim().split("\n").filter(name => !name.startsWith("."))) // 过滤以点开头的隐藏文件
    
  3. 添加日志监控
    在关键步骤添加日志,便于调试:

    .then(content => {
      console.log(`读取列表文件成功,包含 ${ls.length} 个文件`);
      return content.trim().split("\n");
    })
    

该函数通过 Promise 链将多个异步操作线性化,清晰表达了“读取列表 → 处理数据 → 随机选择 → 读取文件”的流程,是异步编程中处理多步依赖操作的典型范式。

function jsonFile(filename) {
  return textFile(filename).then(JSON.parse);
}

jsonFile("package.json").then(console.log);

代码功能说明

这段代码定义了一个异步函数 jsonFile,用于读取 JSON 文件并解析其内容。它基于之前的 textFile 函数(将文件读取转换为 Promise 风格),通过 then 链式调用 JSON.parse 方法,将读取的文本内容自动解析为 JavaScript 对象。

关键逻辑解析

  1. 调用 textFile 读取文件
    textFile(filename) 返回一个 Promise,该 Promise 会在文件读取成功时解析为文件内容(字符串)。

  2. 使用 then 链式处理结果
    .then(JSON.parse) 将文件内容字符串传递给 JSON.parse 方法进行解析。

    • 若解析成功:Promise 会进一步解析为 JavaScript 对象(如 { key: "value" })。
    • 若解析失败(如 JSON 格式错误):Promise 会被拒绝,并抛出 SyntaxError。
  3. 调用示例
    jsonFile("package.json").then(console.log) 读取并解析 package.json 文件,最终将解析后的 JavaScript 对象打印到控制台。

执行流程示例

成功场景(JSON 格式正确)

  1. textFile("package.json") 成功读取文件内容为 {"name":"my-project"}。
  2. JSON.parse('{"name":"my-project"}') 解析为对象 { name: "my-project" }。
  3. console.log 输出:
    { name: "my-project" }
    

失败场景(JSON 格式错误)

  1. 若文件内容为 {"name":"my-project"(缺少右括号)。
  2. JSON.parse 抛出 SyntaxError: Unexpected end of JSON input。
  3. 错误会被 Promise 捕获,可通过 .catch 处理:
    jsonFile("package.json")
      .then(console.log)
      .catch(error => console.error("解析失败:", error));
    

与传统回调方式对比

传统回调方式

readTextFile("package.json", (text, error) => {
  if (error) {
    console.error("读取失败:", error);
  } else {
    try {
      const data = JSON.parse(text);
      console.log(data);
    } catch (parseError) {
      console.error("解析失败:", parseError);
    }
  }
});

Promise 方式优势

  • 代码更简洁:无需显式 try/catch,错误自动通过 Promise 链传播。
  • 错误处理统一:读取错误和解析错误都可通过 .catch 统一捕获。
  • 支持链式调用:可继续通过 then 连接其他异步操作(如数据处理)。

最佳实践建议

  1. 添加错误处理
    建议在调用链末尾添加 .catch 处理可能的错误(如文件不存在、JSON 格式错误):

    jsonFile("package.json")
      .then(data => processData(data))
      .catch(error => {
        console.error("处理 JSON 文件失败:", error);
        // 可选择重试、降级处理等
      });
    
  2. 处理大文件
    若文件过大,可考虑使用流式解析(如 JSONStream 库),避免一次性加载整个文件。

  3. 类型校验
    解析后的数据可能与预期结构不符,建议添加类型校验(如使用 zod 或 io-ts):

    import { z } from 'zod';
    
    const PackageSchema = z.object({
      name: z.string(),
      version: z.string(),
    });
    
    jsonFile("package.json").then(data => {
      const validatedData = PackageSchema.parse(data);
      // 确保数据结构符合预期
    });
    

这种基于 Promise 的链式调用模式使异步操作更易读、可维护,是现代 JavaScript 处理 JSON 文件的推荐方式。
一般来说,将 Promise 视为一种让代码不必关心值何时到达的工具是很有用的。一个普通的值在我们能够引用它之前必须实际存在。一个 Promise 代表的值可能已经存在,也可能在未来某个时刻出现。通过使用 then 调用将它们连接在一起而定义的基于 Promise 的计算,会在其输入可用时异步执行。

错误处理

常规的 JavaScript 计算可能会通过抛出异常来失败。异步计算通常也需要类似的机制。网络请求可能失败,文件可能不存在,或者异步计算中的某些代码可能会抛出异常。

回调风格的异步编程最紧迫的问题之一是,很难确保将错误正确报告给回调函数。

一种常见的约定是使用回调函数的第一个参数来指示操作失败,第二个参数在操作成功时传递操作产生的值。

someAsyncFunction((error, value) => {
  if (error) handleError(error);
  else processValue(value);
});

代码功能说明

这段代码展示了回调风格的异步函数调用,用于处理异步操作的结果。核心逻辑是通过回调函数接收异步操作的结果,并根据是否发生错误分别执行不同的处理逻辑。

关键逻辑解析

  1. 异步函数调用
    someAsyncFunction 是一个异步函数,接收一个回调函数作为参数。该回调函数会在异步操作完成后被调用,参数为 (error, value):

    • error:异步操作失败时传递的错误对象(若成功则为 null 或 undefined)。
    • value:异步操作成功时返回的结果值(若失败则无实际意义)。
  2. 错误处理逻辑
    在回调函数中:

    • 若 error 存在:调用 handleError(error) 处理错误(例如打印错误信息、重试操作等)。
    • 若 error 不存在:调用 processValue(value) 处理成功结果(例如渲染数据、更新状态等)。

常见场景与问题

典型应用场景

  • 文件读取/写入(如 Node.js 的 fs 模块):
    const fs = require('fs');
    fs.readFile('data.txt', 'utf8', (error, data) => {
      if (error) throw error; // 简单错误处理
      console.log('文件内容:', data);
    });
    
  • 网络请求(如传统的 AJAX 库):
    ajax('https://api.example.com/data', (error, response) => {
      if (error) console.error('请求失败:', error);
      else render(response.data);
    });
    

潜在问题

  • 回调地狱(Callback Hell):当多个异步操作需要链式调用时,会导致多层回调嵌套,代码可读性差。
    step1((err, res1) => {
      if (err) handle(err);
      step2(res1, (err, res2) => { // 第二层回调
        if (err) handle(err);
        step3(res2, (err, res3) => { // 第三层回调
          if (err) handle(err);
          // ...更多层级
        });
      });
    });
    
  • 错误处理不统一:每个回调函数都需要单独处理 error,容易遗漏或导致重复代码。

与 Promise 风格的对比

回调风格 Promise 风格
通过回调函数传递结果,逻辑分散在函数内部。 通过 then/catch 链式调用,逻辑线性化。
需手动检查 error 参数,易遗漏错误处理。 拒绝状态自动传播,catch 统一处理错误。
示例:someAsyncFunction((err, val) => { ... }) 示例:somePromise().then(val => { ... }).catch(err => { ... })

最佳实践建议

  1. 尽早返回错误:在回调函数开头优先处理错误,减少嵌套层级。
    someAsyncFunction((error, value) => {
      if (error) {
        handleError(error);
        return; // 提前返回,避免后续逻辑缩进
      }
      processValue(value);
    });
    
  2. 转换为 Promise:若项目支持 Promise,可将回调风格函数封装为 Promise,以便使用 async/await 等现代语法。
    function promiseWrapper() {
      return new Promise((resolve, reject) => {
        someAsyncFunction((error, value) => {
          error ? reject(error) : resolve(value);
        });
      });
    }
    // 使用时:
    promiseWrapper().then(...).catch(...);
    
  3. 避免过度使用匿名回调:将回调函数提取为具名函数,提高代码可维护性。
    function handleResult(error, value) {
      if (error) handleError(error);
      else processValue(value);
    }
    someAsyncFunction(handleResult); // 直接传递具名函数
    

这种回调模式是早期 JavaScript 异步编程的基础,虽然存在一定缺陷,但在兼容旧代码或特定场景(如 Node.js 传统 API)中仍广泛使用。在新项目中,更推荐使用 Promise 或 async/await 来简化异步逻辑。
这样的回调函数必须始终检查是否接收到异常,并确保它们引发的任何问题,包括它们调用的函数抛出的异常,都能被捕获并传递给正确的函数。
Promise 使这个问题变得更容易处理。它们可以是已解决(操作成功完成 )或已拒绝(操作失败 )状态。已解决处理程序(通过 then 注册 )仅在操作成功时被调用,而拒绝状态会传播到 then 返回的新 Promise 。当一个处理程序抛出异常时,这会自动导致其 then 调用产生的 Promise 被拒绝。如果异步操作链中的任何一个元素失败,整个链的结果都会被标记为拒绝,并且在失败点之后的成功处理程序都不会被调用。

与解决 Promise 会提供一个值类似,拒绝 Promise 也会提供一个值,通常称为拒绝原因。当处理程序函数中的异常导致拒绝时,异常值将被用作拒绝原因。同样,当处理程序返回一个被拒绝的 Promise 时,该拒绝会流入下一个 Promise 。有一个 Promise.reject 函数可以创建一个新的、立即被拒绝的 Promise 。

为了显式处理这种拒绝,Promise 有一个 catch 方法,用于注册一个在 Promise 被拒绝时调用的处理程序,类似于 then 处理程序处理正常解决的方式。它也和 then 非常相似,因为它会返回一个新的 Promise ,当原始 Promise 正常解决时,该 Promise 会解析为原始 Promise 的值,否则解析为 catch 处理程序的结果。如果 catch 处理程序抛出错误,新的 Promise 也会被拒绝。

作为一种简写形式,then 也接受一个拒绝处理程序作为第二个参数,所以你可以在单个方法调用中安装这两种处理程序:.then(acceptHandler, rejectHandler) 。

传递给 Promise 构造函数的函数除了接收 resolve 函数外,还会接收第二个参数,它可用于拒绝新的 Promise 。

当我们的 readTextFile 函数遇到问题时,它会将错误作为第二个参数传递给回调函数。我们的 textFile 包装函数实际上应该检查这个参数,以便失败会导致它返回的 Promise 被拒绝。

function textFile(filename) {
  return new Promise((resolve, reject) => {
    readTextFile(filename, (text, error) => {
      if (error) reject(error);
      else resolve(text);
    });
  });
}

通过对 then 和 catch 的调用创建的 Promise 值链,因此形成了一个管道,异步值或错误在其中流动。由于这样的链是通过注册处理程序创建的,所以每个环节都关联有一个成功处理程序或一个拒绝处理程序(或两者都有 )。与结果类型(成功或失败 )不匹配的处理程序将被忽略。匹配的处理程序将被调用,它们的结果决定了接下来的值类型 —— 当它们返回一个非 Promise 值时为成功,当它们抛出异常时为拒绝,当它们返回一个 Promise 时为该 Promise 的结果。

代码功能说明

这段代码定义了一个基于 Promise 的文件读取函数 textFile,用于将传统的 回调风格异步函数 readTextFile 转换为 Promise 风格,以更优雅地处理异步操作的成功结果和失败状态。

关键逻辑解析

  1. 创建 Promise 实例
    通过 new Promise() 构造函数创建一个 Promise 对象,该对象接收一个 执行器函数 (resolve, reject)。

    • resolve:异步操作成功时调用,传递成功结果。
    • reject:异步操作失败时调用,传递错误信息。
  2. 调用原始回调函数 readTextFile
    假设 readTextFile 是一个已有的异步函数,其参数为:

    • filename:要读取的文件名。
    • callback:回调函数,异步操作完成后调用,参数为 (text, error):
      • text:文件内容(读取成功时)。
      • error:错误对象(读取失败时)。
  3. 处理异步结果
    在 readTextFile 的回调函数中:

    • 若读取失败(error 存在):调用 reject(error),将 Promise 状态设置为 拒绝,并传递错误信息。
    • 若读取成功(text 存在):调用 resolve(text),将 Promise 状态设置为 完成,并传递文件内容。

用法示例

读取成功场景

textFile("example.txt")
  .then((text) => {
    console.log("文件内容:", text); // 处理成功结果
  })
  .catch((error) => {
    console.error("读取失败:", error); // 处理失败(此处不会触发)
  });

读取失败场景

textFile("nonexistent.txt")
  .then((text) => {
    console.log("文件内容:", text); // 不会执行
  })
  .catch((error) => {
    console.error("读取失败:", error); // 触发,打印错误信息
  });

优势与适用场景

  • 优势:
    • 避免“回调地狱”,通过 then/catch 链式调用实现异步流程的线性化。
    • 统一处理成功与失败逻辑,代码结构更清晰。
  • 适用场景:
    • 需要将旧的回调风格异步函数(如 Node.js 中的传统 I/O 操作)转换为 Promise 风格,以便与现代异步编程模型(如 async/await)兼容。

注意:实际使用时需确保 readTextFile 函数的存在及参数正确性,若该函数为虚构(如示例中),需根据具体环境(浏览器或 Node.js)替换为真实的文件读取实现(如 Node.js 的 fs.readFile)。

new Promise((_, reject) => reject(new Error("Fail")))
 .then(value => console.log("Handler 1:", value))
 .catch(reason => {
    console.log("Caught failure " + reason);
    return "nothing";
  })
 .then(value => console.log("Handler 2:", value));
// → Caught failure Error: Fail
// → Handler 2: nothing

第一个 then 处理程序函数不会被调用,因为在管道的那个点上 Promise 处于拒绝状态。catch 处理程序处理了该拒绝并返回一个值,这个值被传递给第二个 then 处理程序函数。
就像未捕获的异常由环境处理一样,JavaScript 环境可以检测到 Promise 拒绝未被处理的情况,并将其报告为错误。

代码解释

  1. 创建并拒绝 Promise
    new Promise((_, reject) => reject(new Error("Fail"))) 创建了一个 Promise 实例。传递给 Promise 构造函数的函数接收两个参数(这里第一个参数 _ 是约定俗成表示被忽略的参数 ),通过调用 reject 函数并传入 new Error("Fail") ,使这个 Promise 立即处于拒绝状态,拒绝原因是 Error("Fail") 。
  2. 第一个 then 调用
    .then(value => console.log("Handler 1:", value)) 尝试注册一个成功处理函数。但由于 Promise 是拒绝状态,这个处理函数不会被执行。
  3. catch 调用
    .catch(reason => { console.log("Caught failure " + reason); return "nothing"; }) 注册了一个拒绝处理函数。当 Promise 处于拒绝状态时,该函数被调用。它打印出捕获到的失败信息(即拒绝原因 Error: Fail ),然后返回字符串 "nothing" 。这个返回值会作为下一个 Promise (由 catch 返回 )的解析值。
  4. 第二个 then 调用
    .then(value => console.log("Handler 2:", value)) 注册了一个成功处理函数。由于前面 catch 返回的 Promise 是已解析状态(解析值为 "nothing" ),这个处理函数被调用,打印出 "Handler 2: nothing" 。

总结

这段代码展示了 Promise 在拒绝状态下,then 和 catch 的行为。then 的成功处理函数在 Promise 拒绝时不执行,catch 捕获拒绝并处理,并且 catch 返回的新 Promise 可以继续被后续 then 处理。

卡拉

柏林阳光明媚。在这座已停用的旧机场跑道上,挤满了骑自行车的人和溜旱冰的人。在靠近垃圾箱的草丛里,一群乌鸦正喧闹地乱转,试图说服一群游客分享他们的三明治。

有一只乌鸦格外引人注目——这是一只体型硕大、羽毛杂乱的雌鸟,右翼上有几根白色羽毛。她正用一种技巧和自信引诱着人们,仿佛已深谙此道许久。当一位老人被另一只乌鸦的滑稽动作分散注意力时,她趁机俯冲而下,从老人手中抢走半块吃剩的面包,展翅飞走了。

与其他似乎乐于在此虚度时光的同伴不同,这只大乌鸦看起来目标明确。她衔着战利品,径直飞向机库大楼的屋顶,消失在一个通风口里。

在大楼内部,你能听到一种奇怪的敲击声——轻柔却持续不断。声音来自未完工楼梯井屋顶下的狭窄空间。那只乌鸦正坐在那里,周围堆满了偷来的零食、六部智能手机(其中几部还开着机)和一团杂乱的线缆。她用喙快速敲击着其中一部手机的屏幕,屏幕上不断出现文字。若不是亲眼所见,你会以为她正在打字。

这只乌鸦在同伴中被称为“cāāw - krö”。但由于这个发音不适合人类声带,我们就称她为卡拉。

卡拉是一只颇为特别的乌鸦。年轻时,她痴迷于人类语言,偷听人们说话,直到充分理解他们的意思。后来,她的兴趣转向了人类科技,开始偷手机来研究。她目前的项目是学习编程。在隐蔽实验室里,她敲击输入的文本实际上是一段异步JavaScript代码。

入侵

卡拉热爱互联网。麻烦的是,她正在使用的手机预付费数据即将耗尽。大楼里有无线网络,但需要密码才能连接。

幸运的是,大楼里的无线路由器已使用了20年,防护措施很差。经过一番研究,卡拉发现网络认证机制存在一个可利用的漏洞。设备加入网络时,必须发送正确的六位密码。接入点会根据密码是否正确,返回成功或失败的消息。然而,当发送部分密码(比如仅三位)时,响应会根据这些数字是否是正确密码的开头而有所不同:发送错误数字会立即返回失败消息;发送正确数字时,接入点会等待输入更多数字。

这使得猜测密码的速度大大加快。卡拉可以依次尝试每个数字,直到找到一个不会立即返回失败的数字,从而确定第一位。知道第一位后,她可以用同样的方法找到第二位,依此类推,直到猜出整个密码。

假设卡拉有一个joinWifi函数,传入网络名称和密码字符串后,该函数会尝试连接网络,并返回一个Promise——连接成功则 resolve,认证失败则 reject。她首先需要一种方法来包装这个Promise,使其在耗时过长时自动 reject,以便程序在接入点无响应时能快速继续执行。

这个 withTimeout 函数的作用是为传入的 promise 添加超时机制,当 promise 在指定时间 time(单位:毫秒)内未完成时,自动触发拒绝(reject)。以下是对该函数的详细分析:

函数逻辑解析

function withTimeout(promise, time) {
  return new Promise((resolve, reject) => {
    // 1. 绑定原始 promise 的结果处理
    promise.then(resolve, reject);
    
    // 2. 设置超时定时器
    setTimeout(() => {
      reject("Timed out"); // 超时后触发拒绝,携带错误信息
    }, time);
  });
}
  • 核心机制:通过创建一个新的 Promise,同时监听原始 promise 的完成状态和超时定时器。
    • 正常情况:如果原始 promise 在 time 内成功(resolve)或失败(reject),新 Promise 会直接传递结果。
    • 超时情况:如果超过 time 时间,新 Promise 会主动调用 reject("Timed out"),终止等待。

使用示例

假设 joinWifi 是卡拉用于尝试连接 WiFi 的函数:

async function attemptJoin(networkName, code) {
  try {
    // 为 joinWifi 调用添加超时(例如 500 毫秒)
    await withTimeout(joinWifi(networkName, code), 500);
    console.log("成功连接!密码正确:", code);
    return true;
  } catch (error) {
    if (error === "Timed out") {
      // 超时意味着密码前缀正确,等待后续输入
      console.log("前缀正确,继续猜测下一位...");
      return "pending"; // 卡拉需要继续尝试下一位
    } else {
      // 立即失败,说明当前前缀错误
      console.log("密码错误:", code);
      return false;
    }
  }
}
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容