likes
comments
collection
share

通过一个常见的业务场景彻底搞懂Vue3中的hooks

作者站长头像
站长
· 阅读数 17

一、前置知识

1. mixin是什么

mixin 也就是混入,不仅仅在 Vue 框架中存在 mixin,确切的说 mixin 是一种混入的思想,他会自动的将混入的东西准确的分配到指定的组件中。

举个例子:现在组件A 中的 watch 中需要处理的逻辑是 hanldleParams, 在组件B 中的 watch 中同样需要这样的逻辑 hanldleParams,那么我们应该如何将这两块相同的逻辑抽象出来复用呢?

两种方法:

  1. 抽函数:将 hanldleParams 以函数的形式抽出来,然后在 watch 中调用 hanldleParams
  2. mixin:上一种抽函数方法虽然可以解决一定的复用问题,但是我们还是需要在组件中写 watch,如果每个组件都写 watch,那么 watch 也是重复的东西,因此 mixin 就是将 watch 钩子都可以抽出来的组件。也就是说,mixin 抽出来不仅仅是纯函数逻辑,还可以将 Vue 组件特有的钩子等逻辑也可以抽出来,达到进一步复用,而且 mixin 中的数据和方法都是独立的,组件之间使用后是互不影响的。

上面这两种方法的区别就代表了mixin和utils的区别

2. mixin解决的问题

mixin 解决了两种复用:

  1. 逻辑函数的复用
  2. Vue 组件配置复用

注意:组件配置复用是指,组件中的选项式API(例如:data,computed,watch)或者组件的生命周期钩子(created、mounted、destroyed)

3. mixin的使用场景

关键:在 Vue中,mixin 定义的就是一个对象,对象中放置的 Vue 组件相应的选项式API和对应的生命周期钩子

export const mixins = {
  data() {
    return {};
  },
  computed: {},
  created() {},
  mounted() {},
  methods: {},
};

使用如下:

// 定义一个mixin
export const mixins = {
  data() {
    return {
      msg: "我是小猪课堂",
    };
  },
  computed: {},
  created() {
    console.log("我是mixin中的created生命周期函数");
  },
  mounted() {
    console.log("我是mixin中的mounted生命周期函数");
  },
  methods: {
    clickMe() {
      console.log("我是mixin中的点击事件");
    },
  },
};
//

// src/App.vue中使用导出的mixin
<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png" />
    <button @click="clickMe">点击我</button>
  </div>
</template>

<script>
import { mixins } from "./mixin/index";
export default {
  name: "App",
  mixins: [mixins], // 注册mixin,这样mixin中所有的钩子函数等同于组件中钩子
  components: {},
  created(){
    console.log("组件调用minxi数据",this.msg);
  },
  mounted(){
    console.log("我是组件的mounted生命周期函数")
  }
};
</script>

以上这段代码等价于:

// src/App.vue中使用导出的mixin
<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png" />
    <button @click="clickMe">点击我</button>
  </div>
</template>

<script>
import { mixins } from "./mixin/index";
export default {
  name: "App",
  mixins: [mixins], // 注册mixin,这样mixin中所有的钩子函数等同于组件中钩子
  data() {
    return {
      msg: "我是小猪课堂",
    };
  },
  // 原来的组件中没有methdos方法,mixin中的方法直接放入组件中
  // 注意:vue中当发生methods函数名和mixin中的methods方法冲突时,解决方案是本组件中优先级高于mixin
  methods: {
    clickMe() {
      console.log("我是mixin中的点击事件");
    },
  },
  created(){
    console.log("我是mixin中的created生命周期函数"); 
    // mixin中的created钩子中的,生命周期中mixin的钩子优先级要高于组件中的优先级
    console.log("组件调用minxi数据",this.msg);
  },
  mounted(){
    // mixin中的 mounted 钩子中的,生命周期中mixin的钩子优先级要高于组件中的优先级
    console.log("我是mixin中的mounted生命周期函数");
    console.log("我是组件的mounted生命周期函数")
  }
};
</script>

注意:mixin 中和 Vue 组件中相同的钩子的优先级:

  • mixin 中的生命周期函数会和组件的生命周期函数一起合并执行
  • mixin 中的 data 数据在组件中也可以使用
  • mixin 中的方法在组件内部可以直接调用
  • 生命周期函数合并后执行顺序:先执行 mixin 中的,后执行组件的

此外,mixin 对于不同组件的导入,相互之间数据是不会影响的

4. mixin的缺点

(1) 相同钩子中注册的函数名相同会发生冲突(Vue 中冲突的解决方案是本组件中优先级高于 mixin) (2) 定位错误需要花费时间 (3) 滥用会造成维护问题

二、hooks是什么

一般来说,我们开发中会自动抽象出逻辑函数放在 utils 中,utils 中放的纯逻辑,不存在属于组件的东西;而 hooks 就是在 utils 的基础上再包一层组件级别的东西(钩子函数等)。hooks 和 utils 的区别: hooks 中如果涉及到 ref,reactive,computed 这些 api 的数据,那这些数据是具有响应式的,而 utils 只是单纯提取公共方法就不具备响应式,因此可以把 hooks 理解为加入 Vue3 api 的公共方法。

那么 hooks 相当于组件级别的逻辑封装,这种逻辑封装在 Vue2 中的 mixin 也可以实现,那为什么还要使用 hooks 呢?开篇的时候我们已经了解了 mixin 的缺点,而 hooks 最大的优势是灵活,下面通过两个例子来体验下两者的区别:

1. 使用mixin的例子:

// 混入文件:name-mixin.js
export default {
  data() {
    return {
      name: 'zhng'
    }
  },
  methods: {
    setName(name) {
      this.name = name
    }
  }
}

// 组件:my-component.vue
<template>
  <div>{{ name }}</div>
<template>
<script>
import nameMixin from './name-mixin';
export default {
  mixins: [nameMixin],
  mounted() {
    setTimeout(() => {
      this.setName('Tom') // 通过在组件中调用setName传入参数值,无法传入参数,限制了Mixin的灵活性
    }, 3000)
  }
}
<script>

2. 使用hooks的例子:

import { computed, ref, Ref } from "vue"
// 定义hook方法
type CountResultProps = {
    count:Ref<number>;
    multiple:Ref<number>;
    increase:(delta?:number)=>void;
    decrease:(delta?:number)=> void;
}
export default function useCount(initValue = 1):CountResultProps{
    const count = ref(initValue)
    const increase = (delta?:number):void =>{
        if(typeof delta !== 'undefined'){
            count.value += delta
        }else{
            count.value += 1
        }
    }
    const multiple  = computed(()=>count.value * 2)
    const decrease = (delta?:number):void=>{
        if(typeof delta !== "undefined"){
            count.value -= delta
        }else{
            count.value -= 1
        }
    }
    return {
        count,
        increase,
        decrease,
        multiple
    }
 
}

// 在组件中的使用
<template>
   <p>count:{{count}}</p>
   <p>倍数:{{multiple}}</p>
   <div>
     <button @click="increase(10)">加一</button>
     <button @click="decrease(10)">减一</button> // 在模版中直接使用hooks中的方法作为回调函数
   </div>
</template>
<script setup lang="ts">
import useCount from "../views/Hook"
const {count,multiple,increase,decrease}  = useCount(10)
</script>
<style>
 
</style>    

自定义hook需要满足的规范:

通过一个常见的业务场景彻底搞懂Vue3中的hooks

三、使用hooks的实际例子

在开发管理后台过程中,一定会遇到不少了增删改查页面,而这些页面的逻辑大多都是相同的,如获取列表数据,分页,筛选功能这些基本功能,而不同的是呈现出来的数据项,还有一些操作按钮。

通过一个常见的业务场景彻底搞懂Vue3中的hooks

对于刚开始只有 1,2 个页面的时候大多数开发者可能会直接将之前的页面代码再拷贝多一份出来,而随着项目的推进类似页面可能会越来越多,这直接导致项目代码耦合度越来越高,这也是为什么在项目中一些可复用的函数或组件要抽离出来的主要原因之一。

下面我们封装一个通用的useList,适配大多数增删改查的列表页面,让你更快更高效的完成任务,准点下班 ~

1. 定义分页数据

export default function useList() {
  // 加载态
  const loading = ref(false);
  // 当前页
  const curPage = ref(1);
  // 总数量
  const total = ref(0);
  // 分页大小
  const pageSize = ref(10);
}

2. 获取列表数据

useList 函数接收一个 listRequestFn 参数,用于请求数据。定义一个 list 变量,用于存放接口返回的数据,由于在内部无法直接确定列表数据类型,通过泛型的方式让外部提供列表数据类型。

export default function useList<ItemType extends Object>(
  listRequestFn: Function
) {
  // 忽略其他代码
  const list = ref<ItemType[]>([]);
}

useList 中创建一个 loadData 函数,用于获取数据,该函数接收一个参数用于获取指定页数的数据;同时使用 watch 监听数据,当 curPagepageSize 的值发生改变时调用 loadData 函数获取新的数据。

export default function useList<ItemType extends Object>(
  listRequestFn: Function
) {
  // 忽略其他代码
  // 数据
  const list = ref<ItemType[]>([]);
  // 过滤数据
  // 获取列表数据
  const loadData = async (page = curPage.value) => {
    // 设置加载中
    loading.value = true;
    try {
      const {
        data,
        meta: { total: count },
      } = await listRequestFn(pageSize.value, page);
      list.value = data;
      total.value = count;
    } catch (error) {
      console.log("请求出错了", "error");
    } finally {
      // 关闭加载中
      loading.value = false;
    }
  };
  // 监听分页数据改变
  watch([curPage, pageSize], () => {
    loadData(curPage.value);
  });
}

3. 实现数据筛选器

在庞大的数据列表中,数据筛选是必不可少的功能,在 useList 函数中,第二个参数接收一个 filterOption 对象,对应列表中的筛选条件字段。调整一下 loadData 函数,在请求函数中传入 filterOption 对象。注意,这里 filterOption 参数类型需要的是 ref 类型,否则会丢失响应式 无法正常工作。

export default function useList<
  ItemType extends Object,
  FilterOption extends Object
>(listRequestFn: Function, filterOption: Ref<Object>) {
  const loadData = async (page = curPage.value) => {
    // 设置加载中
    loading.value = true;
    try {
      const {
        data,
        meta: { total: count },
      } = await listRequestFn(pageSize.value, page, filterOption.value);
      list.value = data;
      total.value = count;
    } catch (error) {
      console.log("请求出错了", "error");
    } finally {
      // 关闭加载中
      loading.value = false;
    }
  };
}

4. 清空筛选器字段

在页面中,有一个重置的按钮,用于清空筛选条件,这个重复的动作可以交给 reset 函数处理。通过使用 Reflect 将所有值设定为 undefined,再重新请求一次数据。

export default function useList<
  ItemType extends Object,
  FilterOption extends Object
>(listRequestFn: Function, filterOption: Ref<Object>) {
  const reset = () => {
    if (!filterOption.value) return;
    const keys = Reflect.ownKeys(filterOption.value);
    filterOption.value = {} as FilterOption;
    keys.forEach((key) => {
      Reflect.set(filterOption.value!, key, undefined);
    });
    loadData();
  };
}

5. 添加导出功能

除了对数据的查看,有些界面还需要有导出数据功能(例如导出 excel 文件),此时我们也需要把导出功能写到 useList 里。通常,导出功能是调用后端提供的导出 Api 获取一个文件下载地址,和 loadData 函数类似,从外部获取 exportRequestFn 函数来调用 Api

export default function useList<
  ItemType extends Object,
  FilterOption extends Object
>(
  listRequestFn: Function,
  filterOption: Ref<Object>,
  exportRequestFn?: Function
) {
  // 忽略其他代码
  const exportFile = async () => {
    if (!exportRequestFn) {
      throw new Error("当前没有提供exportRequestFn函数");
    }
    if (typeof exportRequestFn !== "function") {
      throw new Error("exportRequestFn必须是一个函数");
    }
    try {
      const {
        data: { link },
      } = await exportRequestFn(filterOption.value);
      window.open(link);
    } catch (error) {
      console.log("导出失败", "error");
    }
  };
}

6. 进一步优化代码

现在,整个 useList 已经满足了页面上的需求了,拥有了获取数据,筛选数据,导出数据,分页功能;还有一些细节方面,例如在上面所有代码中的 catch 代码片段并没有做任何的处理等。

6.1 定义 Options 类型

useList 新增一个 Options 对象参数,用于函数成功、失败时执行指定钩子函数与输出消息内容。

export interface MessageType {
  GET_DATA_IF_FAILED?: string;
  GET_DATA_IF_SUCCEED?: string;
  EXPORT_DATA_IF_FAILED?: string;
  EXPORT_DATA_IF_SUCCEED?: string;
}
export interface OptionsType {
  requestError?: () => void;
  requestSuccess?: () => void;
  message: MessageType;
}

export default function useList<
  ItemType extends Object,
  FilterOption extends Object
>(
  listRequestFn: Function,
  filterOption: Ref<Object>,
  exportRequestFn?: Function,
  options? :OptionsType
) {
  // ...
}

6.2 设置Options默认值

const DEFAULT_MESSAGE = {
  GET_DATA_IF_FAILED: "获取列表数据失败",
  EXPORT_DATA_IF_FAILED: "导出数据失败",
};

const DEFAULT_OPTIONS: OptionsType = {
  message: DEFAULT_MESSAGE,
};

export default function useList<
  ItemType extends Object,
  FilterOption extends Object
>(
  listRequestFn: Function,
  filterOption: Ref<Object>,
  exportRequestFn?: Function,
  options = DEFAULT_OPTIONS
) {
  // ...
}

6.3 修改加载数据、导出函数

首先基于 elementui 封装 message 方法:

import { ElMessage, MessageOptions } from "element-plus";

export function message(message: string, option?: MessageOptions) {
  ElMessage({ message, ...option });
}
export function warningMessage(message: string, option?: MessageOptions) {
  ElMessage({ message, ...option, type: "warning" });
}
export function errorMessage(message: string, option?: MessageOptions) {
  ElMessage({ message, ...option, type: "error" });
}
export function infoMessage(message: string, option?: MessageOptions) {
  ElMessage({ message, ...option, type: "info" });
}

loadData 函数:

const loadData = async (page = curPage.value) => {
  loading.value = true;
  try {
    const {
      data,
      meta: { total: count },
    } = await listRequestFn(pageSize.value, page, filterOption.value);
    list.value = data;
    total.value = count;
    // 执行成功钩子
    options?.message?.GET_DATA_IF_SUCCEED &&
      message(options.message.GET_DATA_IF_SUCCEED);
    options?.requestSuccess?.();
  } catch (error) {
    options?.message?.GET_DATA_IF_FAILED &&
      errorMessage(options.message.GET_DATA_IF_FAILED);
    // 执行失败钩子
    options?.requestError?.();
  } finally {
    loading.value = false;
  }
};

exportFile 函数:

const exportFile = async () => {
  if (!exportRequestFn) {
    throw new Error("当前没有提供exportRequestFn函数");
  }
  if (typeof exportRequestFn !== "function") {
    throw new Error("exportRequestFn必须是一个函数");
  }
  try {
    const {
      data: { link },
    } = await exportRequestFn(filterOption.value);
    window.open(link);
    // 显示信息
    options?.message?.EXPORT_DATA_IF_SUCCEED &&
      message(options.message.EXPORT_DATA_IF_SUCCEED);
    // 执行成功钩子
    options?.exportSuccess?.();
  } catch (error) {
    // 显示信息
    options?.message?.EXPORT_DATA_IF_FAILED &&
      errorMessage(options.message.EXPORT_DATA_IF_FAILED);
    // 执行失败钩子
    options?.exportError?.();
  }
};

7. 在组件中使用useList

<template>
  <el-collapse class="mb-6">
    <el-collapse-item title="筛选条件" name="1">
      <el-form label-position="left" label-width="90px" :model="filterOption">
        <el-row :gutter="20">
          <el-col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
            <el-form-item label="用户名">
              <el-input
                v-model="filterOption.name"
                placeholder="筛选指定签名名称"
              />
            </el-form-item>
          </el-col>
          <el-col :xs="24" :sm="12" :md="8" :lg="8" :xl="8">
            <el-form-item label="注册时间">
              <el-date-picker
                v-model="filterOption.timeRange"
                type="daterange"
                unlink-panels
                range-separator="到"
                start-placeholder="开始时间"
                end-placeholder="结束时间"
                format="YYYY-MM-DD HH:mm"
                value-format="YYYY-MM-DD HH:mm"
              />
            </el-form-item>
          </el-col>
          <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
            <el-row class="flex mt-4">
              <el-button type="primary" @click="filter">筛选</el-button>
              <el-button type="primary" @click="reset">重置</el-button>
            </el-row>
          </el-col>
        </el-row>
      </el-form>
    </el-collapse-item>
  </el-collapse>
  <el-table v-loading="loading" :data="list" border style="width: 100%">
    <el-table-column label="用户名" min-width="110px">
      <template #default="scope">
        {{ scope.row.name }}
      </template>
    </el-table-column>
    <el-table-column label="手机号码" min-width="130px">
      <template #default="scope">
        {{ scope.row.mobile || "未绑定手机号码" }}
      </template>
    </el-table-column>
    <el-table-column label="邮箱地址" min-width="130px">
      <template #default="scope">
        {{ scope.row.email || "未绑定邮箱地址" }}
      </template>
    </el-table-column>
    <el-table-column prop="createAt" label="注册时间" min-width="220px" />
    <el-table-column width="200px" fixed="right" label="操作">
      <template #default="scope">
        <el-button type="primary" link @click="detail(scope.row)"
          >详情</el-button
        >
      </template>
    </el-table-column>
  </el-table>
  <div v-if="total > 0" class="flex justify-end mt-4">
    <el-pagination
      v-model:current-page="curPage"
      v-model:page-size="pageSize"
      background
      layout="sizes, prev, pager, next"
      :total="total"
      :page-sizes="[10, 30, 50]"
    />
  </div>
</template>
<script setup lang="ts">
import { UserInfoApi } from "@/network/api/User";
import useList from "@/lib/hooks/useList/index";
const filterOption = ref<UserInfoApi.FilterOptionType>({});
const {
  list,
  loading,
  reset,
  filter,
  curPage,
  pageSize,
  reload,
  total,
  loadData,
} = useList<UserInfoApi.UserInfo[], UserInfoApi.FilterOptionType>(
  UserInfoApi.list,
  filterOption
);
</script>

原文出处:juejin.cn/post/720811…