问题描述

小程序使用过程中,突然出现页面卡顿的问题:

  • 点击任何模块都没有反应
  • 只能通过微信自带的返回或右上角图标操作
  • 重新进入小程序后恢复正常

复现场景:用户在网络较慢时快速多次点击按钮,触发相同的接口请求。

问题原因

在 HTTP 请求封装中实现了"防重复请求"功能,但实现方式有缺陷:

​```javascript
// 有问题的代码
const map = new Set();

const http = (config) => {
return new Promise((resolve, reject) => {
const key = ${config.url}${JSON.stringify(config.data)};

// 问题:检测到重复请求后直接 return
if (map.has(key)) {
  console.log('检测到重复请求,已拦截:', key);
  return;  // ❌ Promise 永远 pending,不会 resolve/reject
}

map.add(key);
uni.request({
  // ...
  complete() {
    map.delete(key);
  },
});

});
};


**问题分析**:

1. 检测到重复请求时,函数直接 `return;`
2. 这只是从 Promise executor 回调中返回,**并不会 resolve 或 reject Promise**
3. 调用方得到一个**永远处于 pending 状态**的 Promise
4. 页面的 loading/mask 依赖 Promise 完成来关闭,但永远等不到
5. 用户界面被"锁定",表现为"卡顿"

Promise 状态流转对比:

正常请求: pending → resolved/rejected ✅
被拦截的重复请求: pending → (永远 pending) ❌

loading 永不消失
页面无法响应


## 解决方案

将"直接拦截返回"改为"复用同一个 Promise":

```javascript
// 修复后的代码
const pendingRequests = new Map();  // 改用 Map 存储 Promise

/**
 * 生成请求唯一标识
 */
const generateRequestKey = (config) => {
  const dataStr = config.data ? JSON.stringify(config.data) : '';
  return `${config.method || 'GET'}_${config.url}_${dataStr}`;
};

const http = (config) => {
  const key = generateRequestKey(config);

  // ✅ 如果存在相同的请求正在进行中,复用该 Promise
  if (pendingRequests.has(key)) {
    console.log('检测到重复请求,复用已有Promise:', key);
    return pendingRequests.get(key);  // 返回已有的 Promise
  }

  // 创建新的 Promise
  const promise = new Promise((resolve, reject) => {
    uni.request({
      method: 'GET',
      ...config,
      success(response) {
        resolve(response.data);
      },
      fail(err) {
        reject(err);
      },
      complete() {
        // 请求完成后从缓存中移除
        pendingRequests.delete(key);
      },
    });
  });

  // 在返回前将 Promise 存入缓存
  pendingRequests.set(key, promise);

  return promise;
};

完整 Demo 示例

// http.js - 完整的请求封装

/**
 * 请求缓存 Map
 * 结构: { requestKey: Promise }
 */
const pendingRequests = new Map();

/**
 * 生成请求唯一标识
 * @param {Object} config 请求配置
 * @returns {string} 唯一标识
 */
const generateRequestKey = (config) => {
  const method = (config.method || 'GET').toUpperCase();
  const url = config.url || '';
  const dataStr = config.data ? JSON.stringify(config.data) : '';
  return `${method}_${url}_${dataStr}`;
};

/**
 * 封装的 HTTP 请求方法
 * @param {Object} config 请求配置
 * @returns {Promise} 请求 Promise
 */
const http = (config) => {
  const key = generateRequestKey(config);

  // 检查是否有相同的请求正在进行
  if (pendingRequests.has(key)) {
    console.log('[HTTP] 复用已有请求:', key);
    // 核心修复:返回已有的 Promise,而非 return;
    return pendingRequests.get(key);
  }

  // 创建新的请求 Promise
  const promise = new Promise((resolve, reject) => {
    // 默认配置
    const defaultConfig = {
      method: 'GET',
      timeout: 30000,
      header: {
        'Content-Type': 'application/json',
      },
    };

    // 合并配置
    const finalConfig = {
      ...defaultConfig,
      ...config,
      header: {
        ...defaultConfig.header,
        ...config.header,
      },
    };

    console.log('[HTTP] 发送请求:', key);

    // 发送请求(uni-app 环境)
    uni.request({
      ...finalConfig,
      success(response) {
        const { statusCode, data } = response;

        if (statusCode >= 200 && statusCode < 300) {
          // 业务层状态码判断
          if (data.code === 0 || data.code === 200) {
            resolve(data.data || data);
          } else {
            reject(new Error(data.message || '请求失败'));
          }
        } else {
          reject(new Error(`HTTP Error: ${statusCode}`));
        }
      },
      fail(err) {
        console.error('[HTTP] 请求失败:', err);
        reject(new Error(err.errMsg || '网络请求失败'));
      },
      complete() {
        // 关键:请求完成后清理缓存
        pendingRequests.delete(key);
        console.log('[HTTP] 请求完成,清理缓存:', key);
      },
    });
  });

  // 将 Promise 存入缓存
  pendingRequests.set(key, promise);

  return promise;
};

/**
 * GET 请求
 */
const get = (url, params = {}, config = {}) => {
  return http({
    method: 'GET',
    url,
    data: params,
    ...config,
  });
};

/**
 * POST 请求
 */
const post = (url, data = {}, config = {}) => {
  return http({
    method: 'POST',
    url,
    data,
    ...config,
  });
};

/**
 * 取消所有正在进行的请求
 * 用于页面卸载等场景
 */
const cancelAllRequests = () => {
  pendingRequests.clear();
  console.log('[HTTP] 已清理所有待处理请求');
};

/**
 * 获取当前待处理请求数量
 */
const getPendingCount = () => {
  return pendingRequests.size;
};

export default http;
export { get, post, cancelAllRequests, getPendingCount };

使用示例

<template>
  <div class="page">
    <button @click="fetchData" :disabled="loading">
      {{ loading ? '加载中...' : '获取数据' }}
    </button>
    <div v-if="data">{{ data }}</div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { get } from '@/api/http';

const loading = ref(false);
const data = ref(null);

const fetchData = async () => {
  loading.value = true;

  try {
    // 即使用户快速点击多次,也只会发送一个请求
    // 后续点击复用第一个请求的 Promise
    const result = await get('/api/user/info', { id: 123 });
    data.value = result;
  } catch (error) {
    console.error('请求失败:', error.message);
  } finally {
    // Promise 正常完成,loading 正确关闭
    loading.value = false;
  }
};
</script>

核心机制详解

1. 请求流程对比

修复前(问题):
┌────────────────────────────────────────────────────┐
│ 第1次点击: http(config) → Promise1(pending)        │
│   └─ uni.request 发送,等待响应                    │
│                                                    │
│ 第2次点击: http(config) → Promise2(pending)        │
│   └─ 检测到重复,return;                           │
│   └─ Promise2 永远 pending ❌                      │
│                                                    │
│ 响应返回: Promise1 → resolved                      │
│           Promise2 → 仍然 pending ❌               │
│                                                    │
│ 结果: 第2次调用的 .finally() 永远不会执行          │
│       loading 不关闭,页面卡死                     │
└────────────────────────────────────────────────────┘

修复后(正确):
┌────────────────────────────────────────────────────┐
│ 第1次点击: http(config) → Promise1(pending)        │
│   └─ uni.request 发送,等待响应                    │
│   └─ pendingRequests.set(key, Promise1)            │
│                                                    │
│ 第2次点击: http(config) → Promise1(pending)        │
│   └─ 检测到重复,return pendingRequests.get(key)   │
│   └─ 返回同一个 Promise1 ✅                        │
│                                                    │
│ 响应返回: Promise1 → resolved                      │
│   └─ 第1次和第2次调用同时得到响应 ✅               │
│   └─ complete() 清理缓存                           │
│                                                    │
│ 结果: 两个调用的 .finally() 都正常执行             │
│       loading 正确关闭,页面响应正常               │
└────────────────────────────────────────────────────┘

2. 为什么用 Map 而非 Set

// Set 只能存储键,无法存储 Promise
const set = new Set();
set.add(key);
// 无法获取对应的 Promise

// Map 可以存储键值对
const map = new Map();
map.set(key, promise);
const cachedPromise = map.get(key);  // 可以获取 Promise

3. 请求标识的生成

// 不完善:相同 URL 不同方法会冲突
const key = url + JSON.stringify(data);

// 完善:包含请求方法
const key = `${method}_${url}_${JSON.stringify(data)}`;

// 示例:
// GET_/api/user_{}
// POST_/api/user_{"name":"test"}
// 这两个是不同的请求

4. 缓存清理的时机

uni.request({
  success() {
    // 成功回调先执行
    resolve(data);
  },
  fail() {
    // 失败回调先执行
    reject(error);
  },
  complete() {
    // complete 总是最后执行
    // 此时 Promise 已经 settled
    // 可以安全地清理缓存
    pendingRequests.delete(key);
  },
});

经验总结

1. Promise executor 中 return 的陷阱

// 错误理解
new Promise((resolve, reject) => {
  if (condition) {
    return;  // ❌ 这只是从函数返回,Promise 仍然 pending
  }
  resolve(data);
});

// 正确理解
new Promise((resolve, reject) => {
  if (condition) {
    resolve(null);  // ✅ 明确 resolve
    return;
  }
  // 或者
  if (condition) {
    reject(new Error('条件不满足'));  // ✅ 明确 reject
    return;
  }
  resolve(data);
});

2. 防重复请求的正确姿势

// 方案1: 复用 Promise(本文方案)
const cache = new Map();

const request = (config) => {
  const key = generateKey(config);

  if (cache.has(key)) {
    return cache.get(key);  // 复用已有 Promise
  }

  const promise = doRequest(config);
  cache.set(key, promise);

  promise.finally(() => cache.delete(key));

  return promise;
};

// 方案2: 取消之前的请求
const abortControllers = new Map();

const request = (config) => {
  const key = generateKey(config);

  // 取消之前的请求
  if (abortControllers.has(key)) {
    abortControllers.get(key).abort();
  }

  const controller = new AbortController();
  abortControllers.set(key, controller);

  return fetch(config.url, {
    signal: controller.signal,
  }).finally(() => {
    abortControllers.delete(key);
  });
};

3. 使用 finally 确保清理

// 不推荐:分别在 success 和 fail 中清理
success() {
  pendingRequests.delete(key);  // 需要写两遍
},
fail() {
  pendingRequests.delete(key);  // 容易遗漏
}

// 推荐:使用 complete/finally
complete() {
  pendingRequests.delete(key);  // 只写一次,保证执行
}

// 或者 Promise.finally
promise.finally(() => {
  cache.delete(key);
});

4. 页面卸载时的清理

// 页面组件中
import { cancelAllRequests } from '@/api/http';

onUnload(() => {
  // 清理所有待处理请求,防止页面已销毁但回调仍在执行
  cancelAllRequests();
});

调试技巧

1. 模拟慢网络

// 在请求封装中添加延迟,方便调试重复请求问题
const http = (config) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      // 实际请求逻辑
    }, 3000);  // 模拟 3 秒延迟
  });
};

2. 日志输出

const http = (config) => {
  const key = generateRequestKey(config);

  console.log('[HTTP] 请求开始:', key);
  console.log('[HTTP] 当前缓存数量:', pendingRequests.size);
  console.log('[HTTP] 缓存内容:', [...pendingRequests.keys()]);

  if (pendingRequests.has(key)) {
    console.log('[HTTP] 命中缓存,复用 Promise');
  }

  // ...
};

3. 检测 Promise 状态

// 辅助函数:检测 Promise 是否已完成
const isPromiseSettled = async (promise) => {
  const unique = Symbol('unique');
  const result = await Promise.race([
    promise.then(() => 'resolved').catch(() => 'rejected'),
    Promise.resolve(unique),
  ]);
  return result !== unique;
};

// 使用
const promise = http(config);
console.log('是否已完成:', await isPromiseSettled(promise));

问题排查清单

当遇到类似的"页面卡死"问题时,可按以下步骤排查:

  1. 检查 Promise 是否正确 resolve/reject

    • 搜索代码中的 return; 是否在 Promise executor 内部
    • 确保所有分支都有明确的 resolve 或 reject
  2. 检查 loading/mask 的关闭逻辑

    • 是否依赖 Promise 的 finally/then/catch
    • 是否有遗漏的关闭路径
  3. 检查防重复请求的实现

    • 是否正确处理了重复请求的情况
    • 被拦截的请求是否返回了可用的 Promise
  4. 网络请求超时设置

    • 是否设置了合理的超时时间
    • 超时后是否正确触发了 reject
Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐