当前位置:   article > 正文

Vue3 写了 hook 三天,治好了我的组件封装强迫症_vue3 hooks封装fetch

vue3 hooks封装fetch

前言

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

另外,我还在一些群里看到有人抱怨vue3不如vue2好用,主要是适应不了setup写法,希望这篇博客能改变你的看法。

怎么用hook改造我的组件

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

普通实现

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

  1. <script setup name="DDemo" lang="ts">
  2.   import { onMounted, ref } from 'vue';
  3.   //   模拟调用接口
  4.   function getRemoteData() {
  5.     return new Promise<any[]>((resolve) => {
  6.       setTimeout(() => {
  7.         resolve([
  8.           {
  9.             key1,
  10.             name: '苹果',
  11.             value1,
  12.           },
  13.           {
  14.             key2,
  15.             name: '香蕉',
  16.             value2,
  17.           },
  18.           {
  19.             key3,
  20.             name: '橘子',
  21.             value3,
  22.           },
  23.         ]);
  24.       }, 3000);
  25.     });
  26.   }
  27.   
  28.   const optionsArr = ref<any[]>([]);
  29.   onMounted(() => {
  30.     getRemoteData().then((data=> {
  31.       optionsArr.value = data;
  32.     });
  33.   });
  34. </script>
  35. <template>
  36.   <div>
  37.     <a-select :options="optionsArr" />
  38.   </div>
  39. </template>
  40. <style lang="less" scoped></style>

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

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

  1. <script setup name="DDemo" lang="ts">
  2.   import { onMounted, ref } from 'vue';
  3.   //   模拟调用接口
  4.   function getRemoteData() {
  5.     return new Promise<any[]>((resolve, reject) => {
  6.       setTimeout(() => {
  7.         // 模拟接口调用有概率出错
  8.         if (Math.random() > 0.5) {
  9.           resolve([
  10.             {
  11.               key1,
  12.               name: '苹果',
  13.               value1,
  14.             },
  15.             {
  16.               key2,
  17.               name: '香蕉',
  18.               value2,
  19.             },
  20.             {
  21.               key3,
  22.               name: '橘子',
  23.               value3,
  24.             },
  25.           ]);
  26.         } else {
  27.           reject(new Error('不小心出错了!'));
  28.         }
  29.       }, 3000);
  30.     });
  31.   }
  32.   const optLoading = ref(false);
  33.   const optionsArr = ref<any[]>([]);
  34.   function initSelect() {
  35.     optLoading.value = true;
  36.     getRemoteData()
  37.       .then((data=> {
  38.         optionsArr.value = data;
  39.       })
  40.       .catch((e) => {
  41.         // 请求出线错误时将错误信息显示到select中,给用户一个友好的提示
  42.         optionsArr.value = [
  43.           {
  44.             key: -1,
  45.             value: -1,
  46.             label: e.message,
  47.             disabled: true,
  48.           },
  49.         ];
  50.       })
  51.       .finally(() => {
  52.         optLoading.value = false;
  53.       });
  54.   }
  55.   onMounted(() => {
  56.     initSelect();
  57.   });
  58. </script>
  59. <template>
  60.   <div>
  61.     <a-select :loading="optLoading" :options="optionsArr" />
  62.   </div>
  63. </template>

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

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

  1. 把字典下拉框封装成一个组件

  2. 把请求、加载中、错误这些处理逻辑封装到hook里;

第一种大家都知道,就不多说了,直接说第二种

封装下拉框hook
  1. import { onMounted, reactive, ref } from 'vue';
  2. // 定义下拉框接收的数据格式
  3. export interface SelectOption {
  4.   valuestring;
  5.   label: string;
  6.   disabled?: boolean;
  7.   key?: string;
  8. }
  9. // 定义入参格式
  10. interface FetchSelectProps {
  11.   apiFun: () => Promise<any[]>;
  12. }
  13. export function useFetchSelect(props: FetchSelectProps) {
  14.   const { apiFun } = props;
  15.   const options = ref<SelectOption[]>([]);
  16.   const loading = ref(false);
  17.   /* 调用接口请求数据 */
  18.   const loadData = () => {
  19.     loading.value = true;
  20.     options.value = [];
  21.     return apiFun().then(
  22.       (data=> {
  23.         loading.value = false;
  24.         options.value = data;
  25.         return data;
  26.       },
  27.       (err) => {
  28.         // 未知错误,可能是代码抛出的错误,或是网络错误
  29.         loading.value = false;
  30.         options.value = [
  31.           {
  32.             value'-1',
  33.             label: err.message,
  34.             disabled: true,
  35.           },
  36.         ];
  37.         // 接着抛出错误
  38.         return Promise.reject(err);
  39.       }
  40.     );
  41.   };
  42.   //   onMounted 中调用接口
  43.   onMounted(() => {
  44.     loadData();
  45.   });
  46.   return reactive({
  47.     options,
  48.     loading,
  49.   });
  50. }

然后在组件中调用

  1. <script setup name="DDemo" lang="ts">
  2.   import { useFetchSelect } from './hook';
  3.   //   模拟调用接口
  4.   function getRemoteData() {
  5.     return new Promise<any[]>((resolve, reject) => {
  6.       setTimeout(() => {
  7.         // 模拟接口调用有概率出错
  8.         if (Math.random() > 0.5) {
  9.           resolve([
  10.             {
  11.               key1,
  12.               name: '苹果',
  13.               value1,
  14.             },
  15.             {
  16.               key2,
  17.               name: '香蕉',
  18.               value2,
  19.             },
  20.             {
  21.               key3,
  22.               name: '橘子',
  23.               value3,
  24.             },
  25.           ]);
  26.         } else {
  27.           reject(new Error('不小心出错了!'));
  28.         }
  29.       }, 3000);
  30.     });
  31.   }
  32.    
  33.    // 将之前用的 options,loading,和调用接口的逻辑都抽离到hook中
  34.   const selectBind = useFetchSelect({
  35.     apiFun: getRemoteData,
  36.   });
  37. </script>
  38. <template>
  39.   <div>
  40.     <!-- 将hook返回的接口,通过 v-bind 绑定给组件 -->
  41.     <a-select v-bind="selectBind" />
  42.   </div>
  43. </template>

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

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

Loading状态hook

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

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

hook.ts

  1. import { Refref } from 'vue';
  2. type TApiFun<TData, TParams extends Array<any>> = (...params: TParams) => Promise<TData>;
  3. interface AutoRequestOptions {
  4.    // 定义一下初始状态
  5.   loading?: boolean;
  6.   // 接口调用成功时的回调
  7.   onSuccess?: (dataany=> void;
  8. }
  9. type AutoRequestResult<TData, TParams extends Array<any>> = [Ref<boolean>, TApiFun<TData, TParams>];
  10. /* 控制loading状态的自动切换hook */
  11. export function useAutoRequest<TData, TParams extends any[] = any[]>(fun: TApiFun<TData, TParams>options?: AutoRequestOptions): AutoRequestResult<TData, TParams> {
  12.   const { loading = false, onSuccess } = options || { loading: false };
  13.   const requestLoading = ref(loading);
  14.   const run: TApiFun<TData, TParams> = (...params) => {
  15.     requestLoading.value = true;
  16.     return fun(...params)
  17.       .then((res) => {
  18.         onSuccess && onSuccess(res);
  19.         return res;
  20.       })
  21.       .finally(() => {
  22.         requestLoading.value = false;
  23.       });
  24.   };
  25.   return [requestLoading, run];
  26. }

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

api/index.ts

  1. export function submitApi(text: string) {
  2.   return new Promise((resolve, reject) => {
  3.     setTimeout(() => {
  4.       // 模拟接口调用有概率出错
  5.       if (Math.random() > 0.5) {
  6.         resolve({
  7.           status"ok",
  8.           text: text,
  9.         });
  10.       } else {
  11.         reject(new Error("不小心出错了!"));
  12.       }
  13.     }, 3000);
  14.   });
  15. }

使用:

index.vue

  1. <script setup name="Index" lang="ts">
  2. import { useAutoRequest } from "./hook";
  3. import { Button } from "ant-design-vue";
  4. import { submitApi } from "@/api";
  5. const [loading, submit] = useAutoRequest(submitApi);
  6. function onSubmit() {
  7.    submit("aaa").then((res) => {
  8.     console.log("res", res);
  9.   });
  10. }
  11. </script>
  12. <template>
  13.   <div class="col">
  14.     <Button :loading="loading" @click="onSubmit">提交</Button>
  15.   </div>
  16. </template>

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

这个hook还有另一种玩法:

hook2.ts

  1. import type { Ref } from "vue";
  2. import { ref } from "vue";
  3. type AutoLoadingResult = [
  4.   Ref<boolean>,
  5.   <T>(requestPromise: Promise<T>=> Promise<T>
  6. ];
  7. /* 在给run方法传入一个promise,会在promise执行前或执行后将loading状态设为true,在执行完成后设为false */
  8. export function useAutoLoading(defaultLoading = false): AutoLoadingResult {
  9.   const ld = ref(defaultLoading);
  10.   function run<T>(requestPromise: Promise<T>): Promise<T> {
  11.     ld.value = true;
  12.     return requestPromise.finally(() => {
  13.       ld.value = false;
  14.     });
  15.   }
  16.   return [ld, run];
  17. }

使用:

index.vue

  1. <script setup name="Index" lang="ts">
  2. // import { useAutoRequest } from "./hook";
  3. import { useAutoLoading } from "./hook2";
  4. import { Button } from "ant-design-vue";
  5. import { submitApi, cancelApi } from "@/api";
  6. // const [loading, submit] = useAutoRequest(submitApi);
  7. const [commonLoading, fetch] = useAutoLoading();
  8. function onSubmit() {
  9. fetch(submitApi("submit")).then((res) => {
  10. console.log("res", res);
  11. });
  12. }
  13. function onCancel() {
  14. fetch(cancelApi("cancel")).then((res) => {
  15. console.log("res", res);
  16. });
  17. }
  18. </script>
  19. <template>
  20. <div class="col">
  21. <Button type="primary" :loading="commonLoading" @click="onSubmit">
  22. 提交
  23. </Button>
  24. <Button :loading="commonLoading" @click="onCancel">取消</Button>
  25. </div>
  26. </template>

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

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

解放组件

在骨架屏组件中,我是用传入的res对象的code属性来判断当前显示的视图状态。长话短说就是, res是接口返回给前端的数据,如

  1. {
  2.     "code":0,
  3.     "msg":'查询成功',
  4.     "data":{
  5.         "username":"小王",
  6.         "age":20,
  7.     }
  8. }

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

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

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

骨架屏组件

SkeletonView/index.vue

  1. <script setup lang="ts">
  2. import { defineProps, computed } from "vue";
  3. import { LoadingOutlined } from "@ant-design/icons-vue";
  4. import { isArray } from "@/utils/is";
  5. import { Button } from "ant-design-vue";
  6. /* status:'loading','error','success','empty' */
  7. type ViewStatus = "loading" | "error" | "success" | "empty";
  8. interface SkeletonProps<= any> {
  9.   status: ViewStatus;
  10.   result: T;
  11.   placeholderResult: T;
  12.   emptyMsg?: string;
  13.   errorMsg?: string;
  14.   isEmpty?: (result: T) => boolean;
  15. }
  16. const props = withDefaults(defineProps<SkeletonProps>(), {
  17.   status"loading",
  18.   emptyMsg: "暂无数据",
  19.   errorMsg: "未知错误",
  20. });
  21. const emits = defineEmits(["retry"]);
  22. const retryClick = () => {
  23.   emits("retry");
  24. };
  25. const viewStatus = computed(() => {
  26.   const status = props.status;
  27.   if (status === "success") {
  28.     let isEmp = false;
  29.     const result = props.result;
  30.     if (props.isEmpty) {
  31.       isEmp = props.isEmpty(props.result);
  32.     } else {
  33.       if (isArray(result)) {
  34.         isEmp = result.length === 0;
  35.       } else if (!result) {
  36.         isEmp = true;
  37.       } else {
  38.         isEmp = false;
  39.       }
  40.     }
  41.     if (isEmp) {
  42.       return "empty";
  43.     }
  44.     return "success";
  45.   }
  46.   return status;
  47. });
  48. const placeholderData = computed(() => {
  49.   if (props.result) {
  50.     return props.result;
  51.   }
  52.   return props.placeholderResult;
  53. });
  54. </script>
  55. <template>
  56.   <div v-if="viewStatus === 'empty'" key="empty" class="empty_view flex-col">
  57.     <span>{{ emptyMsg }}</span>
  58.     <Button class="mt4 max-w-160px" @click="retryClick">重试</Button>
  59.   </div>
  60.   <div
  61.     key="error"
  62.     v-else-if="viewStatus === 'error'"
  63.     class="empty_view flex-col"
  64.   >
  65.     <span>{{ errorMsg }}</span>
  66.     <Button class="mt4 max-w-160px" @click="retryClick">重试</Button>
  67.   </div>
  68.   <div
  69.     v-else
  70.     key="loadingOrContent"
  71.     :class="[
  72.       placeholderData && viewStatus === 'loading'
  73.         ? 'skeleton-view-empty-view'
  74.         : 'skeleton-view-default-view',
  75.     ]"
  76.   >
  77.     <div
  78.       v-if="!placeholderData && viewStatus === 'loading'"
  79.       class="loading-center"
  80.     >
  81.       <LoadingOutlined style="font-size: 40px; color: #2a6de5" />
  82.     </div>
  83.     <slot
  84.       v-else
  85.       :result="placeholderData"
  86.       :status="viewStatus"
  87.       :success="viewStatus === 'success'"
  88.       :mask="viewStatus === 'loading' ? 'skeleton-mask' : ''"
  89.     ></slot>
  90.   </div>
  91. </template>
  92. <style>
  93. .clam-box {
  94.   width: 100%;
  95.   height: 100%;
  96. }
  97. .empty_view {
  98.   padding-top: 50px;
  99.   padding-bottom: 50px;
  100.   align-items: center;
  101. }
  102. .empty_img {
  103.   width: 310px;
  104.   height: 218px;
  105. }
  106. .trip_text {
  107.   font-size: 20px;
  108.   color: #999999;
  109. }
  110. .mt4 {
  111.   margin-top: 4px;
  112. }
  113. .flex-col {
  114.   display: flex;
  115.   flex-direction: column;
  116. }
  117. .loading-center {
  118.   padding: 20px;
  119.   display: flex;
  120.   justify-content: center;
  121.   align-items: center;
  122. }
  123. .skeleton-view-default-view span,
  124. .skeleton-view-default-view a,
  125. .skeleton-view-default-view img,
  126. .skeleton-view-default-view td,
  127. .skeleton-view-default-view button {
  128.   transition-duration: 0.7s;
  129.   transition-timing-function: ease;
  130.   transition-property: background, width;
  131. }
  132. .skeleton-view-empty-view {
  133.   position: relative;
  134.   pointer-events: none;
  135. }
  136. .skeleton-view-empty-view::before {
  137.   content" ";
  138.   position: absolute;
  139.   width: 100%;
  140.   height: 100%;
  141.   top0;
  142.   left0;
  143.   background: linear-gradient(
  144.     110deg,
  145.     rgba(2552552550.140%,
  146.     rgba(1801992550.350%,
  147.     rgba(2552552550.160%
  148.   );
  149.   background-size: 200100%;
  150.   background-position-x: 180%;
  151.   animation: loading 1s ease-in-out infinite;
  152.   z-index: 1;
  153. }
  154. @keyframes loading {
  155.   to {
  156.     background-position-x: -20%;
  157.   }
  158. }
  159. .skeleton-view-empty-view .skeleton-mask {
  160.   position: relative;
  161. }
  162. .skeleton-view-empty-view .skeleton-mask::before {
  163.   content" ";
  164.   background-color: #f5f5f5;
  165.   position: absolute;
  166.   width: 100%;
  167.   height: 100%;
  168.   border: 1px solid #f5f5f5;
  169.   top: -1px;
  170.   left: -1px;
  171.   z-index: 1;
  172. }
  173. .skeleton-view-empty-view button,
  174. .skeleton-view-empty-view span,
  175. .skeleton-view-empty-view input,
  176. .skeleton-view-empty-view td,
  177. .skeleton-view-empty-view a {
  178.   color: rgba(0000) !important;
  179.   border: none;
  180.   background: #f5f5f5 !important;
  181. }
  182. /* [src=""],img:not([src])*/
  183. .skeleton-view-empty-view img {
  184.   content: url(./no_url.png);
  185.   border-radius: 2px;
  186.   background: #f5f5f5 !important;
  187. }
  188. </style>

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

hook代码 useAutoSkeletonView.ts

  1. import { computed, onMounted, reactive, ref } from "vue";
  2. import type { UnwrapRef } from "vue";
  3. type TApiFun<TData, TParams extends Array<any>> = (
  4.   ...params: TParams
  5. => Promise<TData>;
  6. /* 定义可自定义的默认状态 */
  7. export type SkeletonStatus = "loading" | "success";
  8. export interface IUseAutoSkeletonViewProps<TData, TParams extends any[]> {
  9.   apiFun: TApiFun<TData, TParams>;// 调用接口api
  10.   placeholderResult?: TData; // 骨架屏用到的占位数据
  11.   queryInMount?: boolean// 在父组件挂载时自动调用接口,默认true
  12.   initQueryParams?: TParams; // 调用接口用到的参数
  13.   transformDataFun?: (data: TData) => TData; // 接口请求完成后,转换数据
  14.   updateParamsOnFetch?: boolean// 手动调用接口后,更新请求参数
  15.   defaultStatus?: SkeletonStatus; // 默认骨架屏组件状态
  16.   onSuccess?: (dataany=> void; // 接口调用成功的回调
  17.   isEmpty?: (data: TData) => boolean// 重写骨架屏判空逻辑
  18. }
  19. export type IAutoSkeletonViewResult<TData, TParams extends any[]> = UnwrapRef<{
  20.   execute: TApiFun<TData, TParams>;
  21.   result: TData | null;
  22.   retry: () => Promise<TData>;
  23.   loading: boolean;
  24.   status: SkeletonStatus | "error";
  25.   getField: (keystring=> any;
  26.   bindProps: {
  27.     result: TData | null;
  28.     status: SkeletonStatus | "error";
  29.     errorMsg: string;
  30.     placeholderResult?: TData;
  31.     isEmpty?: (data: TData) => boolean;
  32.   };
  33.   bindEvents: {
  34.     retry: () => Promise<TData>;
  35.   };
  36. }>;
  37. export function useAutoSkeletonView<TData = any, TParams extends any[] = any[]>(
  38.   prop: IUseAutoSkeletonViewProps<TData, TParams>
  39. ): IAutoSkeletonViewResult<TData, TParams> {
  40.   const {
  41.     apiFun,
  42.     defaultStatus = "loading",
  43.     placeholderResult,
  44.     isEmpty,
  45.     initQueryParams = [],
  46.     transformDataFun,
  47.     onSuccess,
  48.     updateParamsOnFetch = true,
  49.     queryInMount = true,
  50.   } = prop;
  51.   const status = ref<SkeletonStatus | "error">(defaultStatus);
  52.   const result = ref<TData | null>(null);
  53.   const placeholder = ref<TData | undefined>(placeholderResult);
  54.   const errorMsg = ref("");
  55.   const lastFetchParams = ref<TParams>(initQueryParams as TParams);
  56.   const executeApiFun: TApiFun<TData, TParams> = (...params: TParams) => {
  57.     if (updateParamsOnFetch) {
  58.       lastFetchParams.value = params;
  59.     }
  60.     status.value = "loading";
  61.     return apiFun(...params)
  62.       .then((res) => {
  63.         let dataany = res;
  64.         if (transformDataFun) {
  65.           data = transformDataFun(res);
  66.         }
  67.         placeholder.value = data;
  68.         result.value = data;
  69.         status.value = "success";
  70.         onSuccess && onSuccess(data);
  71.         return res;
  72.       })
  73.       .catch((e) => {
  74.         console.error("--useAutoSkeletonView--", e);
  75.         status.value = "error";
  76.         errorMsg.value = e.message;
  77.         throw e;
  78.       });
  79.   };
  80.   function retry() {
  81.     return executeApiFun(...(lastFetchParams.value as TParams));
  82.   }
  83.   onMounted(() => {
  84.     if (queryInMount && defaultStatus === "loading") {
  85.       executeApiFun(...(initQueryParams as TParams));
  86.     }
  87.   });
  88.   const loading = computed(() => {
  89.     return status.value === "loading";
  90.   });
  91.   function getField(keystring) {
  92.     if (status.value !== "success") {
  93.       return "";
  94.     }
  95.     if (result.value) {
  96.       // @ts-ignore
  97.       return result.value[key];
  98.     }
  99.     return "";
  100.   }
  101.   return reactive({
  102.     execute: executeApiFun,
  103.     result: result,
  104.     retry,
  105.     loading,
  106.     status,
  107.     getField,
  108.     bindProps: {
  109.       result: result,
  110.       status,
  111.       errorMsg,
  112.       placeholderResult: placeholder,
  113.       isEmpty,
  114.     },
  115.     bindEvents: {
  116.       retryretry,
  117.     },
  118.   });
  119. }

使用 index.vue

  1. <script setup name="SkeletonView" lang="ts">
  2. import SkeletonView from "@/components/SkeletonView/index.vue";
  3. import { useAutoSkeletonView } from "./useAutoSkeletonView";
  4. import { listApi } from "@/api";
  5. const view = useAutoSkeletonView({
  6.   apiFun: listApi,
  7. });
  8. </script>
  9. <template>
  10.   <div class="col">
  11.     <SkeletonView
  12.       v-slot="{ result }"
  13.       v-bind="view.bindProps"
  14.       v-on="view.bindEvents"
  15.     >
  16.       <span>{{ result }}</span>
  17.     </SkeletonView>
  18.   </div>
  19. </template>

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

使用优化

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

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

比起vuev-bindv-on算是简便了不少。那么,有没有一种办法也能做到差不多的效果呢?就比如能做到v-xxx="select"

博主首先想到的就是vue的自定义指令了,文档在这里(https://cn.vuejs.org/guide/reusability/custom-directives.html),但是折腾了半天发现行不通,因为自定义指令主要还是针对dom来的。vue官网原话:

总的来说,推荐在组件上使用自定义指令。

那么就只能考虑打包插件了,只要我们在vue解析template之前把v-xxx="select"翻译成v-bind="select.bindProps" v-on="select.bindEvents" 就好了,听起来并不难,只要我们开发的时候规定绑定组件的hook返回格式必须有bindPropsbindEvents就好了。

思路有了,直接开干,现在vue官网的默认创建方式也改成vite,我们就直接写vite的插件(不想看可以跳到最后用现成的):

  1. // component-enhance-hook
  2. import type { PluginOption } from "vite";
  3. // 可以自定义hook绑定的前缀、绑定的属性值合集对应的键和事件合集对应的键
  4. type HookBindPluginOptions = {
  5.   prefix?: string;
  6.   bindKey?: string;
  7.   eventKey?: string;
  8. };
  9. export const viteHookBind = (options?: HookBindPluginOptions): PluginOption => {
  10.   const { prefix, bindKey, eventKey } = Object.assign(
  11.     {
  12.       prefix: "v-ehb",
  13.       bindKey: "bindProps",
  14.       eventKey: "bindEvents",
  15.     },
  16.     options
  17.   );
  18.   return {
  19.     name: "vite-plugin-vue-component-enhance-hook-bind",
  20.     enforce: "pre",
  21.     transform: (code, id) => {
  22.       const last = id.substring(id.length - 4);
  23.       if (last === ".vue") {
  24.         // 处理之前先判断一下
  25.         if (code.indexOf(prefix) === -1) {
  26.           return code;
  27.         }
  28.         // 获取 template 开头
  29.         const templateStrStart = code.indexOf("<template>");
  30.         // 获取 template 结尾
  31.         const templateStrEnd = code.lastIndexOf("</template>");
  32.         let templateStr = code.substring(templateStrStart, templateStrEnd + 11);
  33.         let startIndex;
  34.         // 循环转换 template 中的hook绑定指令
  35.         while ((startIndex = templateStr.indexOf(prefix)) > -1) {
  36.           const endIndex = templateStr.indexOf(`"`, startIndex + 7);
  37.           const str = templateStr.substring(startIndex, endIndex + 1);
  38.           const obj = str.split(`"`)[1];
  39.           const newStr = templateStr.replace(
  40.             str,
  41.             `v-bind="${obj}.${bindKey}" v-on="${obj}.${eventKey}"`
  42.           );
  43.           templateStr = newStr;
  44.         }
  45.         // 拼接并返回
  46.         return (
  47.           code.substring(0, templateStrStart) +
  48.           templateStr +
  49.           code.substring(templateStrEnd + 11)
  50.         );
  51.       }
  52.       return code;
  53.     },
  54.   };
  55. };

应用插件

  1. import { fileURLToPath, URL } from "node:url";
  2. import { defineConfig } from "vite";
  3. import vue from "@vitejs/plugin-vue";
  4. import vueJsx from "@vitejs/plugin-vue-jsx";
  5. import { viteHookBind } from "./vBindPlugin";
  6. // https://vitejs.dev/config/
  7. export default defineConfig({
  8.   plugins: [vue(), vueJsx(), viteHookBind()],
  9.   resolve: {
  10.     alias: {
  11.       "@"fileURLToPath(new URL("./src"import.meta.url)),
  12.     },
  13.   },
  14. });

修改一下vue中的用法

  1. <script setup name="SkeletonView" lang="ts">
  2. import SkeletonView from "@/components/SkeletonView/index.vue";
  3. import { useAutoSkeletonView } from "./useAutoSkeletonView";
  4. import { listApi } from "@/api";
  5. const view = useAutoSkeletonView({
  6.   queryInMount: true,
  7.   apiFun: listApi,
  8.   placeholderResult: [
  9.     {
  10.       key1,
  11.       name: "苹果",
  12.       value1,
  13.     },
  14.     {
  15.       key2,
  16.       name: "香蕉",
  17.       value2,
  18.     },
  19.     {
  20.       key3,
  21.       name: "橘子",
  22.       value3,
  23.     },
  24.   ],
  25. });
  26. </script>
  27. <template>
  28.   <div class="col">
  29.     <SkeletonView v-slot="{ result }" v-ehb="view">
  30.       <span>{{ result }}</span>
  31.     </SkeletonView>
  32.   </div>
  33. </template>

OK! 完成了!

使用npm安装

不过我也提前打包编译好了发布在了npm上,需要的话可以直接使用这个

npm i vite-plugin-vue-hook-enhance \-D

改一下引入方式就可以了

import { viteHookBind } from "vite-plugin-vue-hook-enhance";
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/花生_TL007/article/detail/257372
推荐阅读
相关标签
  

闽ICP备14008679号