vue3项目常用hooks——从零到一搭建一个高颜值Vue3后台管理系统(四)

什么是hooks?

简单说就是我们将文件里的一些功能抽离出去封装后达到复用的效果。看到这里你会想这不就是以前的utils换个名字吗?其实我们更倾向于把一些如ref、reactive、onMounted等vue的api抽离的函数叫做hooks函数,它区别于mixin的不好排查异常和维护。

useTable hooks

项目中通常会出现大量表格,我们除了对视图的封装外,也可以将逻辑抽离出来达到复用,这样开发效率更好,后期修改也不用每个文件每个文件的去找,只需要关注输入输出即可。 一般表格操作主要涉及数据获取、loading加载、分页切换、表格搜索、表格重置,接下来我们需要在src目录下新建hooks文件,新建useTable.ts文件

ini 复制代码
import { reactive, ref } from 'vue';

interface RequestResult<T = any> {
    code: number;
    message: string;
    data: T;
}
  
interface TableResult<T = any> {
    total: number;
    list: T[];
}
  
type RequestTableResult<T = any> = RequestResult<TableResult<T> | T>;

interface Options<T = any> {
  // api
  apiFn: (params: any) => Promise<RequestTableResult>;
  // api请求参数
  params?: Recordable;
  // api返回值不是约定的TableResult的处理
  callback?: (data: any) =>TableResult<T>;
  // 显示分页数据
  isPageable?: boolean;
  // 立即执行getList函数
  immediate?: boolean;
}

export const useTable = <T = any>(options: Options) => {
  // 列表数据
  const tableData = ref<T[]>([]);
  // loading变量
  const loading = ref<boolean>(false);
  // 请求参数
  const paramsInit = JSON.parse(JSON.stringify(options.params || {}));
  // 分页数据
  const page = reactive({
    page: 1,
    pageSize: 20,
    pageSizes: [10, 20, 30, 50],
    total: 10
  });

  const getList = async () => {
    loading.value = true;
    const isPageable = options.isPageable ?? true;
    // 根据传入的isPageable属性判断列表请求是否携带分页参数
    const pageParams = isPageable ? { page: page.page, pageSize: page.pageSize } : {};
    // 总的请求参数
    const totalParams = Object.assign({}, options.params, pageParams);
    let { data } = await options.apiFn(totalParams).finally(() => (loading.value = false));
    // 如果后端返回格式不规范,需要传入回调处理成我们想要的格式
    options.callback && (data = options.callback(data));
    // 根据是否分页取值,所以如果接口格式不正确可以传入cb处理
    tableData.value = isPageable ? data.list : data;
    page.total = data.total;
  };

  // 页码总数切换
  const handleSizeChange = async (val: number) => {
    page.page = 1;
    page.pageSize = val;
    await getList();
  };

  // 分页切换
  const handleCurrentChange = async (val: number) => {
    page.page = val;
    await getList();
  };

  // 重置搜索数据
  const resetParams = () => {
    Object.keys(paramsInit).forEach((item) => {
      options.params![item] = paramsInit[item];
    });
    getList();
  };
  
//是否默认执行getList
if (options.immediate ?? true) getList();

// 返回相关需要的变量和方法
  return {
    tableData,
    page,
    loading,
    getList,
    resetParams,
    handleSizeChange,
    handleCurrentChange
  };
};

使用表格hooks,在需要的页面引入hooks

php 复制代码
import { useTable } from '@/hooks/useTable';
// api
import { getMenuList } from '@/api/auth';

const { tableData, loading } = useTable({
  apiFn: getMenuList,
  isPageable: false
});

// 解构出列表数据和loading
const { tableData, loading } = useTable({
  apiFn: getMenuList,
  isPageable: false
});

useEcharts hooks

下面是echarts的hooks,echarts在项目中使用也比较多,不想写太多重复性代码这里也抽离出来统一封装成hooks,新建文件useEcharts.ts

typescript 复制代码
import echarts, { type ECOption } from '@/utils/echarts';
import { useDebounceFn } from '@vueuse/core';
import { useThemeStore } from '@/store/modules/theme';
import { watch, onBeforeUnmount, markRaw, Ref } from 'vue';

export const useEcharts = (elRef: HTMLDivElement, options: Ref<ECOption>) => {
  let myChart: echarts.ECharts | null = null;
  
  // 获取store的主题配置
  const themeStore = useThemeStore();
  
  // 初始化
  const initCharts = () => {
    // 适配暗黑主题
    const theme = themeStore.isDark ? 'dark' : 'default';
    myChart = markRaw(echarts.init(elRef, theme));
    setOptions(options.value);
  };

  // 设置echarts
  const setOptions = (updateOptions: ECOption) => {
    myChart && myChart.setOption({ ...updateOptions, backgroundColor: 'transparent' });
  };

 // 屏幕适配,这里使用了一个防抖
  const resize = useDebounceFn(() => {
    myChart && myChart.resize();
  }, 200);

  // 初始化执行
  initCharts();

  // 监听options
  watch(
    options,
    (newValue) => {
      setOptions(newValue);
    },
    { deep: true }
  );

  // 暗黑适配
  watch(
    () => themeStore.isDark,
    () => {
      if (myChart) {
        myChart.dispose();
        initCharts();
      }
    }
  );

  window.addEventListener('resize', resize);
  
  // 清除副作用
  onBeforeUnmount(() => {
    if (!myChart) return;
    window.removeEventListener('resize', resize);
    myChart.dispose();
  });

  // 暴露变量和方法
  return {
    myChart
  };
};

使用图表hooks,在需要的页面引入hooks

xml 复制代码
<template>
  <div ref="lineEcharts" class="w-full h-20"></div>
</template>

<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { ECOption } from '@/utils/echarts';
import { graphic } from 'echarts/core';
import { useEcharts } from '@/hooks/useEcharts';
defineOptions({ name: 'BotCard' });

const lineEcharts = ref<HTMLDivElement | null>(null);
const lineOptions = ref<ECOption>({
  tooltip: {
    trigger: 'axis',
    formatter(params: any) {
      return '当前销售额' + params[0].data;
    }
  },
  grid: {
    left: '3',
    right: '3',
    top: '10',
    bottom: '10'
  },
  xAxis: {
    type: 'category',
    data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    axisLabel: { show: false },
    boundaryGap: false,
    axisLine: {
      show: false
    },
    axisTick: {
      show: false
    }
  },
  yAxis: {
    type: 'value',
    axisLine: {
      show: false
    },
    axisLabel: {
      show: false
    },
    splitLine: {
      show: false
    }
  },
  series: [
    {
      data: [400, 600, 655, 406, 1113, 777, 900],
      type: 'line',
      smooth: true,
      showSymbol: false,
      lineStyle: {
        width: 3,
        color: new graphic.LinearGradient(0, 0, 1, 0, [
          {
            offset: 0,
            color: 'rgba(250, 220, 25, 1)'
          },
          {
            offset: 0.5,
            color: 'rgba(255, 125, 0, 1)'
          },
          {
            offset: 1,
            color: 'rgba(245, 63, 63, 1)'
          }
        ])
      }
    }
  ]
});

// 确保dom获取成功
onMounted(() => {
  useEcharts(lineEcharts.value as HTMLDivElement, lineOptions);
});
</script>

useWatermark hooks

下面是useWatermark的hooks,水印在项目中也可能会使用到,我们弄一个没法删除的水印,主要借用了MutationObserver这个方法去监听相关dom的变化,如果是属性被修改,我们重新添加属性,如果是dom被删除,我们强制增加一个水印就行了,下面统一封装成hooks,新建文件useWatermark.ts

ini 复制代码
import { useThrottleFn } from '@vueuse/core';

import { shallowRef, ref, Ref, unref } from 'vue';

interface IAttr {
  /** 字体,默认 `18px Vedana` */
  font?: string;
  /** 填充绘制图形的颜色 */
  fillStyle?: string;
  /** 水印旋转,默认 `-20` */
  rotate?: number;
  /** 水印无法删除,默认 `false` */
  forever: boolean;
}

export function useWatermark(appendEl: Ref<HTMLElement | null> = ref(document.body)) {
  let style = '';
  let attr: IAttr | undefined = undefined;
  let observer: MutationObserver | null = null;
  const id = Symbol('watermark').toString();
  const watermarkEl = shallowRef<HTMLElement | null>(null);

  // 绘制文字背景图
  function createBase64(text: string, attr?: IAttr) {
    const can = document.createElement('canvas');
    can.width = 200;
    can.height = 120;

    const cans = can.getContext('2d');
    if (cans) {
      cans.rotate(((attr?.rotate ?? -20) * Math.PI) / 120);
      cans.font = attr?.font ?? '18px Vedana';
      cans.fillStyle = attr?.fillStyle ?? 'rgba(0, 0, 0, 0.12)';
      cans.textAlign = 'left';
      cans.textBaseline = 'middle';
      cans.fillText(text, can.width / 20, can.height);
    }
    return can.toDataURL('image/png');
  }

  // 绘制水印层
  const createWatermark = (text: string, attr?: IAttr) => {
    if (unref(watermarkEl)) {
      updateWatermark({ text, attr });
      return id;
    }
    const div = document.createElement('div');
    watermarkEl.value = div;
    div.id = id;
    div.style.pointerEvents = 'none';
    div.style.top = '0px';
    div.style.left = '0px';
    div.style.position = 'absolute';
    div.style.zIndex = '100000';
    const el = unref(appendEl);
    if (!el) return id;
    el.style.position = 'relative';
    const { clientHeight: height, clientWidth: width } = el;
    updateWatermark({ text, width, height, attr });
    el.appendChild(div);
    return id;
  };

  // 页面随窗口调整更新水印
  function updateWatermark(
    options: {
      width?: number;
      height?: number;
      text?: string;
      attr?: IAttr;
    } = {}
  ) {
    const el = unref(watermarkEl);
    if (!el) return;
    if (options.width) {
      el.style.width = `${options.width}px`;
    }
    if (options.height) {
      el.style.height = `${options.height}px`;
    }
    if (options.text) {
      el.style.background = `url(${createBase64(options.text, options.attr)}) left top repeat`;
    }
    style = el.style.cssText;
  }

  // 对外提供的设置水印方法
  function setWatermark(text: string, attrProps?: IAttr) {
    if (attrProps) attr = attrProps;
    createWatermark(text, attr);
    attr?.forever && createObserver(text, attr);
    window.addEventListener('resize', func);
  }

  const func = useThrottleFn(function () {
    const el = unref(appendEl);
    if (!el) return;
    const { clientHeight: height, clientWidth: width } = el;
    updateWatermark({ height, width });
  }, 30);

  // 清除水印
  const clear = () => {
    const domId = unref(watermarkEl);
    watermarkEl.value = null;
    observer?.disconnect();
    observer ??= null;
    const el = unref(appendEl);
    if (!el) return;
    domId && el.removeChild(domId);
    window.removeEventListener('resize', func);
  };

  // 监听 DOM 变化
  const createObserver = (text: string, attr?: IAttr) => {
    const domId = unref(watermarkEl);
    if (!domId) return;
    observer = new MutationObserver((mutationsList) => {
      if (mutationsList.length) {
        const { removedNodes, type, target } = mutationsList[0];
        const currStyle = domId.getAttribute('style');
        if (removedNodes[0] === domId) {
          watermarkEl.value = null;
          observer!.disconnect();
          createWatermark(text, attr);
        } else if (type === 'attributes' && target === domId && currStyle !== style) {
          domId.setAttribute('style', style);
        }
      }
    });
    observer.observe(unref(appendEl)!, {
      childList: true,
      attributes: true,
      subtree: true
    });
  };

  return { setWatermark, clear };
}

使用水印hooks,在需要的页面引入hooks

xml 复制代码
<template>
  <el-card shadow="never" class="h-full">
    <el-button @click="setWatermark('测试水印')">设置全屏水印</el-button>
    <el-button @click="clear()">清除水印</el-button>
    <div ref="divRef" class="my-4 w-350 h-100 border-dotted border-2 border-primary"></div>
  </el-card>
</template>

<script lang="ts" setup>
import { useWatermark } from '@/hooks/useWatermark';

import { onBeforeUnmount, ref, onMounted } from 'vue';

const divRef = ref();

const { setWatermark, clear } = useWatermark();
const { setWatermark: setPartWatermark, clear: clearPart } = useWatermark(divRef);

onMounted(() => {
  setPartWatermark('不可删除的水印', {
    forever: true,
    fillStyle: '#2ecc71'
  });
});

onBeforeUnmount(() => {
  clear();
  clearPart();
});
</script>

<style lang="scss" scoped></style>

本文项目地址:Element-Admin

学会hooks,快速下班的好帮手

相关推荐
起这个名字2 分钟前
LangGraphJs 核心概念、工作流程理解及应用
前端·人工智能
小赵同学WoW2 分钟前
vue组件基础知识
前端
牛奶11 分钟前
浏览器藏了这么多神器,你居然不知道?
前端·chrome·api
WebInfra16 分钟前
Rspack 2.0 正式发布!
前端·javascript·前端框架
极速蜗牛23 分钟前
Cursor最近变傻了?
前端
码字小学妹33 分钟前
Claude Opus 4.7 接入指南(2026):国内配置 + xhigh 推理 + 成本计算
前端
小赵同学WoW35 分钟前
插槽【vue2】与 【vue3】对比
前端
代码随想录35 分钟前
Agent大厂面试题汇总:ReAct、Function Calling、MCP、RAG高频问题
前端·react.js·前端框架
前端那点事35 分钟前
Vue响应式原理|从底层实现到面试考点,一文吃透(Vue2+Vue3全解析)
前端·vue.js
walking95737 分钟前
Vite 打包优化终极指南:从 30MB 到 800KB 的性能飞跃
前端·vue.js·vite