- A+
这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助
前言
我以前很喜欢封装组件,什么东西不喜欢别人的,总喜欢自己搞搞,这让人很有成就感,虽然是重复造轮子,但是能从无聊的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
行,虽说用户体验确实好了不少,但是这也忒费事了,而且这还只是一个下拉框,页面里有好几个下拉框也是很常见的,如此这般,可能什么逻辑都没写,页面代码就要上百行了。
这个时候,就需要我们来封装一下了,我们有两种选择:
- 把字典下拉框封装成一个
组件
; - 把请求、加载中、错误这些处理逻辑封装到
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, } }
我们假定当code
为0
时代表成功,不为0
表示失败,为-100
时表示正在加载,当然接口并不会也不需要返回-100
,-100
是我们本地捏造出来的,只是为了让骨架屏组件显示对应的加载状态。 在页面中使用时,我们需要先声明一个code
为-100
的res
对象绑定给骨架屏组件,然后在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}>; }
比起vue
的v-bind
和v-on
算是简便了不少。那么,有没有一种办法也能做到差不多的效果呢?就比如能做到v-xxx="select"
。
博主首先想到的就是vue的自定义指令了,文档在这里,但是折腾了半天发现行不通,因为自定义指令主要还是针对dom来的。vue官网原话:
总的来说,不推荐在组件上使用自定义指令。
那么就只能考虑打包插件了,只要我们在vue
解析template
之前把v-xxx="select"
翻译成v-bind="select.bindProps" v-on="select.bindEvents"
就好了,听起来并不难,只要我们开发的时候规定绑定组件的hook返回格式必须有bindProps
和bindEvents
就好了。
思路有了,直接开干,现在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";