A hand-drawn doodle illustration on pure white pap

前言

HarmonyOS7 的原生 HTTP 模块 @kit.NetworkKit,说好用也好用,说难用也难用——每次请求都要 createHttp、设 header、处理回调、destroy,代码写一堆还容易忘销毁。与其每次都写一遍,不如封装一个用着顺手的工具类。

这篇从原生 API 讲起,到封装思路,再到完整实现,最后跟 @State 联动刷新 UI,一条龙搞定。

我一开始直接用 http.createHttp() 写请求,一个页面 3 个接口,写了 3 遍几乎一样的代码。后来发现忘调 destroy(),内存泄漏了。这就很离谱——写网络请求还得记着销毁对象?所以必须封装。

原生 HTTP 模块基础

先看看原生 API 长什么样。

GET 请求

import { http } from '@kit.NetworkKit'

function doGet() {
  const httpRequest = http.createHttp()

  httpRequest.request('https://api.example.com/users', {
    method: http.RequestMethod.GET,
    header: { 'Content-Type': 'application/json' },
    readTimeout: 10000,
    connectTimeout: 10000
  }, (err, data) => {
    if (!err) {
      console.info('响应码: ' + data.responseCode)
      console.info('响应体: ' + JSON.stringify(data.result))
    } else {
      console.error('请求失败: ' + JSON.stringify(err))
    }
    httpRequest.destroy()
  })
}

A hand-drawn doodle illustration on pure white pap

POST 请求

function doPost() {
  const httpRequest = http.createHttp()

  httpRequest.request('https://api.example.com/login', {
    method: http.RequestMethod.POST,
    header: { 'Content-Type': 'application/json' },
    extraData: { username: 'admin', password: '123456' },
    readTimeout: 10000,
    connectTimeout: 10000
  }, (err, data) => {
    if (!err) {
      console.info('响应码: ' + data.responseCode)
      console.info('响应体: ' + JSON.stringify(data.result))
    } else {
      console.error('请求失败: ' + JSON.stringify(err))
    }
    httpRequest.destroy()
  })
}

问题很明显:

  • 每次都要 createHttp() + destroy()
  • 错误处理、超时处理每次都写一遍
  • 回调式写法,容易嵌套地狱
  • 没有统一的 header 管理(比如 token)
  • 没有统一的响应格式处理

封装思路

封装前先想清楚要什么:

需求实现方式
统一基础 URL构造函数传入 baseUrl
自动带 token请求拦截器,自动注入 header
Promise 化把回调封装成 Promise
自动销毁请求完成后自动 destroy

A hand-drawn doodle illustration on pure white pap

| 统一错误处理 | 拦截器 + 统一错误码映射 |
| 统一响应格式 | 泛型解析 data.result |
| 超时统一配置 | 默认值 + 可覆盖 |

类图简化版:

HttpClient
├── baseUrl: string
├── defaultHeaders: Record<string, string>
├── timeout: number
├── get<T>(url, params?) → Promise<T>
├── post<T>(url, data?) → Promise<T>
├── put<T>(url, data?) → Promise<T>
├── delete<T>(url) → Promise<T>
└── request<T>(method, url, options?) → Promise<T>  (核心方法)

完整封装代码

import { http, HttpResponse } from '@kit.NetworkKit'

interface Response<T> {
  code: number
  message: string
  data: T
}

class HttpError extends Error {
  code: number
  constructor(code: number, message: string) {
    super(message)
    this.code = code
  }
}

class HttpClient {
  private baseUrl: string = ''
  private defaultHeaders: Record<string, string> = {
    'Content-Type': 'application/json'
  }
  private timeout: number = 15000

  setBaseUrl(url: string) {
    this.baseUrl = url
  }

  setHeader(key: string, value: string) {
    this.defaultHeaders[key] = value
  }

  removeHeader(key: string) {
    delete this.defaultHeaders[key]
  }

  setToken(token: string) {
    this.defaultHeaders['Authorization'] = `Bearer ${token}`
  }

  clearToken() {
    delete this.defaultHeaders['Authorization']
  }

  get<T>(url: string, params?: Record<string, string>): Promise<T> {
    const fullUrl = this.buildUrl(url, params)
    return this.request<T>(http.RequestMethod.GET, fullUrl)
  }

  post<T>(url: string, data?: Object): Promise<T> {
    return this.request<T>(http.RequestMethod.POST, this.baseUrl + url, data)
  }

  put<T>(url: string, data?: Object): Promise<T> {
    return this.request<T>(http.RequestMethod.PUT, this.baseUrl + url, data)
  }

  delete<T>(url: string): Promise<T> {
    return this.request<T>(http.RequestMethod.DELETE, this.baseUrl + url)
  }

  private buildUrl(url: string, params?: Record<string, string>): string {
    let fullUrl = this.baseUrl + url
    if (params) {
      const query = Object.keys(params)
        .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
        .join('&')
      fullUrl += (fullUrl.includes('?') ? '&' : '?') + query
    }
    return fullUrl
  }

  private request<T>(method: http.RequestMethod, url: string, data?: Object): Promise<T> {
    return new Promise<T>((resolve, reject) => {
      const httpRequest = http.createHttp()

      const options: http.HttpRequestOptions = {
        method: method,
        header: { ...this.defaultHeaders },
        readTimeout: this.timeout,
        connectTimeout: this.timeout,
        expectDataType: http.HttpDataType.STRING
      }

      if (data && method !== http.RequestMethod.GET) {
        options.extraData = JSON.stringify(data)
      }

      httpRequest.request(url, options, (err, responseData: HttpResponse) => {
        httpRequest.destroy()

        if (err) {
          reject(new HttpError(err.code ?? -1, err.message ?? '网络请求失败'))
          return
        }

        if (responseData.responseCode === 200 || responseData.responseCode === 201) {
          try {
            const result = JSON.parse(responseData.result as string) as Response<T>
            if (result.code === 0 || result.code === 200) {
              resolve(result.data)
            } else {
              reject(new HttpError(result.code, result.message))
            }
          } catch (e) {
            reject(new HttpError(-1, '数据解析失败'))
          }
        } else if (responseData.responseCode === 401) {
          this.clearToken()
          reject(new HttpError(401, '登录已过期,请重新登录'))
        } else {
          reject(new HttpError(responseData.responseCode, `请求失败(${responseData.responseCode})`))
        }
      })
    })
  }
}

export const httpClient = new HttpClient()

逐方法讲解

setBaseUrl / setToken 初始化时调一次就行。token 变了就重新 setToken

get<T>(url, params?) 泛型方法,T 是响应数据的类型。params 会自动拼成 query string。用法:

const users = await httpClient.get<User[]>('/users', { page: '1', size: '20' })

post<T>(url, data?) data 自动 JSON.stringify。用法:

const result = await httpClient.post<LoginResult>('/login', { phone: '138xxxx', code: '1234' })

buildUrl 处理 query 参数拼接,做了 encodeURIComponent,防止特殊字符炸 URL。

request<T>(核心方法):

  1. createHttp() 创建请求对象
  2. 合并默认 header 和超时配置
  3. GET 请求不带 body,其他方法自动 JSON.stringify(data)
  4. 请求完成后立刻 destroy(),不管成功失败
  5. 200/201 → 解析 JSON → 检查业务 code → 返回 data
  6. 401 → 清 token + 抛过期错误(可以在这里加跳登录页的逻辑)
  7. 其他状态码 → 直接抛 HttpError

请求拦截与错误处理

上面的封装已经包含了基础的拦截(token 注入、401 处理)。如果需要更复杂的拦截,比如自动重试、请求日志,可以这样扩展:

private async request<T>(method: http.RequestMethod, url: string, data?: Object): Promise<T> {
  const maxRetry = 2
  let lastError: Error = new Error('unknown')

  for (let i = 0; i <= maxRetry; i++) {
    try {
      return await this.doRequest<T>(method, url, data)
    } catch (err) {
      lastError = err as Error
      if (err instanceof HttpError && err.code === 401) {
        throw err
      }
      if (i < maxRetry) {
        console.warn(`请求失败,第 ${i + 1} 次重试...`)
        await this.delay(1000 * (i + 1))
      }
    }
  }

  throw lastError
}

private delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms))
}

逻辑:

  • 失败后自动重试,最多 2 次
  • 401 不重试,直接抛(重试也没用)
  • 重试间隔递增:1 秒、2 秒
  • 其他错误重试完再抛

别滥用重试。写操作(POST/PUT/DELETE)重试可能导致重复提交,只给 GET 加重试就好。

与 @State 联动刷新 UI

封装好了,怎么在页面里用?配合 @State 实现请求即刷新:

import { httpClient } from '../utils/HttpClient'

interface Article {
  id: number
  title: string
  content: string
}

@Entry
@Component
struct ArticleListPage {
  @State articles: Article[] = []
  @State isLoading: boolean = false
  @State errorMsg: string = ''

  aboutToAppear() {
    this.loadArticles()
  }

  private async loadArticles() {
    this.isLoading = true
    this.errorMsg = ''

    try {
      this.articles = await httpClient.get<Article[]>('/articles')
    } catch (err) {
      this.errorMsg = (err as Error).message || '加载失败'
    } finally {
      this.isLoading = false
    }
  }

  build() {
    Column() {
      if (this.isLoading) {
        LoadingProgress().width(48).height(48)
      } else if (this.errorMsg) {
        Column() {
          Text(this.errorMsg).fontColor('#FF4444').fontSize(14)
          Button('重试')
            .margin({ top: 12 })
            .onClick(() => this.loadArticles())
        }
      } else {
        List() {
          ForEach(this.articles, (item: Article) => {
            ListItem() {
              Column() {
                Text(item.title).fontSize(16).fontWeight(FontWeight.Bold)
                Text(item.content).fontSize(13).fontColor('#666666').maxLines(2)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .padding(16)
            }
          })
        }
        .width('100%')
        .layoutWeight(1)
      }
    }
    .width('100%')
    .height('100%')
  }
}

要点:

  • @State articles@State isLoading:请求结果直接赋值给 @State 变量,UI 自动刷新
  • try/catch/finally:标准模式——加载中 → 成功显示 / 失败提示 → 关闭 loading
  • 失败时显示"重试"按钮,用户可以手动再请求
  • aboutToAppear 里发请求,页面出现就加载

写在最后

网络请求封装没有什么高深的,核心就三件事:Promise 化、自动销毁、统一错误处理。我这套封装不完美,没有文件上传、没有请求取消,但覆盖了 80% 的场景。够用就行,别过度设计。

等业务真的需要文件上传了再加,不要提前堆功能。代码越少越好维护,这是真理。

Logo

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

更多推荐