《uni-app 请求与路由拦截终极封装:自动取消请求 + 登录鉴权 + 防重复请求》

1,501 阅读8分钟

前言

在 uni-app 项目开发过程中,我们经常会遇到一些影响体验和开发效率的共性问题,比如页面跳转时残留请求导致的数据错乱、重复登录拦截逻辑分散、重复请求浪费资源等。这些问题如果处理不当,会严重影响应用的稳定性和可维护性。

本文将介绍一套针对这些痛点的高可用解决方案,该方案已通过线上项目验证,支持微信小程序 / APP/H5 多端运行,旨在帮助开发者更高效地管理请求和路由,提升项目质量。


一、开发中常见的痛点问题

在 uni-app 项目中,你是否遇到过这些问题?

  • 🔥 页面跳转时残留请求导致数据错乱:页面跳转后,前页面未完成的请求仍在继续,可能导致后续数据处理异常。
  • 🔐 重复登录拦截逻辑散落在各个页面:每个页面都需要判断登录状态,代码冗余且难以统一维护。
  • 🔄 重复请求浪费资源且难以管理:同一接口短时间内被多次调用,增加服务器压力,也可能导致数据冲突。

二、解决方案核心功能亮点

针对上述问题,封装的解决方案具备以下核心功能:

1️⃣ 智能请求管理

  • 自动生成唯一请求标识,拦截重复请求(取消前序相同请求),避免资源浪费。
  • 路由跳转时自动取消所有 pending 请求,防止内存泄漏和数据错乱。
  • 支持按requestId精准取消单个请求,灵活控制请求生命周期。

2️⃣ 路由拦截增强

  • 重写navigateTo/switchTab等导航方法,统一拦截登录态,减少重复代码。
  • 白名单机制 + 自动跳转登录页 + 回跳原页面,优化登录体验。
  • 内置防抖逻辑,禁止 Tab 页重复点击,避免无效操作。

3️⃣ 精细化错误处理

  • 401 未登录自动跳转(排除首次启动场景),统一处理登录失效问题。
  • 统一 Toast 错误提示,实现业务码逻辑解耦,便于维护。
  • 清晰区分请求取消、业务错误、网络异常等不同错误类型,精准处理。

三、适用场景

该解决方案适用于以下场景:

  • ✅ 需要严格管理请求生命周期的中大型项目:中大型项目请求量大、页面多,请求生命周期管理尤为重要。
  • ✅ 多页面需登录鉴权的电商 / 社交类应用:这类应用对登录状态依赖高,统一的登录拦截能提升开发效率。
  • ✅ 追求代码高复用性与可维护性的团队:方案通过封装减少重复代码,提升项目可维护性。

四、核心模块介绍

1. 路由拦截器:一键搞定导航全场景

路由拦截器接管所有导航行为(navigateTo/switchTab等),自动处理以下问题:

  • 白名单页面直接放行,非白名单页面无 token 时,自动跳登录页并记住回跳地址,登录后可直接返回原页面。
  • 切换路由时自动取消 pending 请求,避免接口 “打架” 导致的数据问题。
  • 提供带加载动画的导航方法,统一管理加载状态,无需在各页面重复编写showLoading逻辑。

使用方式简单,在main.js中引入即可生效:

// main.js引入路由拦截器
import './utils/interceptor';
import http from "./request";

/**
 * 路由拦截与跳转封装工具
 * 基于uni-app导航API二次封装,支持登录校验、权限控制等全局拦截
 */
const RouterInterceptor = {
    // 白名单路由(无需登录)
    whiteList: ['/pages_b/mine/login'],

    /**
     * 初始化路由拦截
     */
    init() {
        // 保存原始导航方法
        const originalNavigateTo = uni.navigateTo;
        const originalRedirectTo = uni.redirectTo;
        const originalReLaunch = uni.reLaunch;
        const originalSwitchTab = uni.switchTab;
        const originalNavigateBack = uni.navigateBack; // 新增:保存原始navigateBack方法

        // 重写导航方法
        uni.navigateTo = this.wrapNavigate('navigateTo', originalNavigateTo);
        uni.redirectTo = this.wrapNavigate('redirectTo', originalRedirectTo);
        uni.reLaunch = this.wrapNavigate('reLaunch', originalReLaunch);
        uni.switchTab = this.wrapNavigate('switchTab', originalSwitchTab);
        uni.navigateBack = this.wrapNavigate('navigateBack', originalNavigateBack); // 新增:重写navigateBack方法
    },

    /**
     * 导航方法包装器
     * @param {string} method - 导航方法名(navigateTo/redirectTo/reLaunch/switchTab/navigateBack)
     * @param {Function} original - 原始导航方法
     * @returns {Function} 包装后的导航方法
     */
    wrapNavigate(method, original) {
        // 使用箭头函数确保this指向RouterInterceptor
        return async (options) => {
            http.cancelRequest();

            // navigateBack特殊处理 (无url参数)
            if (method === 'navigateBack') {
                return original(options);
            }

            const { url } = options;
            const purePath = url.split('?')[0];

            // 避免对登录页本身进行拦截检查,防止无限循环
            if (purePath === '/pages_b/mine/login') {
                return original(options);
            }

            // 获取当前页面路径
            const currentPages = getCurrentPages();
            const currentRoute = currentPages.length ? currentPages[currentPages.length - 1].route : '';

            // 路由拦截逻辑
            if (!this.checkPermission(url)) {
                // 未登录跳转登录页
                return uni.navigateTo({
                    url: `/pages_b/mine/login?redirect=${encodeURIComponent(url)}`
                });
            }

            // 处理tabBar页面特殊逻辑
            if (method === 'switchTab' && url === `/${currentRoute}`) {
                // 防止重复点击tabBar
                return;
            }

            // 执行原始导航方法
            return original(options);
        };
    },

    /**
     * 权限检查
     * @param {string} url - 目标页面路径
     * @returns {boolean} 是否有权限访问
     */
    checkPermission(url) {
        // 提取纯净路径(去除参数)
        const purePath = url.split('?')[0];
        // 白名单路由直接放行
        if (this.whiteList.includes(purePath)) {
            return true;
        }
        // 检查是否登录
        const token = uni.getStorageSync('token');
        return !!token;
    },

    /**
     * 封装带加载动画的导航
     * @param {Object} options - 导航参数
     * @param {string} options.url - 目标页面路径
     * @param {boolean} [options.showLoading=true] - 是否显示加载动画
     * @param {string} [options.loadingText='加载中...'] - 加载动画文本
     */
    async navigateWithLoading(options) {
        const { url, showLoading = true, loadingText = '加载中...' } = options;
        if (showLoading) {
            uni.showLoading({
                title: loadingText,
                mask: true
            });
        }

        try {
            await uni.navigateTo({
                url
            });
        } catch (e) {
            console.error('导航失败:', e);
        } finally {
            if (showLoading) {
                uni.hideLoading();
            }
        }
    }
};

// 初始化路由拦截
RouterInterceptor.init();

export default RouterInterceptor;

2. 请求封装:网络层稳如老狗

基于 luch-request 强化的请求封装,解决请求层 90% 的问题:

  • 请求头自动携带 token,适配 H5 / 小程序 / App 多端 header 差异。
  • 重复请求自动拦截,同一接口即使被多次触发,也只会发送一次请求。
  • 401/500 等错误全局拦截,登录过期自动跳登录页,无需在每个接口中单独判断。
import Request from '../sdk/luch-request/luch-request/index.js';
const http = new Request();

/**
 * 请求任务管理Map,存储请求ID与任务对象的映射
 */
const requestTasks = new Map();

/**
 * 请求标识管理Map,存储请求唯一标识与请求ID的映射,用于检测重复请求
 */
const requestKeys = new Map();

/**
 * 请求ID计数器
 */
let requestId = 0;

/**
 * 生成请求的唯一标识
 * @param {Object} config - 请求配置对象
 * @param {string} config.url - 请求URL
 * @param {string} [config.method='GET'] - 请求方法
 * @param {Object} [config.params={}] - URL查询参数
 * @param {Object} [config.data={}] - 请求体数据
 * @returns {string} 请求唯一标识字符串
 */
function generateRequestKey(config) {
    const { url, method = 'GET' } = config;
    
    // 处理参数,确保顺序一致
    const params = config.params || {};
    const data = config.data || {};

    // 将参数转换为有序字符串
    const paramsStr = Object.keys(params).sort().map(key => `${key}=${params[key]}`).join('&');
    const dataStr = Object.keys(data).sort().map(key => `${key}=${data[key]}`).join('&');

    return `${method.toUpperCase()}:${url}?${paramsStr}#${dataStr}`;
}

/**
 * 修改全局默认配置
 * @param {Function} configCallback - 配置回调函数
 * @returns {Object} 修改后的配置对象
 */
http.setConfig((config) => {
    /* config 为默认全局配置*/
    config.baseURL = import.meta.env.VITE_APP_BASE_API + '/api'; /* 根域名 */
    config.header = {
        'Content-Type': 'application/json;charset=utf-8',
        // #ifdef MP-WEIXIN
        'x-platform': 'wechat',
        'x-app-id': import.meta.env.VITE_APP_WECHAT_ID,
        // #endif
        // #ifdef APP-PLUS
        'x-platform': 'app',
        'x-app-id': import.meta.env.VITE_APP_APP_ID,
        // #endif
    };
    // 添加getTask回调以获取请求任务对象
    config.getTask = (task, options) => {
        if (options.requestId) {
            // 存储任务对象而非配置
            requestTasks.set(options.requestId, task);
        }
    };
    return config;
});

/**
 * 请求拦截器
 * @param {Object} config - 请求配置对象
 * @returns {Object} 处理后的请求配置对象
 */
http.interceptors.request.use(
    (config) => {
        // 生成请求唯一标识
        const requestKey = generateRequestKey(config);

        // 检查是否存在重复请求
        if (requestKeys.has(requestKey)) {
            // 取消之前的重复请求
            const previousRequestId = requestKeys.get(requestKey);
            http.cancelRequest(previousRequestId, '重复请求,已取消');
        }

        // 生成唯一请求ID
        const currentRequestId = requestId++;
        config.requestId = currentRequestId;

        // 存储请求标识与requestId的映射
        requestKeys.set(requestKey, currentRequestId);

        const token = uni.getStorageSync('token')
        if (token) {
            config.header.authorization = 'Bearer ' + token
        }

        return config;
    },
    (config) => {
        // 可使用async await 做异步操作
        return Promise.reject(config);
    },
);

/**
 * 响应拦截器
 * @param {Object} response - 响应对象
 * @returns {Promise<any>} 处理后的响应数据
 */
http.interceptors.response.use(
    async (response) => {
        // 请求完成后移除请求任务和请求标识
        if (response.config?.requestId) {
            console.log('请求完成:', response.config.requestId);
            requestTasks.delete(response.config.requestId);

            // 生成请求标识并从映射中删除
            const requestKey = generateRequestKey(response.config);
            if (requestKeys.get(requestKey) === response.config.requestId) {
                requestKeys.delete(requestKey);
            }
        }

        const res = response.data
        // 业务成功处理
        if (res.code === 0) return res.data

        uni.showToast({
            title: res.message || '请求失败',
            icon: 'none',
            mask: true
        })

        return Promise.reject(new Error(res.message || 'Error'))
    },
    async (error) => {
        // 请求失败后移除请求任务和请求标识
        if (error.config?.requestId) {
            requestTasks.delete(error.config.requestId);

            // 生成请求标识并从映射中删除
            const requestKey = generateRequestKey(error.config);
            if (requestKeys.get(requestKey) === error.config.requestId) {
                requestKeys.delete(requestKey);
            }
        }

        // 处理取消请求的错误
        if (error && error.errMsg === "request:fail abort") {
            console.log('请求已取消:', error.message || error.errMsg);
            return Promise.reject(new Error('请求已取消'));
        }

        const isFirstLaunch = uni.getStorageSync('isFirstLaunch');
        if (!isFirstLaunch && error.statusCode === 401) {
            uni.navigateTo({
                url: '/pages_b/mine/login'
            })
        }
        uni.showToast({
            title: error?.data?.message || '请求失败',
            icon: 'none'
        })
        return Promise.reject(error);
    },
);

/**
 * 取消请求的方法
 * @param {number} [requestId] - 可选,请求ID,如果提供则取消特定请求,否则取消所有请求
 * @param {string} [message='切换路由,已取消'] - 可选,取消请求的原因
 */
http.cancelRequest = function (requestId, message = '切换路由,已取消') {
    if (requestId !== undefined) {
        // 取消特定请求
        const task = requestTasks.get(requestId);
        if (task) {
            // 使用任务对象的abort方法取消请求
            task.abort();
            requestTasks.delete(requestId);
            console.log(`取消请求: ${requestId}, 原因: ${message}`);
        }
    } else {
        // 取消所有请求
        requestTasks.forEach((task, id) => {
            task.abort();
            console.log(`取消请求: ${id}, 原因: ${message}`);
        });
        requestTasks.clear();
        requestKeys.clear(); // 清空请求标识映射
    }
};

export default http;

五、使用示例

路由跳转示例

// 直接使用重写后的导航方法,自动触发拦截逻辑
uni.navigateTo({ url: '/pages/index/detail' });

请求调用示例

// 引入封装的请求工具
import http from '../utils/request';
// 定义接口调用方法
export const fetchData = (params) => {
  return http.request({
    url: '/api/data/list',
    method: 'GET',
    params
  });
};

总结

本文介绍的 uni-app 请求与路由管理解决方案,通过智能请求管理、路由拦截增强和精细化错误处理,有效解决了开发中常见的请求混乱、登录拦截分散、重复请求等痛点问题。

该方案不仅提升了应用的稳定性和用户体验,还通过代码封装减少了重复开发工作,提高了团队的开发效率。方案支持多端运行且已通过线上验证,适合在中大型 uni-app 项目中推广使用。