在这里插入图片描述

一、我们要做什么

1.1 需求背景

AtomGit App 需同时支持 Android、iOS 和鸿蒙三大平台。早期开发中,网络请求散落在各业务组件里,每次 API 调用都要重复编写请求头、错误处理和 Loading 逻辑。问题逐渐暴露:Token 过期处理散落十余处、错误提示不统一、弱网场景无限重试导致页面卡死、5xx 响应时前端毫无感知。

UniApp 的 uni.request 只是薄薄的 HTTP 客户端封装,缺少请求拦截、响应拦截、自动重试、统一错误处理等企业级能力。鸿蒙 NEXT 采用全新安全沙箱:网络权限须在 module.json5 中声明,默认禁止明文 HTTP 流量。这些平台差异如不在封装层处理,业务代码将充斥着平台判断语句。

1.2 核心需求清单

功能性需求:统一请求入口,所有 API 调用通过同一 request 函数发起,业务层不感知底层实现。Token 自动注入,从 Pinia store 读取 accessToken 并附加到 Authorization 请求头,无需每个接口手动拼接。双拦截器链,请求拦截器修改配置,响应拦截器统一处理错误码与 Token 刷新。智能重试,指数退避策略最多 3 次。Loading 与并发控制,全局遮罩所有请求完成后关闭。

平台适配需求:Android/iOS 用 uni.request,鸿蒙端用 @ohos.net.http,通过条件编译 // #ifdef 在编译期解决平台差异。鸿蒙端声明网络权限。

质量需求:TypeScript 类型全覆盖、统一错误处理、可读的调试日志。

1.3 主要挑战

拦截器设计模式:不同模型在异常传播、异步处理上行为各异,选错会导致 Token 刷新等异步逻辑无法正确等待,引发竞态条件。

Token 刷新的竞态处理:accessToken 过期时,响应拦截器发起 Token 刷新请求,此时其他并发请求正在等待。需避免重复刷新(惊群效应),也需在刷新失败时统一报错。引入 Promise 缓存机制解决。

鸿蒙端权限与 API 差异:鸿蒙网络 API 在超时、进度回调等方面与 Android 不一致。封装层通过条件编译在编译期处理,避免运行时判断的性能损耗。


二、数据模型设计

2.1 请求配置与响应结构

// network/types.ts

/** 基础请求配置 */
export interface BaseConfig {
  baseURL: string;                          // 基础 URL
  timeout: number;                           // 默认超时(毫秒)
  headers: Record<string, string>;           // 默认请求头
}

/** 单次请求配置 */
export interface RequestConfig {
  url: string;
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
  params?: Record<string, string | number>;  // 替换 url 中 :id 占位符
  data?: any;                                // 请求体
  headers?: Record<string, string>;
  timeout?: number;
  loading?: boolean;                         // 默认 true
  ignoreError?: boolean;
  retry?: boolean;                            // 默认 false
  retryCount?: number;                        // 默认 3
  retryDelay?: number;                        // 默认 1000ms
}

/** 标准化响应(业务层使用) */
export interface NetworkResponse<T = any> {
  success: boolean;   // 2xx
  code: number;       // 后端业务码,0=成功
  message: string;
  data: T;
  raw: RawResponse;
  duration: number;   // 耗时 ms
}

/** 原始响应 */
export interface RawResponse { statusCode: number; data: any; headers: Record<string, string>; }

/** 内部配置(含运行时字段) */
export interface InternalConfig extends RequestConfig {
  baseURL: string;
  requestId: string;
  startTime: number;
}

2.2 错误类型体系

// network/types.ts(续)

export enum NetworkErrorType {
  NETWORK_UNAVAILABLE = 'NETWORK_UNAVAILABLE',
  TIMEOUT = 'TIMEOUT',
  SERVER_ERROR = 'SERVER_ERROR',      // 5xx
  CLIENT_ERROR = 'CLIENT_ERROR',       // 4xx
  UNAUTHORIZED = 'UNAUTHORIZED',
  REQUEST_CANCELLED = 'REQUEST_CANCELLED',
  UNKNOWN = 'UNKNOWN',
}

export class NetworkError extends Error {
  type: NetworkErrorType;
  code: number;
  raw?: RawResponse;
  isNetworkError: boolean;

  constructor(type: NetworkErrorType, message: string, code = -1, raw?: RawResponse) {
    super(message);
    this.name = 'NetworkError';
    this.type = type; this.code = code; this.raw = raw;
    this.isNetworkError = [NetworkErrorType.NETWORK_UNAVAILABLE, NetworkErrorType.TIMEOUT, NetworkErrorType.UNKNOWN].includes(type);
  }
}

2.3 认证状态 Pinia Store

// stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useAuthStore = defineStore('auth', () => {
  const accessToken = ref<string>('');
  const refreshToken = ref<string>('');
  const expiresAt = ref<number>(0);
  const isLoggedIn = computed(() => !!accessToken.value && Date.now() < expiresAt.value);

  function setTokens(access: string, refresh: string, ttlSeconds = 7200) {
    accessToken.value = access; refreshToken.value = refresh;
    expiresAt.value = Date.now() + ttlSeconds * 1000;
  }
  function clearTokens() { accessToken.value = ''; refreshToken.value = ''; expiresAt.value = 0; }
  async function refreshAccessToken(): Promise<string> { return refreshToken.value; }
  return { accessToken, refreshToken, expiresAt, isLoggedIn, setTokens, clearTokens, refreshAccessToken };
});

三、核心设计决策

3.1 底层 HTTP 客户端选型

维度 uni.request fetch API 混合方案(最终)
Android/iOS 兼容性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
鸿蒙端支持 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
超时控制 原生 需手动 原生
请求取消 原生 AbortController AbortController
开发成本

uni.request 为主、鸿蒙端降级 @ohos.net.http,通过条件编译 // #ifdef APP-HARMONY 在编译期处理平台差异。

3.2 拦截器链:同步 Promise 顺序链

每个拦截器是返回 Promise<InternalConfig>Promise<NetworkResponse> 的异步函数,拦截器管理器维护有序数组,依次调用并传递前一个的输出。链中任一拦截器抛出异常,后续不执行(Fail-Fast)。响应拦截器的异常应被捕获并转换为错误对象,而非向上传播导致未捕获 rejection。

3.3 重试策略:指数退避 + 抖动

固定间隔重试可能在服务端过载时加剧问题。带抖动的指数退避:第 N 次等待 baseDelay × 2^(N-1) × (1 + random(0,0.5)),即 1s、2s、4s 叠加随机抖动,避免 Thundering Herd Problem(大量客户端同时集中重试)。

3.4 Loading:全局 Pinia 计数器

组件级 Loading 在多请求并发时闪烁。全局计数器:从 0→1 显示遮罩,从 1→0 关闭遮罩。多个请求并发时遮罩只显示一次,天然解决闪烁问题。

3.5 并发控制:请求取消 + 最新结果优先

快速搜索场景中多个请求并发,后端乱序返回导致展示错误结果。方案:Abortable 请求取消旧请求,结合请求 ID 确保只有最新请求结果被处理。


四、完整代码实现

4.1 核心请求类

// network/request.ts
import type { RequestConfig, InternalConfig, NetworkResponse, RawResponse, BaseConfig } from './types';
import { NetworkError, NetworkErrorType } from './types';
import { useAuthStore } from '@/stores/auth';
import { useLoadingStore } from '@/stores/loading';

class UniRequest {
  private baseConfig: BaseConfig;
  private reqInterceptors: Array<(c: InternalConfig) => InternalConfig | Promise<InternalConfig>> = [];
  private resInterceptors: Array<(r: NetworkResponse) => NetworkResponse | Promise<NetworkResponse>> = [];
  private refreshTokenPromise: Promise<string> | null = null;

  constructor(config: BaseConfig) { this.baseConfig = config; this.setupDefaultInterceptors(); }

  requestInterceptor(fn: (c: InternalConfig) => InternalConfig | Promise<InternalConfig>) { this.reqInterceptors.push(fn); }
  responseInterceptor(fn: (r: NetworkResponse) => NetworkResponse | Promise<NetworkResponse>) { this.resInterceptors.push(fn); }

  private setupDefaultInterceptors() {
    // Token 注入拦截器
    this.requestInterceptor(async (config) => {
      const auth = useAuthStore();
      if (auth.accessToken) config.headers['Authorization'] = `Bearer ${auth.accessToken}`;
      config.headers['Content-Type'] = config.headers['Content-Type'] || 'application/json';
      return config;
    });
    // 401 处理拦截器
    this.responseInterceptor(async (res) => { if (res.code === 401) await this.handleUnauthorized(); return res; });
  }

  /** 对外暴露的请求入口 */
  async request<T = any>(config: RequestConfig): Promise<NetworkResponse<T>> {
    const internal = this.buildInternalConfig(config);
    const loadingStore = useLoadingStore();
    if (internal.loading) loadingStore.increment();
    try {
      const processed = await this.runRequestInterceptors(internal);
      const raw = await this.executeWithRetry(processed);
      const normalized = this.normalize<T>(raw, internal);
      return await this.runResponseInterceptors(normalized);
    } catch (err) { return this.handleError(err, internal); }
    finally { if (internal.loading) loadingStore.decrement(); }
  }

  private buildInternalConfig(cfg: RequestConfig): InternalConfig {
    let url = cfg.url;
    if (cfg.params) Object.entries(cfg.params).forEach(([k, v]) => { url = url.replace(`:${k}`, String(v)); });
    return {
      url, method: (cfg.method || 'GET').toUpperCase(), baseURL: this.baseConfig.baseURL,
      params: cfg.params || {}, data: cfg.data,
      headers: { ...this.baseConfig.headers, ...cfg.headers },
      timeout: cfg.timeout ?? this.baseConfig.timeout,
      loading: cfg.loading ?? true, ignoreError: cfg.ignoreError ?? false,
      retry: cfg.retry ?? false, retryCount: cfg.retryCount ?? 3, retryDelay: cfg.retryDelay ?? 1000,
      requestId: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
      startTime: Date.now(),
    };
  }

  private async runRequestInterceptors(cfg: InternalConfig): Promise<InternalConfig> {
    let result = cfg;
    for (const fn of this.reqInterceptors) result = await fn(result);
    return result;
  }

  private async runResponseInterceptors(res: NetworkResponse): Promise<NetworkResponse> {
    let result = res;
    for (const fn of this.resInterceptors) result = await fn(result);
    return result;
  }

  /** 含重试逻辑的执行 */
  private async executeWithRetry(cfg: InternalConfig): Promise<RawResponse> {
    let lastError: Error | null = null;
    for (let i = 0; i <= cfg.retryCount; i++) {
      try { return await this.executeRequest(cfg); }
      catch (err: any) {
        lastError = err;
        if (!this.shouldRetry(err, cfg, i) || i === cfg.retryCount) throw err;
        const delay = cfg.retryDelay * Math.pow(2, i) * (1 + Math.random() * 0.5);
        await new Promise(r => setTimeout(r, delay));
      }
    }
    throw lastError!;
  }

  private shouldRetry(err: NetworkError, cfg: InternalConfig, attempt: number): boolean {
    if (!cfg.retry) return false;
    return [NetworkErrorType.TIMEOUT, NetworkErrorType.SERVER_ERROR, NetworkErrorType.NETWORK_UNAVAILABLE].includes(err.type) && attempt < cfg.retryCount;
  }

  /** 平台差异化请求 */
  private async executeRequest(cfg: InternalConfig): Promise<RawResponse> {
    const url = this.buildFullURL(cfg);
    // #ifdef APP-HARMONY
    return this.executeHarmony(cfg, url);
    // #endif
    // #ifndef APP-HARMONY
    return this.executeUni(cfg, url);
    // #endif
  }

  private executeUni(cfg: InternalConfig, url: string): Promise<RawResponse> {
    return new Promise((resolve, reject) => {
      uni.request({
        url, method: cfg.method as any, header: cfg.headers,
        data: cfg.data, timeout: cfg.timeout,
        success: (res: any) => resolve({ statusCode: res.statusCode, data: res.data, headers: res.header || {} }),
        fail: (err: any) => reject(this.toNetworkError(err, cfg)),
      });
    });
  }

  private executeHarmony(cfg: InternalConfig, url: string): Promise<RawResponse> {
    return new Promise((resolve, reject) => {
      try {
        const http = uni.requireNativePlugin('harmony-http');
        http.request({
          url, method: cfg.method, header: cfg.headers,
          extraData: cfg.data, connectTimeout: cfg.timeout, readTimeout: cfg.timeout,
          success: (res: any) => resolve({ statusCode: res.statusCode, data: res.data, headers: res.header || {} }),
          fail: (err: any) => reject(this.toNetworkError(err, cfg)),
        });
      } catch (e) { reject(this.toNetworkError(e, cfg)); }
    });
  }

  private buildFullURL(cfg: InternalConfig): string {
    const base = cfg.url.startsWith('http') ? cfg.url : `${cfg.baseURL}${cfg.url}`;
    const qs = new URLSearchParams(cfg.params as any).toString();
    return qs ? `${base}${base.includes('?') ? '&' : '?'}${qs}` : base;
  }

  private normalize<T>(raw: RawResponse, cfg: InternalConfig): NetworkResponse<T> {
    const ok = raw.statusCode >= 200 && raw.statusCode < 300;
    const d = raw.data;
    return { success: ok, code: ok ? (d?.code ?? 0) : raw.statusCode, message: d?.message || (ok ? '成功' : '失败'), data: d?.data ?? d, raw, duration: Date.now() - cfg.startTime };
  }

  private toNetworkError(err: any, cfg: InternalConfig): NetworkError {
    let type: NetworkErrorType = NetworkErrorType.UNKNOWN;
    if (err.errMsg?.includes('timeout')) type = NetworkErrorType.TIMEOUT;
    else if (err.statusCode === 401) type = NetworkErrorType.UNAUTHORIZED;
    else if (err.statusCode >= 500) type = NetworkErrorType.SERVER_ERROR;
    else if (err.statusCode >= 400) type = NetworkErrorType.CLIENT_ERROR;
    return new NetworkError(type, err.errMsg || '请求失败', err.statusCode || -1, err);
  }

  private async handleError(err: any, cfg: InternalConfig): Promise<never> {
    const ne = err instanceof NetworkError ? err : this.toNetworkError(err, cfg);
    if (!cfg.ignoreError) {
      const tips: Record<string, string> = { NETWORK_UNAVAILABLE: '网络不可用', TIMEOUT: '请求超时', SERVER_ERROR: '服务器异常', UNAUTHORIZED: '登录已过期' };
      uni.showToast({ title: tips[ne.type] || '网络异常', icon: 'none', duration: 2000 });
    }
    throw ne;
  }

  /** Token 刷新:Promise 缓存防止惊群 */
  private async handleUnauthorized(): Promise<void> {
    if (this.refreshTokenPromise) { await this.refreshTokenPromise; return; }
    this.refreshTokenPromise = this.doRefresh();
    try { await this.refreshTokenPromise; } finally { this.refreshTokenPromise = null; }
  }

  private async doRefresh(): Promise<string> {
    const auth = useAuthStore();
    try { return await auth.refreshAccessToken(); }
    catch { auth.clearTokens(); uni.reLaunch({ url: '/pages/login/index' }); throw new Error('refresh failed'); }
  }
}
export default UniRequest;

4.2 Loading 状态管理

// stores/loading.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';

export const useLoadingStore = defineStore('loading', () => {
  const count = ref(0);
  let toastId: number | null = null;

  function increment() {
    count.value++;
    if (count.value === 1) {
      setTimeout(() => { if (count.value > 0 && !toastId) toastId = 1; }, 200); // 延迟避免闪烁
    }
  }
  function decrement() { if (count.value <= 0) return; count.value--; if (count.value === 0) toastId = null; }
  function reset() { count.value = 0; toastId = null; }
  return { count, increment, decrement, reset };
});

4.3 业务 API 封装与组件调用

// api/index.ts
import UniRequest from '@/network/request';
export function createApiClient(config: { baseURL: string; timeout?: number; headers?: Record<string, string> }) {
  return new UniRequest({ baseURL: config.baseURL, timeout: config.timeout ?? 15000, headers: config.headers ?? {} });
}

// api/user.ts
import { createApiClient } from './index';
const api = createApiClient({ baseURL: 'https://api.atomgit.com' });

export const getUserProfile = (userId: string) =>
  api.request<{ id: string; name: string; avatar: string }>({ url: `/users/:id`, method: 'GET', params: { id: userId } });

export const login = (data: { username: string; password: string }) =>
  api.request<{ accessToken: string; refreshToken: string }>({ url: '/auth/login', method: 'POST', data, loading: true });

// pages/profile/index.vue
<script setup lang="ts">
import { ref } from 'vue';
import { getUserProfile } from '@/api/user';
import { NetworkError } from '@/network/types';

const userInfo = ref<any>(null);
async function loadUser(userId: string) {
  try { const res = await getUserProfile(userId); userInfo.value = res.data; }
  catch (err) { if (err instanceof NetworkError && !err.isNetworkError) console.error(err.message); }
}
loadUser('12345');
</script>

4.4 鸿蒙端网络权限配置

// module.json5
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET",
        "reason": "$string:internet_permission_reason",
        "usedScene": { "abilities": ["EntryAbility"], "when": "always" }
      }
    ]
  }
}

五、深度技术原理

5.1 HTTP 请求生命周期

HTTP 请求经过以下环节:业务层调用 request() 后,请求对象先经过请求拦截器链;拦截器链完成后,框架将配置转换为 HTTP 报文(请求行、请求头、请求体);报文经操作系统网络协议栈封装后,通过 TCP 连接(三次握手)及 TLS 握手(HTTPS)发送到服务器;服务器处理后返回响应报文(状态行、响应头、响应体);数据经反序列化后进入响应拦截器链,最终规范化后返回业务层。每一环节都可能出错,网络层封装的核心职责是对这些环节统一感知和处理。

5.2 拦截器链的异步执行

拦截器链本质是责任链模式的应用。请求拦截器链如同一条水管:数据从一端进入,经多个过滤节点,每个节点可检查、修改甚至替换流经它的数据。关键在于异常传播:链中任一拦截器抛出异常,后续拦截器不执行,请求直接失败(Fail-Fast)——请求拦截器中 Token 读取失败应阻止请求而非静默跳过。响应拦截器链的异常应被捕获并转换为有意义的错误对象,而非向上传播导致未捕获的 Promise rejection。

5.3 Token 刷新竞态处理

为什么需要 Promise 缓存?考虑场景:请求 A 和 B 同时收到 401 响应。如果不缓存刷新 Promise,响应拦截器同时触发两次 Token 刷新请求,第二次可能因旧 refreshToken 已失效而失败。缓存机制确保只有第一个请求真正发起刷新,其他请求等待(await)同一个 Promise,等刷新完成后再继续。这就是 Promise 去重(Deduplication)模式,在处理共享资源获取(Token、配置)时尤为重要。

5.4 鸿蒙 Stage 模型网络权限

鸿蒙 NEXT 采用 Stage 模型,每个 Ability 运行在独立 Stage 中,拥有独立权限沙箱。ohos.permission.INTERNET 权限仅允许建立网络连接,不允许访问设备敏感资源。鸿蒙的安全验证发生在系统内核层面——即使代码正确,module.json5 缺少网络权限声明时,系统在进程启动阶段直接拒绝分配网络资源,返回权限错误而非网络错误。这迫使开发者在设计阶段就明确网络需求。

5.5 指数退避与抖动

指数退避(Exponential Backoff):第 N 次重试等待 baseDelay × 2^(N-1),即 1s、2s、4s——与 TCP 拥塞控制的二进制指数退避同源,目的是减少服务端压力。但纯粹退避会引发 Thundering Herd Problem——大量客户端同时以相同间隔重试,压力曲线呈尖峰。叠加随机抖动(Jitter):baseDelay × 2^(N-1) × (1 + random(0,0.5)),同一批出错客户端在不同时间重试,将压力曲线平滑化。Full Jitter(全部退避时间随机化)在实践中效果最佳。


六、常见问题解答

Q1:请求拦截器中如何读取本地缓存的 Token?

A1:Token 存储在 Pinia store 中,通过 useAuthStore().accessToken 同步读取,无需 await。注意:不要在请求拦截器中等待 Token 刷新——拦截器中的 Token 读取失败应直接阻止请求(Fail-Fast),而非静默跳过。Token 刷新异步等待应在响应拦截器中(处理 401 时)进行。

Q2:如何实现请求取消(Abort)功能?

A2:uni.request 支持记录 requestTask 后调用 uni.abortRequest() 取消。在 Vue 组件的 onUnmounted 生命周期中取消当前页面仍在进行的请求,避免页面卸载后响应返回导致内存泄漏或状态更新在已卸载组件上触发 Vue 警告。封装层可暴露 cancelRequest(requestId) 方法通过请求 ID 找到对应 task 并取消。鸿蒙端通过 @ohos.net.http 的 session.destroy() 终止请求。

Q3:为什么有时请求成功但 Loading 一直不消失?

A3:几乎一定是 Loading 计数器不匹配。最常见原因:请求在 ignoreError 场景中抛出异常,finally 块中 decrement 未执行。解决方案是使用 try-finally 确保计数器无论如何都递减,开发环境添加断言检查计数器不为负数。另一个可能:页面路由跳转导致组件卸载,但 store 计数器未 reset。应在路由守卫中检测未完成的请求。

Q4:鸿蒙端与 Android/iOS 网络行为有何主要差异?

A4:三个主要差异需重点关注。其一,SSL 证书验证:鸿蒙端默认强制 HTTPS 且验证证书有效性,不支持自签名证书(除非在 module.json5 的 sslSettings 中显式配置)。其二,超时参数:uni.request 的 timeout 在鸿蒙端对应 connectTimeout 和 readTimeout 两个独立参数,需分别设置。其三,响应编码:鸿蒙端默认 UTF-8,对 gbk 编码后端接口可能乱码,需在请求头声明 Accept-Encoding: gbk

Q5:请求重试是否会影响接口幂等性?

A5:GET、DELETE 等天然幂等的请求,重试安全。对于 POST、PUT 等非幂等操作,重试可能导致数据重复创建。正确做法:POST 请求默认不启用自动重试(retry: false);如业务必须重试(如支付回调),后端应实现幂等键(Idempotency Key)机制——请求头携带唯一 ID,服务器根据 ID 缓存响应结果,重复请求返回缓存结果。可在封装层请求拦截器中为所有请求自动生成 uuid 作为 Idempotency Key。

Q6:如何为不同环境配置不同的 baseURL?

A6:使用 vite 环境变量方案。在 .env.development.env.production 等文件定义 VITE_API_BASE_URL,创建 UniRequest 实例时读取 import.meta.env.VITE_API_BASE_URL。打包时不同环境产物自动使用对应 baseURL,无需运行时判断。


七、运行效果

在这里插入图片描述

超时重试场景:

executeRequest → 超时 ✗ (attempt=0) → 等待 ~1000ms
executeRequest → 503 ✗ (attempt=1) → 等待 ~2000ms
executeRequest → 200 ✅ (attempt=2) → 响应拦截器链 → 返回

八、扩展方向

接口文档自动化:通过 TypeScript 装饰器从 API 函数定义自动生成 OpenAPI 3.0 文档,配合 apifox 提供可视化调试,接口变更自动同步。

离线缓存:弱网场景通过本地 SQLite 实现 Stale-While-Revalidate 策略——先展示缓存(即时响应),后台静默更新,保证最终一致性。

性能监控:在响应拦截器集成性能打点,监控 P99 响应时间与错误率,设置告警阈值快速发现后端性能退化。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐