阅读视图

发现新文章,点击刷新页面。

Vue3 后台分页写腻了?我用 1 个 Hook 删掉 90% 重复代码(附源码)

还在为每个列表页写重复的分页代码而烦恼吗? 还在复制粘贴 currentPage、pageSize、loading 等状态吗? 一个 Hook 帮你解决所有分页痛点,减少90%重复代码

背景与痛点

在后台管理系统开发中,分页列表查询非常常见,我们通常需要处理:

  • 当前页、页大小、总数等分页状态
  • 加载中、错误处理等请求状态
  • 搜索、刷新、翻页等分页操作
  • 数据缓存和重复请求处理

这些重复逻辑分散在各个组件中,维护起来很麻烦。

为了解决这个烦恼,我专门封装了分页数据管理 Hook。现在只需要几行代码,就能轻松实现分页查询,省时又高效,减少了大量重复劳动

使用前提 - 接口格式约定

查询接口返回的数据格式:

{
  list: [        // 当前页数据数组
    { id: 1, name: 'user1' },
    { id: 2, name: 'user2' }
  ],
  total: 100     // 数据总条数
}

先看效果:分页查询只需几行代码!

import usePageFetch from '@/hooks/usePageFetch' // 引入分页查询 Hook,封装了分页逻辑和状态管理
import { getUserList } from '@/api/user'        // 引入请求用户列表的 API 方法

// 使用 usePageFetch Hook 实现分页数据管理
const {
  currentPage,      // 当前页码
  pageSize,         // 每页条数
  total,            // 数据总数
  data,             // 当前页数据列表
  isFetching,       // 加载状态,用于控制 loading 效果
  search,           // 搜索方法
  onSizeChange,     // 页大小改变事件处理方法
  onCurrentChange   // 页码改变事件处理方法
} = usePageFetch(
  getUserList,  // 查询API
  { initFetch: false }  // 是否自动请求一次(组件挂载时自动拉取第一页数据)     
)

这样子每次分页查询只需要引入hook,然后传入查询接口就好了,减少了大量重复劳动

解决方案

我设计了两个相互配合的 Hook:

  • useFetch:基础请求封装,处理请求状态和缓存
  • usePageFetch:分页逻辑封装,专门处理分页相关的状态和操作
usePageFetch (分页业务层)
├── 管理 page / pageSize / total 状态
├── 处理搜索、刷新、翻页逻辑  
├── 统一错误处理和用户提示
└── 调用 useFetch (请求基础层)
    ├── 管理 loading / data / error 状态
    ├── 可选缓存机制(避免重复请求)
    └── 成功回调适配不同接口格式

核心实现

useFetch - 基础请求封装

// hooks/useFetch.js
import { ref } from 'vue'

const Cache = new Map()

/**
 * 基础请求 Hook
 * @param {Function} fn - 请求函数
 * @param {Object} options - 配置选项
 * @param {*} options.initValue - 初始值
 * @param {string|Function} options.cache - 缓存配置
 * @param {Function} options.onSuccess - 成功回调
 */
function useFetch(fn, options = {}) {
  const isFetching = ref(false)
  const data = ref()
  const error = ref()

  // 设置初始值
  if (options.initValue !== undefined) {
    data.value = options.initValue
  }

  function fetch(...args) {
    isFetching.value = true
    let promise

    if (options.cache) {
      const cacheKey = typeof options.cache === 'function'
        ? options.cache(...args)
        : options.cache || `${fn.name}_${args.join('_')}`

      promise = Cache.get(cacheKey) || fn(...args)
      Cache.set(cacheKey, promise)
    } else {
      promise = fn(...args)
    }

    // 成功回调处理
    if (options.onSuccess) {
      promise = promise.then(options.onSuccess)
    }

    return promise
      .then(res => {
        data.value = res
        isFetching.value = false
        error.value = undefined
        return res
      })
      .catch(err => {
        isFetching.value = false
        error.value = err
        return Promise.reject(err)
      })
  }

  return {
    fetch,
    isFetching,
    data,
    error
  }
}

export default useFetch

usePageFetch - 分页逻辑封装

// hooks/usePageFetch.js
import { ref, onMounted, toRaw, watch } from 'vue'
import useFetch from './useFetch' // 即上面的hook ---> useFetch 
import { ElMessage } from 'element-plus'

/**
 * 分页数据管理 Hook
 * @param {Function} fn - 请求函数
 * @param {Object} options - 配置选项
 * @param {Object} options.params - 默认参数
 * @param {boolean} options.initFetch - 是否自动初始化请求
 * @param {Ref} options.formRef - 表单引用
 */
function usePageFetch(fn, options = {}) {
  // 分页状态
  const page = ref(1)
  const pageSize = ref(10)
  const total = ref(0)
  const data = ref([])
  const params = ref()
  const pendingCount = ref(0)

  // 初始化参数
  params.value = options.params

  //  使用基础请求 Hook
  const { isFetching, fetch: fetchFn, error, data: originalData } = useFetch(fn)

  //  核心请求方法
  const fetch = async (searchParams, pageNo, size) => {
    try {
      // 更新分页状态
      page.value = pageNo
      pageSize.value = size
      params.value = searchParams

      // 发起请求
      await fetchFn({
        page: pageNo,
        pageSize: size,
        // 使用 toRaw 避免响应式对象问题
        ...(searchParams ? toRaw(searchParams) : {})
      })

      // 处理响应数据
      data.value = originalData.value?.list || []
      total.value = originalData.value?.total || 0
      pendingCount.value = originalData.value?.pendingCounts || 0
    } catch (e) {
      console.error('usePageFetch error:', e)
      ElMessage.error(e?.msg || e?.message || '请求出错')
      // 清空数据,提供更好的用户体验
      data.value = []
      total.value = 0
    }
  }

  //  搜索 - 重置到第一页
  const search = async (searchParams) => {
    await fetch(searchParams, 1, pageSize.value)
  }

  // 刷新当前页
  const refresh = async () => {
    await fetch(params.value, page.value, pageSize.value)
  }

  // 改变页大小
  const onSizeChange = async (size) => {
    await fetch(params.value, 1, size) // 重置到第一页
  }

  // 切换页码
  const onCurrentChange = async (pageNo) => {
    await fetch(params.value, pageNo, pageSize.value)
  }

  // 组件挂载时自动请求
  onMounted(() => {
    if (options.initFetch !== false) {
      search(params.value)
    }
  })

  // 监听表单引用变化(可选功能)
  watch(
    () => options.formRef,
    (formRef) => {
      if (formRef) {
        console.log('Form ref updated:', formRef)
      }
    }
  )

  return {
    // 分页状态
    currentPage: page,
    pageSize,
    total,
    pendingCount,
    
    // 数据状态
    data,
    originalData,
    isFetching,
    error,
    
    // 操作方法
    search,
    refresh,
    onSizeChange,
    onCurrentChange
  }
}

export default usePageFetch

完整使用示例

用element ui举例

<template>
    <el-form :model="searchForm" >
      <el-form-item label="用户名">
        <el-input v-model="searchForm.username" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="handleSearch">搜索</el-button>
      </el-form-item>
    </el-form>
  <!-- 表格数据展示,绑定 data 和 loading 状态 -->
  <el-table :data="data" v-loading="isFetching">
    <!-- ...表格列定义... -->
  </el-table>

  <!-- 分页组件,绑定当前页、页大小、总数,并响应切换事件 -->
  <el-pagination
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    :total="total"
    @size-change="onSizeChange"
    @current-change="onCurrentChange"
  />
</template>
<script setup>
import { ref } from 'vue'
import usePageFetch from '@/hooks/usePageFetch' // 引入分页查询 Hook,封装了分页逻辑和状态管理
import { getUserList } from '@/api/user'        // 引入请求用户列表的 API 方法

// 搜索表单数据,响应式声明
const searchForm = ref({
  username: ''
})

// 使用 usePageFetch Hook 实现分页数据管理
const {
  currentPage,      // 当前页码
  pageSize,         // 每页条数
  total,            // 数据总数
  data,             // 当前页数据列表
  isFetching,       // 加载状态,用于控制 loading 效果
  search,           // 搜索方法
  onSizeChange,     // 页大小改变事件处理方法
  onCurrentChange   // 页码改变事件处理方法
} = usePageFetch(
  getUserList, 
  { initFetch: false }  // 是否自动请求一次(组件挂载时自动拉取第一页数据)     
)

/**
* 处理搜索操作
*/
const handleSearch = () => {
  search({ username: searchForm.value.username })
}

</script>

高级用法

带缓存

const {
  data,
  isFetching,
  search
} = usePageFetch(getUserList, {
  cache: (params) => `user-list-${JSON.stringify(params)}` // 自定义缓存 key
})

设计思路解析

  • 职责分离:useFetch 专注请求状态管理,usePageFetch 专注分页逻辑
  • 统一错误处理:在 usePageFetch 层统一处理错误
  • 智能缓存机制:支持多种缓存策略
  • 生命周期集成:自动在组件挂载时请求数据

总结

这套分页管理 Hook 的优势:

  • 开发效率高,减少90%的重复代码,新增列表页从 30 分钟缩短到 5 分钟
  • 状态管理完善,自动处理加载、错误、数据状态
  • 缓存机制,避免重复请求
  • 错误处理统一,用户体验一致
  • 易于扩展,支持自定义配置和回调

如果觉得对您有帮助,欢迎点赞 👍 收藏 ⭐ 关注 🔔 支持一下!

同事以为要重写,我8行代码让 Vue 2 公共组件跑进 Vue 3

前言

最近遇到一个非常典型的前端兼容问题:产品要求将官网底部 footer 组件统一成公司平台的公共组件。查阅对接文档后发现,这个公共组件库是基于 Vue 2 开发的,而我们的项目已经升级到 Vue 3

这就尴尬了,难道要为了一个组件降级整个项目?或者重新写一遍?都不现实。

最后我想到一个解决方案:在 Vue 3 项目中动态加载并运行 Vue 2 组件。经过实践,成功解决了这个问题,今天分享给大家。

环境准备:先安装 Vue 2 依赖

由于我们需要在 Vue 3 项目中动态加载 Vue 2 的运行时和vue2组件,必须先安装 Vue 2 相关依赖。否则 import 时会报模块未找到的错误。

安装命令

# 安装 Vue 2 运行时
npm install vue@2

# 安装 Vue 2 公司公共组件
npm install @xxx/navigation-components

解决思路

基本思路是这样的:

  1. 动态加载 Vue 2 的运行时代码和目标组件;
  2. 在 Vue 3 组件中创建独立的 Vue 2 实例,与主项目隔离;
  3. 将 Vue 2 组件挂载到指定 DOM 节点
  4. 通过 props 传递数据,甚至事件通信。
Vue 3 项目
├── 正常的 Vue 3 组件
├── 动态导入 Vue 2 运行时
├── 创建独立的 Vue 2 实例
└── Vue 2 组件在独立实例中运行

这种方式可以让 Vue 2 组件像“小程序”一样在 Vue 3 项目中运行,互不干扰。


具体实现

1. 动态导入资源

动态加载资源,这样打包的时候就会进行代码分割,减少主包体积,提升首屏性能。

import Vue2 from 'vue2/dist/vue.runtime.min.js'  // vue2运行时
import * as vue2Component from 'xxx/navigation-components' // vue2公共组件

打包结果:

dist/
├── index.html
├── main.js (500KB)           ← 主要代码
├── vue2.chunk.js (34KB)      ← Vue 2 运行时
├── nav.chunk.js (15KB)       ← 导航组件
└── main.css

在组件中按需引入之后,打包的时候把不是立即需要的代码(如Vue2运行时、第三方组件)单独打包,等到需要使用到的时候才下载,这样主包变小,首屏加载更快,用户体验更好。如果不是按需加载,在main.js中直接引入的话,用户访问网站就必须等主包下载完才能看到页面。

优势:

  • 并行加载,提升加载速度
  • 代码分割,减少主包体积
  • 按需加载,优化首屏性能
  1. 在vue3文件中创建 Vue 2 实例

<script setup>
import { onMounted } from 'vue';
import Vue2 from 'vue2/dist/vue.runtime.min.js'
import vue2Component from 'xxx/navigation-components'

onMounted(async () => {
   new Vue2({
      el: '#nav-footer',
      render: (h) => h(vue2Component)}
   )
})
</script>
  1. 模板中预留挂载点

<template>
  <div>
    <!-- Vue 3 的其他内容 -->
    <main>...</main>
    
    <!-- 为 Vue 2 组件预留挂载点 -->
    <div id="nav-footer"></div>
  </div>
</template>

优势总结

  1. 兼容性好:无需修改现有 Vue 3 项目架构

  2. 隔离性强:Vue 2 和 Vue 3 实例完全独立,互不影响

  3. 性能优化:按需加载,代码分割

  4. 维护成本低:可以直接使用现有的 Vue 2 组件

源码

<script setup>
import { onMounted } from 'vue';
import Vue2 from 'vue2/dist/vue.runtime.min.js'
import vue2Component from 'xxx/navigation-components'

onMounted(async () => {
   new Vue2({
      el: '#nav-footer',
      render: (h) => h(vue2Component)}
   )
})

</script>
<template>
  <div id="nav-footer"></div>
</template>

总结

用这个方案,Vue 3 项目也能直接用 Vue 2 组件,不用大改项目,也不用重写代码。这样既兼容又省事,还能让页面加载更快,维护也更简单。适合项目升级过渡时用,等以后有时间,再慢慢把组件升级到 Vue 3 就行了。

如果这篇文章对你有帮助,记得点赞👍收藏⭐分享📤三连哦~

❌