Vue 3 + Vite工程化实践:Element Plus分页组件深度封装指南

在现代化前端工程中,组件封装的艺术往往决定了项目的可维护性和开发效率。当Vue 3的Composition API遇上Vite的闪电般构建速度,再结合Element Plus这一UI库的强大能力,我们该如何打造一个既优雅又实用的分页组件?本文将带你从工程化角度,完整走过组件设计、自动导入优化、样式隔离和业务集成的全流程。

1. 工程化组件设计基础

优秀的组件封装始于合理的项目结构规划。在Vite+Vue 3的项目中,我们推荐采用以下目录组织方式:

src/
├── components/
│   ├── base/          # 基础通用组件
│   │   └── Pagination/
│   │       ├── index.ts  # 组件入口文件
│   │       ├── src/
│   │       │   ├── Pagination.vue  # 组件实现
│   │       │   └── types.ts    # 类型定义
│   │       └── style/      # 组件样式
├── composables/       # 组合式函数
└── utils/             # 工具函数

这种结构将组件的逻辑、样式和类型定义分离,同时保持完整的封装性。对于分页组件,我们需要先明确其核心职责:

  • 接收分页参数(currentPage/pageSize/total等)
  • 渲染分页UI并与Element Plus的el-pagination交互
  • 处理分页变化事件并通知父组件
  • 提供自定义样式插槽的能力

在Vue 3的Composition API下,组件的props设计应该充分考虑TypeScript支持。以下是基础props的类型定义示例:

// src/components/base/Pagination/src/types.ts
export interface PaginationProps {
  currentPage: number
  pageSize: number
  total: number
  pageSizes?: number[]
  layout?: string
  background?: boolean
  small?: boolean
  disabled?: boolean
  hideOnSinglePage?: boolean
}

2. Composition API下的组件实现

基于上述类型定义,我们可以开始构建组件主体。使用Composition API的最大优势是能够将相关逻辑组织在一起,而不是按照选项API的范式强制分离。

<!-- src/components/base/Pagination/src/Pagination.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import type { PaginationProps } from './types'

const props = withDefaults(defineProps<PaginationProps>(), {
  pageSizes: () => [10, 20, 50, 100],
  layout: 'total, sizes, prev, pager, next, jumper',
  background: true,
  small: false,
  disabled: false,
  hideOnSinglePage: false
})

const emit = defineEmits<{
  (e: 'update:currentPage', val: number): void
  (e: 'update:pageSize', val: number): void
  (e: 'change', val: { currentPage: number, pageSize: number }): void
}>()

const internalPageSize = computed({
  get: () => props.pageSize,
  set: (val) => emit('update:pageSize', val)
})

const internalCurrentPage = computed({
  get: () => props.currentPage,
  set: (val) => emit('update:currentPage', val)
})

const handleSizeChange = (val: number) => {
  internalPageSize.value = val
  emit('change', { 
    currentPage: internalCurrentPage.value,
    pageSize: val 
  })
}

const handleCurrentChange = (val: number) => {
  internalCurrentPage.value = val
  emit('change', { 
    currentPage: val,
    pageSize: internalPageSize.value 
  })
}
</script>

这段代码展示了几个关键实践:

  1. 使用 withDefaults 为props提供默认值
  2. 定义严格的emit类型约束
  3. 通过计算属性实现v-model双向绑定
  4. 统一封装分页变化事件

模板部分则需要特别注意Element Plus组件的正确使用:

<template>
  <div class="pagination-container">
    <el-pagination
      v-model:current-page="internalCurrentPage"
      v-model:page-size="internalPageSize"
      :page-sizes="pageSizes"
      :layout="layout"
      :background="background"
      :small="small"
      :disabled="disabled"
      :hide-on-single-page="hideOnSinglePage"
      :total="total"
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
    />
  </div>
</template>

3. Vite环境下的自动导入优化

在Vite项目中,我们可以利用unplugin-vue-components实现Element Plus组件的自动导入,避免手动注册的麻烦。首先确保vite.config.ts配置正确:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),
    Components({
      resolvers: [
        ElementPlusResolver({
          importStyle: 'sass',
        }),
      ],
      dts: 'src/components.d.ts',
      directoryAsNamespace: true,
    }),
  ],
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "~/styles/element/index.scss" as *;`,
      },
    },
  },
})

对于我们封装的分页组件,还需要在入口文件中进行导出:

// src/components/base/Pagination/index.ts
import Pagination from './src/Pagination.vue'

export { Pagination }
export default Pagination

然后在项目的任何地方,都可以直接使用组件而无需显式导入:

<script setup>
// 无需import,自动导入机制会处理
const pagination = ref({
  currentPage: 1,
  pageSize: 10,
  total: 100
})
</script>

<template>
  <Pagination
    v-model:current-page="pagination.currentPage"
    v-model:page-size="pagination.pageSize"
    :total="pagination.total"
  />
</template>

4. 样式隔离与主题定制

在组件封装中,样式管理常常被忽视,但却至关重要。我们推荐采用以下策略:

1. 作用域样式 :使用scoped确保组件样式不会泄漏

<style scoped lang="scss">
.pagination-container {
  margin-top: 20px;
  text-align: right;

  :deep(.el-pagination) {
    // 覆盖Element Plus默认样式
    .btn-prev,
    .btn-next {
      border-radius: 4px;
    }
  }
}
</style>

2. 主题变量覆盖 :通过SCSS变量统一修改

// styles/element/var.scss
$--color-primary: #1890ff;
$--pagination-button-width: 32px;

3. 提供样式插槽 :允许外部自定义部分样式

<template>
  <div class="pagination-container">
    <slot name="prefix"></slot>
    <el-pagination ... />
    <slot name="suffix"></slot>
  </div>
</template>

5. 高级功能扩展

基础分页组件完成后,我们可以考虑添加一些增强功能:

快速跳转优化

<script setup>
const quickJump = (page) => {
  if (page > 0 && page <= Math.ceil(props.total / props.pageSize)) {
    internalCurrentPage.value = page
  }
}
</script>

<template>
  <div class="quick-jumper">
    跳至 <input 
      type="number" 
      :min="1" 
      :max="Math.ceil(total / pageSize)"
      @keyup.enter="quickJump($event.target.value)"
    > 页
  </div>
</template>

分页大小记忆功能

import { useLocalStorage } from '@vueuse/core'

const pageSize = useLocalStorage('pagination-page-size', 10)

与表格组件的联动

<script setup>
const tableData = ref([])
const loading = ref(false)
const pagination = ref({
  currentPage: 1,
  pageSize: 10,
  total: 0
})

const fetchData = async () => {
  loading.value = true
  try {
    const res = await api.getList({
      page: pagination.value.currentPage,
      size: pagination.value.pageSize
    })
    tableData.value = res.data
    pagination.value.total = res.total
  } finally {
    loading.value = false
  }
}

watch(() => [
  pagination.value.currentPage,
  pagination.value.pageSize
], fetchData, { immediate: true })
</script>

6. 单元测试与类型安全

完善的组件应该包含单元测试。使用Vitest可以方便地测试我们的分页组件:

// tests/components/Pagination.spec.ts
import { mount } from '@vue/test-utils'
import Pagination from '@/components/base/Pagination'

describe('Pagination.vue', () => {
  it('emits change event when page changes', async () => {
    const wrapper = mount(Pagination, {
      props: {
        currentPage: 1,
        pageSize: 10,
        total: 100
      }
    })
    
    await wrapper.find('.el-pager li:nth-child(2)').trigger('click')
    expect(wrapper.emitted('change')).toBeTruthy()
    expect(wrapper.emitted('change')[0]).toEqual([{
      currentPage: 2,
      pageSize: 10
    }])
  })
})

对于类型安全,我们可以利用Volar和Vue 3的TS支持来获得完美的开发体验。确保在tsconfig.json中配置:

{
  "compilerOptions": {
    "types": ["vite/client", "unplugin-vue-components/types"]
  }
}

7. 性能优化与最佳实践

在大型项目中,分页组件的性能优化不容忽视:

  1. 防抖处理 :快速点击分页按钮时
import { debounce } from 'lodash-es'

const handleCurrentChange = debounce((val: number) => {
  // 处理逻辑
}, 300)
  1. 虚拟滚动集成 :大数据量时
<template>
  <el-table-v2
    :columns="columns"
    :data="data"
    :pagination="{
      currentPage,
      pageSize,
      total,
      onChange: handlePageChange
    }"
  />
</template>
  1. 服务端分页与前端分页的智能切换
const isServerPagination = computed(() => total.value > 10000)
  1. 分页器响应式布局
<script setup>
const layout = computed(() => {
  return window.innerWidth < 768 
    ? 'prev, pager, next' 
    : 'total, sizes, prev, pager, next, jumper'
})
</script>

8. 业务场景深度集成

最后,我们来看几个实际业务中的集成案例:

案例一:带筛选条件的分页

const filters = ref({})
const fetchData = () => {
  api.getList({
    ...filters.value,
    page: pagination.value.currentPage,
    size: pagination.value.pageSize
  })
}

案例二:多标签页分页记忆

const tabPagination = ref({
  tab1: { currentPage: 1, pageSize: 10 },
  tab2: { currentPage: 1, pageSize: 20 }
})

const handleTabChange = (tab) => {
  pagination.value = { ...tabPagination.value[tab] }
}

案例三:分页与路由同步

const route = useRoute()
const router = useRouter()

watch(() => route.query.page, (page) => {
  if (page) pagination.value.currentPage = Number(page)
})

watch(() => pagination.value.currentPage, (page) => {
  router.push({ query: { ...route.query, page } })
})

在真实项目中使用时,我发现将分页逻辑提取为组合式函数可以极大提高复用性:

// composables/usePagination.ts
export function usePagination(initial = { currentPage: 1, pageSize: 10 }) {
  const pagination = reactive({ ...initial })
  
  const reset = () => {
    pagination.currentPage = 1
  }

  return { pagination, reset }
}

更多推荐