Vue3 如何封装一个超级好用的Hook👾

本文将通过介绍什么是Hook、如何在Vue使用Hook,以及在实践场景中如何封装自己的 Vue Hook,带你走进Hook的世界,写出更优雅的代码。

什么是Hook?

Vue3官方文档是这样定义组合式函数的。 A "composable" is a function that leverages Vue's Composition API to encapsulate and reuse stateful logic. ,一个利用Vue的组合式API来封装和复用具有状态逻辑的函数。

这个概念借鉴自React的Hook。在16.8的版本中,React引入了React Hook。这是一项特别强大的技术,通过封装有状态的函数,极大提高了组件的编写效率和维护性。在下文中也是使用Hook来替代"组合式函数"进行叙述。

在开发中,我们经常会发现一些可以重复利用的代码段,于是我们将其封装成函数以供调用。这类函数包括工具函数,但是又不止工具函数,因为我们可能也会封装一些重复的业务逻辑。以往,在前端原生开发中,我们封装的这些函数都是"无状态"的。为了建立数据与视图之间的联系,基于MVC架构的React框架和基于MVVM的Vue框架都引入了"状态"这一概念,状态是特殊的JavaScript变量,它的变化会引起视图的变化。在这类框架中,如果一个变量的变化不会引起视图的变化,那么它就是普通变量,如果一个变量已经被框架注册为状态,那么这个变量的变化就会引发视图的变化,我们称之为响应式变量。如果一个函数包含了状态(响应式变量),那么它就是一个Hook函数。

在具备"状态"的框架的基础上,才有Hook这一说。Hook函数与普通函数的本质区别在于是否具有"状态"。

比如,在一个Vue项目中,我们可能同时引入了lodash库和VueUse库,这两个库都是提供一些方便的工具函数。工具函数库只引入一个不行吗,不会重复吗?或许不行,因为lodash的函数是无状态的,用来处理普通变量或者响应式变量中的数据部分,而VueUse提供的api都是Hook。如果你的项目中既有普通变量又有响应式变量,你或许就会在同一个项目中同时接触到这两个库。

React官方为我们提供了一些非常方便的Hook函数,比如useState、useEffect(我们通常使用use作为前缀来标识Hook函数),但是这远远不够,或者说,它们足够通用但是不够具体。为了在具体业务下复用某些逻辑,我们往往会封装自己的Hook,即自定义Hook。为什么这里会反复提到React中呢?因为提到Hook,就不可能避开React。Hook是React发扬光大的,使用Hook已经是React社区的主流。然而,只要框架具备"状态"这一概念,都可以使用Hook技术!下面文章将会介绍如何将Hook应用到Vue当中。

在Vue中使用Hook

下面我们来看一个简单的自定义Hook(来自Vue官方文档):

需求:在页面实时显示鼠标的坐标。实现:没有使用Hook。

js 复制代码
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const x = ref(0)
const y = ref(0)

function update(event) {
  x.value = event.pageX
  y.value = event.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<template>Mouse position is at: {{ x }}, {{ y }}</template>

在没有封装的情况下,如果我们在另一个页面也需要这个功能,我们需要将代码复制过去。另外,可以看出,它声明了两个变量,并且在生命周期钩子 onMounted和 onUnmounted中书写了一些代码,如果这个页面需要更多的功能,那么会出现代码中存在很多变量、生命周期中存在很多逻辑写在一起的现象,使得这些逻辑混杂在一起,而使用Hook可以将其分隔开来(这也是为什么会有很多人使用Hook的原因,分离代码,提高可维护性!)

使用Hook:

js 复制代码
<script setup>
import { useMouse } from './mouse.js'

const { x, y } = useMouse()
</script>

<template>Mouse position is at: {{ x }}, {{ y }}</template>

可以发现,比原来的代码更加简洁,这时如果加入其它功能的变量,也不会觉得眼花缭乱了。

当然,我们需要在外部定义这个Hook:

js 复制代码
// mouse.js
import { ref, onMounted, onUnmounted } from 'vue'

// 按照惯例,组合式函数名以"use"开头
export function useMouse() {
  // 被组合式函数封装和管理的状态
  const x = ref(0)
  const y = ref(0)

  // 组合式函数可以随时更改其状态。
  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  // 一个组合式函数也可以挂靠在所属组件的生命周期上
  // 来启动和卸载副作用
  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  // 通过返回值暴露所管理的状态
  return { x, y }
}

或许,你可以试着去VueUse库找到别人封装好的useMouse!

js 复制代码
import { useMouse } from 'VueUse'

恭喜你,掌握了VueUse库的使用方法。如果需要其它Hook,你可以先试着去官方文档VueUse | VueUse(vueuse.org/) 查找,使用现成的函数,而不是自己去封装。

封装一(入门级的表格Hook)

在前面,我们介绍完了Hook的概念,完成了一个简单的自定义Hook,还学会了使用社区提供的大量现成的Hook函数(VueUse库),接下来,我们将结合实际业务,完成我们自己的Hook函数!

场景分析

首先定义一个表格:

js 复制代码
<template>
  <el-table :data="tableData" style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>
  <button @click="refresh">refresh</button>
</template>

表格的数据通过api获取(一般写法):

js 复制代码
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi } from "./api.ts";

const tableData = ref([]);
const refresh=async () => {
  const data = await getTableDataApi();
  tableData.value = data;
}

onMounted(refresh);
</script>

模拟api:

js 复制代码
// api.ts
export const getTableDataApi = () => {
  const data = [
    {
      date: '2016-05-03',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-02',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-04',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-01',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
  ]
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(data)
    }, 100);
  })
}

如果存在多个表格,我们的js代码会变得比较复杂:

js 复制代码
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi1, getTableDataApi2, getTableDataApi3 } from "./api.ts";

const tableData1 = ref([]);
const refresh1=async () => {
  const data = await getTableDataApi1();
  tableData1.value = data;
}

const tableData2 = ref([]);
const refresh2=async () => {
  const data = await getTableDataApi2();
  tableData2.value = data;
}

const tableData3 = ref([]);
const refresh3=async () => {
  const data = await getTableDataApi3();
  tableData3.value = data;
}

onMounted(refresh1);
</script>

封装实例

封装我们的useTable:

js 复制代码
// useTable.ts
import { ref } from 'vue'
export function useTable(api) {
  const data = ref([])
  const refresh = () => { api().then(res => data.value = res) };
  refresh()
  return [data, refresh]
}

改造代码:

js 复制代码
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi1, getTableDataApi2, getTableDataApi3 } from "./api.ts";
import { useTable } from './useTable.ts'

const [tableData1, refresh1] = useTable(getTableDataApi1);
const [tableData2, refresh2] = useTable(getTableDataApi2);
const [tableData3, refresh3] = useTable(getTableDataApi3);

onMounted(refresh1);
</script>

封装技巧 - Hook 返回值

  1. 一般自定义Hook有返回数组的,也有返回对象的,上面useTable使用了返回数组的写法,useMouse使用了返回对象的写法。数组是对应位置命名的,可以方便重命名,对象对于类型和语法提示更加友好。两种写法都是可以替换的。
  2. 因为Hook返回对象或者数组,那么它一定是一个非async函数(async函数一定返回Promise),所以在Hook中,一般使用then 而不是await来处理异步请求。
  3. 返回值如果是对象,一般在函数中通过reactive创建一个对象,最后通过toRefs导出,这样做的原因是可以产生批量的可以解构的Ref对象,以免在解构返回值时丢失响应性。
js 复制代码
// 使用 reactive 和 toRefs 可以快速创建多个ref对象,并在解构后使用时不丢失其响应性和与原先数据的关联性
function usePaginaion(){
 const pagination = reactive({
  current: 1,
  total: 0,
  sizeOption,
  size: sizeOption[0]
 })
 ...
 return {...toRefs(pagination)}
}

const { current,total } = usePagination()

封装二(支持分页查询)

需求分析

上面我们封装了一个简单的hook,但是实际应用中并不会如此简单,下面我列出一个比较完整的useTable在实践中应该具备的功能,并在后续的文章部分完成它。

封装表格组件逻辑:

  1. 维护qpi的调用和刷新(已完成)
  2. 支持分页查询(页数、总条数、每页大小等)
  3. 支持api参数
  4. 增加辅助功能(loading、立即执行等)

下面我们将对useTable进行改造,使其支持分页器。

先改造一些我们的api,使其支持分页查询:

js 复制代码
export const getTableDataApi = (page, limit) => {
  const data = [
    {
      date: '2016-05-03',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-02',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-04',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2016-05-01',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2017-05-03',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2017-05-02',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2017-05-04',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
    {
      date: '2017-05-01',
      name: 'Tom',
      address: 'No. 189, Grove St, Los Angeles',
    },
  ]
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({
        total: data.length,
        data: data.slice((page - 1) * limit, (page - 1) * limit + limit)
      })
    }, 100);
  })
}

如果没有使用Hook,我们的vue文件应该是这样的:

js 复制代码
<template>
  <el-table :data="tableData" style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>
  <button @click="refresh">refresh</button>
  <!-- 分页器 -->
  <el-pagination
    v-model:current-page="current"
    :page-size="size"
    layout="total, prev, pager, next"
    :page-sizes="sizeOption"
    :total="total"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />
</template>

<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi } from "./api.ts";

const tableData = ref([]); // 表格数据
const current = ref(1); // 当前页数
const sizeOption = [10, 20, 50, 100, 200]; // 每页大小选项
const size = ref(sizeOption[0]); //每页大小
const total = ref(0); // 总条数

// 每页大小变化
const handleSizeChange = (size: number) => {
  size.value = size;
  current.value = 1;
  // total.value = 0;
  refresh();
};

// 页数变化
const handleCurrentChange = (page: number) => {
  current.value = page;
  // total.value = 0;
  refresh();
};

const refresh = async () => {
  const result = await getTableDataApi({
    page: current.value,
    limit: size.value,
  });
  tableData.value = result.data || [];
  total.value = result.total || 0;
};

onMounted(refresh);
</script>

可以看出,如果存在多个表格,会创建很多套变量和重复的代码。

封装实例

先写个usePagination:该钩子接受一个回调函数,当页数改变时就会调用该函数。

js 复制代码
import { reactive } from "vue";
export function usePagination(
  cb: any,
  sizeOption: Array<number> = [10, 20, 50, 100, 200]
): any {
  const pagination = reactive({
    current: 1,
    total: 0,
    sizeOption,
    size: sizeOption[0],
    // 维护page和size(一般是主动触发)
    onPageChange: (page: number) => {
      pagination.current = page;
      return cb();
    },
    onSizeChange: (size: number) => {
      pagination.current = 1;
      pagination.size = size;
      return cb();
    },
    // 一般调用cb后会还会修改total(一般是被动触发)
    setTotal: (total: number) => {
      pagination.total = total;
    },
    reset() {
      pagination.current = 1;
      pagination.total = 0;
      pagination.size = pagination.sizeOption[0];
    },
  });

  return [
    pagination,
    pagination.onPageChange,
    pagination.onSizeChange,
    pagination.setTotal,
  ];
}

与usetable结合:代码非常简单,在调用api时传入参数,并在接受返回值时更新data和total。这里我们的refresh函数是一个返回Promise的函数,能够支持在调用refresh处再链接then进行下一层处理。

js 复制代码
export function useTable(api: (params: any) => Promise<T>) {
  const [pagination, , , setTotal] = usePagination(() => refresh());
  const data = ref([]);

  const refresh = () => {
    return api({ page: pagination.current, limit: pagination.size }).then(
      (res) => {
        data.value = res.data;
        setTotal(res.total);
      }
    );
  };
  return [data, refresh, pagination];
}

注:我们新建一个文件customHooks.js并将usePagination和useTable放在里面。

使用useTable:

js 复制代码
<template>
  <el-table :data="tableData" style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>
  <button @click="refresh">refresh</button>
  <!-- 分页器 -->
  <el-pagination
    v-model:current-page="pagination.current"
    :page-size="pagination.size"
    layout="total, prev, pager, next"
    :page-sizes="pagination.sizeOption"
    :total="pagination.total"
    @size-change="pagination.onSizeChange"
    @current-change="pagination.onCurrentChange"
  />
</template>

<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi } from "./api.ts";
import { useTable } from './customHooks.ts'

const [tableData, refresh, pagination] = useTable(getTableDataApi);

onMounted(refresh);
</script>

封装三(支持不同接口字段)

封装分析

上面我们封装了一个"看起来"比较实用的useTable函数,但实际上,你会发现很多问题:

  1. 每次都要写onMounted来初始化数据。
  2. 接口接受的格式可能不一样,比如,页数的字段为"currentPage",而不是"page"。
  3. 接口返回的格式可能不一样,比如,返回的data并不在refresh方法定义的"data"上。
相关推荐
计算机学姐41 分钟前
基于python+django+vue的家居全屋定制系统
开发语言·vue.js·后端·python·django·numpy·web3.py
Ripple1111 小时前
Vue源码速读 | 第二章:深入理解Vue虚拟DOM:从vnode创建到渲染
vue.js
秋沐1 小时前
vue中的slot插槽,彻底搞懂及使用
前端·javascript·vue.js
QGC二次开发1 小时前
Vue3 : Pinia的性质与作用
前端·javascript·vue.js·typescript·前端框架·vue
A_aspectJ2 小时前
前端框架对比和选择
前端框架
想退休的搬砖人3 小时前
vue选项式写法项目案例(购物车)
前端·javascript·vue.js
啥子花道3 小时前
Vue3.4 中 v-model 双向数据绑定新玩法详解
前端·javascript·vue.js
清灵xmf3 小时前
揭开 Vue 3 中大量使用 ref 的隐藏危机
前端·javascript·vue.js·ref
多多*3 小时前
OJ在线评测系统 登录页面开发 前端后端联调实现全栈开发
linux·服务器·前端·ubuntu·docker·前端框架
学习路上的小刘3 小时前
vue h5 蓝牙连接 webBluetooth API
前端·javascript·vue.js