无论是网站还是后台管理或者是APP,列表数据都是一个很常用的需求,而围绕列表,分页和查询也是不可或缺的元素,那么如何更好的编码实现需求呢?这里介绍一种自我感觉良好的方法,供大家参考。
需求分析
想一想,我们要做一个通用的列表模块的 controller,需要哪些因素:
- 列表容器:存放后端发过来的列表数据,一般是数组。
- 加载数据的方法:一般用 axios 加载数据,也可以是其他方式,看需求。
- 翻页状态:分页组件、瀑布流等都需要一个分页的状态,比如当前页号等。
- 查询状态:根据用户输入的查询条件加载数据。
- 用 Typescript 定义需要的类型、接口等
- 封装成 controller (hooks)
- 最好可以通用、复用,可以适合各种模块的需求
基本需求差不多就是这样了,我们来做接口设计。
定义类型、接口
以前比较不喜欢 Typescript,认为 js 就应该灵活、应该无拘无束,但是学习之后发现:真香。
所以现在做点啥都喜欢先定义好类型、接口。
分页
分页比较简单,记录总记录数、当前页号即可,也可以顺便记录一下一页记录数等信息,反正需要啥就设置啥好了。
/**
* 分页状态
*/
export type TPagerState = {
pagerSize: number, // 一页的记录数
count: number, // 总记录数
pagerIndex: number // 当前页号
}
查询状态
查询呢简单的说,记录字段名和查询关键字即可;复杂的说呢,也有很多需要注意的地方,这里先来个简单的。
- 经典结构:
{
colName: string,
value: string
}
// 或者
{
colName: string,
valueStart: string // 开始条件
valueEnd: string // 结束条件
}
// 或者
{
colName: string,
ids: number[] // 集合,比如[1,2,3]
}
- 迷你结构:
/*
* 字段名作为 key 的名称,查询关键字放数组里面
* 一个关键字:colName:[101, xxx]
* 两个关键字:colName:[103, 开始, 结束] // 范围查询
* 集合查询:colName:[109, [1,2,3]]
*/
export type TQueryMini = {
// 字段名作为key,查询方式,一个关键字 | 集合查询,范围查询的第二个关键字,
[key: string]:
[ number, string] // 1
| [ number, string, string] // 2
| [ number, number[]] // 3
}
这里面有个小问题,标准做法是这样的,比如:
[
{
colName: 'name',
value: '小天地'
},
{
colName: 'telphone',
value: '138'
},
]
这种形式清晰、好看懂,也还好解析,只是有一个小小的问题,如果要求查询条件必须用 get 的方式提交,那么 url 长度是有限制的,万一查询条件多了一点,弄超了咋办?
所以我想呀,是不是可以弄一个短一点的,比如这样:
{
name:[小天地],
telphone: [138]
}
这样是不是短了很多呢?为啥是数组呢?还有范围查询呢,用数组可以放两个查询关键字。
你可能会说:这不标准,key 不明确,不好解析,后面 value 的含义也不清晰,谁知道数组是啥意思。
这个嘛,不是有设计文档嘛,看文档不就行了。
好吧,我知道,别人的观点是不会被改变的,所以我做了两种,喜欢哪个就用哪个。
/**
* 查询状态,二选一
*/
export type TQueryState = {
mini: TQueryMini, // 查询条件的精简形式
array: Array<{colName:string, value:string}>, // 查询条件的对象形式
}
列表数据
数据列表,一般是数组的形式,应该比较简单,但是谁叫 vue 弄出来个响应性呢,还有好几种 API。
- 方案一:如果数组只是整体赋值,那么可以选择 shallowRef,这样可以避免不必要的监控。
- 方案二:如果数组还有 push、splice 等操作,那么就需要使用 ref(或者 reactive)了。
另外,还需要设置一个泛型,以便应对不同的业务模块。
ShallowRef<Array<T>>
// 或者
Ref<Array<T>>
返回的数据格式
后端需要返回两个数据:一个是总记录数,一个是数据列表,这里设置一个泛型,是为了兼容各种数据对象。
/**
* 加载数据的函数,返回的结构,用泛型表示列表数据的类型
*/
export type TResource<T> = {
allCount: number,
list: Array<T> // 普通数组
}
列表的 controller 、状态
整合一下,就是这样:
/**
* 列表 controller 用的状态
*/
export type TListState<T extends object> = {
dataList: ShallowRef<Array<T>>, // 数据列表
query: TQueryState, // 查询条件
pagerState: TPagerState, // 分页信息
}
- 加载数据的方法是否需要暴露出来?
看具体的需求,如果需要暴露、那么就暴露出来,可以灵活设置。
定义函数(hooks)
其实我不太喜欢hooks这种说法,但是没办法,说的人多了就成了。
定义 controller,创建状态
还是先看看代码:
// 用 Symbol 做标记,避免重名冲突
const flag = Symbol('pager') as InjectionKey<string>
/**
* 创建数据列表的状态,局部有效
* @param service 获取数据的回调函数。
* * service: (
* * * query: TQueryState,
* * * pagerInfo: TPagerState
* * ) => Promise<TResource<T>>
* @returns 总记录数和列表数据, Promise<TResource<T>>
*/
export function createListState<T extends object>(
service: (
query: TQueryState,
pagerInfo: TPagerState
) => Promise<TResource<T>>
) {
// 记录列表数据
const dataList = shallowRef<Array<T>>([])
// 查询状态
const query: TQueryState = reactive({
mini: {}, // 查询条件的精简形式
array: [], // 查询条件的对象形式
})
// 分页状态
const pagerState: TPagerState = reactive({
pagerSize: 5,
count: 20, // 总数
pagerIndex: 1 // 当前页号
})
/**
* 内部用的更新数据的函数,
* @param isReset true:初始化、查询的情况,页号设置为1;false:仅翻页
*/
async function updateData () {
// 获取数据,并设置
const { allCount, list } = await service(query, pagerState)
pagerState.count = allCount
dataList.value = list
}
// 监听页号,翻页后更新数据
watch(() => pagerState.pagerIndex, () => {
updateData()
}, {
immediate: true // 立即执行
})
// 监听查询条件,更新数据
watch(query.mini, () => {
updateData()
})
// 整合需要暴露出去的数据和方法
const state: TListState<T> = {
dataList,
pagerState, // 分页信息
query // 查询条件
}
// 依赖注入,共享给子组件
provide<TListState<T>>(flag, state)
// 返回给父组件
return state
}
内容挺简单只有三个部分:
- 设置一个加载数据的参数:service
- 定义数据和状态:列表、分页、查询
- 定义一个更新数据的方法
- 用 watch 代替以前的事件驱动,把上面三者联系起来。
- watch 最好统一使用,避免混乱。
- 使用依赖注入,共享给子组件
应该不用细说了吧。不过这里有一个小问题,不知道大家发现没有:updateData函数有可能被重复调用。
当查询条件变更的时候,把页号也顺便改了,这样会触发第二个watch,这样就重复了。
所以需要设置一个“开关”控制一下。
// 是否需要加载数据
let canUpdate = true
// 监听查询条件,更新数据
watch(query.mini, async () => {
// 设置为不需要 翻页触发
canUpdate = false
// 设置到第一页
pagerState.pagerIndex = 1
await updateData()
canUpdate = true // 恢复
})
// 监听页号,翻页后更新数据
watch(() => pagerState.pagerIndex, () => {
if (canUpdate) {
updateData()
}
}, {
immediate: true // 立即执行
})
子组件获取状态的方法
/**
* 子组件用 inject 获取状态
* @returns TListState<T>
*/
export function getListState<T extends object>(): TListState<T> {
const re = inject<TListState<T>>(flag)
return re as TListState<T>
}
就是封装一下 inject。
用委托的方式加载数据
加载数据的部分不应放在 controller 内部,因为可能有多种加载方式,一般从后端获取,但是也可能从第三方API获取,也可能从indexedDB获取,可能用axios,也可能用其他方式,所以传个函数进来就好。
这里使用模拟数据的方式:
- service.ts
export default async (query: TQueryState, pagerInfo: TPagerState) => {
// 设置模拟数据
const re: TResource<TCompany> = {
allCount: 1000,
list: [...] as TCompany[]
}
return re
}
这里只关心接口是否一致,至于如何获取数据,用什么方法都行,不同的需求可以设置不同的函数。
页面部分的实现
准备工作都做好了,开始搭建页面
列表页面
这是一个父组件,可以看成是一个筐,装载其他子组件。
<template>
<!--模板-->
<h1>列表页面</h1>
<!--查询-->
<find></find>
<!--列表-->
<grid></grid>
<!--分页-->
<pager></pager>
</template>
突出重点,不做美化工作了,我也不会css。
import { reactive } from 'vue'
import pager from './pager.vue'
import find from './find.vue'
import grid from './grid.vue'
import getData from './service'
// 获取父组件的状态
import { createListState } from './list-state'
// 创建一个状态,传入获取数据的函数
const state = createListState(getData)
引入子组件、引入加载数据的函数、引入状态,是不是很简洁。
我喜欢细粒度,能分子组件那就分出去,父组件建立简单,这样便于维护。
分页组件
使用 el-pagination 实现分页功能,设置currentPage实现联动。
<template>
<el-pagination
background
layout="prev, pager, next"
v-model:currentPage="pagerState.pagerIndex"
:total="pagerState.count">
</el-pagination>
</template>
<script setup lang="ts">
// 获取父组件的状态
import { getListState } from './list-state'
// 获取分页状态
const { pagerState } = getListState()
</script>
列表组件
一样的套路,获取状态,获取数据列表,然后用你喜欢的方式绑定好即可。不贴代码了。
查询组件
还是一样的套路,获取查询状态,然后让查询组件处理,具体处理方式就不写了,不是重点。
总结
把业务逻辑都集中在 controller 里面处理,相同的放在里面,不同的放在外面,比如 service。
突然不知道说啥了。