什么是 Hook?
Vue3 官方文档是这样定义组合式函数的。A "composable" is a function that leverages Vue's Composition API to encapsulate and reuse stateful logic.
,一个利用 Vue 的组合式 API 来封装和复用具有状态逻辑的函数。
在开发中,我们经常会发现一些可以重复利用的代码段,于是我们将其封装成函数以供调用。这类函数包括工具函数,但是又不止工具函数,因为我们可能也会封装一些重复的业务逻辑。以往,在前端原生开发中,我们封装的这些函数都是“无状态”的。为了建立数据与视图之间的联系,基于 MVC 架构的 React 框架和基于 MVVM 的 Vue 框架都引入了“状态”这一概念,状态是特殊的 JavaScript 变量,它的变化会引起视图的变化。在这类框架中,如果一个变量的变化不会引起视图的变化,那么它就是普通变量,如果一个变量已经被框架注册为状态,那么这个变量的变化就会引发视图的变化,我们称之为响应式变量。如果一个函数包含了状态(响应式变量),那么它就是一个 Hook 函数。
组合式 API 提供了和 React Hooks 相同级别的逻辑组织能力,但它们之间有着一些重要的区别。React Hooks 在组件每次更新时都会重新调用。这就产生了一些即使是经验丰富的 React 开发者也会感到困惑的问题。这也带来了一些性能问题,并且相当影响开发体验。例如:
-
Hooks 有严格的调用顺序,并不可以写在条件分支中。
-
React 组件中定义的变量会被一个钩子函数闭包捕获,若开发者传递了错误的依赖数组,它会变得“过期”。这导致了 React 开发者非常依赖 ESLint 规则以确保传递了正确的依赖,然而,这些规则往往不够智能,保持正确的代价过高,在一些边缘情况时会遇到令人头疼的、不必要的报错信息。
-
昂贵的计算需要使用
useMemo
,这也需要传入正确的依赖数组。 -
在默认情况下,传递给子组件的事件处理函数会导致子组件进行不必要的更新。子组件默认更新,并需要显式的调用
useCallback
作优化。这个优化同样需要正确的依赖数组,并且几乎在任何时候都需要。忽视这一点会导致默认情况下对应用进行过度渲染,并可能在不知不觉中导致性能问题。 -
要解决变量闭包导致的问题,再结合并发功能,使得很难推理出一段钩子代码是什么时候运行的,并且很不好处理需要在多次渲染间保持引用 (通过
useRef
) 的可变状态。
相比起来,Vue 的组合式 API:
-
仅调用
setup()
或<script setup>
的代码一次。这使得代码更符合日常 JavaScript 的直觉,不需要担心闭包变量的问题。组合式 API 也并不限制调用顺序,还可以有条件地进行调用。 -
Vue 的响应性系统运行时会自动收集计算属性和侦听器的依赖,因此无需手动声明依赖。
-
无需手动缓存回调函数来避免不必要的组件更新。Vue 细粒度的响应性系统能够确保在绝大部分情况下组件仅执行必要的更新。对 Vue 开发者来说几乎不怎么需要对子组件更新进行手动优化。
在 Vue 中使用Hook
下面我们来看一个简单的自定义 Hook(来自 Vue 官方文档):
需求:在页面实时显示鼠标的坐标。 实现:没有使用 Hook。
在没有封装的情况下,如果我们在另一个页面也需要这个功能,我们需要将代码复制过去。另外,可以看出,它声明了两个变量,并且在生命周期钩子 onMounted
和 onUnmounted
中书写了一些代码,如果这个页面需要更多的功能,那么会出现代码中存在很多变量、生命周期中存在很多逻辑写在一起的现象,使得这些逻辑混杂在一起,而使用 Hook 可以将其分隔开来(这也是为什么会有很多人使用 Hook 的原因,分离代码,提高可维护性!)
使用 Hook:
<script setup>
import { useMouse } from './mouse.js'
const { x, y } = useMouse()
</script>
<template>Mouse position is at: {{ x }}, {{ y }}</template>
可以发现,比原来的代码更加简洁,这时如果加入其它功能的变量,也不会觉得眼花缭乱了。
当然,我们需要在外部定义这个 Hook:
// 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!
import { useMouse } from 'VueUse'
自定义hook
首先定义一个表格:
<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 获取(一般写法):
<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:
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 代码会变得比较复杂:
<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:
// 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]
}
改造代码:
<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 有返回数组的,也有返回对象的,上面 useTable 使用了返回数组的写法,useMouse 使用了返回对象的写法。数组是对应位置命名的,可以方便重命名,对象对于类型和语法提示更加友好。两种写法都是可以替换的。
-
因为 Hook 返回对象或者数组,那么它一定是一个非 async 函数(async 函数一定返回 Promise),所以在 Hook 中,一般使用 then 而不是 await 来处理异步请求。
-
返回值如果是对象,一般在函数中通过 reactive 创建一个对象,最后通过 toRefs 导出,这样做的原因是可以产生批量的可以解构的 Ref 对象,以免在解构返回值时丢失响应性。
上面我们封装了一个简单的 hook,但是实际应用中并不会如此简单,下面我列出一个比较完整的 useTable 在实践中应该具备的功能,并在后续的文章部分完成它。
封装表格组件逻辑:
-
维护 api 的调用和刷新(已完成)
-
支持分页查询(页数、总条数、每页大小等)
-
支持 api 参数。
-
增加辅助功能(loading、立即执行等)
下面我们将对 useTable 进行改造,使其支持分页器。
先改造一些我们的 api,使其支持分页查询:
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 文件应该是这样的:
<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:该钩子接受一个回调函数,当页数改变时就会调用该函数。
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 进行下一层处理。
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];
}
使用 useTable:
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:
<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>
本文通过介绍 Hook 的概念和使用方法,并在实践的过程中封装了一个 Hook 函数,但是它还有很多可以拓展的地方。