基于 Umi 搭建 React 快速开发框架:封装高效的列表增删改查功能

在当今快速迭代的前端开发环境中,减少重复性工作、提升开发效率是每个团队追求的目标。对于中后台管理系统而言,列表页的增删改查操作占据了开发的半壁江山。如果每个页面都从零开始编写查询表单、表格、分页、模态框等,无疑是巨大的资源浪费。

Umi,作为一个可扩展的企业级前端应用框架,为 React 技术栈提供了开箱即用的工程化能力。结合 Umi 的插件生态和最佳实践,我们可以搭建一套高复用性的快速开发框架,将常见的增删改查操作进行抽象和封装。

本文将详细讲解如何基于 Umi@4 搭建一个 React 开发框架,并核心封装一套功能完善、易于使用的列表增删改查高阶组件(HOC)或自定义 Hook,从而实现列表页面的快速开发。

目录#

  1. 项目初始化与基础配置
  2. 技术栈选择与理由
  3. 核心思想:抽象与封装
  4. 实现步骤详解
  5. 实践案例:用户管理页面
  6. 最佳实践与常见问题
  7. 总结
  8. 参考资料

一、项目初始化与基础配置#

首先,我们使用 Umi 官方脚手架创建一个新项目。

# 使用 pnpm 创建项目(推荐)
pnpm dlx create-umi@latest my-app
# 或使用 npm
npx create-umi@latest my-app
 
cd my-app

在项目初始化时,选择 Simple App 模板即可。完成后,安装我们后续所需的依赖。

pnpm add @ant-design/pro-components antd
# 确保 umi 和 @umijs/plugins 版本正确,通常在 package.json 中已包含

关键配置 (.umirc.ts):

import { defineConfig } from 'umi';
 
export default defineConfig({
  plugins: ['@umijs/plugins/dist/dva', '@umijs/plugins/dist/pro-components'],
  dva: {}, // 启用 dva 状态管理
  // 配置代理,解决跨域问题(根据实际后端地址调整)
  proxy: {
    '/api': {
      target: 'https://your-api-domain.com',
      changeOrigin: true,
      pathRewrite: { '^/api': '' },
    },
  },
  // 引入 Antd 并自动优化样式
  antd: {},
});

二、技术栈选择与理由#

  • Umi@4:作为底层框架,提供路由、构建、部署、插件等一站式解决方案,稳定性高。
  • React + TypeScript:保证代码的类型安全和可维护性。
  • Ant Design:UI 组件库,提供丰富的、设计语言统一的组件。
  • ProComponents:基于 Ant Design 的高阶组件库,特别是 ProTable,极大地简化了表格和表单的开发。
  • Dva:一个基于 Redux 和 Redux-saga 的数据流方案,用于管理异步操作和全局状态。虽然 React Query 和 SWR 也很流行,但 Dva 与 Umi 集成更简单,概念对于团队上手更容易。

三、核心思想:抽象与封装#

我们的目标是创建一个名为 withTablePro 的高阶组件或一个 useTablePro 的 Hook。它需要封装以下功能:

  1. 状态管理:查询条件、分页信息、表格加载状态、选中行等。
  2. 数据获取:自动根据查询条件和分页信息发起请求。
  3. 常用操作:刷新、重置、删除、批量删除等。
  4. UI 集成:与 ProTableProForm 无缝集成。

这样,开发一个列表页只需要:

  1. 定义接口 API。
  2. 配置表格列和查询表单项。
  3. withTablePro 包裹页面组件或使用 useTablePro Hook。

四、实现步骤详解#

4.1 定义通用类型#

首先,在 src/types/table.d.ts 中定义通用的类型,这是 TypeScript 项目保持类型安全的基础。

// 通用的分页响应结构(与后端约定)
export interface PaginationResponse<T> {
  list: T[];
  current: number;
  pageSize: number;
  total: number;
}
 
// 通用的查询参数基础结构
export interface BaseQueryParams {
  current: number;
  pageSize: number;
  [key: string]: any; // 允许其他任意查询字段
}
 
// 列表页组件的 Props(用于 HOC)
export interface TableProProps<T, Q extends BaseQueryParams> {
  queryParams: Q;
  tableData: PaginationResponse<T> | undefined;
  loading: boolean;
  selectedRows: T[];
  onSearch: (params?: Partial<Q>) => void;
  onReset: () => void;
  onDelete: (record: T) => Promise<void>;
  onBatchDelete: (records: T[]) => Promise<void>;
  // ... 其他需要透传的方法
}

4.2 构建基础查询表单组件#

创建一个可复用的查询表单组件 src/components/SearchForm/index.tsx。它接收一个表单配置项数组,并自动渲染表单。

import React from 'react';
import { ProForm, ProFormText, ProFormSelect } from '@ant-design/pro-components';
 
interface SearchFormProps {
  onSearch: (values: any) => void;
  onReset: () => void;
}
 
const SearchForm: React.FC<SearchFormProps> = ({ onSearch, onReset }) => {
  return (
    <ProForm
      submitter={{
        searchConfig: {
          submitText: '查询',
          resetText: '重置',
        },
        onReset: () => {
          onReset();
        },
      }}
      onFinish={async (values) => {
        onSearch(values);
      }}
      layout="inline"
    >
      <ProFormText name="name" label="用户名" placeholder="请输入用户名" />
      <ProFormSelect
        name="status"
        label="状态"
        valueEnum={{
          1: '启用',
          0: '禁用',
        }}
        placeholder="请选择状态"
      />
      {/* 更多的表单项可以根据页面 Props 动态传入 */}
    </ProForm>
  );
};
 
export default SearchForm;

4.3 封装数据请求层#

src/services/ 目录下创建 API 文件。我们使用 Umi 的 request 方法(基于 fetch)。

// src/services/user.ts
import { request } from 'umi';
import type { PaginationResponse, BaseQueryParams } from '@/types/table';
 
export interface UserItem {
  id: number;
  name: string;
  email: string;
  status: number;
  createTime: string;
}
 
export interface UserQueryParams extends BaseQueryParams {
  name?: string;
  status?: number;
}
 
export async function queryUserList(params: UserQueryParams) {
  return request<PaginationResponse<UserItem>>('/api/users', {
    method: 'GET',
    params,
  });
}
 
export async function deleteUser(id: number) {
  return request(`/api/users/${id}`, {
    method: 'DELETE',
  });
}

4.4 实现核心 Hook:useTablePro#

这是封装的核心,我们创建一个自定义 Hook src/hooks/useTablePro.ts 来管理所有状态和逻辑。

import { useState, useCallback, useRef } from 'react';
import { message } from 'antd';
import type { PaginationResponse, BaseQueryParams } from '@/types/table';
 
interface UseTableProOptions<T, Q extends BaseQueryParams> {
  // 获取数据的函数
  fetchData: (params: Q) => Promise<PaginationResponse<T>>;
  // 默认的查询参数
  defaultQueryParams?: Q;
}
 
export default function useTablePro<T, Q extends BaseQueryParams>({
  fetchData,
  defaultQueryParams,
}: UseTableProOptions<T, Q>) {
  // 状态定义
  const [queryParams, setQueryParams] = useState<Q>({
    current: 1,
    pageSize: 10,
    ...defaultQueryParams,
  } as Q);
  const [tableData, setTableData] = useState<PaginationResponse<T>>();
  const [loading, setLoading] = useState(false);
  const [selectedRows, setSelectedRows] = useState<T[]>([]);
 
  // 用于强制刷新表格的引用
  const fetchIdRef = useRef(0);
 
  // 核心:获取表格数据
  const getTableData = useCallback(async () => {
    const fetchId = ++fetchIdRef.current;
    setLoading(true);
 
    try {
      const response = await fetchData(queryParams);
      // 防止旧的请求覆盖新的请求结果
      if (fetchId === fetchIdRef.current) {
        setTableData(response);
      }
    } catch (error) {
      message.error('获取数据失败');
      console.error(error);
    } finally {
      if (fetchId === fetchIdRef.current) {
        setLoading(false);
      }
    }
  }, [fetchData, queryParams]);
 
  // 初始化或查询条件变化时自动获取数据
  // 你可以使用 useEffect 触发,或在页面组件中手动调用 onSearch
  // useEffect(() => { getTableData(); }, [getTableData]);
 
  // 搜索
  const onSearch = useCallback((newParams?: Partial<Q>) => {
    setQueryParams((prev) => ({
      ...prev,
      ...newParams,
      current: 1, // 搜索时重置到第一页
    }));
    // 注意:这里不直接调用 getTableData,由 useEffect 依赖项触发更安全
  }, []);
 
  // 重置
  const onReset = useCallback(() => {
    setQueryParams({
      current: 1,
      pageSize: 10,
      ...defaultQueryParams,
    } as Q);
  }, [defaultQueryParams]);
 
  // 删除单条记录
  const onDelete = useCallback(
    async (record: T) => {
      try {
        // 这里需要根据实际情况调用删除 API
        // await deleteUser((record as any).id);
        message.success('删除成功');
        getTableData(); // 刷新表格
      } catch (error) {
        message.error('删除失败');
      }
    },
    [getTableData]
  );
 
  // 批量删除
  const onBatchDelete = useCallback(
    async (records: T[]) => {
      if (records.length === 0) {
        message.warning('请至少选择一条记录');
        return;
      }
      try {
        // const ids = records.map((r: any) => r.id);
        // await batchDeleteUser(ids);
        message.success('批量删除成功');
        setSelectedRows([]);
        getTableData();
      } catch (error) {
        message.error('批量删除失败');
      }
    },
    [getTableData]
  );
 
  // 处理表格变化(分页、排序、筛选)
  const onTableChange = (pagination: any, filters: any, sorter: any) => {
    setQueryParams((prev) => ({
      ...prev,
      current: pagination.current,
      pageSize: pagination.pageSize,
      ...filters,
      ...(sorter.field && { sorter: `${sorter.field}_${sorter.order}` }),
    }));
  };
 
  return {
    // 状态
    queryParams,
    tableData,
    loading,
    selectedRows,
    // 方法
    setQueryParams,
    setSelectedRows,
    getTableData,
    onSearch,
    onReset,
    onDelete,
    onBatchDelete,
    onTableChange,
  };
}

4.5 创建高阶组件 withTablePro#

虽然 Hook 很流行,但高阶组件对于某些场景(如装饰器模式)依然清晰。我们可以选择性地实现一个 HOC。

// src/hocs/withTablePro.tsx
import React from 'react';
import useTablePro from '@/hooks/useTablePro';
import type { TableProProps, BaseQueryParams, PaginationResponse } from '@/types/table';
 
function withTablePro<T, Q extends BaseQueryParams>(
  fetchData: (params: Q) => Promise<PaginationResponse<T>>,
  defaultQueryParams?: Q
) {
  return (WrappedComponent: React.ComponentType<TableProProps<T, Q>>) => {
    return (props: any) => {
      const tablePro = useTablePro<T, Q>({ fetchData, defaultQueryParams });
 
      return <WrappedComponent {...props} {...tablePro} />;
    };
  };
}
 
export default withTablePro;

五、实践案例:用户管理页面#

现在,我们来使用上面封装的 Hook 快速创建一个用户列表页面 src/pages/user/index.tsx

import React, { useEffect } from 'react';
import { ProTable } from '@ant-design/pro-components';
import { Button, Space, Popconfirm, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { ProColumns } from '@ant-design/pro-components';
import useTablePro from '@/hooks/useTablePro';
import SearchForm from '@/components/SearchForm';
import { queryUserList, deleteUser, UserItem, UserQueryParams } from './service';
 
const UserList: React.FC = () => {
  // 使用封装的 Hook,传入 API 和默认参数
  const {
    queryParams,
    tableData,
    loading,
    selectedRows,
    setSelectedRows,
    onSearch,
    onReset,
    onDelete,
    onBatchDelete,
    onTableChange,
    getTableData,
  } = useTablePro<UserItem, UserQueryParams>({
    fetchData: queryUserList,
    defaultQueryParams: { status: 1 },
  });
 
  // 组件挂载后手动触发数据获取,或者可以在 useTablePro 中使用 useEffect
  useEffect(() => {
    getTableData();
  }, [queryParams]); // 依赖 queryParams,当其变化时自动重新获取
 
  // 表格列配置
  const columns: ProColumns<UserItem>[] = [
    {
      title: 'ID',
      dataIndex: 'id',
      key: 'id',
      width: 80,
    },
    {
      title: '用户名',
      dataIndex: 'name',
      key: 'name',
    },
    {
      title: '邮箱',
      dataIndex: 'email',
      key: 'email',
    },
    {
      title: '状态',
      dataIndex: 'status',
      key: 'status',
      valueEnum: {
        1: { text: '启用', status: 'Success' },
        0: { text: '禁用', status: 'Error' },
      },
    },
    {
      title: '创建时间',
      dataIndex: 'createTime',
      key: 'createTime',
      valueType: 'dateTime',
    },
    {
      title: '操作',
      key: 'action',
      width: 200,
      render: (_, record) => (
        <Space>
          <Button size="small" type="link">
            编辑
          </Button>
          <Popconfirm
            title="确定要删除吗?"
            onConfirm={() => onDelete(record)}
            okText="确定"
            cancelText="取消"
          >
            <Button size="small" type="link" danger>
              删除
            </Button>
          </Popconfirm>
        </Space>
      ),
    },
  ];
 
  return (
    <div>
      {/* 查询表单 */}
      <SearchForm
        onSearch={(values) => onSearch(values)}
        onReset={onReset}
      />
      {/* 操作按钮栏 */}
      <ProTable<UserItem>
        headerTitle="用户列表"
        rowKey="id"
        columns={columns}
        dataSource={tableData?.list || []}
        loading={loading}
        pagination={{
          current: queryParams.current,
          pageSize: queryParams.pageSize,
          total: tableData?.total || 0,
          showSizeChanger: true,
          showQuickJumper: true,
        }}
        rowSelection={{
          selectedRowKeys: selectedRows.map((item) => item.id),
          onChange: (_, selectedRows) => {
            setSelectedRows(selectedRows);
          },
        }}
        tableAlertRender={({ selectedRowKeys, onCleanSelected }) => (
          <span>
            已选 {selectedRowKeys.length} 项
            <a style={{ marginLeft: 8 }} onClick={onCleanSelected}>
              取消选择
            </a>
          </span>
        )}
        tableAlertOptionRender={({ onCleanSelected }) => (
          <Button type="link" danger onClick={() => onBatchDelete(selectedRows)}>
            批量删除
          </Button>
        )}
        toolBarRender={() => [
          <Button key="add" type="primary" icon={<PlusOutlined />}>
            新建用户
          </Button>,
        ]}
        onChange={onTableChange}
      />
    </div>
  );
};
 
export default UserList;

六、最佳实践与常见问题#

最佳实践#

  1. TypeScript 化:始终为接口、组件 Props、函数参数等定义清晰的类型,这是长期维护的基石。
  2. 关注点分离:将数据获取逻辑(Service)、UI 展示逻辑(Page/Component)和通用业务逻辑(Hook/HOC)清晰地分离。
  3. 适度的抽象:不要过度封装。useTablePro 应保持核心通用功能,对于特定页面的特殊逻辑,应在页面组件中实现,或通过传入配置项来扩展。
  4. 错误处理:在 fetchData 和操作函数中要有完善的错误捕获和用户提示(如 message)。
  5. 性能优化:使用 useCallbackuseMemo 避免不必要的重渲染。useRef 用于处理竞态请求。

常见问题#

  1. Q: 请求竞态问题如何解决? A: 我们在 useTablePro 中使用了 useRef 来生成一个唯一的 fetchId,确保只有最后一次请求的结果会被渲染。
  2. Q: 如何添加更复杂的查询表单? A: 可以改造 SearchForm 组件,使其接收一个 fields 配置数组,根据配置动态渲染不同的 ProForm 组件。
  3. Q: 如何集成新建/编辑的模态框? A: 可以在 useTablePro 中添加 visible, editingRecord 等状态,并提供 onCreate, onEdit 方法。或者在页面组件中单独管理模态框状态,只利用 Hook 管理表格数据。
  4. Q: 想用 React Query 替代 Dva/useEffect 做数据获取可以吗? A: 完全可以。Umi 社区有 @umijs/plugins/dist/react-query 插件。你可以将 useTablePro 中的 useEffect 数据获取逻辑替换为 useQuery,并将删除等操作改为 useMutation,这样可以获得更强大的缓存和同步能力。

七、总结#

通过本文的步骤,我们成功地基于 Umi 框架搭建了一个高效的 React 开发环境,并核心封装了一个功能强大的 useTablePro Hook(及可选的 withTablePro HOC)。这个封装将列表页的通用逻辑(状态、数据获取、操作)高度抽象,使开发者能够专注于页面本身的配置和业务细节。

这套方案的优势在于:

  • 开发效率极高:新建一个标准列表页只需配置 columns 和 API。
  • 一致性:所有列表页遵循相同的模式和交互,便于维护和理解。
  • 可扩展性:Hook 的设计使其易于扩展以满足更复杂的业务场景。

希望这篇详细的指南能帮助你搭建属于自己的前端快速开发框架。

参考资料#

  1. Umi 官方文档
  2. Ant Design 官方文档
  3. ProComponents 官方文档
  4. React 自定义 Hook 官方指南