Vue3为了封装组件,我写了三天的 vue3 hook!学起来!

前言

我以前很喜欢封装组件,什么东西不喜欢别人的,总喜欢自己搞搞,这让人很有成就感,虽然是重复造轮子,但是能从无聊的crud业务中暂时解脱出来,对我来说也算是一种休息,相信有很多人跟我一样有这个习惯。 这种习惯在独立开发时无所谓,毕竟没人会关心你咋实现的,但是在跟人合作时就给别人造成了很大的困扰了,毕竟每个人封装的东西都是根据自己习惯来的,别人看着多少会有点不顺眼,而且自己封装的组件大概率也是没有写文档和注释的,所以项目其他成员的使用率也不会太高,所以今天,我试着解决这个问题。 另外,我还在一些群里看到有人抱怨vue3不如vue2好用,主要是适应不了setup写法,希望这篇博客能改变你的看法。

怎么用hook改造我的组件

关于hook是什么之类的介绍,我这就不赘述了, 前言中说到重复造轮子的组件,除开一些毫无必要的重复以外,有一些功能组件确实需要封装一下,比如说,一些需要请求后端字典到前端展示的下来选择框,点击之后要展示loading状态的按钮,带有查询条件的表单,这些非常常用的业务场景,我们就可以封装成组件,但是封装成组件就会遇到前面说的问题,每个人的使用习惯和封装习惯不一样,很难让每个人都满意,这种场景,就可以让hook来解决。

普通实现
就拿字典选择下拉框来说,如果不做封装,我们是这样写的 (这里拿ant-design-vue组件库来做示例)


  import { onMounted, ref } from 'vue';

  //   模拟调用接口
  function getRemoteData() {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve([
          {
            key: 1,
            name: '苹果',
            value: 1,
          },
          {
            key: 2,
            name: '香蕉',
            value: 2,
          },
          {
            key: 3,
            name: '橘子',
            value: 3,
          },
        ]);
      }, 3000);
    });
  }
  
  const optionsArr = ref([]);

  onMounted(() => {
    getRemoteData().then((data) => {
      optionsArr.value = data;
    });
  });






看起来很简单是吧,忽略我们模拟调用接口的代码,我们用在ts/js部分的代码才只有6行而已,看起来根本不需要什么封装。

但是这只是一个最简单的逻辑,不考虑接口请求超时和错误的情况,甚至都没考虑下拉框的loading表现。 如果我们把所有的意外情况都考虑到的话,代码就会变得很臃肿了。





这一次,代码直接来到了22行,虽说用户体验确实好了不少,但是这也忒费事了,而且这还只是一个下拉框,页面里有好几个下拉框也是很常见的,如此这般,可能什么逻辑都没写,页面代码就要上百行了。

这个时候,就需要我们来封装一下了,我们有两种选择:

1.把字典下拉框封装成一个组件;
2.把请求、加载中、错误这些处理逻辑封装到hook里;
第一种大家都知道,就不多说了,直接说第二种封装下拉框hook

import { onMounted, reactive, ref } from 'vue';
// 定义下拉框接收的数据格式
export interface SelectOption {
  value: string;
  label: string;
  disabled?: boolean;
  key?: string;
}
// 定义入参格式
interface FetchSelectProps {
  apiFun: () => Promise;
}

export function useFetchSelect(props: FetchSelectProps) {
  const { apiFun } = props;

  const options = ref([]);

  const loading = ref(false);

  /* 调用接口请求数据 */
  const loadData = () => {
    loading.value = true;
    options.value = [];
    return apiFun().then(
      (data) => {
        loading.value = false;
        options.value = data;
        return data;
      },
      (err) => {
        // 未知错误,可能是代码抛出的错误,或是网络错误
        loading.value = false;
        options.value = [
          {
            value: '-1',
            label: err.message,
            disabled: true,
          },
        ];
        // 接着抛出错误
        return Promise.reject(err);
      }
    );
  };

  //   onMounted 中调用接口
  onMounted(() => {
    loadData();
  });

  return reactive({
    options,
    loading,
  });
}


然后在组件中调用





这样一来,代码行数直接又从20行降到3行,甚至比刚开始最简单的那个还要少两行,但是功能却一点不少,用户体验也是比较完善的。

如果你觉着上面这个例子不能打动你的话,可以看看下面这个

Loading状态hook
点击按钮,调用接口是另一个我们经常遇到的场景,为了更好的用户体验,提示用户操作已经响应,同时防止用户多次点击,我们要在调用接口的同时将按钮置为loading状态,虽说只有一个loading状态,但是写多了也觉着麻烦。

为此我们可以封装一个非常简单的hook:

hook.ts

import { Ref, ref } from 'vue';

type TApiFun> = (...params: TParams) => Promise;

interface AutoRequestOptions {
   // 定义一下初始状态
  loading?: boolean;
  // 接口调用成功时的回调
  onSuccess?: (data: any) => void;
}

type AutoRequestResult> = [Ref, TApiFun];

/* 控制loading状态的自动切换hook */
export function useAutoRequest(fun: TApiFun, options?: AutoRequestOptions): AutoRequestResult {
  const { loading = false, onSuccess } = options || { loading: false };

  const requestLoading = ref(loading);

  const run: TApiFun = (...params) => {
    requestLoading.value = true;
    return fun(...params)
      .then((res) => {
        onSuccess && onSuccess(res);
        return res;
      })
      .finally(() => {
        requestLoading.value = false;
      });
  };

  return [requestLoading, run];
}



这次把模拟接口的方法单独抽出一个文件

api/index.ts

export function submitApi(text: string) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 模拟接口调用有概率出错
      if (Math.random() > 0.5) {
        resolve({
          status: "ok",
          text: text,
        });
      } else {
        reject(new Error("不小心出错了!"));
      }
    }, 3000);
  });
}


使用:

index.vue




这样封装一下,我们使用时就不再需要手动切换loading的状态了。

这个hook还有另一种玩法:

hook2.ts

import type { Ref } from "vue";
import { ref } from "vue";

type AutoLoadingResult = [
  Ref,
  (requestPromise: Promise) => Promise
];

/* 在给run方法传入一个promise,会在promise执行前或执行后将loading状态设为true,在执行完成后设为false */
export function useAutoLoading(defaultLoading = false): AutoLoadingResult {
  const ld = ref(defaultLoading);

  function run(requestPromise: Promise): Promise {
    ld.value = true;
    return requestPromise.finally(() => {
      ld.value = false;
    });
  }

  return [ld, run];
}



使用:

index.vue

import type { Ref } from "vue";
import { ref } from "vue";

type AutoLoadingResult = [
  Ref,
  (requestPromise: Promise) => Promise
];

/* 在给run方法传入一个promise,会在promise执行前或执行后将loading状态设为true,在执行完成后设为false */
export function useAutoLoading(defaultLoading = false): AutoLoadingResult {
  const ld = ref(defaultLoading);

  function run(requestPromise: Promise): Promise {
    ld.value = true;
    return requestPromise.finally(() => {
      ld.value = false;
    });
  }

  return [ld, run];
}


这里也是用到了promise链式调用的特性,在接口调用之后马上将loading置为true,在接口调用完成后置为false。而useAutoRequest则是在接口调用之前就将loading置为true。

useAutoRequest调用时代码更简洁,useAutoLoading的使用则更灵活,可以同时服务给多个接口使用,比较适合提交、取消这种互斥的场景。

解放组件
 res接口返回给前端的数据,如

{
    "code":0,
    "msg":'查询成功',
    "data":{
        "username":"小王",
        "age":20,
    }
}



我们假定当code为0时代表成功,不为0表示失败,为-100时表示正在加载,当然接口并不会也不需要返回-100,-100是我们本地捏造出来的,只是为了让骨架屏组件显示对应的加载状态。 在页面中使用时,我们需要先声明一个code为-100的res对象绑定给骨架屏组件,然后在onMounted中调用查询接口,调用成功后更新res对象。

如果像上面这样使用res对象来给骨架屏组件设置状态的话,就感觉非常的麻烦,有时候我们只是要设置一个初始时的加载状态,但是要搞好几行没用的代码,但是如果我们把res拆解成一个个参数单独传递的话,父组件需要维护的变量就会非常多了,这时我们就可以封装hook来解决这个问题,把拆解出来的参数都扔到hook里面保存。

上代码(这部分代码比较长,想要详细了解的话可以去看原文章)

骨架屏组件
SkeletonView/index.vue






这里样式中用到的no_url.png只是一张空白透明图片,防止加载时图片显示裂图。

hook代码 useAutoSkeletonView.ts

import { computed, onMounted, reactive, ref } from "vue";
import type { UnwrapRef } from "vue";

type TApiFun> = (
  ...params: TParams
) => Promise;

/* 定义可自定义的默认状态 */
export type SkeletonStatus = "loading" | "success";

export interface IUseAutoSkeletonViewProps {
  apiFun: TApiFun;// 调用接口api
  placeholderResult?: TData; // 骨架屏用到的占位数据
  queryInMount?: boolean; // 在父组件挂载时自动调用接口,默认true
  initQueryParams?: TParams; // 调用接口用到的参数
  transformDataFun?: (data: TData) => TData; // 接口请求完成后,转换数据
  updateParamsOnFetch?: boolean; // 手动调用接口后,更新请求参数
  defaultStatus?: SkeletonStatus; // 默认骨架屏组件状态
  onSuccess?: (data: any) => void; // 接口调用成功的回调
  isEmpty?: (data: TData) => boolean; // 重写骨架屏判空逻辑
}

export type IAutoSkeletonViewResult = UnwrapRef<{
  execute: TApiFun;
  result: TData | null;
  retry: () => Promise;
  loading: boolean;
  status: SkeletonStatus | "error";
  getField: (key: string) => any;
  bindProps: {
    result: TData | null;
    status: SkeletonStatus | "error";
    errorMsg: string;
    placeholderResult?: TData;
    isEmpty?: (data: TData) => boolean;
  };
  bindEvents: {
    retry: () => Promise;
  };
}>;

export function useAutoSkeletonView(
  prop: IUseAutoSkeletonViewProps
): IAutoSkeletonViewResult {
  const {
    apiFun,
    defaultStatus = "loading",
    placeholderResult,
    isEmpty,
    initQueryParams = [],
    transformDataFun,
    onSuccess,
    updateParamsOnFetch = true,
    queryInMount = true,
  } = prop;

  const status = ref(defaultStatus);

  const result = ref(null);

  const placeholder = ref(placeholderResult);

  const errorMsg = ref("");

  const lastFetchParams = ref(initQueryParams as TParams);

  const executeApiFun: TApiFun = (...params: TParams) => {
    if (updateParamsOnFetch) {
      lastFetchParams.value = params;
    }

    status.value = "loading";

    return apiFun(...params)
      .then((res) => {
        let data: any = res;
        if (transformDataFun) {
          data = transformDataFun(res);
        }
        placeholder.value = data;
        result.value = data;
        status.value = "success";
        onSuccess && onSuccess(data);
        return res;
      })
      .catch((e) => {
        console.error("--useAutoSkeletonView--", e);
        status.value = "error";
        errorMsg.value = e.message;
        throw e;
      });
  };

  function retry() {
    return executeApiFun(...(lastFetchParams.value as TParams));
  }

  onMounted(() => {
    if (queryInMount && defaultStatus === "loading") {
      executeApiFun(...(initQueryParams as TParams));
    }
  });

  const loading = computed(() => {
    return status.value === "loading";
  });

  function getField(key: string) {
    if (status.value !== "success") {
      return "";
    }
    if (result.value) {
      // @ts-ignore
      return result.value[key];
    }
    return "";
  }

  return reactive({
    execute: executeApiFun,
    result: result,
    retry,
    loading,
    status,
    getField,
    bindProps: {
      result: result,
      status,
      errorMsg,
      placeholderResult: placeholder,
      isEmpty,
    },
    bindEvents: {
      retry: retry,
    },
  });
}



使用 index.vue




这里的SkeletonView不光用v-bind绑定了hook抛出的属性,还用v-on绑定的事件,目的就是监听请求报错时出现的“重试”按钮的点击事件。

使用优化
经常写react的朋友可能早就看出来了,这不是跟react中的一部分hook用法如出一辙吗?没错,很多人写react就这么写,而且react中绑定hook跟组件更简单,只需要...就可以了,比如:

function Demo(){
    const select = useSelect({
        apiFun:getDict
    })
    // 这里可以直接用...将useSelect返回的属性与方法全部绑定给Select组件
    return