Vue 3 项目中编写业务接口与使用 Hook 的最佳实践

发布于 5 天前  11 次阅读


在现代前端开发中,通过封装 API 请求和利用自定义 Hook,可以有效提高代码的复用性和可维护性。本文将以实际业务代码为例,总结如何在 Vue 3 项目中编写接口请求逻辑以及使用 Hook 获取和处理数据。


一、封装业务接口

封装接口的目的是使代码模块化、清晰化,并便于复用。在示例中,使用了 @vueuse/integrations/useAxios 结合 Axios 实现接口请求封装。

核心代码

import { useAxios } from '@vueuse/integrations/useAxios';
import { RequestParams } from '@/store/dashboard-store-creator/typing';
import { ResponseType } from '@/types/http';
import ApiUrl from '@/utils/apiUrl';
import http from '@/utils/http';

export function fetchChartData<RS = any, RP = any>() {
  const request = useAxios<ResponseType<RS>, RequestParams<RP>>(
    '',
    {
      method: 'POST',
    },
    http,
    { immediate: false },
  );
  return {
    ...request,
    execute(params: RequestParams<RP>) {
      return request.execute(`${ApiUrl.Dashboard.getChartData}/${params.chartId}`, { data: params });
    },
  };
}

实现步骤

  1. 依赖引入

    • 使用 @vueuse/integrations/useAxios 提供的 Hook,结合 Axios 实现请求封装。
    • 将请求参数和返回值类型化,便于后续使用时获得更好的类型提示。
  2. 动态接口调用

    • 将接口地址与动态参数绑定,支持灵活传递 chartId 等动态路径。
  3. 优化请求配置

    • immediate: false 确保接口不会在 Hook 初始化时自动触发请求,便于在需要时调用。
  4. 统一封装响应格式

    • 使用 ResponseType 类型统一接口响应的数据结构。

二、编写 Hook 实现业务逻辑

通过自定义 Hook 封装数据获取逻辑,将请求接口与视图层解耦,提升组件复用性。

核心代码

import { getFilterApiParams } from '@/components/BinSelectFilter/utils';
import { fetchChartData } from '@/service/dashboard-chart';
import { useOverallYieldStore } from '../store';

const store = useOverallYieldStore();

export const useBinTableData = () => {
  const { isLoading, execute, data } = fetchChartData();

  watchDebounced(
    [
      () => store.binTab.binSelect.binDefinition,
      () => store.binTab.binSelect.binFilter,
      () => store.binTab.binSelect.binType,
      store.binTab.binSelect.appliedBins,
      () => store.binTab.binSelect.appliedBins,
      store.filters,
    ],
    () => {
      const { binFilter, binDefinition, binType } = store.binTab.binSelect;

      const binFilterParams = getFilterApiParams(binFilter);
      const dataSource = 'TEST_RAW_DATA';

      execute({
        chartId: 'overallBinPareto',
        fields: [],
        metrics: [],
        filters: store.globalFilters,
        dataSource,
        setting: {
          dataSource,
          binType,
          binDefinition,
          needFillNoDataFieldData: true,
          selectedBins: store.binTab.binSelect.appliedBins ?? [],
          ...binFilterParams,
          binSortType: 'BIN_RATE',
          binSort: 'DESC',
        },
      });
    },
    {
      debounce: 100,
      immediate: true,
    },
  );

  // 同步数据到 store
  watch(data, (res) => {
    store.binTab.binTable.data = res?.data ?? [];
  });

  return {
    isLoading,
  };
};

实现步骤

  1. 初始化接口请求

    • 调用 fetchChartData 接口 Hook,获取数据加载状态、执行方法 execute 和响应数据 data
  2. 监听数据变化

    • 使用 watchDebounced 监听多个 store 中的数据源(如 binFilterbinType),在数据变化时触发 execute 请求。
  3. 请求参数动态化

    • 动态生成接口所需的复杂参数(如 binFilterParamsselectedBins),确保接口的灵活性。
  4. 防抖和立即执行

    • 在监听逻辑中引入 debounce 参数以减少频繁的接口调用,同时设置 immediate: true 确保初次加载时立即执行。
  5. 结果处理

    • 使用 watch 监听接口返回数据,并将结果同步到 Store 中供组件使用。

三、最佳实践总结

1. 接口封装

  • 抽象公共逻辑:对 Axios 请求的初始化和响应格式进行统一封装。
  • 类型安全:在参数和返回值中使用 TypeScript,增强代码可靠性。
  • 灵活性:支持动态路径和参数组合。

2. 使用 Hook

  • 解耦逻辑与视图:将数据获取和逻辑处理封装到自定义 Hook 中,保持组件轻量化。
  • 动态监听:结合 watchwatchDebounced 动态监听状态变化,并根据变化触发数据请求。
  • 状态同步:及时将请求结果同步到全局状态管理(如 Pinia)中。

3. 性能优化

  • 防抖优化:通过 debounce 减少频繁请求。
  • 懒加载接口:设置 immediate: false 避免 Hook 初始化时多余的接口调用。

四、完整使用场景

通过上述封装,业务组件中可以直接调用 useBinTableData Hook 获取所需状态:

const { isLoading } = useBinTableData();

在模板中:

<template>
  <LoadingSpinner v-if="isLoading" />
  <BinTable v-else :data="store.binTab.binTable.data" />
</template>

通过这种方式,开发者可以专注于视图层逻辑,数据获取与处理完全由 Hook 和封装接口负责,代码更加清晰简洁。

PS: 看情况和需要,也可以不使用store,而是把datareturn出去在业务组件里使用。


这套接口与 Hook 的实践既适合中小型项目的快速开发,也能满足大型项目的复杂需求,值得在 Vue 3 项目中推广。

五、接口竞态覆盖问题解决方案

思路大概是,在接口的某个合适的上下文中维护一个abortControllersMap,每次发起新的请求时检查一下这个 Map 中有没有对应的旧请求,如果有则调用abort方法取消旧的请求(需要将AbortController的信号传递给Axios的请求配置中才能取消Axios的请求),每次请求成功响应后在 Map 中清空对应的请求记录。

对「一、」中代码改造举例:

import { useAxios } from '@vueuse/integrations/useAxios';

import { RequestParams } from '@/store/dashboard-store-creator/typing';
import { ResponseType } from '@/types/http';
import ApiUrl from '@/utils/apiUrl';
import http from '@/utils/http';

export function fetchChartData<RS = any, RP = any>(abortPrevious: boolean = true) {
  // * [src/utils/dashboard/dashboard-context/provider.ts] 为每个有独立 meta 的图表创建了新的 Chart 上下文
  // * 只有同一个 meta 的图表共用一个 request, 会触发旧调用废弃逻辑, 所以默认开启 abort 逻辑没有问题
  const abortControllers = new Map<string, AbortController>(); // 存储每个 chartId 对应的 AbortController

  const request = useAxios<ResponseType<RS>, RequestParams<RP>>(
    '',
    {
      method: 'POST',
    },
    http,
    { immediate: false },
  );

  return {
    ...request,
    execute(params: RequestParams<RP>) {
      const { chartId } = params;

      // 如果开启 abortPrevious 且 chartId 相同,取消之前的请求
      if (abortPrevious && abortControllers.has(chartId)) {
        const previousController = abortControllers.get(chartId);
        if (previousController) {
          previousController.abort(); // 取消旧请求
        }
      }

      // 创建新的 AbortController
      const controller = new AbortController();
      abortControllers.set(chartId, controller); // 存储新的 AbortController

      // 发起请求
      return request
        .execute(`${ApiUrl.Dashboard.getChartData}/${chartId}`, {
          data: params,
          signal: controller.signal, // 将 AbortController 的信号传递给请求
        })
        .then(() => {
          // 请求完成后,清理 AbortController
          if (abortControllers.get(chartId) === controller) {
            abortControllers.delete(chartId);
          }
        }).catch(() => {
          // 请求完成后,清理 AbortController
          if (abortControllers.get(chartId) === controller) {
            abortControllers.delete(chartId);
          }
        });
    },
  };
}

A Student on the way to full stack of Web3.