【vue3】compositionAPI的最佳实践:列表篇

483 阅读8分钟

无论是网站还是后台管理或者是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。

突然不知道说啥了。