闽公网安备 35020302035485号
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
// 堆代码 duidaima.com
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 的原因,分离代码,提高可维护性!)<script setup>
import { useMouse } from './mouse.js'
const { x, y } = useMouse()
</script>
<template>Mouse position is at: {{ x }}, {{ y }}</template>
可以发现,比原来的代码更加简洁,这时如果加入其它功能的变量,也不会觉得眼花缭乱了。// 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'
恭喜你,掌握了 VueUse 库的使用方法。如果需要其它 Hook,你可以先试着去官方文档VueUse | VueUse(https://vueuse.org/)查找,使用现成的函数,而不是自己去封装。<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:// 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 代码会变得比较复杂:<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.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 返回值一般自定义 Hook 有返回数组的,也有返回对象的,上面 useTable 使用了返回数组的写法,useMouse 使用了返回对象的写法。数组是对应位置命名的,可以方便重命名,对象对于类型和语法提示更加友好。两种写法都是可以替换的。因为 Hook 返回对象或者数组,那么它一定是一个非 async 函数(async 函数一定返回 Promise),所以在 Hook 中,一般使用 then 而不是 await 来处理异步请求。
// 使用 reactive 和 toRefs 可以快速创建多个ref对象,并在解构后使用时不丢失其响应性和与原先数据的关联性
function usePaginaion(){
const pagination = reactive({
current: 1,
total: 0,
sizeOption,
size: sizeOption[0]
})
...
return {...toRefs(pagination)}
}
const { current,total } = usePagination()
封装二(支持分页查询)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>
可以看出,如果存在多个表格,会创建很多套变量和重复的代码。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];
}
注:我们新建一个文件 customHooks.js 并将 usePagination 和 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>
封装三(支持不同接口字段)import { get, has, defaults } from "lodash-es";
type keyPath = Array<string> | string;
export function useTable<T>(
api: (params: any) => Promise<T>,
options?: {
path?: { data?: keyPath; total?: keyPath; page?: string; size?: string };
immediate?: boolean;
}
) {
// 参数处理
defaults(options, {
path: { data: "data", total: "total", page: "page", size: "size" },
immediate: false,
});
const [pagination, , , setTotal] = () => refresh();
const data = ref([]);
const loading = ref(false)
const refresh = () => {
loading.value = true
return api({ [options?.path?.page]: pagination.current, [options?.path?.size]: pagination.size }).then(
(res) => {
data.value = get(res, options!.path?.data, []);
setTotal(get(res, options!.path?.total, 0));
// 友好提示
if (!has(res, options!.path?.data) || !has(res, options!.path?.total)) {
console.warn("useTable:响应数据缺少所需字段");
}
}.finally(() => {
loading.value = false
})
);
};
// 立即执行
options!.immediate && refresh();
return [data, refresh, loading, pagination];
}
这里引入了 lodash 库中的三个工具函数来辅助处理对象:<el-table v-loding="loading" ...>...</el-table>改造后:不管接口接受的格式还是响应的格式字段是什么样的,都可以正常接收。设置 immediate 为 true,调用 useTable 时立即执行一遍 api,onMounted 都不用写了。
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { getTableDataApi } from "./api.ts";
import { useTable } from './customHooks.ts'
const [tableData, refresh, loading, pagination] = useTable(getTableDataApi, {
path: {
data: 'data',
total: 'total',
page: 'page',
size: 'limit'
},
immediate: true
});
// onMounted(refresh);
</script>
JavaScript 函数传参技巧export function useTable<T>(
api: (params: any) => Promise<T>,
options: {
path?: { data?: keyPath; total?: keyPath; page?: string; size?: string };
immediate?: boolean;
} = {
path: { data: "data", total: "total", page: "page", size: "size" },
immediate: false,
}
){...函数体}
useTable(xxxApi,{immediate:false})
只要该位置的值非 undefined,那么 options 将不会使用默认值,这意味着,此时 options 的值为 {immediate:false},其它地方的默认值不会生效,{path:undefined,}。 所以对于函数参数为对象的,我们往往通过在函数体内赋默认值,比如:{
options.path = options.path || {}
options.path.data = options.path.data || 'data'
options.path.total = options.path.total || 'total'
options.path.page = options.path.page || 'page'
options.path.size = options.path.size || 'size'
options.immediate = options.immediate ?? false
}
需要注意元素的层次,在不存在 path 时,给 path. data 赋值会出现错误,需要先保证 path 有值,才能给 path 的下一层赋值。 defaults(options, {
path: { data: "data", total: "total", page: "page", size: "size" },
immediate: false,
});
封装四(接口传参-定义时)const params = {
id:2
}
// api本身
getTableDataApi({limit:3,page:2,...params})
// useTable也可以接受参数
const [data,refresh]=useTable(getTableDataApi,params,api)
// refresh也可以接受参数
refresh(params)
从使用上看,我们在 refresh 上接受参数,和我们在 getTableDataApi 的使用上感觉是最相似的,因为 refresh 本来就是在 api 的基础上增加 then 维护了页数而已。但是我们还是先从 useTable 传参开始讲起,最后我们两种方式都可以接受!function useTable(api,id,options){
...
const refresh=()=>api(id).then(res=>data=res)
return [data,refresh]
}
const [data,refresh]=useTable(api,id)
refresh()
refresh() // 都是id=2
如果我们传入的是引用类型,那么在后续调用中,我们可以通过改变对象的属性值来改变 refresh 的参数(但是需要一些技巧,因为我们需要和分页参数进行结合)。const params = { id:12 }
function useTable(api,params,options){
...
// 错误,使用解构会丢失与原来对象的联系,导致原来的对象params更改,但这里仍使用旧值。
const refresh=()=>api({[options.path.size]:pagination.size,[options.path.page]:pagination.page,...params}).then(res=>data=res)
// 正确,可以保持与外部params的联系。
const refresh=()=>api(Object.assign(params,{[options.path.size]:pagination.size,[options.path.page]:pagination.page})).then(res=>data=res)
return [data,refresh]
}
const [data,refresh]=useTable(api,params)
refresh() // id=12
params.id = 10
refresh() // id=10
这样,我们就实现了 api 参数的传递,而且如果 params 的属性 id 是响应式的,还可以与页面结合,实现搜索功能!然而,使用同一个引用 params,可以解决传参问题,但是还是存在一些问题:在 refresh 中,Object. assign 会给原来的对象 params 增加两个属性,要注意避免在 params 中与这两个属性发生冲突。另外,我们可以看到这里的参数间存在了一种优先级,就是如果我们在 param 中也传入了分页参数,会在 refresh 中被 pagination 的分页参数覆盖调,pagination 的分页参数比 params 中的分页参数优先级更高,这样好吗?const params={id:12}
const paramsFn =()=>{ id: params.id }
function useTable(api,paramsFn(),options){
...
const refresh=()=>api(Object.assign(paramsFn(),{[options.path.size]:pagination.size,[options.path.page]:pagination.page})).then(res=>data=res)
return [data,refresh]
}
const [data,refresh]=useTable(api,paramsFn)
refresh() // id=12
params.id = 10
refresh() // id=10
export function useTable<T>(
api: (params: any) => Promise<T>,
params?: object | (() => object),
options?: {
path?: { data?: keyPath; total?: keyPath; page?: string; size?: string }
immediate?: boolean
},
) {
// 参数处理
defaults(options, {
path: { data: 'data', total: 'total', page: 'page', size: 'size' },
immediate: false,
})
const [pagination, , , setTotal] = usePagination(() =>refresh())
const loading = ref(false)
const data = ref([])
const refresh = (extraData?: object | (() => object)) => {
const requestData = {
[options?.path?.page as string]: pagination.current,
[options?.path?.size as string]: pagination.size,
}
if (params) {
if (typeof params === 'function') {
Object.assign(requestData, params())
} else {
Object.assign(requestData, params)
}
}
loading.value = true
return api(requestData)
.then((res) => {
data.value = get(res, options!.path?.data, [])
setTotal(get(res, options!.path?.total, 0))
if (!has(res, options!.path?.data) || !has(res, options!.path?.total)) {
console.warn('useTable:响应数据缺少所需字段')
}
})
.finally(() => {
loading.value = false
})
}
options!.immediate && refresh()
return [data as T, refresh, loading, pagination]
}
这里代码主要新增了三处改变:<template>
<ul>
// 自定义组件,点击时emit发送onClick事件并传入item的id
<Item v-for="item in list" :key="item.key" :label="item.label" @on-click="handleClick">
...
</ul>
</template>
<script>
...
// 这里接受item的id
const handleClick=(id:number)=>{
params.id=number;
refresh()
}
...
</script>
封装五(接口传参-调用时)<script>
...
// 这里接受item的id
const handleClick=(id:number)=>{
refresh({id})
}
...
</script>
可以省去 params 和 paramsFn 的定义了!export function useTable<T>(
api: (params: any) => Promise<T>,
params?: object | (() => object),
options?: {
path?: { data?: keyPath; total?: keyPath; page?: string; size?: string }
immediate?: boolean
},
) {
defaults(options, {
path: { data: 'data', total: 'total', page: 'page', size: 'size' },
immediate: false,
})
// 堆代码 duidaima.com
// 使用()=>fn()而不是fn()区别在于后者只是一个值且立即执行
const [pagination, , , setTotal] = usePagination((extraData?: object) =>
extraData ? refresh(extraData) : refresh(),
)
const loading = ref(false)
const data = ref([])
const refresh = (extraData?: object | (() => object)) => {
const requestData = {
[options?.path?.page as string]: pagination.current,
[options?.path?.size as string]: pagination.size,
}
if (extraData) {
if (typeof extraData === 'function') {
Object.assign(requestData, extraData())
} else {
Object.assign(requestData, extraData)
}
}
if (params) {
if (typeof params === 'function') {
Object.assign(requestData, params())
} else {
Object.assign(requestData, params)
}
}
loading.value = true
return api(requestData)
.then((res) => {
// TODO 检查响应状态码
data.value = get(res, options!.path?.data, [])
setTotal(get(res, options!.path?.total, 0))
// 友好提示
if (!has(res, options!.path?.data) || !has(res, options!.path?.total)) {
console.warn('useTable:响应数据缺少所需字段')
}
})
.finally(() => {
loading.value = false
})
}
return[data,refresh,paginaiton,loading]
}
需要注意的是,usePagination 处接受的回调函数也要适当修改。当然,pagination 也是要修改的了(增加回调函数有参数的情况,之前回调是没有参数的)。这里还额外新增了一个 reset 方法,用于重置分页器状态,这或许会有用!export function usePagination(
cb: any,
sizeOption: Array<number> = [10, 20, 50, 100, 200],
): any {
const pagination = reactive({
current: 1,
total: 0,
size: sizeOption[0],
sizeOption,
onPageChange: (page: number, extraData?: object) => {
pagination.current = page
return extraData ? cb(extraData) : cb()
},
onSizeChange: (size: number, extraData?: object) => {
pagination.current = 1
pagination.size = size
return extraData ? cb(extraData) : cb()
},
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,
]
}
使用: <!-- 分页器 -->
<el-pagination
v-model:current-page="current"
:page-size="size"
layout="total, prev, pager, next"
:page-sizes="sizeOption"
:total="total"
@size-change="(size)=>handleSizeChange(size,params.id)"
@current-change="(page)=>handleCurrentChange(page,params.id)"
/>
在此之前,需要保存 item. id 作为全局变量以供读取。const handleClick=(id:number)=>{
params.id=id;
}
这样,我们就完成了一个功能相对完善的 Hook 函数。