当前位置:   article > 正文

【Vue3】如何封装一个超级好用的 Hook !

【Vue3】如何封装一个超级好用的 Hook !

目录

什么是 Hook

在 Vue 中使用Hook

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

场景分析

封装实例

封装技巧 - Hook 返回值

封装二(支持分页查询)

需求分析

封装实例

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

封装分析

封装实例

JavaScript 函数传参技巧

封装四(接口传参-定义时)

封装分析

使用示例

封装五(接口传参-调用时)

总结


本文将通过介绍什么是 Hook、如何在 Vue 使用 Hook,以及在实践场景中如何封装自己的 Vue Hook,带你走进 Hook 的世界,写出更优雅的代码。如果你觉得这篇文章写的不错,可以点赞支持一下,如果文章中存在不足(代码量多,难免出现 bug,咳咳),欢迎在评论区指出!

什么是 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

  1. <script setup>
  2. import { ref, onMounted, onUnmounted } from 'vue'
  3. const x = ref(0)
  4. const y = ref(0)
  5. function update(event) { x.value = event.pageX y.value = event.pageY }
  6. onMounted(() => window.addEventListener('mousemove', update))
  7. onUnmounted(() => window.removeEventListener('mousemove', update))
  8. </script>
  9. <template>Mouse position is at: {{ x }}, {{ y }}</template>

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

使用 Hook:

  1. <script setup>
  2. import { useMouse } from './mouse.js'
  3. const { x, y } = useMouse()
  4. </script>
  5. <template>
  6. Mouse position is at: {{ x }}, {{ y }}
  7. </template>

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

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

  1. // mouse.js
  2. import { ref, onMounted, onUnmounted } from 'vue'
  3. // 按照惯例,组合式函数名以“use”开头
  4. export function useMouse() {
  5. // 被组合式函数封装和管理的状态
  6. const x = ref(0) const y = ref(0)
  7. // 组合式函数可以随时更改其状态。
  8. function update(event) {
  9. x.value = event.pageX
  10. y.value = event.pageY
  11. }
  12. // 一个组合式函数也可以挂靠在所属组件的生命周期上
  13. // 来启动和卸载副作用
  14. onMounted(() => window.addEventListener('mousemove', update))
  15. onUnmounted(() => window.removeEventListener('mousemove', update))
  16. // 通过返回值暴露所管理的状态
  17. return { x, y }
  18. }

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

import { useMouse } from 'VueUse'

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

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

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

场景分析

首先定义一个表格:

  1. <template>
  2. <el-table :data="tableData" style="width: 100%">
  3. <el-table-column prop="date" label="Date" width="180" />
  4. <el-table-column prop="name" label="Name" width="180" />
  5. <el-table-column prop="address" label="Address" />
  6. </el-table>
  7. <button @click="refresh">refresh</button>
  8. </template>

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

  1. <script lang="ts" setup>
  2. import { onMounted, ref } from "vue";
  3. import { getTableDataApi } from "./api.ts";
  4. const tableData = ref([]);
  5. const refresh=async () => {
  6. const data = await getTableDataApi();
  7. tableData.value = data;
  8. }
  9. onMounted(refresh);
  10. </script>

模拟 api:

  1. // api.ts
  2. export const getTableDataApi = () => {
  3. const data = [
  4. { date: '2016-05-03',
  5. name: 'Tom', address: 'No. 189,
  6. Grove St, Los Angeles',
  7. },
  8. {
  9. date: '2016-05-02',
  10. name: 'Tom',
  11. address: 'No. 189, Grove St, Los Angeles',
  12. },
  13. {
  14. date: '2016-05-04',
  15. name: 'Tom',
  16. address: 'No. 189, Grove St, Los Angeles',
  17. },
  18. {
  19. date: '2016-05-01',
  20. name: 'Tom',
  21. address: 'No. 189, Grove St, Los Angeles',
  22. },
  23. ]
  24. return new Promise(
  25. resolve => {
  26. setTimeout(() => {
  27. resolve(data)
  28. }, 100);
  29. })
  30. }

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

  1. <script lang="ts" setup>
  2. import { onMounted, ref } from "vue";
  3. import { getTableDataApi1, getTableDataApi2, getTableDataApi3 } from "./api.ts";
  4. const tableData1 = ref([]);
  5. const refresh1=async () => {
  6. const data = await getTableDataApi1();
  7. tableData1.value = data;
  8. }
  9. const tableData2 = ref([]);
  10. const refresh2=async () => {
  11. const data = await getTableDataApi2();
  12. tableData2.value = data;
  13. }
  14. const tableData3 = ref([]);
  15. const refresh3=async () => {
  16. const data = await getTableDataApi3();
  17. tableData3.value = data;
  18. }
  19. onMounted(refresh1);
  20. </script>

封装实例

封装我们的 useTable:

  1. // useTable.ts
  2. import { ref } from 'vue'
  3. export function useTable(api) {
  4. const data = ref([])
  5. const refresh = api;
  6. api().then(res =>data.value = res)
  7. return [data, refresh]
  8. }

改造代码:

  1. <script lang="ts" setup>
  2. import { onMounted, ref } from "vue";
  3. import { getTableDataApi1, getTableDataApi2, getTableDataApi3 } from "./api.ts";
  4. import { useTable } from './useTable.ts'
  5. const [tableData1, refresh1] = useTable(getTableDataApi1);
  6. const [tableData2, refresh2] = useTable(getTableDataApi2);
  7. const [tableData3, refresh3] = useTable(getTableDataApi3);
  8. onMounted(refresh1);
  9. </script>

封装技巧 - Hook 返回值

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

封装二(支持分页查询

需求分析

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

封装表格组件逻辑:

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

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

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

  1. export const getTableDataApi = (page, limit) => {
  2. const data = [
  3. {
  4. date: '2016-05-03',
  5. name: 'Tom',
  6. address: 'No. 189, Grove St, Los Angeles',
  7. },
  8. {
  9. date: '2016-05-02',
  10. name: 'Tom',
  11. address: 'No. 189, Grove St, Los Angeles',
  12. },
  13. {
  14. date: '2016-05-04',
  15. name: 'Tom',
  16. address: 'No. 189, Grove St, Los Angeles',
  17. },
  18. {
  19. date: '2016-05-01',
  20. name: 'Tom',
  21. address: 'No. 189, Grove St, Los Angeles',
  22. },
  23. {
  24. date: '2017-05-03',
  25. name: 'Tom',
  26. address: 'No. 189, Grove St, Los Angeles',
  27. },
  28. {
  29. date: '2017-05-02',
  30. name: 'Tom',
  31. address: 'No. 189, Grove St, Los Angeles',
  32. },
  33. {
  34. date: '2017-05-04',
  35. name: 'Tom',
  36. address: 'No. 189, Grove St, Los Angeles',
  37. },
  38. {
  39. date: '2017-05-01',
  40. name: 'Tom',
  41. address: 'No. 189, Grove St, Los Angeles',
  42. },
  43. ]
  44. return new Promise(resolve => {
  45. setTimeout(() => {
  46. resolve({ total: data.length, data: data.slice((page - 1) * limit, (page - 1) * limit + limit)
  47. })
  48. }, 100);
  49. })
  50. }

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

  1. <template>
  2. <el-table :data="tableData" style="width: 100%">
  3. <el-table-column prop="date" label="Date" width="180" />
  4. <el-table-column prop="name" label="Name" width="180" />
  5. <el-table-column prop="address" label="Address" />
  6. </el-table> <button @click="refresh">refresh</button>
  7. <!-- 分页器 -->
  8. <el-pagination
  9. v-model:current-page="current"
  10. :page-size="size"
  11. layout="total, prev, pager, next"
  12. :page-sizes="sizeOption"
  13. :total="total"
  14. @size-change="handleSizeChange"
  15. @current-change="handleCurrentChange"
  16. />
  17. </template>
  18. <script lang="ts" setup>
  19. import { onMounted, ref } from "vue";
  20. import { getTableDataApi } from "./api.ts";
  21. const tableData = ref([]); // 表格数据
  22. const current = ref(1); // 当前页数
  23. const sizeOption = [10, 20, 50, 100, 200]; // 每页大小选项
  24. const size = ref(sizeOption[0]); //每页大小
  25. const total = ref(0); // 总条数
  26. // 每页大小变化
  27. const handleSizeChange = (size: number) => {
  28. size.value = size;
  29. current.value = 1;
  30. // total.value = 0; refresh(); };
  31. // 页数变化
  32. const handleCurrentChange = (page: number) => {
  33. current.value = page;
  34. // total.value = 0; refresh();
  35. };
  36. const refresh = async () => {
  37. const result = await getTableDataApi({
  38. page: current.value,
  39. limit: size.value,
  40. });
  41. tableData.value = result.data || [];
  42. total.value = result.total || 0;
  43. };
  44. onMounted(refresh);
  45. </script>

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

封装实例

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

  1. import { reactive } from "vue";
  2. export function usePagination(
  3. cb: any,
  4. sizeOption: Array<number> = [10, 20, 50, 100, 200] ):
  5. any {
  6. const pagination = reactive({
  7. current: 1,
  8. total: 0,
  9. sizeOption,
  10. size: sizeOption[0],
  11. // 维护page和size(一般是主动触发)
  12. onPageChange: (page: number) => {
  13. pagination.current = page; return cb();
  14. },
  15. onSizeChange: (size: number) => {
  16. pagination.current = 1;
  17. pagination.size = size;
  18. return cb();
  19. },
  20. // 一般调用cb后会还会修改total(一般是被动触发)
  21. setTotal: (total: number) => {
  22. pagination.total = total; },
  23. reset() {
  24. pagination.current = 1;
  25. pagination.total = 0;
  26. pagination.size = pagination.sizeOption[0];
  27. },
  28. });
  29. return [
  30. pagination,
  31. pagination.onPageChange,
  32. pagination.onSizeChange,
  33. pagination.setTotal,
  34. ];
  35. }

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

  1. export function useTable(
  2. api: (params: any) => Promise<T>){
  3. const [pagination, , , setTotal] = usePagination(() => refresh());
  4. const data = ref([]);
  5. const refresh = () => {
  6. return api({ page: pagination.current, limit: pagination.size}).then(
  7. (res) => {
  8. data.value = res.data;
  9. setTotal(res.total);
  10. }
  11. );
  12. };
  13. return [data, refresh, pagination];
  14. }

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

使用 useTable:

  1. <template>
  2. <el-table :data="tableData" style="width: 100%">
  3. <el-table-column prop="date" label="Date" width="180" />
  4. <el-table-column prop="name" label="Name" width="180" />
  5. <el-table-column prop="address" label="Address" />
  6. </el-table>
  7. <button @click="refresh">refresh</button>
  8. <!-- 分页器 -->
  9. <el-pagination
  10. v-model:current-page="pagination.current"
  11. :page-size="pagination.size"
  12. layout="total, prev, pager, next"
  13. :page-sizes="pagination.sizeOption"
  14. :total="pagination.total"
  15. @size-change="pagination.onSizeChange"
  16. @current-change="pagination.onCurrentChange"
  17. />
  18. </template>
  19. <script lang="ts" setup>
  20. import { onMounted, ref } from "vue";
  21. import { getTableDataApi } from "./api.ts";
  22. import { useTable } from './customHooks.ts'
  23. const [tableData, refresh, pagination] = useTable(getTableDataApi);
  24. onMounted(refresh);
  25. </script>

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

封装分析

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

  1. 每次都要写 onMounted 来初始化数据。
  2. 接口接受的格式可能不一样,比如,页数的字段为"currentPage",而不是“page”。
  3. 接口返回的格式可能不一样,比如,返回的 data 并不在 refresh 方法定义的“data”上。

封装实例

接下来,我们通过增加 useTable 函数的参数,来解决上面所有问题!

这里引入了 lodash 库中的三个工具函数来辅助处理对象:

  • defaults,将后面参数的属性,赋值到第一个对象的值为 undefined 的属性上,用于初始化函数参数。
  • get,获取对象属性,如果为 undefined,使用第三个参数的值。
  • has,判断对象属性。

具体用法可以查看官方文档(Lodash 简介 | Lodash中文文档 | Lodash中文网) 此外,还新增了 loading,可以挂载到 el-table 的 v-loading 上,展示数据加载中的效果。

<el-table v-loding="loading" ...>...</el-table>

改造后:不管接口接受的格式还是响应的格式字段是什么样的,都可以正常接收。设置 immediate 为 true,调用 useTable 时立即执行一遍 api,onMounted 都不用写了。

  1. <script lang="ts" setup>
  2. import { onMounted, ref } from "vue";
  3. import { getTableDataApi } from "./api.ts";
  4. import { useTable } from './customHooks.ts'
  5. const [tableData, refresh, loading, pagination] = useTable(getTableDataApi, {
  6. path: {
  7. data: 'data',
  8. total: 'total',
  9. page: 'page',
  10. size: 'limit'
  11. },
  12. immediate: true
  13. });
  14. // onMounted(refresh);
  15. </script>

JavaScript 函数传参技巧

  1. 一般函数定义参数越少越好,最好不要超过两个,所以这里我只定义了两个参数 api 和options。
  2. 在函数头上可以给参数定义默认值,但是如果参数是一个对象,只要传入一个属性,就不会使用默认值,比如:
  1. export function useTable<T>(
  2. api: (params: any) => Promise<T>,
  3. options: {
  4. path?: { data?: keyPath; total?: keyPath; page?: string; size?: string };
  5. immediate?: boolean;
  6. } = { path:
  7. { data: "data", total: "total", page: "page", size: "size" },
  8. immediate: false,
  9. } ){...函数体}
  10. useTable(xxxApi,{immediate:false})

只要该位置的值非 undefined,那么 options 将不会使用默认值,这意味着,此时 options 的值为 {immediate:false},其它地方的默认值不会生效,{path:undefined,}。 所以对于函数参数为对象的,我们往往通过在函数体内赋默认值,比如:

  1. 保证options只传入一个值,其它位置也会有默认值
  2. {
  3. options.path = options.path || {}
  4. options.path.data = options.path.data || 'data'
  5. options.path.total = options.path.total || 'total'
  6. options.path.page = options.path.page || 'page'
  7. options.path.size = options.path.size || 'size'
  8. options.immediate = options.immediate ?? false
  9. }

需要注意元素的层次,在不存在 path 时,给 path. data 赋值会出现错误,需要先保证 path 有值,才能给 path 的下一层赋值。

使用 defaults 可以快速给整个对象赋默认值:

  1. defaults(options, {
  2. path: { data: "data", total: "total", page: "page", size: "size" },
  3. immediate: false,
  4. });

封装四(接口传参-定义时)

封装分析

现在,我们的 useTable 趋近完整了:

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

我们还可以让我们的 api 接受参数。但是如何实现?还需要考虑一下。

首先我们想一想那里可以接受 api 的参数?

  1. const params = {
  2. id:2
  3. }
  4. // api本身
  5. getTableDataApi({limit:3,page:2,...params})
  6. // useTable也可以接受参数
  7. const [data,refresh]=useTable(getTableDataApi,params,api)
  8. // refresh也可以接受参数
  9. refresh(params)

从使用上看,我们在 refresh 上接受参数,和我们在 getTableDataApi 的使用上感觉是最相似的,因为 refresh 本来就是在 api 的基础上增加 then 维护了页数而已。但是我们还是先从 useTable 传参开始讲起,最后我们两种方式都可以接受!

方案一:在调用 useTable 的时候就接受参数,在 useTable 内部将这个参数传给 refresh。 存在问题:如果我们传入的是值类型,那么这个值会被拷贝过去,并传给 refresh,后续调用 refresh,都是不变的参数。只适合需要传参但参数之后都不会变的接口,比如接受当前用户的 id。如果参数会变,这种方法是不行的。

  1. function useTable(api,id,options){
  2. ...
  3. const refresh=()=>api(id).then(res=>data=res)
  4. return [data,refresh]
  5. }
  6. const [data,refresh]=useTable(api,id)
  7. refresh()
  8. refresh() // 都是id=2

如果我们传入的是引用类型,那么在后续调用中,我们可以通过改变对象的属性值来改变 refresh 的参数(但是需要一些技巧,因为我们需要和分页参数进行结合)。

  1. const params = { id:12 }
  2. function useTable(api,params,options){
  3. ...
  4. // 错误,使用解构会丢失与原来对象的联系,导致原来的对象params更改,但这里仍使用旧值。
  5. const refresh=()=>api(
  6. {
  7. options.path.size]:pagination.size,
  8. [options.path.page]:pagination.page,...params}).then(
  9. res=>data=res)
  10. // 正确,可以保持与外部params的联系。
  11. const refresh=()=>api(Object.assign(
  12. params,
  13. {
  14. [options.path.size]:pagination.size,
  15. [options.path.page]:pagination.page})).then(
  16. res=>data=res
  17. )
  18. return [data,refresh]
  19. }
  20. const [data,refresh]=useTable(api,params)
  21. refresh() // id=12
  22. params.id = 10
  23. refresh() // id=10

这样,我们就实现了 api 参数的传递,而且如果 params 的属性 id 是响应式的,还可以与页面结合,实现搜索功能!然而,使用同一个引用 params,可以解决传参问题,但是还是存在一些问题:在 refresh 中,Object. assign 会给原来的对象 params 增加两个属性,要注意避免在 params 中与这两个属性发生冲突。另外,我们可以看到这里的参数间存在了一种优先级,就是如果我们在 param 中也传入了分页参数,会在 refresh 中被 pagination 的分页参数覆盖调,pagination 的分页参数比 params 中的分页参数优先级更高,这样好吗?

第一个问题,在 refresh 中每次都会被 pagination 的属性覆盖,所以并不会出现什么问题,除非你在 params 上保存相同属性名的数据,这将被覆盖掉。第二个问题和第一个问题本质是一样的,就是覆盖问题。根本原因就是都是引用同一个对象。如果我们能够额外创建一个对象,就不会改变原来的对象,但是如何保持新创建对象能够动态变化呢?

方案二:试试 useTable 接受传入函数 params 如何?

  1. const params={id:12}
  2. const paramsFn =()=>{
  3. id: params.id
  4. }
  5. function useTable(api,paramsFn(),options){
  6. ...
  7. const refresh=()=>api(Object.assign(paramsFn(),{
  8. [options.path.size]:pagination.size,
  9. [options.path.page]:pagination.page})).then(
  10. res=>data=res)
  11. return [data,refresh]
  12. }
  13. const [data,refresh]=useTable(api,paramsFn) refresh() // id=12
  14. params.id = 10
  15. refresh() // id=10

完美解决。

最后,兼容一下两种参数,让传入 useTable 的 api 参数既可以是函数,又可以是对象:

  1. export function useTable<T>(
  2. api: (params: any) => Promise<T>, params?:
  3. object | (() => object),
  4. options?:{
  5. path?: { data?: keyPath; total?: keyPath; page?: string; size?: string }
  6. immediate?: boolean
  7. }, ) {
  8. // 参数处理
  9. defaults(options, {
  10. path: { data: 'data', total: 'total', page: 'page', size: 'size' },
  11. immediate: false,
  12. })
  13. const [pagination, , , setTotal] = usePagination(() =>refresh())
  14. const loading = ref(false)
  15. const data = ref([])
  16. const refresh = (extraData?: object | (() => object)) => {
  17. const requestData = {
  18. [options?.path?.page as string]: pagination.current,
  19. [options?.path?.size as string]: pagination.size,
  20. }
  21. if (params) {
  22. if (typeof params === 'function') {
  23. Object.assign(requestData, params())
  24. } else {
  25. Object.assign(requestData, params)
  26. } }
  27. loading.value = true
  28. return api(requestData) .then((res) => {
  29. data.value = get(res, options!.path?.data, [])
  30. setTotal(get(res, options!.path?.total, 0))
  31. if (!has(res, options!.path?.data) || !has(res, options!.path?.total)) {
  32. console.warn('useTable:响应数据缺少所需字段')
  33. }
  34. }) .finally(() => {
  35. loading.value = false
  36. })
  37. }
  38. options!.immediate && refresh()
  39. return [data as T, refresh, loading, pagination]
  40. }

这里代码主要新增了三处改变:

  1. 如果 params 是对象,直接使用,如果是函数,则读取其返回值。
  2. 优先级调整:paginaiton 的参数可以被 params 的同名属性覆盖,适用于开发者自己维护分页参数。
  3. 定义了返回值的类型。

使用示例

试想一个常见,点击列表的某一项,就展示列表对应 id 的表格,如何实现?

  1. <template>
  2. <ul>
  3. // 自定义组件,点击时emit发送onClick事件并传入item的id
  4. <Item v-for="item in list" :key="item.key" :label="item.label" @on-click="handleClick">
  5. ...
  6. </ul>
  7. </template>
  8. <script>
  9. ...
  10. // 这里接受item的id
  11. const handleClick=(id:number)=>{
  12. params.id=number;
  13. refresh()
  14. }
  15. ...
  16. </script>

封装五(接口传参-调用时)

最后,来让 refresh 函数也能接受我们的传参。 先看效果:

  1. <script>
  2. ...
  3. // 这里接受item的id
  4. const handleClick=(id:number)=>{
  5. refresh({id})
  6. }
  7. ...
  8. </script>

可以省去 params 和 paramsFn 的定义了!

实现代码:在定义 refresh 时允许加入参数。

  1. export function useTable<T>(
  2. api: (params: any) => Promise<T>,
  3. params?: object | (() => object),
  4. options?: {
  5. path?: { data?: keyPath; total?: keyPath; page?: string; size?: string }
  6. immediate?: boolean
  7. },
  8. ) { defaults(options, {
  9. path: { data: 'data', total: 'total', page: 'page', size: 'size' },
  10. immediate: false,
  11. })
  12. // 使用()=>fn()而不是fn()区别在于后者只是一个值且立即执行
  13. const [pagination, , , setTotal] = usePagination((extraData?: object) =>
  14. extraData ? refresh(extraData) : refresh(),
  15. )
  16. const loading = ref(false)
  17. const data = ref([])
  18. const refresh = (extraData?: object | (() => object)) => {
  19. const requestData = {
  20. [options?.path?.page as string]: pagination.current,
  21. [options?.path?.size as string]: pagination.size,
  22. }
  23. if (extraData) {
  24. if (typeof extraData === 'function') {
  25. Object.assign(requestData, extraData())
  26. } else {
  27. Object.assign(requestData, extraData)
  28. }
  29. }
  30. if (params) {
  31. if (typeof params === 'function') {
  32. Object.assign(requestData, params())
  33. } else {
  34. Object.assign(requestData, params)
  35. }
  36. }
  37. loading.value = true
  38. return api(requestData)
  39. .then((res) => {
  40. // TODO 检查响应状态码
  41. data.value = get(res, options!.path?.data, [])
  42. setTotal(get(res, options!.path?.total, 0))
  43. // 友好提示
  44. if (!has(res, options!.path?.data) || !has(res, options!.path?.total)) {
  45. console.warn('useTable:响应数据缺少所需字段')
  46. }
  47. }) .finally(() => {
  48. loading.value = false
  49. })
  50. }
  51. return[data,refresh,paginaiton,loading]
  52. }

需要注意的是,usePagination 处接受的回调函数也要适当修改。当然,pagination 也是要修改的了(增加回调函数有参数的情况,之前回调是没有参数的)。这里还额外新增了一个 reset 方法,用于重置分页器状态,这或许会有用!

  1. export function usePagination(
  2. cb: any,
  3. sizeOption: Array<number> = [10, 20, 50, 100, 200],
  4. ): any {
  5. const pagination = reactive({
  6. current: 1,
  7. total: 0,
  8. size: sizeOption[0],
  9. sizeOption,
  10. onPageChange: (page: number, extraData?: object) => {
  11. pagination.current = page
  12. return extraData ? cb(extraData) : cb()
  13. },
  14. onSizeChange: (size: number, extraData?: object) => {
  15. pagination.current = 1
  16. pagination.size = size return extraData ? cb(extraData) : cb() },
  17. setTotal: (total: number) => {
  18. pagination.total = total
  19. }, reset() {
  20. pagination.current = 1
  21. pagination.total = 0
  22. pagination.size = pagination.sizeOption[0]
  23. },
  24. })
  25. return [
  26. pagination,
  27. pagination.onPageChange,
  28. pagination.onSizeChange,
  29. pagination.setTotal,
  30. ]
  31. }

使用:

  1. <!-- 分页器 -->
  2. <el-pagination
  3. v-model:current-page="current"
  4. :page-size="size"
  5. layout="total, prev, pager, next"
  6. :page-sizes="sizeOption"
  7. :total="total"
  8. @size-change="(size)=>handleSizeChange(size,params.id)"
  9. @current-change="(page)=>handleCurrentChange(page,params.id)"
  10. />

在此之前,需要保存 item. id 作为全局变量以供读取。

  1. const handleClick=(id:number)=>{
  2. params.id=id;
  3. }

这样,我们就完成了一个功能相对完善的 Hook 函数。

总结

本文通过介绍 Hook 的概念和使用方法,并在实践的过程中封装了一个功能相对完善的 Hook 函数,但是它还有很多可以拓展的地方,比如 useTable 中可以再导出一个 clear 函数,用来将 data 赋值为空数组,以及对 data 数据的每一项进行查找、删除,或者新增一个 showData,用来过滤 data 并展示在视图上,总之,我们打开了 Hook 世界的大门,看到了 Hook 这项技术的强大之处:状态复用!

因为本文主要讲解 Hook 封装,所以比较少提及组件封装。如果代码需要复用,首先考虑组件封装,因为它可以对 html、css 和 javacript 代码进行复用,而 Hook 只是复用 JavaScript 代码。如果将二者结合,能够高效地提高你的开发效率,以及项目的可维护性,帮助你写出优雅的代码。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家小花儿/article/detail/257354
推荐阅读
相关标签
  

闽ICP备14008679号