zl程序教程

您现在的位置是:首页 >  前端

当前栏目

vue3 hooks 组件封装

2023-09-14 09:13:38 时间

前言
我以前很喜欢封装组件,什么东西不喜欢别人的,总喜欢自己搞搞,这让人很有成就感,虽然是重复造轮子,但是能从无聊的crud业务中暂时解脱出来,对我来说也算是一种休息,相信有很多人跟我一样有这个习惯。 这种习惯在独立开发时无所谓,毕竟没人会关心你咋实现的,但是在跟人合作时就给别人造成了很大的困扰了,毕竟每个人封装的东西都是根据自己习惯来的,别人看着多少会有点不顺眼,而且自己封装的组件大概率也是没有写文档和注释的,所以项目其他成员的使用率也不会太高,所以今天,我试着解决这个问题。 另外,我还在一些群里看到有人抱怨vue3不如vue2好用,主要是适应不了setup写法,希望这篇博客能改变你的看法。
怎么用hook改造我的组件
关于hook是什么之类的介绍,我这就不赘述了,请看这篇文章浅谈:为啥vue和react都选择了Hooks🏂?[1]。 前言中说到重复造轮子的组件,除开一些毫无必要的重复以外,有一些功能组件确实需要封装一下,比如说,一些需要请求后端字典到前端展示的下来选择框,点击之后要展示loading状态的按钮,带有查询条件的表单,这些非常常用的业务场景,我们就可以封装成组件,但是封装成组件就会遇到前面说的问题,每个人的使用习惯和封装习惯不一样,很难让每个人都满意,这种场景,就可以让hook来解决。
普通实现
就拿字典选择下拉框来说,如果不做封装,我们是这样写的 (这里拿ant-design-vue组件库来做示例)

复制代码
看起来很简单是吧,忽略我们模拟调用接口的代码,我们用在ts/js部分的代码才只有6行而已,看起来根本不需要什么封装。
但是这只是一个最简单的逻辑,不考虑接口请求超时和错误的情况,甚至都没考虑下拉框的loading表现。 如果我们把所有的意外情况都考虑到的话,代码就会变得很臃肿了。

复制代码 这一次,代码直接来到了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,
});
}
复制代码
然后在组件中调用

复制代码 这样一来,代码行数直接又从20行降到3行,甚至比刚开始最简单的那个还要少两行,但是功能却一点不少,用户体验也是比较完善的。 如果你觉着上面这个例子不能打动你的话,可以看看下面这个 Loading状态hook 点击按钮,调用接口是另一个我们经常遇到的场景,为了更好的用户体验,提示用户操作已经响应,同时防止用户多次点击,我们要在调用接口的同时将按钮置为loading状态,虽说只有一个loading状态,但是写多了也觉着麻烦。 为此我们可以封装一个非常简单的hook: hook.ts import { Ref, ref } from 'vue';

type TApiFun<TData, TParams extends Array> = (…params: TParams) => Promise;

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

type AutoRequestResult<TData, TParams extends Array> = [Ref, 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

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

/* 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(), {
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;
});

{{ emptyMsg }}
{{ errorMsg }}

复制代码
这里样式中用到的no_url.png只是一张空白透明图片,防止加载时图片显示裂图。
hook代码 useAutoSkeletonView.ts
import { computed, onMounted, reactive, ref } from “vue”;
import type { UnwrapRef } from “vue”;

type TApiFun<TData, TParams extends Array> = (
…params: TParams
) => Promise;

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

export interface IUseAutoSkeletonViewProps<TData, TParams extends any[]> {
apiFun: TApiFun<TData, TParams>;// 调用接口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<TData, TParams extends any[]> = UnwrapRef<{
execute: TApiFun<TData, TParams>;
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<TData = any, TParams extends any[] = any[]>(
prop: IUseAutoSkeletonViewProps<TData, TParams>
): IAutoSkeletonViewResult<TData, TParams> {
const {
apiFun,
defaultStatus = “loading”,
placeholderResult,
isEmpty,
initQueryParams = [],
transformDataFun,
onSuccess,
updateParamsOnFetch = true,
queryInMount = true,
} = prop;

const status = ref<SkeletonStatus | “error”>(defaultStatus);

const result = ref<TData | null>(null);

const placeholder = ref<TData | undefined>(placeholderResult);

const errorMsg = ref(“”);

const lastFetchParams = ref(initQueryParams as TParams);

const executeApiFun: TApiFun<TData, TParams> = (…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

{{ result }}
复制代码 这里的SkeletonView不光用v-bind绑定了hook抛出的属性,还用v-on绑定的事件,目的就是监听请求报错时出现的“重试”按钮的点击事件。 使用优化 经常写react的朋友可能早就看出来了,这不是跟react中的一部分hook用法如出一辙吗?没错,很多人写react就这么写,而且react中绑定hook跟组件更简单,只需要...就可以了,比如: function Demo(){ const select = useSelect({ apiFun:getDict }) // 这里可以直接用...将useSelect返回的属性与方法全部绑定给Select组件 return

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

那么就只能考虑打包插件了,只要我们在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中的用法

{{ result }}
复制代码 OK! 完成了! 使用npm安装 不过我也提前打包编译好了发布在了npm上,需要的话可以直接使用这个 npm i vite-plugin-vue-hook-enhance -D 改一下引入方式就可以了 import { viteHookBind } from "vite-plugin-vue-hook-enhance";