记录–【vue3】写hook三天,治好了我的组件封装强迫症。

  • 记录–【vue3】写hook三天,治好了我的组件封装强迫症。已关闭评论
  • 101 次浏览
  • A+
所属分类:Web前端
摘要

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


这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助

记录--【vue3】写hook三天,治好了我的组件封装强迫症。

前言

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

怎么用hook改造我的组件

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

普通实现

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

<script setup name="DDemo" lang="ts">   import { onMounted, ref } from 'vue';    //   模拟调用接口   function getRemoteData() {     return new Promise<any[]>((resolve) => {       setTimeout(() => {         resolve([           {             key: 1,             name: '苹果',             value: 1,           },           {             key: 2,             name: '香蕉',             value: 2,           },           {             key: 3,             name: '橘子',             value: 3,           },         ]);       }, 3000);     });   }      const optionsArr = ref<any[]>([]);    onMounted(() => {     getRemoteData().then((data) => {       optionsArr.value = data;     });   }); </script>  <template>   <div>     <a-select :options="optionsArr" />   </div> </template>  <style lang="less" scoped></style> 

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

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

<script setup name="DDemo" lang="ts">   import { onMounted, ref } from 'vue';    //   模拟调用接口   function getRemoteData() {     return new Promise<any[]>((resolve, reject) => {       setTimeout(() => {         // 模拟接口调用有概率出错         if (Math.random() > 0.5) {           resolve([             {               key: 1,               name: '苹果',               value: 1,             },             {               key: 2,               name: '香蕉',               value: 2,             },             {               key: 3,               name: '橘子',               value: 3,             },           ]);         } else {           reject(new Error('不小心出错了!'));         }       }, 3000);     });   }    const optLoading = ref(false);   const optionsArr = ref<any[]>([]);    function initSelect() {     optLoading.value = true;     getRemoteData()       .then((data) => {         optionsArr.value = data;       })       .catch((e) => {         // 请求出线错误时将错误信息显示到select中,给用户一个友好的提示         optionsArr.value = [           {             key: -1,             value: -1,             label: e.message,             disabled: true,           },         ];       })       .finally(() => {         optLoading.value = false;       });   }    onMounted(() => {     initSelect();   }); </script>  <template>   <div>     <a-select :loading="optLoading" :options="optionsArr" />   </div> </template>

这一次,代码直接来到了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<any[]>; }  export function useFetchSelect(props: FetchSelectProps) {   const { apiFun } = props;    const options = ref<SelectOption[]>([]);    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,   }); }

然后在组件中调用

<script setup name="DDemo" lang="ts">   import { useFetchSelect } from './hook';    //   模拟调用接口   function getRemoteData() {     return new Promise<any[]>((resolve, reject) => {       setTimeout(() => {         // 模拟接口调用有概率出错         if (Math.random() > 0.5) {           resolve([             {               key: 1,               name: '苹果',               value: 1,             },             {               key: 2,               name: '香蕉',               value: 2,             },             {               key: 3,               name: '橘子',               value: 3,             },           ]);         } else {           reject(new Error('不小心出错了!'));         }       }, 3000);     });   }        // 将之前用的 options,loading,和调用接口的逻辑都抽离到hook中   const selectBind = useFetchSelect({     apiFun: getRemoteData,   }); </script>  <template>   <div>     <!-- 将hook返回的接口,通过 v-bind 绑定给组件 -->     <a-select v-bind="selectBind" />   </div> </template>

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

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

Loading状态hook

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

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

hook.ts

import { Ref, ref } from 'vue';  type TApiFun<TData, TParams extends Array<any>> = (...params: TParams) => Promise<TData>;  interface AutoRequestOptions {    // 定义一下初始状态   loading?: boolean;   // 接口调用成功时的回调   onSuccess?: (data: any) => void; }  type AutoRequestResult<TData, TParams extends Array<any>> = [Ref<boolean>, TApiFun<TData, TParams>];  /* 控制loading状态的自动切换hook */ export function useAutoRequest<TData, TParams extends any[] = any[]>(fun: TApiFun<TData, TParams>, options?: AutoRequestOptions): AutoRequestResult<TData, TParams> {   const { loading = false, onSuccess } = options || { loading: false };    const requestLoading = ref(loading);    const run: TApiFun<TData, TParams> = (...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

<script setup name="Index" lang="ts"> import { useAutoRequest } from "./hook"; import { Button } from "ant-design-vue"; import { submitApi } from "@/api";  const [loading, submit] = useAutoRequest(submitApi);  function onSubmit() {    submit("aaa").then((res) => {     console.log("res", res);   }); } </script>  <template>   <div class="col">     <Button :loading="loading" @click="onSubmit">提交</Button>   </div> </template>

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

这个hook还有另一种玩法:

hook2.ts

import type { Ref } from "vue"; import { ref } from "vue";  type AutoLoadingResult = [   Ref<boolean>,   <T>(requestPromise: Promise<T>) => Promise<T> ];  /* 在给run方法传入一个promise,会在promise执行前或执行后将loading状态设为true,在执行完成后设为false */ export function useAutoLoading(defaultLoading = false): AutoLoadingResult {   const ld = ref(defaultLoading);    function run<T>(requestPromise: Promise<T>): Promise<T> {     ld.value = true;     return requestPromise.finally(() => {       ld.value = false;     });   }    return [ld, run]; }

使用:

index.vue

<script setup name="Index" lang="ts"> // import { useAutoRequest } from "./hook"; import { useAutoLoading } from "./hook2"; import { Button } from "ant-design-vue"; import { submitApi, cancelApi } from "@/api";  // const [loading, submit] = useAutoRequest(submitApi);  const [commonLoading, fetch] = useAutoLoading();  function onSubmit() {   fetch(submitApi("submit")).then((res) => {     console.log("res", res);   }); }  function onCancel() {   fetch(cancelApi("cancel")).then((res) => {     console.log("res", res);   }); } </script>  <template>   <div class="col">     <Button type="primary" :loading="commonLoading" @click="onSubmit">       提交     </Button>     <Button :loading="commonLoading" @click="onCancel">取消</Button>   </div> </template>

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

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

解放组件

如果你翻看过我的这篇博客一个省心省力的骨架屏实现方案,那么肯定知道在骨架屏组件中,我是用了传入的res对象的code属性来判断当前显示的视图状态。长话短说就是, res是接口返回给前端的数据,如

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

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

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

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

骨架屏组件

SkeletonView/index.vue

<script setup lang="ts"> import { defineProps, computed } from "vue"; import { LoadingOutlined } from "@ant-design/icons-vue"; import { isArray } from "@/utils/is"; import { Button } from "ant-design-vue";  /* status:'loading','error','success','empty' */ type ViewStatus = "loading" | "error" | "success" | "empty";  interface SkeletonProps<T = any> {   status: ViewStatus;   result: T;   placeholderResult: T;   emptyMsg?: string;   errorMsg?: string;   isEmpty?: (result: T) => boolean; }  const props = withDefaults(defineProps<SkeletonProps>(), {   status: "loading",   emptyMsg: "暂无数据",   errorMsg: "未知错误", });  const emits = defineEmits(["retry"]);  const retryClick = () => {   emits("retry"); };  const viewStatus = computed(() => {   const status = props.status;    if (status === "success") {     let isEmp = false;     const result = props.result;     if (props.isEmpty) {       isEmp = props.isEmpty(props.result);     } else {       if (isArray(result)) {         isEmp = result.length === 0;       } else if (!result) {         isEmp = true;       } else {         isEmp = false;       }     }     if (isEmp) {       return "empty";     }     return "success";   }   return status; });  const placeholderData = computed(() => {   if (props.result) {     return props.result;   }   return props.placeholderResult; }); </script>  <template>   <div v-if="viewStatus === 'empty'" key="empty" class="empty_view flex-col">     <span>{{ emptyMsg }}</span>     <Button class="mt4 max-w-160px" @click="retryClick">重试</Button>   </div>    <div     key="error"     v-else-if="viewStatus === 'error'"     class="empty_view flex-col"   >     <span>{{ errorMsg }}</span>     <Button class="mt4 max-w-160px" @click="retryClick">重试</Button>   </div>    <div     v-else     key="loadingOrContent"     :class="[       placeholderData && viewStatus === 'loading'         ? 'skeleton-view-empty-view'         : 'skeleton-view-default-view',     ]"   >     <div       v-if="!placeholderData && viewStatus === 'loading'"       class="loading-center"     >       <LoadingOutlined style="font-size: 40px; color: #2a6de5" />     </div>     <slot       v-else       :result="placeholderData"       :status="viewStatus"       :success="viewStatus === 'success'"       :mask="viewStatus === 'loading' ? 'skeleton-mask' : ''"     ></slot>   </div> </template>  <style> .clam-box {   width: 100%;   height: 100%; } .empty_view {   padding-top: 50px;   padding-bottom: 50px;   align-items: center; } .empty_img {   width: 310px;   height: 218px; } .trip_text {   font-size: 20px;   color: #999999; }  .mt4 {   margin-top: 4px; }  .flex-col {   display: flex;   flex-direction: column; }  .loading-center {   padding: 20px;   display: flex;   justify-content: center;   align-items: center; }  .skeleton-view-default-view span, .skeleton-view-default-view a, .skeleton-view-default-view img, .skeleton-view-default-view td, .skeleton-view-default-view button {   transition-duration: 0.7s;   transition-timing-function: ease;   transition-property: background, width; }  .skeleton-view-empty-view {   position: relative;   pointer-events: none; }  .skeleton-view-empty-view::before {   content: " ";   position: absolute;   width: 100%;   height: 100%;   top: 0;   left: 0;   background: linear-gradient(     110deg,     rgba(255, 255, 255, 0.1) 40%,     rgba(180, 199, 255, 0.3) 50%,     rgba(255, 255, 255, 0.1) 60%   );   background-size: 200% 100%;   background-position-x: 180%;   animation: loading 1s ease-in-out infinite;   z-index: 1; }  @keyframes loading {   to {     background-position-x: -20%;   } }  .skeleton-view-empty-view .skeleton-mask {   position: relative; } .skeleton-view-empty-view .skeleton-mask::before {   content: " ";   background-color: #f5f5f5;   position: absolute;   width: 100%;   height: 100%;   border: 1px solid #f5f5f5;   top: -1px;   left: -1px;   z-index: 1; }  .skeleton-view-empty-view button, .skeleton-view-empty-view span, .skeleton-view-empty-view input, .skeleton-view-empty-view td, .skeleton-view-empty-view a {   color: rgba(0, 0, 0, 0) !important;   border: none;   background: #f5f5f5 !important; } /* [src=""],img:not([src])*/ .skeleton-view-empty-view img {   content: url(./no_url.png);   border-radius: 2px;   background: #f5f5f5 !important; } </style>

使用 index.vue

<script setup name="SkeletonView" lang="ts"> import SkeletonView from "@/components/SkeletonView/index.vue"; import { useAutoSkeletonView } from "./useAutoSkeletonView"; import { listApi } from "@/api";  const view = useAutoSkeletonView({   apiFun: listApi, }); </script>  <template>   <div class="col">     <SkeletonView       v-slot="{ result }"       v-bind="view.bindProps"       v-on="view.bindEvents"     >       <span>{{ result }}</span>     </SkeletonView>   </div> </template>

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

使用优化

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

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

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

博主首先想到的就是vue的自定义指令了,文档在这里,但是折腾了半天发现行不通,因为自定义指令主要还是针对dom来的。vue官网原话:

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

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

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

// component-enhance-hook import type { PluginOption } from "vite";  // 可以自定义hook绑定的前缀、绑定的属性值合集对应的键和事件合集对应的键 type HookBindPluginOptions = {   prefix?: string;   bindKey?: string;   eventKey?: string; }; export const viteHookBind = (options?: HookBindPluginOptions): PluginOption => {   const { prefix, bindKey, eventKey } = Object.assign(     {       prefix: "v-ehb",       bindKey: "bindProps",       eventKey: "bindEvents",     },     options   );    return {     name: "vite-plugin-vue-component-enhance-hook-bind",     enforce: "pre",     transform: (code, id) => {       const last = id.substring(id.length - 4);        if (last === ".vue") {         // 处理之前先判断一下         if (code.indexOf(prefix) === -1) {           return code;         }         // 获取 template 开头         const templateStrStart = code.indexOf("<template>");         // 获取 template 结尾         const templateStrEnd = code.lastIndexOf("</template>");          let templateStr = code.substring(templateStrStart, templateStrEnd + 11);          let startIndex;         // 循环转换 template 中的hook绑定指令         while ((startIndex = templateStr.indexOf(prefix)) > -1) {           const endIndex = templateStr.indexOf(`"`, startIndex + 7);           const str = templateStr.substring(startIndex, endIndex + 1);           const obj = str.split(`"`)[1];            const newStr = templateStr.replace(             str,             `v-bind="${obj}.${bindKey}" v-on="${obj}.${eventKey}"`           );            templateStr = newStr;         }          // 拼接并返回         return (           code.substring(0, templateStrStart) +           templateStr +           code.substring(templateStrEnd + 11)         );       }        return code;     },   }; };

应用插件

import { fileURLToPath, URL } from "node:url";  import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import vueJsx from "@vitejs/plugin-vue-jsx";  import { viteHookBind } from "./vBindPlugin";  // https://vitejs.dev/config/ export default defineConfig({   plugins: [vue(), vueJsx(), viteHookBind()],   resolve: {     alias: {       "@": fileURLToPath(new URL("./src", import.meta.url)),     },   }, });

修改一下vue中的用法

<script setup name="SkeletonView" lang="ts"> import SkeletonView from "@/components/SkeletonView/index.vue"; import { useAutoSkeletonView } from "./useAutoSkeletonView"; import { listApi } from "@/api";  const view = useAutoSkeletonView({   queryInMount: true,   apiFun: listApi,   placeholderResult: [     {       key: 1,       name: "苹果",       value: 1,     },     {       key: 2,       name: "香蕉",       value: 2,     },     {       key: 3,       name: "橘子",       value: 3,     },   ], }); </script>  <template>   <div class="col">     <SkeletonView v-slot="{ result }" v-ehb="view">       <span>{{ result }}</span>     </SkeletonView>   </div> </template>

OK! 完成了!

使用npm安装

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

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

改一下引入方式就可以了

import { viteHookBind } from "vite-plugin-vue-hook-enhance";

本文转载于:

https://juejin.cn/post/7181712900094951483

如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

 记录--【vue3】写hook三天,治好了我的组件封装强迫症。