XMLHttpRequest实战指南从基础到高级应用的完整踩坑记录

小智营 交互 阅读 2,983
赞 20 收藏
二维码
手机扫码查看
反馈

XMLHttpRequest超时处理又出问题了

昨天在项目里碰到一个诡异的问题,XMLHttpRequest请求有时候会卡住很久才报错,用户体验特别差。本来以为是个简单的timeout设置,结果折腾了一下午才搞定,还是记录一下吧。

XMLHttpRequest实战指南从基础到高级应用的完整踩坑记录

一开始的想法太天真

我最开始的做法特别简单粗暴:

function makeRequest(url) {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.timeout = 5000; // 设置5秒超时
    
    xhr.onload = function() {
        if (xhr.status === 200) {
            console.log('成功', xhr.responseText);
        } else {
            console.log('失败', xhr.status);
        }
    };
    
    xhr.ontimeout = function() {
        console.log('请求超时');
    };
    
    xhr.onerror = function() {
        console.log('网络错误');
    };
    
    xhr.send();
}

看起来挺正常的对吧?但问题是,有时候在网速极慢的情况下,请求还是会hang住很久才触发timeout。而且在某些移动端浏览器上,timeout的响应也不够及时。

这里我踩了个坑

折腾了半天发现,XMLHttpRequest的timeout机制其实有局限性。它只计算从send()开始到接收到响应的时间,但如果网络完全断了,或者DNS解析阶段就卡住了,timeout可能不会按预期工作。特别是移动端,网络环境复杂,经常出现这种情况。

我还发现一个问题,当用户切换到后台应用时,有些浏览器会暂停网络请求,这样timeout的时间就不准确了。比如用户切到微信聊了会天,回来的时候发现页面还在loading,但实际可能已经超时很久了。

改进版:手动控制时间

后来试了下发现,结合原生timeout事件和手动时间控制会更可靠:

function makeRequestWithBetterTimeout(url) {
    const xhr = new XMLHttpRequest();
    const timeoutDuration = 8000; // 8秒超时
    let timeoutId;
    
    // 设置手动超时
    timeoutId = setTimeout(() => {
        xhr.abort(); // 先取消请求
        console.log('手动超时处理');
        handleTimeout();
    }, timeoutDuration);
    
    // 原生timeout事件
    xhr.timeout = timeoutDuration;
    
    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4) {
            clearTimeout(timeoutId); // 请求完成,清除定时器
            
            if (xhr.status >= 200 && xhr.status < 300) {
                handleSuccess(xhr.responseText);
            } else if (xhr.status === 0) {
                // 网络错误或请求被取消
                handleError('Network error or cancelled');
            } else {
                handleError(HTTP ${xhr.status});
            }
        }
    };
    
    xhr.ontimeout = function() {
        clearTimeout(timeoutId);
        handleTimeout();
    };
    
    xhr.onerror = function() {
        clearTimeout(timeoutId);
        handleError('Request failed');
    };
    
    try {
        xhr.send();
    } catch (e) {
        clearTimeout(timeoutId);
        handleError('Send error: ' + e.message);
    }
}

function handleSuccess(data) {
    console.log('请求成功:', data);
}

function handleError(error) {
    console.log('请求失败:', error);
}

function handleTimeout() {
    console.log('请求超时,请检查网络连接');
}

还有个更复杂的场景

项目里还有一个特殊需求,就是需要支持重试机制。用户网络不稳定时,不能直接显示失败,要自动重试几次。这就涉及到timeout和重试的配合:

function makeRequestWithRetry(url, maxRetries = 3, retryDelay = 1000) {
    let attempts = 0;
    
    function attemptRequest() {
        const xhr = new XMLHttpRequest();
        const timeoutDuration = 10000;
        let timeoutId;
        
        timeoutId = setTimeout(() => {
            xhr.abort();
            handleAttemptFailure('timeout');
        }, timeoutDuration);
        
        xhr.timeout = timeoutDuration;
        
        xhr.onreadystatechange = function() {
            if (xhr.readyState === 4) {
                clearTimeout(timeoutId);
                
                if (xhr.status >= 200 && xhr.status < 300) {
                    handleSuccess(xhr.responseText);
                } else if (xhr.status === 0) {
                    // 网络错误,可能是timeout或其他网络问题
                    handleAttemptFailure('network');
                } else {
                    // HTTP错误,通常是服务器问题
                    handleAttemptFailure('http');
                }
            }
        };
        
        xhr.ontimeout = function() {
            clearTimeout(timeoutId);
            handleAttemptFailure('timeout');
        };
        
        xhr.onerror = function() {
            clearTimeout(timeoutId);
            handleAttemptFailure('network');
        };
        
        try {
            xhr.send();
        } catch (e) {
            clearTimeout(timeoutId);
            handleAttemptFailure('send');
        }
    }
    
    function handleAttemptFailure(type) {
        attempts++;
        console.log(尝试第${attempts}次失败,类型:${type});
        
        if (attempts <= maxRetries) {
            console.log(等待${retryDelay}ms后重试);
            setTimeout(attemptRequest, retryDelay);
            retryDelay *= 1.5; // 递增延迟时间
        } else {
            console.log(已重试${maxRetries}次,放弃请求);
            finalFailure(type);
        }
    }
    
    function handleSuccess(data) {
        console.log('最终成功:', data);
        // 这里处理成功的逻辑
    }
    
    function finalFailure(type) {
        console.log('所有重试都失败了,类型:', type);
        // 这里处理最终失败的逻辑
    }
    
    attemptRequest();
}

移动端特殊处理

后来发现在iOS Safari上有个奇葩问题,就是当设备进入休眠状态后,timeout计时可能会暂停,导致实际的超时时间比设定的要长很多。为了应对这个情况,我又加了一个页面可见性检测:

// 页面可见性变化处理
document.addEventListener('visibilitychange', function() {
    if (document.visibilityState === 'visible') {
        // 页面重新可见,可以做一些清理工作
        // 比如检查是否有长时间挂起的请求
        cleanupStaleRequests();
    }
});

let activeRequests = new Map(); // 存储活跃的请求

function makeRequestWithVisibilityCheck(url) {
    const requestId = Date.now() + Math.random();
    const xhr = new XMLHttpRequest();
    const timeoutDuration = 10000;
    let startTime = Date.now();
    
    // 记录活跃请求
    activeRequests.set(requestId, {
        xhr: xhr,
        startTime: startTime
    });
    
    const timeoutId = setTimeout(() => {
        xhr.abort();
        activeRequests.delete(requestId);
        handleTimeout();
    }, timeoutDuration);
    
    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4) {
            clearTimeout(timeoutId);
            activeRequests.delete(requestId);
            
            if (xhr.status >= 200 && xhr.status < 300) {
                handleSuccess(xhr.responseText);
            } else {
                handleError('Request failed');
            }
        }
    };
    
    return {
        xhr: xhr,
        cancel: () => {
            clearTimeout(timeoutId);
            xhr.abort();
            activeRequests.delete(requestId);
        }
    };
}

function cleanupStaleRequests() {
    const now = Date.now();
    activeRequests.forEach((req, id) => {
        if (now - req.startTime > 15000) { // 超过15秒的请求
            req.xhr.abort();
            activeRequests.delete(id);
        }
    });
}

性能考虑

上面这些方案虽然解决了问题,但也带来了一些性能上的顾虑。每次请求都创建多个定时器,频繁的abort操作是否会影响性能?经过测试发现,在正常情况下影响不大,但如果是高频率的请求场景,确实需要注意资源管理。

所以最后我又优化了一下,把定时器管理统一起来:

class RequestManager {
    constructor() {
        this.requests = new Map();
        this.checkInterval = null;
    }
    
    addRequest(id, xhr, timeoutMs) {
        this.requests.set(id, {
            xhr: xhr,
            startTime: Date.now(),
            timeoutMs: timeoutMs
        });
        
        if (!this.checkInterval) {
            this.startChecking();
        }
    }
    
    removeRequest(id) {
        this.requests.delete(id);
        if (this.requests.size === 0 && this.checkInterval) {
            clearInterval(this.checkInterval);
            this.checkInterval = null;
        }
    }
    
    startChecking() {
        this.checkInterval = setInterval(() => {
            const now = Date.now();
            for (let [id, req] of this.requests) {
                if (now - req.startTime >= req.timeoutMs) {
                    req.xhr.abort();
                    this.removeRequest(id);
                }
            }
        }, 1000); // 每秒检查一次
    }
    
    clear() {
        this.requests.forEach(req => req.xhr.abort());
        this.requests.clear();
        if (this.checkInterval) {
            clearInterval(this.checkInterval);
            this.checkInterval = null;
        }
    }
}

// 使用示例
const requestManager = new RequestManager();

function makeManagedRequest(url) {
    const xhr = new XMLHttpRequest();
    const requestId = Symbol('request');
    
    xhr.onreadystatechange = function() {
        if (xhr.readyState === 4) {
            requestManager.removeRequest(requestId);
            // 处理响应...
        }
    };
    
    requestManager.addRequest(requestId, xhr, 10000);
    xhr.send();
}

踩坑提醒:这几点一定注意

总结一下踩过的几个坑:

  • 单纯依赖XMLHttpRequest的timeout属性不够可靠,特别是在移动端
  • abort()之后状态码是0,要区分真正的网络错误和主动取消
  • 页面隐藏/显示状态下,定时器行为可能不一致
  • 频繁的请求-取消操作可能影响性能,要做好资源管理
  • 重试机制要考虑指数退避,避免给服务器造成压力

目前这套方案在线上跑了几个月,基本上没出现过请求hang死的情况了。虽然代码比最初版本复杂了不少,但稳定性提升很明显。网上很多教程只讲基本用法,实际项目中的复杂场景还是要自己摸索。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论