手写实现一个简易的自定义 Promise.raceWithTimeout:处理异步请求的超时控制

各位开发者,大家好!

在现代Web应用和后端服务中,异步操作无处不在。从前端的API请求到后端的数据库查询,再到微服务间的通信,我们几乎所有的业务逻辑都离不开异步处理。JavaScript的Promise机制极大地简化了异步编程,但随之而来的挑战是如何优雅地管理这些异步操作的生命周期,尤其是当它们耗时过长,或者可能永远无法完成时。这就是“超时控制”的用武之地。

设想一下,你的前端应用向服务器发起了一个重要请求,但由于网络波动或服务器过载,这个请求迟迟没有响应。用户界面会一直处于加载状态,用户体验直线下降,甚至可能导致资源浪费。同样,在后端,一个微服务调用另一个服务,如果被调用服务长时间无响应,调用者可能会阻塞,甚至导致整个系统雪崩。因此,为异步操作设置合理的超时机制,是构建健壮、响应迅速应用的关键一环。

今天,我们将深入探讨如何手写实现一个简易但功能强大的自定义Promise工具:Promise.raceWithTimeout。这个工具旨在解决标准Promise.race在超时控制方面的局限性,提供一个明确的、可控的超时处理方案。我们将从Promise.race的基础讲起,逐步构建和完善我们的raceWithTimeout,并探讨其背后的原理、各种优化以及实际应用场景。

一、理解 Promise.race:异步竞速的起点

在深入自定义实现之前,我们首先需要回顾一下JavaScript原生的Promise.race。理解它的工作方式和设计哲学,是构建我们自定义工具的基础。

1.1 Promise.race 的基本机制

Promise.race(iterable) 是一个静态方法,它接受一个Promise对象的迭代器(例如数组)。当这个迭代器中的任何一个Promise解决(resolved)或拒绝(rejected)时,Promise.race 返回的Promise就会立即以该Promise的解决值或拒绝原因进行解决或拒绝。换句话说,它就像一场赛跑,第一个到达终点的Promise决定了整个Promise.race的结果。

让我们看一个简单的例子:

const promise1 = new Promise((resolve, reject) => {
    setTimeout(() => resolve('Promise 1 resolved'), 500);
});

const promise2 = new Promise((resolve, reject) => {
    setTimeout(() => reject('Promise 2 rejected'), 200);
});

const promise3 = new Promise((resolve, reject) => {
    setTimeout(() => resolve('Promise 3 resolved'), 1000);
});

Promise.race([promise1, promise2, promise3])
    .then(result => {
        console.log('Promise.race resolved with:', result); // Output: Promise.race resolved with: Promise 2 rejected
    })
    .catch(error => {
        console.error('Promise.race rejected with:', error); // This will be caught because promise2 rejects first
    });

// 另一个例子:第一个是resolved
const promiseA = new Promise((resolve) => setTimeout(() => resolve('A'), 300));
const promiseB = new Promise((resolve) => setTimeout(() => resolve('B'), 100));

Promise.race([promiseA, promiseB])
    .then(value => console.log('First to resolve:', value)); // Output: First to resolve: B

在第一个例子中,promise2在200毫秒后拒绝,它是最快的。因此,Promise.race的最终结果就是promise2的拒绝原因。在第二个例子中,promiseB在100毫秒后解决,它是最快的,所以Promise.race的结果就是promiseB的解决值。

1.2 Promise.race 在超时控制中的局限性

乍一看,Promise.race似乎非常适合实现超时控制:我们可以将一个“主”异步操作与一个“计时器”Promise一起放入Promise.race中,如果计时器Promise先完成,那么就表示主操作超时了。

例如:

function fetchDataWithRaceTimeout(url, timeoutMs) {
    const fetchPromise = fetch(url);

    const timeoutPromise = new Promise((resolve, reject) => {
        setTimeout(() => reject(new Error('请求超时')), timeoutMs);
    });

    return Promise.race([fetchPromise, timeoutPromise]);
}

// 使用示例
fetchDataWithRaceTimeout('https://api.example.com/data', 3000)
    .then(response => response.json())
    .then(data => console.log('数据获取成功:', data))
    .catch(error => console.error('数据获取失败或超时:', error.message));

这个方案看起来可行,也确实是许多简易超时实现的基础。然而,它存在一个关键的局限性:

Promise.race 不会取消或停止那些未首先完成的Promise。

这意味着什么?在上面的例子中,如果fetchPromise在3秒内没有完成,timeoutPromise会先拒绝,导致Promise.race的整体结果是超时错误。然而,fetchPromise本身仍然在后台运行!它可能会继续消耗网络资源,最终完成并处理其结果(尽管这个结果已经被Promise.race忽略了)。在某些场景下,这可能导致资源浪费、不必要的处理,甚至更复杂的内存泄漏问题。对于一个真正的超时控制,我们通常希望在超时发生时,能够有效地“取消”或“终止”底层的异步操作。

这就是我们自定义raceWithTimeout的意义所在:不仅要判断是否超时,还要在超时时提供更强大的控制能力。

二、raceWithTimeout 的需求与设计目标

基于Promise.race的局限性,我们设计raceWithTimeout的目标是创建一个更健壮、更灵活的超时控制机制。它应该满足以下核心需求:

  1. 明确的超时信号: 当主操作在规定时间内未能完成时,raceWithTimeout 应该以一个明确的、可识别的超时错误拒绝。
  2. 易于使用: 接口应该简洁明了,方便开发者集成到现有代码中。
  3. 可配置的超时时间: 允许用户自定义超时时长。
  4. 可自定义的超时错误: 允许用户提供一个自定义的错误对象,以便在错误处理时能够区分不同类型的超时。
  5. (高级)可取消底层操作: 在超时发生时,如果底层操作支持取消,raceWithTimeout 应该能够触发取消机制,避免资源浪费。

为了实现这些目标,我们的raceWithTimeout函数将接受以下参数:

  • taskPromise: 这是我们想要进行超时控制的异步操作,它必须是一个Promise实例。
  • timeoutMs: 超时时长,以毫秒为单位。如果taskPromise在此时间内未能解决或拒绝,则视为超时。
  • timeoutError?: 可选参数,一个自定义的错误对象或错误消息。如果未提供,我们将使用一个默认的TimeoutError实例。

其返回结果将是一个Promise:

  • 如果 taskPromisetimeoutMs 内解决,则返回的Promise以 taskPromise 的解决值解决。
  • 如果 taskPromisetimeoutMs 内拒绝,则返回的Promise以 taskPromise 的拒绝原因拒绝。
  • 如果 taskPromise 未能在 timeoutMs 内完成,则返回的Promise以 timeoutError (或默认的 TimeoutError) 拒绝。

三、核心实现策略:Promise.race 与 setTimeout 的结合

Promise.raceWithTimeout 的核心实现仍然会依赖 Promise.race。我们将构建两个Promise:

  1. 主任务Promise (taskPromise): 这是用户传入的实际异步操作。
  2. 超时计时器Promise (timeoutPromise): 这个Promise会在指定的时间后拒绝,并带有一个超时错误。

然后,我们将这两个Promise放入Promise.race中。哪个Promise先“跑赢”,哪个的结果就成为raceWithTimeout的最终结果。

关键在于如何构建这个timeoutPromise,以及如何让它在拒绝时携带一个有意义的错误。

四、逐步构建 raceWithTimeout

现在,让我们一步步实现我们的raceWithTimeout函数。

4.1 版本一:基础实现与默认超时错误

我们先从最简单的版本开始,只关注核心逻辑:将用户提供的Promise和一个定时拒绝的Promise进行竞赛。

/**
 * 一个简易的 Promise.raceWithTimeout 实现。
 *
 * @param {Promise<T>} taskPromise - 需要进行超时控制的主 Promise。
 * @param {number} timeoutMs - 超时时间,单位毫秒。
 * @param {string | Error} [timeoutError='Operation timed out'] - 可选的超时错误消息或 Error 对象。
 * @returns {Promise<T>} - 一个新的 Promise,它会在 taskPromise 完成或超时时解决/拒绝。
 */
function raceWithTimeout(taskPromise, timeoutMs, timeoutError = 'Operation timed out') {
    // 1. 创建一个超时 Promise
    const timerPromise = new Promise((resolve, reject) => {
        const id = setTimeout(() => {
            clearTimeout(id); // 清除定时器,避免在 Promise 解决后仍然占用资源
            if (timeoutError instanceof Error) {
                reject(timeoutError);
            } else {
                reject(new Error(timeoutError));
            }
        }, timeoutMs);

        // 如果 taskPromise 先完成,我们需要确保这个 setTimeout 不会再触发 reject。
        // 虽然 Promise.race 会忽略它,但清理资源是个好习惯。
        // 实际上,Promise.race 的特性使得这里的 clearTimeout(id) 并不是严格必要的,
        // 因为一旦 taskPromise 解决/拒绝,timerPromise 的 reject 就不会影响最终结果了。
        // 但对于更复杂的场景,或者如果 timerPromise 内部有其他副作用,清理是重要的。
        // 在这里,我们先保持简洁,后面再考虑更精细的清理。
    });

    // 2. 使用 Promise.race 将主 Promise 和超时 Promise 进行竞赛
    return Promise.race([taskPromise, timerPromise]);
}

// --- 示例与测试 ---

console.log('--- 示例 1: 任务成功完成 ---');
const successfulTask = new Promise(resolve => {
    setTimeout(() => resolve('数据成功获取!'), 1000);
});

raceWithTimeout(successfulTask, 2000)
    .then(result => console.log('成功结果:', result)) // 预期:数据成功获取! (1秒后)
    .catch(error => console.error('错误结果:', error.message));

console.log('--- 示例 2: 任务超时 ---');
const slowTask = new Promise(resolve => {
    setTimeout(() => resolve('数据姗姗来迟...'), 3000);
});

raceWithTimeout(slowTask, 1500)
    .then(result => console.log('成功结果:', result))
    .catch(error => console.error('错误结果:', error.message)); // 预期:Operation timed out (1.5秒后)

console.log('--- 示例 3: 任务失败 (拒绝) ---');
const failingTask = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('网络请求失败')), 500);
});

raceWithTimeout(failingTask, 2000)
    .then(result => console.log('成功结果:', result))
    .catch(error => console.error('错误结果:', error.message)); // 预期:网络请求失败 (0.5秒后)

console.log('--- 示例 4: 自定义超时错误消息 ---');
const anotherSlowTask = new Promise(resolve => {
    setTimeout(() => resolve('另一个慢任务'), 4000);
});

raceWithTimeout(anotherSlowTask, 1000, '自定义:API调用超时!')
    .then(result => console.log('成功结果:', result))
    .catch(error => console.error('错误结果:', error.message)); // 预期:自定义:API调用超时! (1秒后)

console.log('--- 示例 5: 自定义超时 Error 对象 ---');
class CustomTimeoutError extends Error {
    constructor(message = 'Custom Timeout Error') {
        super(message);
        this.name = 'CustomTimeoutError';
    }
}

const yetAnotherSlowTask = new Promise(resolve => {
    setTimeout(() => resolve('又一个慢任务'), 5000);
});

raceWithTimeout(yetAnotherSlowTask, 1200, new CustomTimeoutError('哎呀,超时了呢!'))
    .then(result => console.log('成功结果:', result))
    .catch(error => {
        console.error('错误类型:', error.name); // CustomTimeoutError
        console.error('错误消息:', error.message); // 哎呀,超时了呢!
        console.error('错误实例:', error instanceof CustomTimeoutError); // true
    });

代码分析:

  • 我们创建了一个名为raceWithTimeout的函数,它接受三个参数:taskPromise(要监控的Promise)、timeoutMs(超时时间)和可选的timeoutError
  • 内部,我们通过new Promise构造函数创建了timerPromise
  • setTimeout用于在timeoutMs之后触发timerPromise的拒绝。
  • setTimeout的回调中,我们通过reject方法抛出一个错误。这里我们判断timeoutError是字符串还是Error实例,以便更灵活地处理。
  • 最后,Promise.race([taskPromise, timerPromise])将这两个Promise组合在一起。无论是taskPromise先完成,还是timerPromise先拒绝,raceWithTimeout返回的Promise都会以最快的结果进行解决或拒绝。

这个版本已经实现了基本的超时控制功能,并且允许自定义超时错误消息或对象。然而,它仍然没有解决Promise.race的根本问题:即使超时发生,taskPromise也可能仍在后台运行。

4.2 版本二:引入自定义错误类 TimeoutError

为了让超时错误更具识别性,而不是仅仅是一个普通的Error实例,我们可以定义一个专门的TimeoutError类。这在错误处理逻辑中非常有用,可以方便地判断捕获到的错误是否是超时引起的。

/**
 * 自定义超时错误类。
 * 继承自 Error,以便包含堆栈信息,并提供一个独特的名称。
 */
class TimeoutError extends Error {
    constructor(message = 'Operation timed out') {
        super(message);
        this.name = 'TimeoutError';
        // 保持正确的堆栈跟踪
        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, TimeoutError);
        }
    }
}

/**
 * 一个简易的 Promise.raceWithTimeout 实现,使用自定义 TimeoutError。
 *
 * @param {Promise<T>} taskPromise - 需要进行超时控制的主 Promise。
 * @param {number} timeoutMs - 超时时间,单位毫秒。
 * @param {string | Error} [customTimeoutError] - 可选的自定义超时错误消息或 Error 对象。
 * @returns {Promise<T>} - 一个新的 Promise,它会在 taskPromise 完成或超时时解决/拒绝。
 */
function raceWithTimeoutV2(taskPromise, timeoutMs, customTimeoutError) {
    // 1. 创建一个超时 Promise
    const timerPromise = new Promise((resolve, reject) => {
        const id = setTimeout(() => {
            clearTimeout(id); // 同样,这里清理定时器主要是为了良好实践
            if (customTimeoutError instanceof Error) {
                reject(customTimeoutError);
            } else if (typeof customTimeoutError === 'string') {
                reject(new TimeoutError(customTimeoutError));
            } else {
                reject(new TimeoutError()); // 使用默认的 TimeoutError
            }
        }, timeoutMs);
    });

    // 2. 使用 Promise.race 将主 Promise 和超时 Promise 进行竞赛
    return Promise.race([taskPromise, timerPromise]);
}

// --- 示例与测试 ---

console.log('n--- V2 示例 1: 任务超时,使用默认 TimeoutError ---');
const slowTaskV2 = new Promise(resolve => {
    setTimeout(() => resolve('慢任务V2完成'), 3000);
});

raceWithTimeoutV2(slowTaskV2, 1000)
    .then(result => console.log('成功结果:', result))
    .catch(error => {
        console.error('错误类型:', error.name); // TimeoutError
        console.error('错误消息:', error.message); // Operation timed out
        console.error('是否为 TimeoutError 实例:', error instanceof TimeoutError); // true
    });

console.log('n--- V2 示例 2: 任务超时,使用自定义消息的 TimeoutError ---');
const anotherSlowTaskV2 = new Promise(resolve => {
    setTimeout(() => resolve('另一个慢任务V2完成'), 4000);
});

raceWithTimeoutV2(anotherSlowTaskV2, 1500, '数据请求耗时过长,请稍后再试。')
    .then(result => console.log('成功结果:', result))
    .catch(error => {
        console.error('错误类型:', error.name); // TimeoutError
        console.error('错误消息:', error.message); // 数据请求耗时过长,请稍后再试。
        console.error('是否为 TimeoutError 实例:', error instanceof TimeoutError); // true
    });

console.log('n--- V2 示例 3: 任务成功完成 ---');
const successfulTaskV2 = new Promise(resolve => {
    setTimeout(() => resolve('任务V2成功!'), 500);
});

raceWithTimeoutV2(successfulTaskV2, 2000)
    .then(result => console.log('成功结果:', result)) // 预期:任务V2成功! (0.5秒后)
    .catch(error => console.error('错误结果:', error.message));

代码分析:

  • 我们定义了TimeoutError类,它继承自Error。这确保了它具有标准错误对象的属性(如messagestack),同时name属性被设置为TimeoutError,便于类型判断。
  • raceWithTimeoutV2函数内部,根据customTimeoutError参数的不同,会拒绝一个TimeoutError实例(无论是默认的还是带有自定义消息的)或者用户提供的自定义Error实例。
  • 通过instanceof TimeoutError或检查error.name === 'TimeoutError',我们可以轻松地在catch块中识别出超时错误。

这个版本在错误处理方面有了显著的提升,但我们还没有解决最关键的问题:如何取消底层仍在运行的taskPromise

4.3 版本三:集成 AbortSignal 实现可取消的超时

要解决Promise.race不取消底层操作的问题,我们需要引入一个现代Web API(也逐渐被Node.js支持)——AbortControllerAbortSignal

AbortController 提供了一个signal属性,这个signal可以传递给支持它的异步API(如fetchXMLHttpRequestEventSource等)。当abortController.abort()被调用时,signal会触发一个abort事件,并将其aborted属性设置为true。支持AbortSignal的API会监听这个信号,并在aborted时终止其操作。

我们的策略是:

  1. raceWithTimeout 内部创建一个 AbortController 实例。
  2. 将这个 AbortControllersignal 传递给 taskPromise。这要求 taskPromise 的创建者(即调用 raceWithTimeout 的人)在构建 taskPromise 时,要考虑接收并使用这个 signal 来实现其内部的取消逻辑。
  3. 如果超时发生,raceWithTimeout 不仅拒绝Promise,还会调用 abortController.abort(),从而触发底层 taskPromise 的取消。

这是一个更复杂,但也更强大的版本。

/**
 * 自定义超时错误类。
 * 继承自 Error,以便包含堆栈信息,并提供一个独特的名称。
 */
class TimeoutError extends Error {
    constructor(message = 'Operation timed out') {
        super(message);
        this.name = 'TimeoutError';
        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, TimeoutError);
        }
    }
}

/**
 * 具有超时控制和 AbortSignal 支持的 Promise.raceWithTimeout 实现。
 *
 * @param {(signal?: AbortSignal) => Promise<T>} taskFactory - 一个函数,它接收一个 AbortSignal 并返回需要进行超时控制的主 Promise。
 *                                                       如果 taskFactory 不接收 signal,则无法取消底层任务。
 * @param {number} timeoutMs - 超时时间,单位毫秒。
 * @param {string | Error} [customTimeoutError] - 可选的自定义超时错误消息或 Error 对象。
 * @returns {Promise<T>} - 一个新的 Promise,它会在 taskPromise 完成或超时时解决/拒绝。
 */
function raceWithTimeoutV3(taskFactory, timeoutMs, customTimeoutError) {
    // 1. 创建 AbortController
    const abortController = new AbortController();
    const signal = abortController.signal;

    // 2. 创建主任务 Promise,并将 signal 传递给 taskFactory
    let taskPromise;
    try {
        taskPromise = taskFactory(signal);
        // 确保 taskFactory 返回的是一个 Promise
        if (!taskPromise || typeof taskPromise.then !== 'function') {
            throw new TypeError('taskFactory must return a Promise.');
        }
    } catch (e) {
        // 如果 taskFactory 自身抛出错误,则直接拒绝
        return Promise.reject(e);
    }

    // 3. 创建一个超时 Promise
    const timerPromise = new Promise((resolve, reject) => {
        const id = setTimeout(() => {
            clearTimeout(id);
            // 触发 AbortController 的 abort 方法,通知底层任务取消
            abortController.abort();

            if (customTimeoutError instanceof Error) {
                reject(customTimeoutError);
            } else if (typeof customTimeoutError === 'string') {
                reject(new TimeoutError(customTimeoutError));
            } else {
                reject(new TimeoutError());
            }
        }, timeoutMs);

        // 如果 taskPromise 成功或失败,我们应该清理超时定时器,
        // 避免在任务完成后,setTimeout 仍然触发 abort() 和 reject()。
        // 这不是 Promise.race 本身能做的,需要我们手动在 taskPromise 完成时取消定时器。
        taskPromise.finally(() => {
            clearTimeout(id);
        });
    });

    // 4. 使用 Promise.race 将主 Promise 和超时 Promise 进行竞赛
    return Promise.race([taskPromise, timerPromise]);
}

// --- 示例与测试 ---

console.log('n--- V3 示例 1: 使用 fetch API 演示取消 ---');

// 模拟一个支持 AbortSignal 的异步操作(例如 fetch)
// 注意:这里用 setTimeout 模拟 fetch 的延迟,并通过监听 signal 来模拟取消
function mockFetch(url, options = {}) {
    return new Promise((resolve, reject) => {
        const signal = options.signal;
        let timerId;

        if (signal) {
            signal.addEventListener('abort', () => {
                clearTimeout(timerId); // 在取消时清除模拟的延迟
                console.log(`[mockFetch] ${url} 被 AbortSignal 取消!`);
                reject(new DOMException('Aborted', 'AbortError'));
            }, { once: true });
        }

        timerId = setTimeout(() => {
            if (signal && signal.aborted) {
                // 如果 signal 已经被中止,这里就不再执行 resolve/reject
                return;
            }
            console.log(`[mockFetch] ${url} 完成。`);
            resolve({
                status: 200,
                json: () => Promise.resolve({ data: `Fetched from ${url}` })
            });
        }, 2000); // 模拟一个2秒的请求
    });
}

// 模拟一个请求,期望超时
raceWithTimeoutV3(
    (signal) => mockFetch('https://api.example.com/data/slow', { signal }),
    1000, // 设置1秒超时
    'API 请求超时,已尝试取消。'
)
    .then(response => response.json())
    .then(data => console.log('V3 成功结果:', data))
    .catch(error => {
        console.error('V3 错误类型:', error.name); // 预期:TimeoutError 或 AbortError
        console.error('V3 错误消息:', error.message); // 预期:API 请求超时,已尝试取消。 或 Aborted
        console.error('V3 是否为 TimeoutError 实例:', error instanceof TimeoutError); // 预期:true
        console.error('V3 是否为 AbortError 实例:', error.name === 'AbortError'); // 预期:如果任务被成功取消,则为true
    });

console.log('n--- V3 示例 2: 任务在超时前完成 ---');
raceWithTimeoutV3(
    (signal) => mockFetch('https://api.example.com/data/fast', { signal }),
    3000 // 设置3秒超时,任务2秒完成
)
    .then(response => response.json())
    .then(data => console.log('V3 成功结果:', data)) // 预期:Fetched from https://api.example.com/data/fast (2秒后)
    .catch(error => console.error('V3 错误结果:', error.message));

console.log('n--- V3 示例 3: taskFactory 自身抛出错误 ---');
raceWithTimeoutV3(
    () => { throw new Error('Task factory initialization error'); },
    1000
)
    .then(result => console.log('V3 成功结果:', result))
    .catch(error => {
        console.error('V3 错误类型:', error.name); // Error
        console.error('V3 错误消息:', error.message); // Task factory initialization error
    });

console.log('n--- V3 示例 4: taskFactory 未返回 Promise ---');
raceWithTimeoutV3(
    () => 'Not a promise', // 错误:未返回 Promise
    1000
)
    .then(result => console.log('V3 成功结果:', result))
    .catch(error => {
        console.error('V3 错误类型:', error.name); // TypeError
        console.error('V3 错误消息:', error.message); // taskFactory must return a Promise.
    });

代码分析:

  • taskFactory 参数变化: raceWithTimeoutV3 不再直接接受 taskPromise,而是接受一个 taskFactory 函数。这个函数会接收一个 AbortSignal 作为参数,并返回一个Promise。这种设计模式使得 raceWithTimeoutV3 能够将内部创建的 signal 注入到用户的异步操作中。
  • AbortController 的创建: 在函数开始时,我们实例化 AbortController 并获取其 signal
  • 传递 signal taskFactory(signal) 负责创建主任务Promise,并将 signal 传递给它。这是实现取消的关键点。如果 taskFactory 返回的Promise是一个fetch请求,它就可以直接使用这个signal
  • 超时时的 abort() 调用:timerPromisesetTimeout回调中,当超时发生时,除了拒绝Promise,我们还调用了abortController.abort()。这将触发所有监听该signal的事件监听器,通知它们取消操作。
  • 清理 setTimeout taskPromise.finally(() => clearTimeout(id)); 这一行非常重要。如果 taskPromise 在超时前完成了(无论是解决还是拒绝),我们需要确保清理掉 timerPromise 内部的 setTimeout。否则,即使 Promise.race 已经有了结果,setTimeout 仍然会在延迟结束后触发 abortController.abort(),这可能会导致不必要的副作用或错误。
  • 鲁棒性检查: 增加了对 taskFactory 返回值是否为Promise的检查,以及对 taskFactory 自身抛出错误的捕获。

表格:raceWithTimeout 版本对比

特性/版本 版本一 (基础) 版本二 (自定义错误) 版本三 (可取消)
核心功能 超时控制 超时控制 超时控制,且能在超时时尝试取消底层任务
超时错误类型 默认 Error,或用户提供字符串/Error 默认 TimeoutError,或用户提供字符串/Error 默认 TimeoutError,或用户提供字符串/Error
错误可识别性 较差 良好 (通过 instanceof TimeoutError) 良好
底层任务取消 否 (底层任务仍会运行) 否 (底层任务仍会运行) 是 (通过 AbortSignal,需底层任务支持)
输入参数 Promise<T>, number, string | Error Promise<T>, number, string | Error (signal?: AbortSignal) => Promise<T>, number, string | Error
复杂性 中等 较高
资源效率 差 (可能浪费资源) 优 (如果底层任务支持取消)

五、鲁棒性与边界条件考虑

一个生产级别的工具需要考虑各种异常情况和边界条件,确保其健壮性。

5.1 零或负数 timeoutMs

如果 timeoutMs 为 0 或负数,setTimeout 的行为是立即执行(或在当前事件循环的下一个tick执行)。对于超时控制,这通常意味着“立即超时”。我们的当前实现能够处理这种情况,0ms的setTimeout会立即触发拒绝。

console.log('n--- 边界条件 1: timeoutMs 为 0 ---');
const taskZeroTimeout = new Promise(resolve => {
    setTimeout(() => resolve('任务在0ms超时后完成'), 10);
});

raceWithTimeoutV3(
    (signal) => taskZeroTimeout,
    0, // 0毫秒超时
    '立即超时!'
)
    .then(result => console.log('成功结果:', result))
    .catch(error => console.error('错误结果:', error.message)); // 预期:立即超时!

这种行为是合理的,因为它表示用户希望任务立即失败,如果它不是同步完成的话。

5.2 非 Promise 输入

raceWithTimeoutV3 中,我们已经通过 taskFactory 模式解决了直接传入非 Promise 的问题。taskFactory 必须返回一个 Promise,否则我们会抛出 TypeError

console.log('n--- 边界条件 2: taskFactory 未返回 Promise ---');
raceWithTimeoutV3(
    () => 'This is not a Promise',
    1000
)
.then(result => console.log('成功结果:', result))
.catch(error => console.error('错误结果:', error.message)); // 预期:TypeError: taskFactory must return a Promise.

这种显式的类型检查增加了函数的健壮性,防止了运行时错误。

5.3 竞态条件与资源清理

raceWithTimeoutV3 中,我们增加了 taskPromise.finally(() => clearTimeout(id)); 来清理 setTimeout。这解决了在 taskPromise 领先完成时,避免 timerPromisesetTimeout 在稍后触发 abort() 的问题。这是一个重要的优化,确保了资源的及时释放和行为的确定性。

// 回顾清理逻辑
taskPromise.finally(() => {
    // 无论 taskPromise 解决还是拒绝,都清除超时定时器
    clearTimeout(id);
});

这种清理确保了即使 Promise.race 已经决定了结果,我们内部创建的 setTimeout 句柄也不会在后台无谓地运行并可能触发不必要的 abort() 调用。

六、高级考量与实际应用场景

raceWithTimeout 作为一个基础工具,可以与其他模式结合,构建更复杂的异步处理系统。

6.1 结合重试机制

当一个请求超时时,往往不是最终的失败,而是暂时的网络问题或服务器负载高。这时,结合重试机制就显得尤为重要。

async function retryWithTimeout(taskFactory, retries = 3, delayMs = 100, timeoutMs = 2000) {
    for (let i = 0; i < retries; i++) {
        try {
            console.log(`尝试 #${i + 1}`);
            return await raceWithTimeoutV3(taskFactory, timeoutMs, `请求超时 (尝试 #${i + 1})`);
        } catch (error) {
            if (error instanceof TimeoutError || error.name === 'AbortError') {
                console.warn(`尝试 #${i + 1} 超时或被取消,正在重试...`);
                if (i < retries - 1) {
                    await new Promise(resolve => setTimeout(resolve, delayMs * (i + 1))); // 指数退避
                } else {
                    throw new Error(`所有重试失败:${error.message}`);
                }
            } else {
                // 其他类型的错误直接抛出
                throw error;
            }
        }
    }
}

// 模拟一个有时会超时,有时会成功的请求
let failCount = 0;
function unreliableFetch(url, options = {}) {
    return new Promise((resolve, reject) => {
        const signal = options.signal;
        let timerId;

        if (signal) {
            signal.addEventListener('abort', () => {
                clearTimeout(timerId);
                console.log(`[unreliableFetch] ${url} 被 AbortSignal 取消!`);
                reject(new DOMException('Aborted', 'AbortError'));
            }, { once: true });
        }

        if (failCount < 2) { // 前两次失败(模拟超时)
            timerId = setTimeout(() => {
                if (signal && signal.aborted) return;
                console.log(`[unreliableFetch] ${url} 模拟超时 (实际延迟 3s)`);
                // 实际上这里不会reject,因为raceWithTimeout会先reject
            }, 3000); // 模拟一个会超时的请求
            failCount++;
        } else { // 之后成功
            timerId = setTimeout(() => {
                if (signal && signal.aborted) return;
                console.log(`[unreliableFetch] ${url} 成功完成 (实际延迟 500ms)`);
                resolve({ status: 200, json: () => Promise.resolve({ data: `Fetched from ${url} after retries` }) });
            }, 500); // 模拟一个成功的请求
        }
    });
}

console.log('n--- 高级考量 1: 结合重试机制 ---');
retryWithTimeout(
    (signal) => unreliableFetch('https://api.example.com/data/unreliable', { signal }),
    3, // 最多重试3次
    200, // 初始延迟200ms
    1000 // 每次尝试的超时时间1秒
)
    .then(response => response.json())
    .then(data => console.log('最终数据获取成功:', data))
    .catch(error => console.error('最终数据获取失败:', error.message));

在这个例子中,retryWithTimeout 函数会多次尝试调用 raceWithTimeoutV3。如果捕获到 TimeoutErrorAbortError,它会进行重试,并在每次重试之间增加延迟(简单的指数退避)。

6.2 资源管理与清理

即使 AbortSignal 能够取消许多现代的异步操作,但并非所有操作都支持。对于那些不支持取消的传统回调API或第三方库,raceWithTimeout 仍然能提供超时通知,但底层任务可能仍会继续执行。在这种情况下,开发者需要:

  • 客户端忽略结果: 在超时发生后,即使底层任务最终完成,其结果也应该被调用者忽略。
  • 服务器端清理: 如果可能,通知服务器端中止相关操作,例如通过发送另一个请求。
  • 内存管理: 对于一些复杂的计算或数据流,确保在超时后及时释放相关的内存和句柄,避免内存泄漏。

6.3 统一错误处理

通过使用 TimeoutError 类,可以构建一个统一的错误处理中间件或逻辑,根据错误类型执行不同的操作。

function handleError(error) {
    if (error instanceof TimeoutError) {
        console.error('业务逻辑:请求超时,请检查网络或稍后重试。', error.message);
        // 显示一个用户友好的超时提示
    } else if (error.name === 'AbortError') {
        console.warn('业务逻辑:请求被用户或系统取消。', error.message);
        // 不向用户显示错误,因为是主动取消
    } else if (error.message.includes('Network request failed')) {
        console.error('业务逻辑:网络连接错误,请检查您的网络。', error.message);
        // 显示网络错误提示
    } else {
        console.error('业务逻辑:发生未知错误。', error.message);
        // 记录错误并显示通用错误提示
    }
}

console.log('n--- 高级考量 2: 统一错误处理 ---');
raceWithTimeoutV3(
    (signal) => mockFetch('https://api.example.com/data/another-slow', { signal }),
    1000,
    '获取数据超时!'
)
.then(response => response.json())
.then(data => console.log('数据:', data))
.catch(handleError);

6.4 性能监控与日志

在生产环境中,记录超时事件是至关重要的。这可以帮助我们发现系统瓶颈、网络问题或外部服务故障。将raceWithTimeout与日志系统集成,可以在超时发生时自动记录相关信息。

七、总结与展望

我们从Promise.race的局限性出发,逐步构建了一个功能更强大、更健壮的Promise.raceWithTimeout工具。从基础的超时控制,到引入自定义错误类型,再到集成AbortSignal实现底层任务的取消,每一步都旨在提升异步操作的可靠性和资源效率。

这个自定义的raceWithTimeout不仅提供了一个明确的超时信号,更重要的是,它通过AbortSignal机制,为开发者提供了一种在超时发生时主动干预和取消底层异步操作的能力。这对于构建响应迅速、资源高效的现代应用程序至关重要。掌握这种模式,将使您在处理复杂的异步流程时更加游刃有余。

未来,随着Web平台和Node.js对取消操作的支持日益完善,类似raceWithTimeout这样的工具将变得更加不可或缺。理解其内部工作原理,并能够根据项目需求进行定制和扩展,是每一位异步编程专家的必备技能。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注