鸿蒙HarmonyOS中Axios网络库封装与文件上传功能实现

在开发鸿蒙HarmonyOS应用时,网络请求功能是必不可少的。axios是一个非常流行的基于Promise的HTTP客户端,适用于浏览器和Node.js环境。本文将介绍如何在鸿蒙HarmonyOS中封装axios库,使其能够支持文件上传,并提供额外的配置选项以满足不同的业务需求。

封装目的

  • 简化网络请求:通过封装,我们可以将常用的HTTP请求操作(如GET、POST等)封装成简洁易用的方法。
  • 统一错误处理:封装后的库可以统一处理HTTP请求中的错误,提供更友好的错误提示和处理逻辑。
  • 支持文件上传:提供对文件上传的支持,包括单个文件和多个文件的上传。

使用的库

axios:一个流行的JavaScript库,用于处理HTTP请求。

封装实现

以下是封装后的AxiosHttpRequest类的实现,支持文件上传:

typescript 复制代码
/**
 * author:csdn猫哥
 * qq:534117529
 * blog:https://blog.csdn.net/yyz_1987
 */
//axiosHttp.ets

import axios, {
  AxiosError,
  AxiosInstance,
  AxiosHeaders,
  AxiosRequestHeaders,
  AxiosResponse,
  FormData,
  AxiosProgressEvent,
  InternalAxiosRequestConfig
} from "@ohos/axios";

interface HttpResponse<T>{
  data: T;
  status: number;
  statusText: string;
  config: HttpRequestConfig;
}
export type HttpPromise<T> = Promise<HttpResponse<T>>;

// 鸿蒙ArkTS文件上传相关接口定义
/**
上传类型支持uri和ArrayBuffer,
uri支持"internal"协议类型和沙箱路径。
"internal://cache/"为必填字段,示例: internal://cache/path/to/file.txt;
沙箱路径示例:cacheDir + '/hello.txt'
 */
export interface UploadFile {
  buffer?: ArrayBuffer;
  fileName?: string;
  mimeType?: string;
  uri?:string;
}

export interface FileUploadConfig extends HttpRequestConfig {
  file?: UploadFile | UploadFile[];
  fileFieldName?: string; // 文件字段名,默认为 'file'
  additionalData?: Record<string, any>; // 额外的表单数据
  onUploadProgress?: (progressEvent: any) => void; // 上传进度回调
}

export interface FileInfo {
  name: string;
  size: number;
  type: string;
}
/**
 * 封装后,不支持传入拦截器
 * 需要自己定义接口继承 AxiosRequestConfig类型
 * 从而支持传入拦截器,但拦截器选项应为可选属性
 * 之后请求实例传入的options为继承了AxiosRequestConfig的自定义类型
 */
interface InterceptorHooks {
  requestInterceptor?: (config: HttpRequestConfig) => Promise<HttpRequestConfig>;
  requestInterceptorCatch?: (error: any) => any;
  responseInterceptor?: (response: AxiosResponse) => AxiosResponse | Promise<AxiosResponse>;
  responseInterceptorCatch?: (error: any) => any;
}

// @ts-ignore
interface HttpRequestConfig extends InternalAxiosRequestConfig {
  showLoading?: boolean; //是否展示请求loading
  checkResultCode?: boolean; //是否检验响应结果码
  checkLoginState?: boolean; //校验用户登陆状态
  needJumpToLogin?: boolean; //是否需要跳转到登陆页面
  interceptorHooks?: InterceptorHooks;//拦截器
  headers?: AxiosRequestHeaders;
  errorHandler?: (error: any) => void; //错误处理
}


/**
 * 网络请求构造
 * 基于axios框架实现
 */
export class AxiosHttpRequest {
  config: HttpRequestConfig;
  interceptorHooks?: InterceptorHooks;
  instance: AxiosInstance;

  constructor(options: HttpRequestConfig) {
    this.config = options;
    this.interceptorHooks = options.interceptorHooks;
    this.instance = axios.create(options);
    this.setupInterceptor()
  }

  setupInterceptor(): void {
    this.instance.interceptors.request.use(
      // 这里主要是高版本的axios中设置拦截器的时候里面的Config属性必须是InternalAxiosRequestConfig,
      // 但是InternalAxiosRequestConfig里面的headers是必传,所以在实现的子类我设置成非必传会报错,加了个忽略注解
      // @ts-ignore
      this.interceptorHooks?.requestInterceptor,
      this.interceptorHooks?.requestInterceptorCatch,
    );
    this.instance.interceptors.response.use(
      this.interceptorHooks?.responseInterceptor,
      this.interceptorHooks?.responseInterceptorCatch,
    );
  }

  // 类型参数的作用,T决定AxiosResponse实例中data的类型
  request<T = any>(config: HttpRequestConfig): HttpPromise<T> {
    return new Promise<HttpResponse<T>>((resolve, reject) => {
      this.instance
        .request<any, HttpResponse<T>>(config)
        .then(res => {
          resolve(res);
        })
        .catch((err) => {
          // 使用传入的 errorHandler 处理错误
          const errorHandler = config.errorHandler || errorHandlerDefault;
          errorHandler(err); 
          if (err) {
            reject(err);
          }
        });
    });
  }

  get<T = any>(config: HttpRequestConfig): HttpPromise<T> {
    return this.request({ ...config, method: 'GET' });
  }

  post<T = any>(config: HttpRequestConfig): HttpPromise<T> {
    return this.request({ ...config, method: 'POST' });
  }

  delete<T = any>(config: HttpRequestConfig): HttpPromise<T> {
    return this.request({ ...config, method: 'DELETE' });
  }

  patch<T = any>(config: HttpRequestConfig): HttpPromise<T> {
    return this.request({ ...config, method: 'PATCH' });
  }

  /**
   * 上传单个文件或多个文件
   * @param config 文件上传配置
   * @returns Promise<HttpResponse<T>>
   */
  uploadFile<T = any>(config: FileUploadConfig): HttpPromise<T> {
    return new Promise<HttpResponse<T>>((resolve, reject) => {
      if (!config.file) {
        reject(new Error('文件不能为空'));
        return;
      }

      const formData = new FormData();
      const fileFieldName = config.fileFieldName || 'file';

      // 处理单个或多个文件
      const files = Array.isArray(config.file) ? config.file : [config.file];
      files.forEach((file, index) => {
        const fieldName = Array.isArray(config.file) ? `${fileFieldName}[${index}]` : fileFieldName;

        // 鸿蒙ArkTS FormData.append 支持第三个参数设置文件名和类型
        if (file.mimeType) {
          formData.append(fieldName, file.buffer, {
            filename: file.fileName,
            type: file.mimeType
          });
        } else if (file.buffer){
          formData.append(fieldName, file.buffer, {
            filename: file.fileName
          });
        }else if (file.uri){
          formData.append(fieldName, file.uri);
        }
      });

      // 添加额外的表单数据
      if (config.additionalData) {
        Object.keys(config.additionalData).forEach(key => {
          formData.append(key, config.additionalData![key]);
        });
      }

      const uploadConfig: HttpRequestConfig = {
        ...config,
        method: 'POST',
        data: formData,
        headers: new AxiosHeaders({
          ...config.headers,
          'Content-Type': 'multipart/form-data'
        })
      };

      // 添加上传进度监听
      if (config.onUploadProgress) {
        uploadConfig.onUploadProgress = config.onUploadProgress;
      }

      this.request<T>(uploadConfig)
        .then(resolve)
        .catch(reject);
    });
  }

  /**
   * 上传多个文件
   * @param config 文件上传配置
   * @returns Promise<HttpResponse<T>>
   */
  uploadFiles<T = any>(config: FileUploadConfig): HttpPromise<T> {
    if (!Array.isArray(config.file)) {
      return Promise.reject(new Error('uploadFiles方法需要传入文件数组'));
    }
    return this.uploadFile<T>(config);
  }

  /**
   * 获取文件信息
   * @param file 文件对象
   * @returns FileInfo
   */
  getFileInfo(file: UploadFile): FileInfo {
    return {
      name: file.fileName,
      size: file.buffer.byteLength,
      type: file.mimeType || 'application/octet-stream'
    };
  }

  /**
   * 验证文件类型
   * @param file 文件对象
   * @param allowedTypes 允许的文件类型数组
   * @returns boolean
   */
  validateFileType(file: UploadFile, allowedTypes: string[]): boolean {
    const fileType = file.mimeType || 'application/octet-stream';
    return allowedTypes.includes(fileType);
  }

  /**
   * 验证文件大小
   * @param file 文件对象
   * @param maxSize 最大文件大小(字节)
   * @returns boolean
   */
  validateFileSize(file: UploadFile, maxSize: number): boolean {
    return file.buffer.byteLength <= maxSize;
  }

  /**
   * 创建文件上传配置
   * @param url 上传地址
   * @param file 文件对象
   * @param options 其他配置选项
   * @returns FileUploadConfig
   */
  createUploadConfig(
    url: string,
    file: UploadFile | UploadFile[],
    options: Partial<FileUploadConfig> = {}
  ): FileUploadConfig {
    return {
      url,
      file,
      fileFieldName: 'file',
      ...options
    };
  }
}

function errorHandlerDefault(error: any) {
  if (error instanceof AxiosError) {
    //showToast(error.message)
  } else if (error != undefined && error.response != undefined && error.response.status) {
    switch (error.response.status) {
    // 401: 未登录
    // 未登录则跳转登录页面,并携带当前页面的路径
    // 在登录成功后返回当前页面,这一步需要在登录页操作。
      case 401:

        break;
    // 403 token过期
    // 登录过期对用户进行提示
    // 清除本地token和清空vuex中token对象
    // 跳转登录页面
      case 403:
        //showToast("登录过期,请重新登录")
      // 清除token
      // localStorage.removeItem('token');
        break;
    // 404请求不存在
      case 404:
        //showToast("网络请求不存在")
        break;

    // 其他错误,直接抛出错误提示
      default:
        //showToast(error.response.data.message)
    }

  }
}
export{AxiosRequestHeaders,AxiosError,AxiosHeaders,AxiosProgressEvent,FormData};
export default AxiosHttpRequest;

使用举例

javascript 复制代码
import fs from '@ohos.file.fs';
import { axiosClient, FileUploadConfig, HttpPromise, UploadFile } from '../../utils/axiosClient';
import { AxiosProgressEvent } from '@nutpi/axios';

async function uploadSingleFile() {
  try {
    // 创建测试文件并读取为ArrayBuffer
    let context = getContext() as common.UIAbilityContext;
    const cacheDir = context.cacheDir;
    const path = cacheDir + '/test.jpg';

    // 写入测试文件
    const file = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
    fs.writeSync(file.fd, "这是一个测试文件内容");
    fs.fsyncSync(file.fd);
    fs.closeSync(file.fd);

    // 读取文件为ArrayBuffer
    const file2 = fs.openSync(path, 0o2);
    const stat = fs.lstatSync(path);
    const buffer = new ArrayBuffer(stat.size);
    fs.readSync(file2.fd, buffer);
    fs.fsyncSync(file2.fd);
    fs.closeSync(file2.fd);

    const uploadFile:UploadFile = {
      fileName: 'test.jpg',
      mimeType: 'text/plain',
      uri:path
    };

    const config:FileUploadConfig = axiosClient.createUploadConfig(
      'upload/image',
      uploadFile,
      {
        context:getContext(),
        onUploadProgress: (progressEvent:AxiosProgressEvent) => {
          const percentCompleted = Math.round((progressEvent.loaded * 100) / (progressEvent?.total ?? 1));
          console.log(`上传进度: ${percentCompleted}%`);
        }
      }
    );

    axiosClient.uploadFile<string>(config).then((res) => {
      //Log.debug(res.data.code)
      console.log('文件上传成功:');
    }).catch((err:BusinessError) => {
      Log.debug("request","err.code:%d",err.code)
      Log.debug("request",err.message)
      console.error('文件上传失败:', err);
    });

  } catch (error) {

  }
}

核心功能解析

1. 文件上传接口

  • UploadFile接口 :定义了文件上传的数据结构,支持uriArrayBuffer两种方式。
  • FileUploadConfig接口 :继承自HttpRequestConfig,增加了对文件和额外表单数据的支持。
  • uploadFile方法:实现文件上传逻辑,支持单个文件和多个文件的上传。
  • uploadFiles方法:专门用于上传文件数组。

2. 文件信息处理

  • getFileInfo方法:获取文件的名称、大小和类型。
  • validateFileType方法:验证文件类型是否在允许的类型列表中。
  • validateFileSize方法:验证文件大小是否超过指定的最大值。

3. 请求拦截器与响应拦截器

  • setupInterceptor方法:设置请求和响应拦截器,以便在请求发送前和响应返回后执行自定义逻辑。

4. 统一错误处理

  • errorHandlerDefault函数:定义了默认的错误处理逻辑。根据响应的状态码提供不同的错误提示。

使用示例

typescript 复制代码
const httpRequest = new AxiosHttpRequest({
  baseURL: 'https://example.com/api',
  interceptorHooks: {
    requestInterceptor: (config) => {
      console.log('Request Interceptor:', config);
      return config;
    },
    responseInterceptor: (response) => {
      console.log('Response Interceptor:', response);
      return response;
    },
  },
  errorHandler: (error) => {
    console.error('Custom Error Handler:', error);
  }
});

const file: UploadFile = {
  buffer: new ArrayBuffer(1024),
  fileName: 'example.txt',
  mimeType: 'text/plain'
};

const config = httpRequest.createUploadConfig('https://example.com/api/upload', file, {
  additionalData: { description: '这是一个示例文件' },
  onUploadProgress: (progress) => {
    console.log('上传进度:', progress);
  }
});

httpRequest.uploadFile(config).then((response) => {
  console.log('上传成功:', response);
}).catch((error) => {
  console.error('上传失败:', error);
});

总结

通过封装axios库,我们可以在鸿蒙HarmonyOS中更方便地实现网络请求和文件上传功能。封装后的库提供了统一的错误处理机制和丰富的配置选项,使得我们的网络请求更加高效和灵活。希望本文能帮助大家更好地理解和使用封装后的axios库。