一、为什么要设计统一的API接口?
在开始写代码之前,我们先聊聊一个很多团队都会遇到的问题:组件库的API在不同框架间无法复用,每次换框架都要重写一遍。
想象一下这个场景:
- 你们团队用Vue开发了一套后台管理系统,封装了20多个业务组件
- 现在要做一个新的React项目,领导说”用你们成熟的组件库”
- 结果呢?团队只能对着Vue组件发呆,因为React根本跑不起来
- 最后只能重新用React写一遍,花了两周时间
这就是典型的重复造轮子,不仅浪费开发时间,还容易引入新的Bug。
所以,我们需要一种框架无关的API设计思维,让同一个组件在不同框架下都能有相似的使用体验。
二、核心设计原则
2.1 单一职责原则
每个组件只做好一件事。比如一个”数据表格”组件,它的职责是展示数据,而不是处理数据请求。请求逻辑应该由调用方提供。
// ❌ 错误做法:组件内部处理数据请求
function DataTable() {
const [data, setData] = useState([]);
useEffect(() => {
fetch('/api/users').then(res => res.json()).then(setData);
}, []);
return <table>...</table>;
}
// ✅ 正确做法:通过props接收数据
function DataTable({ data }) {
return <table>...</table>;
}
2.2 组合优于继承
不要为了扩展功能而创建复杂的继承链。通过props、children、slots等方式组合功能。
2.3 API签名一致性
不同框架下,相同的API应该有一致的签名模式。比如:
data属性在Vue和React中都用来传递数据onSubmit事件在Vue中通过@submit,在React中通过onSubmit,但语义相同
2.4 向前兼容
新增功能时,不要破坏现有API。通过默认值、可选参数等方式实现平滑过渡。
三、统一数据交互模式
3.1 数据传递的三种模式
模式一:Props传递(最简单)
// Vue
<DataTable :data="tableData" :columns="columns" @sort="handleSort" />
// React
<DataTable data={tableData} columns={columns} onSort={handleSort} />
模式二:Context传递(适合全局状态)
// Vue 3
const DataTableContext = createContext();
function DataTableProvider({ children }) {
const data = ref([]);
return (
<DataTableContext.Provider value={{ data }}>
{children}
</DataTableContext.Provider>
);
}
// React
const DataTableContext = createContext();
function DataTableProvider({ children }) {
const [data, setData] = useState([]);
return (
<DataTableContext.Provider value={{ data, setData }}>
{children}
</DataTableContext.Provider>
);
}
模式三:Hooks/Composables封装(最灵活)
// Vue Composable
function useDataTable(options) {
const data = ref([]);
const loading = ref(false);
async function fetch() {
loading.value = true;
const result = await api.fetch(options);
data.value = result;
loading.value = false;
}
return { data, loading, fetch };
}
// React Hook
function useDataTable(options) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
async function fetch() {
setLoading(true);
const result = await api.fetch(options);
setData(result);
setLoading(false);
}
return { data, loading, fetch };
}
3.2 事件处理统一
不同框架的事件命名约定不同,但我们可以抽象出统一的接口:
// 统一的接口定义
interface DataTableEvents {
onSort?: (field: string, order: 'asc' | 'desc') => void;
onPageChange?: (page: number) => void;
onRowClick?: (row: any) => void;
onFilter?: (filters: Record<string, any>) => void;
}
// Vue 实现
function DataTable(props, { emit }) {
function handleSort(field) {
emit('sort', field, 'asc');
}
}
// React 实现
function DataTable({ onSort, onPageChange, onRowClick, onFilter }) {
function handleSort(field) {
onSort?.(field, 'asc');
}
}
四、实战案例:DataTable组件
4.1 设计阶段:定义接口
我们先不关心框架,先定义这个组件应该具备什么功能:
// types.ts
export interface DataTableProps<T = any> {
// 数据
data: T[];
// 列配置
columns: Column<T>[];
// 分页
pagination?: {
current: number;
pageSize: number;
total: number;
};
// 加载状态
loading?: boolean;
// 事件
onSort?: (field: string, order: 'asc' | 'desc') => void;
onPageChange?: (page: number) => void;
onRowClick?: (row: T) => void;
onFilter?: (filters: Record<string, any>) => void;
// 自定义渲染
renderCell?: (column: Column<T>, row: T) => any;
}
export interface Column<T = any> {
title: string;
dataIndex: keyof T;
width?: number;
sortable?: boolean;
filterable?: boolean;
render?: (value: any, row: T) => any;
}
4.2 Vue实现
<!-- DataTable.vue -->
<template>
<div class="data-table">
<!-- 工具栏 -->
<div class="table-toolbar" v-if="$slots.toolbar">
<slot name="toolbar" />
</div>
<!-- 表格 -->
<table class="table">
<thead>
<tr>
<th
v-for="col in columns"
:key="col.dataIndex"
:style="{ width: col.width + 'px' }"
:class="{ sortable: col.sortable }"
@click="handleSort(col)"
>
{{ col.title }}
<span v-if="col.sortable && sortOrder === col.dataIndex">
{{ sortDirection === 'asc' ? '↑' : '↓' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in displayData"
:key="index"
@click="handleRowClick(row)"
>
<td
v-for="col in columns"
:key="col.dataIndex"
>
<!-- 优先使用render函数 -->
<template v-if="col.render">
{{ col.render(row[col.dataIndex], row) }}
</template>
<!-- 其次使用自定义渲染 -->
<template v-else-if="renderCell">
{{ renderCell(col, row) }}
</template>
<!-- 默认显示值 -->
<template v-else>
{{ row[col.dataIndex] }}
</template>
</td>
</tr>
</tbody>
</table>
<!-- 加载状态 -->
<div v-if="loading" class="loading">
加载中...
</div>
<!-- 分页 -->
<div v-if="pagination" class="pagination">
<button @click="handlePageChange(pagination.current - 1)" :disabled="pagination.current === 1">
上一页
</button>
<span>{{ pagination.current }} / {{ totalPages }}</span>
<button @click="handlePageChange(pagination.current + 1)" :disabled="pagination.current === totalPages">
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { DataTableProps, Column } from './types';
const props = withDefaults(defineProps<DataTableProps>(), {
loading: false,
pagination: null,
onSort: undefined,
onPageChange: undefined,
onRowClick: undefined,
onFilter: undefined,
renderCell: undefined,
});
const emit = defineEmits<{
(e: 'sort', field: string, order: 'asc' | 'desc'): void;
(e: 'pageChange', page: number): void;
(e: 'rowClick', row: any): void;
(e: 'filter', filters: Record<string, any>): void;
}>();
// 排序状态
const sortOrder = ref<string | null>(null);
const sortDirection = ref<'asc' | 'desc'>('asc');
// 计算总页数
const totalPages = computed(() => {
if (!props.pagination) return 0;
return Math.ceil(props.pagination.total / props.pagination.pageSize);
});
// 处理排序
function handleSort(col: Column) {
if (!col.sortable) return;
const field = String(col.dataIndex);
if (sortOrder.value === field) {
// 切换排序方向
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
} else {
// 新字段,默认升序
sortOrder.value = field;
sortDirection.value = 'asc';
}
emit('sort', field, sortDirection.value);
}
// 处理分页
function handlePageChange(page: number) {
emit('pageChange', page);
}
// 处理行点击
function handleRowClick(row: any) {
emit('rowClick', row);
}
</script>
<style scoped>
.data-table {
border: 1px solid #e4e7ed;
border-radius: 4px;
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #ebeef5;
}
.table th {
background-color: #f5f7fa;
font-weight: 600;
}
.table th.sortable {
cursor: pointer;
user-select: none;
}
.table th.sortable:hover {
background-color: #e8eaf6;
}
.table tr:hover {
background-color: #f5f7fa;
}
.table tr.clickable {
cursor: pointer;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
gap: 16px;
}
.pagination button {
padding: 6px 12px;
border: 1px solid #dcdfe6;
background: white;
cursor: pointer;
border-radius: 4px;
}
.pagination button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.loading {
text-align: center;
padding: 40px;
color: #909399;
}
</style>
4.3 React实现
// DataTable.jsx
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import './DataTable.css';
function DataTable({
data,
columns,
pagination,
loading = false,
onSort,
onPageChange,
onRowClick,
onFilter,
renderCell,
toolbar,
}) {
const [sortOrder, setSortOrder] = useState(null);
const [sortDirection, setSortDirection] = useState('asc');
// 计算总页数
const totalPages = pagination
? Math.ceil(pagination.total / pagination.pageSize)
: 0;
// 处理排序
const handleSort = useCallback((col) => {
if (!col.sortable) return;
const field = String(col.dataIndex);
if (sortOrder === field) {
// 切换排序方向
const newDirection = sortDirection === 'asc' ? 'desc' : 'asc';
setSortDirection(newDirection);
onSort?.(field, newDirection);
} else {
// 新字段,默认升序
setSortOrder(field);
setSortDirection('asc');
onSort?.(field, 'asc');
}
}, [sortOrder, sortDirection, onSort]);
// 处理分页
const handlePageChange = useCallback((page) => {
onPageChange?.(page);
}, [onPageChange]);
// 处理行点击
const handleRowClick = useCallback((row) => {
onRowClick?.(row);
}, [onRowClick]);
// 渲染单元格
const renderCellContent = useCallback((col, row) => {
// 优先使用列的render函数
if (col.render) {
return col.render(row[col.dataIndex], row);
}
// 其次使用自定义渲染
if (renderCell) {
return renderCell(col, row);
}
// 默认显示值
return row[col.dataIndex];
}, [renderCell]);
return (
<div className="data-table">
{/* 工具栏 */}
{toolbar && (
<div className="table-toolbar">
{toolbar}
</div>
)}
{/* 表格 */}
<table className="table">
<thead>
<tr>
{columns.map((col) => (
<th
key={String(col.dataIndex)}
style={{ width: col.width }}
className={{ sortable: col.sortable }}
onClick={() => handleSort(col)}
>
{col.title}
{col.sortable && sortOrder === col.dataIndex && (
<span className="sort-icon">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, index) => (
<tr
key={index}
className={onRowClick ? 'clickable' : ''}
onClick={() => handleRowClick(row)}
>
{columns.map((col) => (
<td key={String(col.dataIndex)}>
{renderCellContent(col, row)}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* 加载状态 */}
{loading && (
<div className="loading">
加载中...
</div>
)}
{/* 分页 */}
{pagination && (
<div className="pagination">
<button
onClick={() => handlePageChange(pagination.current - 1)}
disabled={pagination.current === 1}
>
上一页
</button>
<span>
{pagination.current} / {totalPages}
</span>
<button
onClick={() => handlePageChange(pagination.current + 1)}
disabled={pagination.current === totalPages}
>
下一页
</button>
</div>
)}
</div>
);
}
// PropTypes验证
DataTable.propTypes = {
data: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string.isRequired,
dataIndex: PropTypes.string.isRequired,
width: PropTypes.number,
sortable: PropTypes.bool,
filterable: PropTypes.bool,
render: PropTypes.func,
})).isRequired,
pagination: PropTypes.shape({
current: PropTypes.number,
pageSize: PropTypes.number,
total: PropTypes.number,
}),
loading: PropTypes.bool,
onSort: PropTypes.func,
onPageChange: PropTypes.func,
onRowClick: PropTypes.func,
onFilter: PropTypes.func,
renderCell: PropTypes.func,
toolbar: PropTypes.node,
};
export default DataTable;
4.4 使用示例对比
Vue中使用
”`vue <DataTable
:data="tableData"
:columns="columns"
:pagination="pagination"
:loading="loading"
@sort="handleSort"
@page-change="handlePageChange"
@row-click="handleRowClick"
<template #toolbar> <Button @click="refresh">刷新</Button> <Button @click="exportData">导出</Button> </template>
