组件设计模式
组件化是前端工程化的核心。设计良好的组件体系决定了代码的可维护性、可复用性和可扩展性。
1. 组件设计原则
1.1 三大核心原则
| 原则 |
说明 |
违反后果 |
| 单一职责 |
一个组件只做一件事,且做好一件事 |
组件臃肿、难以复用、测试困难 |
| 开闭原则 |
对扩展开放,对修改关闭 |
每次需求变更都要改源码,牵一发而动全身 |
| 可组合性 |
组件之间可以灵活组合,而不是继承 |
组件复用粒度不够,重复造轮子 |
1.2 单一职责
一个组件应该只关注一个功能关注点。
// ❌ 一个组件做太多事:渲染数据、处理筛选、做分页、控制弹窗
const UserDashboard = () => {
const [users, setUsers] = useState<User[]>([]);
const [filters, setFilters] = useState<Filters>({});
const [page, setPage] = useState(1);
const [modalVisible, setModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
// ... 混杂了列表展示、表单编辑、筛选逻辑
};
// ✅ 职责拆分
const UserList = () => {}; // 只负责列表展示
const UserFilter = () => {}; // 只负责筛选
const UserFormModal = () => {}; // 只负责表单编辑
const Pagination = () => {}; // 只负责分页
<p data-version="vue">
<!-- ❌ 组件过大,混入多个职责 -->
<script setup lang="ts">
const props = defineProps<{ users: User[] }>();
const emit = defineEmits<{ delete: [id: number] }>();
// 筛选逻辑直接写在这里
const search = ref('');
const filteredUsers = computed(() =>
props.users.filter(u => u.name.includes(search.value))
);
</script>
<template>
<div>
<input v-model="search" placeholder="搜索..." />
<div v-for="user in filteredUsers" :key="user.id">
{{ user.name }}
<button @click="emit('delete', user.id)">删除</button>
</div>
</div>
</template>
<!-- ✅ 职责分离 -->
<!-- 父组件组合使用 -->
<script setup lang="ts">
import UserSearch from './UserSearch.vue';
import UserTable from './UserTable.vue';
import UserDeleteBtn from './UserDeleteBtn.vue';
</script>
</p>
1.3 开闭原则
通过 Props / Slots / Composables / Hooks 等机制提供扩展点,避免直接修改组件内部实现。
| 扩展方式 |
Vue 3 实现 |
React 实现 |
适用场景 |
| 属性配置 |
Props + 默认值 |
Props + defaultProps |
样式、文案等静态配置 |
| 插槽/children |
Slots |
children / renderProps |
内容自定义插入 |
| 渲染函数 |
作用域插槽 |
renderProps |
自定义渲染逻辑 |
| 逻辑注入 |
Composables |
Custom Hooks |
业务逻辑复用 |
// ✅ 通过 Props + Slots 提供扩展点,不修改组件本身
// Vue: 使用 slots 让调用方自定义表头、空状态、加载状态
// React: 使用 renderProps / children 实现相同的灵活性
// ❌ 坏实践:在通用组件内部硬编码业务逻辑
// <Table :columns="columns" :showDelete="user.hasPermission('delete')" />
// 删除权限的判断属于业务层,不应该侵入通用 Table 组件
1.4 可组合性
组合优于继承——组件通过组合而非继承来实现功能复用。
| 维度 |
组合(推荐) |
继承(不推荐) |
| 灵活性 |
高,可自由组合 |
低,受制于继承链 |
| 耦合度 |
低,通过 Props/Events 通信 |
高,子类耦合父类实现 |
| 测试难度 |
易,组件可独立测试 |
难,需要搭建完整继承链 |
| 复用粒度 |
细粒度,按需组合 |
粗粒度,复用整个层级 |
2. 组件分类模式对比
2.1 容器组件 vs 展示组件
| 维度 |
容器组件 (Container) |
展示组件 (Presentational) |
| 职责 |
数据获取、状态管理、业务逻辑 |
UI 渲染、交互反馈 |
| 数据来源 |
Store / API 请求 |
Props |
| 与 Store 耦合 |
强 |
无 |
| 可复用性 |
低(耦合业务) |
高(纯 UI) |
| 测试 |
需要 Mock Store/API |
只需传 Props |
| 修改频率 |
随业务变化 |
相对稳定 |
// ❌ 混在一起:组件既取数据又渲染 UI
const UserProfile: React.FC = () => {
const { data, loading } = useQuery(GET_USER); // 数据层
if (loading) return <Spin />;
return <div>{data.name} - {data.email}</div>; // 渲染层
};
// ✅ 分离后
// 容器组件:只负责数据
const UserProfileContainer: React.FC<{ userId: string }> = ({ userId }) => {
const { data, loading, error } = useGetUser(userId);
if (loading) return <UserProfileSkeleton />;
if (error) return <UserProfileError onRetry={() => refetch()} />;
return <UserProfile user={data!} />;
};
// 展示组件:只负责渲染
interface UserProfileProps {
user: User;
}
const UserProfile: React.FC<UserProfileProps> = ({ user }) => (
<Card>
<Avatar src={user.avatar} />
<Typography.Text>{user.name}</Typography.Text>
<Typography.Text type="secondary">{user.email}</Typography.Text>
</Card>
);
2.2 无状态组件 vs 有状态组件
| 维度 |
无状态组件 |
有状态组件 |
| 内部状态 |
无 |
有 (useState / ref / store) |
| 副作用 |
无 |
有 (useEffect / watch) |
| 复杂度 |
低 |
中高 |
| 测试难度 |
低,纯函数式 |
高,需要模拟状态 |
| 推荐比例 |
尽量多用 (~70%) |
尽量集中在容器层 (~30%) |
// 无状态(纯展示)
const StatusBadge: React.FC<{ status: 'active' | 'inactive' }> = ({ status }) => (
<Tag color={status === 'active' ? 'green' : 'red'}>
{status === 'active' ? '启用' : '禁用'}
</Tag>
);
<p data-version="vue">
<!-- 有状态(容器组件) -->
<script setup lang="ts">
const users = ref<User[]>([]);
const loading = ref(false);
onMounted(async () => {
loading.value = true;
try {
users.value = await userApi.list();
} finally {
loading.value = false;
}
});
</script>
</p>
2.3 通用组件 vs 业务组件
| 维度 |
通用组件 |
业务组件 |
| 复用范围 |
全项目甚至跨项目 |
特定业务域 |
| 关注点 |
UI 样式、交互体验 |
业务逻辑、数据流 |
| Props 复杂度 |
少而精,可配置性强 |
多,与业务实体绑定 |
| 状态来源 |
Props / v-model |
Store / API |
| 代表 |
Button, Table, Modal, Form |
UserSelector, OrderForm, ReportChart |
项目组件分层:
src/components/ # 通用组件(可跨项目复用)
Button/
Table/
Modal/
Form/
src/business/ # 业务组件(绑定特定业务)
user/
UserSelector.vue # 用户选择器(含部门筛选、角色过滤)
UserAvatar.vue # 用户头像(含在线状态、点击查看详情)
order/
OrderTable.vue # 订单表格(含状态流转、操作按钮)
OrderStatusTag.vue # 订单状态标签(含颜色映射、权限控制)
3. Vue 3 组件设计
3.1 Props / Emits / Slots 最佳实践
Props 定义规范
<script setup lang="ts">
// ✅ 好的实践:用 interface 定义,提供默认值,类型精确
interface TableProps {
/** 表格数据源 */
data: Record<string, unknown>[];
/** 列配置 */
columns: ColumnConfig[];
/** 加载状态 */
loading?: boolean;
/** 空状态文案 */
emptyText?: string;
/** 是否显示边框 */
bordered?: boolean;
/** 行点击事件 */
onRowClick?: (row: Record<string, unknown>, index: number) => void;
}
const props = withDefaults(defineProps<TableProps>(), {
loading: false,
emptyText: '暂无数据',
bordered: true,
onRowClick: undefined,
});
</script>
// ❌ 坏实践:所有 Props 都是 any,没有默认值,没有注释
const props = defineProps<{
data: any;
columns: any;
loading: any;
}>();
类型安全的三原则
1. 永远不要用 `any` 定义 Props 类型 —— 丢失所有类型提示
2. 必须提供默认值 —— withDefaults 或 ?: 可选标记
3. 复杂类型用 interface 抽取,不要在 defineProps 内联
Emits 类型定义
<script setup lang="ts">
// ✅ 好的实践:明确事件签名
interface EmitEvents {
(e: 'update:modelValue', value: string): void;
(e: 'change', value: string, oldValue: string): void;
(e: 'delete', id: number): void;
(e: 'submit', payload: FormData): Promise<void>;
}
const emit = defineEmits<EmitEvents>();
// ✅ 使用辅助函数验证参数
const handleDelete = (id: number) => {
if (!id || id <= 0) {
console.warn('[Table] 无效的删除ID:', id);
return;
}
emit('delete', id);
};
</script>
<script setup lang="ts">
// ❌ 坏实践:无类型声明
const emit = defineEmits(['update:modelValue', 'change', 'delete']);
</script>
3.2 插槽模式
| 插槽类型 |
语法 |
适用场景 |
| 默认插槽 |
<slot /> |
组件主体内容 |
| 具名插槽 |
<slot name="header" /> |
多区域自定义(表头、表尾、操作栏) |
| 作用域插槽 |
<slot name="item" :data="item" /> |
自定义列表项渲染 |
<!-- BaseTable.vue - 提供丰富的插槽扩展点 -->
<script setup lang="ts">
interface TableSlots {
data: Record<string, unknown>[];
loading: boolean;
}
const slots = defineSlots<{
default(props: { data: Record<string, unknown>[] }): any;
header: () => any;
empty: () => any;
}>();
// 防御式:slots 可能为 undefined
const hasHeaderSlot = computed(() => !!slots.header);
</script>
<template>
<div class="base-table">
<!-- 具名插槽:表头 -->
<div v-if="hasHeaderSlot" class="table-header">
<slot name="header" />
</div>
<!-- 默认插槽:表格主体 -->
<slot :data="data">
<!-- 默认内容:当插槽无内容时显示 -->
<div class="table-empty">
<slot name="empty">
<Empty description="暂无数据" />
</slot>
</div>
</slot>
</div>
</template>
<!-- 使用方:灵活定制 -->
<template>
<BaseTable :data="users">
<!-- 自定义表头 -->
<template #header>
<div class="flex justify-between">
<h3>用户列表</h3>
<Button type="primary" @click="showAdd">新增用户</Button>
</div>
</template>
<!-- 自定义行渲染 -->
<template #default="{ data }">
<div v-for="user in data" :key="user.id" class="user-row">
<Avatar :src="user.avatar" />
<span>{{ user.name }}</span>
</div>
</template>
<!-- 自定义空状态 -->
<template #empty>
<Result status="404" title="暂无匹配用户" />
</template>
</BaseTable>
</template>
3.3 Provide / Inject 跨层级通信
适用于深层嵌套的组件通信(深度 > 3 层时优于 Props 透传)。
| 方式 |
Props 逐层传递 |
Provide / Inject |
| 层数 |
任意层(但每层都要写) |
任意层(直达) |
| 可追踪性 |
强,显式传递 |
弱,隐式注入 |
| 类型安全 |
天然类型安全 |
需额外维护类型 |
| 性能 |
正常 |
依赖注入有微小开销 |
| 推荐场景 |
2-3 层 |
3 层以上 / 主题 / 国际化 |
// types/injection-keys.ts - 统一管理注入 key
import type { InjectionKey, Ref } from 'vue';
export interface AppContext {
theme: Ref<'light' | 'dark'>;
locale: Ref<'zh-CN' | 'en-US'>;
user: Ref<CurrentUser | null>;
isMobile: Ref<boolean>;
}
export const APP_CONTEXT_KEY: InjectionKey<AppContext> = Symbol('app-context');
export const TABLE_CONTEXT_KEY: InjectionKey<TableContext> = Symbol('table-context');
<!-- 顶层 Provider -->
<script setup lang="ts">
import { APP_CONTEXT_KEY } from '@/types/injection-keys';
const theme = ref<'light' | 'dark'>('light');
const locale = ref<'zh-CN' | 'en-US'>('zh-CN');
const user = ref<CurrentUser | null>(null);
const isMobile = ref(false);
provide(APP_CONTEXT_KEY, { theme, locale, user, isMobile });
</script>
<template>
<ConfigProvider :locale="locale">
<router-view />
</ConfigProvider>
</template>
<!-- 深层消费组件 -->
<script setup lang="ts">
import { inject } from 'vue';
import { APP_CONTEXT_KEY } from '@/types/injection-keys';
// 防御式:inject 可能为 undefined
const appCtx = inject(APP_CONTEXT_KEY, null);
if (!appCtx) {
throw new Error('AppContext 未提供,请确保在 App.vue 中注入');
}
const { theme, locale } = appCtx;
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light';
};
</script>
3.4 defineExpose 暴露方法
| 方式 |
使用场景 |
注意事项 |
| Props |
父传子数据 |
单向数据流 |
| Emits |
子传父事件 |
事件驱动 |
| defineExpose |
父调子方法(命令式) |
破环数据流,谨慎使用 |
<!-- SearchInput.vue -->
<script setup lang="ts">
const inputRef = ref<HTMLInputElement>();
const searchText = ref('');
const focus = () => {
nextTick(() => inputRef.value?.focus());
};
const clear = () => {
searchText.value = '';
};
const validate = (): boolean => {
if (!searchText.value.trim()) {
ElMessage.warning('请输入搜索内容');
return false;
}
return true;
};
// 只暴露必要的方法,不要暴露整个组件实例
defineExpose({ focus, clear, validate });
</script>
<!-- 父组件调用 -->
<script setup lang="ts">
const searchRef = ref<InstanceType<typeof SearchInput>>();
onMounted(() => {
// 页面加载后自动聚焦搜索框
searchRef.value?.focus();
});
const handleSearch = () => {
if (searchRef.value?.validate()) {
// 执行搜索...
}
};
</script>
<template>
<SearchInput ref="searchRef" />
<Button @click="handleSearch">搜索</Button>
</template>
3.5 Composables 抽离组件逻辑
| 场景 |
Composables 名称 |
说明 |
| 表单双向绑定 |
useVModel |
简化 v-model 实现 |
| 弹窗控制 |
useDialog |
统一弹窗开启/关闭/加载状态 |
| 分页 |
usePagination |
分页参数、切换、请求 |
| 表格选择 |
useSelection |
单选/多选/全选/跨页选择 |
| 请求封装 |
useRequest |
加载/错误/重试/防抖 |
| 表单校验 |
useFormValidation |
统一校验规则和错误处理 |
// composables/useDialog.ts
interface UseDialogOptions {
/** 弹窗标题 */
title?: string;
/** 确认回调 */
onConfirm?: () => Promise<void>;
/** 取消回调 */
onCancel?: () => void;
}
export function useDialog(options: UseDialogOptions = {}) {
const visible = ref(false);
const loading = ref(false);
const error = ref<string | null>(null);
const open = () => {
visible.value = true;
error.value = null;
};
const close = () => {
visible.value = false;
loading.value = false;
error.value = null;
};
const confirm = async () => {
if (!options.onConfirm) {
close();
return;
}
loading.value = true;
error.value = null;
try {
await options.onConfirm();
close();
} catch (e) {
error.value = e instanceof Error ? e.message : '操作失败';
// 不关闭弹窗,让用户看到错误
} finally {
loading.value = false;
}
};
// 组件卸载时自动清理
onUnmounted(() => {
visible.value = false;
loading.value = false;
});
return {
visible: readonly(visible),
loading: readonly(loading),
error: readonly(error),
open,
close,
confirm,
};
}
// composables/usePagination.ts
interface PaginationState {
page: number;
pageSize: number;
total: number;
}
export function usePagination(defaults?: Partial<PaginationState>) {
const pagination = reactive<PaginationState>({
page: defaults?.page ?? 1,
pageSize: defaults?.pageSize ?? 20,
total: defaults?.total ?? 0,
});
const offset = computed(() => (pagination.page - 1) * pagination.pageSize);
const onPageChange = (page: number) => {
pagination.page = page;
};
const onPageSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1; // 切换每页条数时重置到第一页
};
const reset = () => {
pagination.page = defaults?.page ?? 1;
pagination.pageSize = defaults?.pageSize ?? 20;
pagination.total = 0;
};
return {
pagination: readonly(pagination),
offset,
onPageChange,
onPageSizeChange,
reset,
};
}
3.6 表单组件设计:受控 vs 非受控
| 维度 |
受控组件 |
非受控组件 |
| 数据源 |
父组件 Props 驱动 |
组件内部维护 |
| 更新方式 |
emit update:modelValue |
内部自行管理 |
| 灵活性 |
高,父组件可干预 |
低 |
| 适用场景 |
需要外部控制值 |
简单表单、不需要外界干预 |
| 推荐 |
通用表单组件 |
简单展示类组件 |
<!-- ✅ 受控输入组件:双向绑定 -->
<script setup lang="ts">
interface Props {
modelValue: string;
placeholder?: string;
maxLength?: number;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请输入',
maxLength: 100,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
// 防御式:对输入值做清理
const onInput = (e: Event) => {
const target = e.target as HTMLInputElement;
let value = target.value;
// 1. 去首尾空格
value = value.trim();
// 2. 截断超长输入
if (value.length > props.maxLength) {
value = value.slice(0, props.maxLength);
}
// 3. 防 XSS:移除危险标签(仅展示安全过滤,服务端必须再做一次)
value = value.replace(/<[^>]*>/g, '');
emit('update:modelValue', value);
};
</script>
<template>
<el-input
:model-value="props.modelValue"
:placeholder="placeholder"
:maxlength="maxLength"
@input="onInput"
show-word-limit
/>
</template>
<!-- ✅ 非受控组件:内部自行管理状态 -->
<script setup lang="ts">
interface Props {
defaultOpen?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
defaultOpen: false,
});
const isOpen = ref(props.defaultOpen);
const toggle = () => {
isOpen.value = !isOpen.value;
};
defineExpose({ toggle });
</script>
<template>
<div>
<slot :is-open="isOpen" :toggle="toggle" />
</div>
</template>
4. React 组件设计
4.1 Props 类型定义与 children
// ✅ 好的实践:精确类型定义
// 基础 Props
interface ButtonProps {
/** 按钮类型 */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
/** 按钮大小 */
size?: 'small' | 'medium' | 'large';
/** 加载状态 */
loading?: boolean;
/** 是否禁用 */
disabled?: boolean;
/** 图标 */
icon?: React.ReactNode;
/** 点击事件 */
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
/** 子元素 */
children?: React.ReactNode;
}
// ✅ 使用 React.FC 明确标注 children
const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'medium',
loading = false,
disabled = false,
icon,
onClick,
children,
}) => {
// 防御式:清理 onClick
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled || loading) return;
onClick?.(e);
};
return (
<AntButton
type={variant === 'primary' ? 'primary' : 'default'}
size={size}
loading={loading}
disabled={disabled}
onClick={handleClick}
>
{icon && <span className="mr-1">{icon}</span>}
{children}
</AntButton>
);
};
// ❌ 坏实践
interface BadProps {
data: any; // 失去类型保护
onChange: Function; // 不明确事件签名
style: string; // 应该用 React.CSSProperties
[key: string]: any; // 索引签名滥用
}
4.2 组合模式 (Compound Components)
Compound Components 是 React 中最实用的组合模式,通过隐式共享状态实现"父组件控制、子组件渲染"。
// ✅ 经典实现:Tabs 组合
import React, { createContext, useContext, useState, useCallback } from 'react';
// 1. 定义 Context
interface TabsContextType {
activeKey: string;
onTabChange: (key: string) => void;
}
const TabsContext = createContext<TabsContextType | null>(null);
// 2. 父组件:管理状态
interface TabsProps {
defaultActiveKey?: string;
activeKey?: string; // 受控模式
onChange?: (key: string) => void;
children: React.ReactNode;
}
const Tabs: React.FC<TabsProps> & {
Tab: typeof Tab;
Panel: typeof Panel;
} = ({ defaultActiveKey, activeKey: controlledKey, onChange, children }) => {
const [internalKey, setInternalKey] = useState(defaultActiveKey ?? '');
const isControlled = controlledKey !== undefined;
const activeKey = isControlled ? controlledKey : internalKey;
const onTabChange = useCallback((key: string) => {
if (!isControlled) setInternalKey(key);
onChange?.(key);
}, [isControlled, onChange]);
const context: TabsContextType = { activeKey, onTabChange };
return (
<TabsContext.Provider value={context}>
{children}
</TabsContext.Provider>
);
};
// 3. 子组件 Tab
interface TabProps {
key: string;
children: React.ReactNode;
disabled?: boolean;
}
const Tab: React.FC<TabProps> = ({ key, children, disabled }) => {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tab must be used within Tabs');
const isActive = ctx.activeKey === key;
return (
<button
className={`tab ${isActive ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
onClick={() => !disabled && ctx.onTabChange(key)}
disabled={disabled}
>
{children}
</button>
);
};
// 4. 子组件 Panel
interface PanelProps {
key: string;
children: React.ReactNode;
}
const Panel: React.FC<PanelProps> = ({ key, children }) => {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Panel must be used within Tabs');
return ctx.activeKey === key ? <div>{children}</div> : null;
};
// 5. 挂载为组件的静态属性
Tabs.Tab = Tab;
Tabs.Panel = Panel;
export default Tabs;
// 使用方
const App = () => (
<Tabs defaultActiveKey="info" onChange={(key) => console.log(key)}>
<Tabs.Tab key="info">基本信息</Tabs.Tab>
<Tabs.Panel key="info">...</Tabs.Panel>
<Tabs.Tab key="settings">设置</Tabs.Tab>
<Tabs.Panel key="settings">...</Tabs.Panel>
</Tabs>
);
4.3 Render Props vs HOC vs Hooks
| 模式 |
复杂度 |
灵活性 |
TypeScript 友好 |
可组合性 |
推荐度 |
| Render Props |
低 |
高 |
一般 |
一般 |
老旧,不推荐 |
| HOC |
中 |
低 |
差(类型推导困难) |
差(嵌套地狱) |
过时,不推荐 |
| Hooks |
低 |
高 |
好 |
好 |
现代首选 |
// ❌ 不推荐:Render Props 模式
interface MouseTrackerProps {
render: (state: { x: number; y: number }) => React.ReactNode;
}
const MouseTracker: React.FC<MouseTrackerProps> = ({ render }) => {
const [position, setPosition] = useState({ x: 0, y: 0 });
return <div onMouseMove={e => setPosition({ x: e.clientX, y: e.clientY })}>
{render(position)}
</div>;
};
// ❌ 不推荐:HOC 模式
const withLoading = <P extends object>(
Component: React.ComponentType<P & { loading: boolean }>
) => {
return (props: P & { loading?: boolean }) => (
props.loading ? <Spin /> : <Component {...props as P} loading={false} />
);
};
// 类型推导困难,props 污染,嵌套地狱
// withAuth(withLoading(withErrorBoundary(MyComponent)))
// ✅ 推荐:Hooks 模式
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return position;
}
function useLoading(initial = false) {
const [loading, setLoading] = useState(initial);
const withLoading = useCallback(async <T>(fn: () => Promise<T>): Promise<T | undefined> => {
setLoading(true);
try {
return await fn();
} finally {
setLoading(false);
}
}, []);
return { loading, withLoading };
}
// 使用方:自由组合
const MyComponent: React.FC = () => {
const { x, y } = useMousePosition();
const { loading, withLoading } = useLoading();
const user = useUser(); // 另一个 Hook
const handleSave = () => withLoading(() => api.save({ x, y }));
};
4.4 forwardRef + useImperativeHandle
// ✅ 需要暴露方法给父组件时使用
interface FormHandle {
validate: () => Promise<boolean>;
reset: () => void;
getValues: () => Record<string, unknown>;
setValues: (values: Record<string, unknown>) => void;
}
interface FormProps {
initialValues?: Record<string, unknown>;
onSubmit?: (values: Record<string, unknown>) => Promise<void>;
children: React.ReactNode;
}
const Form = forwardRef<FormHandle, FormProps>(
({ initialValues, onSubmit, children }, ref) => {
const [values, setValues] = useState(initialValues ?? {});
const [errors, setErrors] = useState<Record<string, string>>({});
useImperativeHandle(ref, () => ({
validate: async () => {
// 校验逻辑...
return Object.keys(errors).length === 0;
},
reset: () => {
setValues(initialValues ?? {});
setErrors({});
},
getValues: () => values,
setValues: (newValues) => setValues(newValues),
}), [values, errors, initialValues]);
return <form>{children}</form>;
}
);
// 使用方
const App: React.FC = () => {
const formRef = useRef<FormHandle>(null);
const handleSubmit = async () => {
// 防御式:ref 可能为 null(未挂载时)
if (!formRef.current) return;
const isValid = await formRef.current.validate();
if (!isValid) return;
const values = formRef.current.getValues();
await api.submit(values);
};
return (
<Form ref={formRef} onSubmit={handleSubmit}>
<Button onClick={() => formRef.current?.reset()}>重置</Button>
</Form>
);
};
4.5 受控组件 vs 非受控组件
// ✅ 受控输入组件
interface ControlledInputProps {
value: string;
onChange?: (value: string) => void;
maxLength?: number;
}
const ControlledInput: React.FC<ControlledInputProps> = ({
value,
onChange,
maxLength = 100,
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value;
// 防御式:截断
if (newValue.length > maxLength) {
newValue = newValue.slice(0, maxLength);
}
onChange?.(newValue);
};
return <Input value={value} onChange={handleChange} showCount maxLength={maxLength} />;
};
// ✅ 非受控组件:初始值驱动,内部管理
interface UncontrolledInputProps {
defaultValue?: string;
maxLength?: number;
onChange?: (value: string) => void;
}
const UncontrolledInput: React.FC<UncontrolledInputProps> = ({
defaultValue = '',
maxLength = 100,
onChange,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
return (
<Input
ref={inputRef}
defaultValue={defaultValue}
maxLength={maxLength}
onChange={(e) => onChange?.(e.target.value)}
/>
);
};
4.6 Context 跨层级通信
// stores/ThemeContext.tsx
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>(() => {
// 防御式:读取 localStorage,出错时降级到默认值
try {
const stored = localStorage.getItem('app-theme') as Theme | null;
return stored ?? 'light';
} catch {
return 'light';
}
});
const toggleTheme = useCallback(() => {
setThemeState(prev => {
const next = prev === 'light' ? 'dark' : 'light';
try { localStorage.setItem('app-theme', next); } catch { /* 静默失败 */ }
return next;
});
}, []);
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
try { localStorage.setItem('app-theme', newTheme); } catch { /* 静默失败 */ }
}, []);
const context = useMemo(() => ({ theme, toggleTheme, setTheme }), [theme, toggleTheme, setTheme]);
return (
<ThemeContext.Provider value={context}>
{children}
</ThemeContext.Provider>
);
};
// 自定义 Hook:消费 Context,带防御式检查
export const useTheme = (): ThemeContextType => {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return ctx;
};
// 使用方
const ThemeToggle: React.FC = () => {
const { theme, toggleTheme } = useTheme();
return <Button onClick={toggleTheme}>{theme === 'light' ? '🌙' : '☀️'}</Button>;
};
Context 的最佳实践:
1. 每个 Context 只关注一个领域 —— 不要创建巨大的 "AppContext"
2. 使用 useMemo 缓存 context value —— 避免不必要的重渲染
3. 提供自定义 Hook 消费 Context —— 简化使用方的代码
4. 消费方做非空判断 —— 防御式编程
5. 避免频繁更新 Context —— 大量更新会导致所有消费方重渲染
5. 常见组件设计案例
5.1 表格组件(排序、筛选、分页、选择)
Vue 3 实现
<!-- SmartTable.vue -->
<script setup lang="ts">
import { ref, computed, watch, type Ref } from 'vue';
import type { ColumnConfig, SortConfig, FilterConfig, PaginationConfig } from './types';
interface SmartTableProps<T extends Record<string, unknown>> {
columns: ColumnConfig<T>[];
data: T[];
rowKey?: keyof T;
pagination?: boolean | PaginationConfig;
selectable?: boolean;
loading?: boolean;
defaultSort?: SortConfig;
}
const props = withDefaults(defineProps<SmartTableProps<any>>(), {
rowKey: 'id' as any,
pagination: true,
selectable: false,
loading: false,
});
const emit = defineEmits<{
(e: 'sort-change', sort: SortConfig | null): void;
(e: 'selection-change', selected: Record<string, unknown>[]): void;
(e: 'page-change', page: number): void;
(e: 'page-size-change', pageSize: number): void;
}>();
// 排序逻辑
const currentSort = ref<SortConfig | null>(props.defaultSort ?? null);
const handleSortChange = (sort: SortConfig | null) => {
currentSort.value = sort;
emit('sort-change', sort);
};
// 选择逻辑
const selectedRowKeys = ref<Set<string | number>>(new Set());
const isAllSelected = computed(() => {
if (props.data.length === 0) return false;
return selectedRowKeys.value.size === props.data.length;
});
const handleSelectAll = (checked: boolean) => {
if (checked) {
selectedRowKeys.value = new Set(props.data.map(d => d[props.rowKey]));
} else {
selectedRowKeys.value.clear();
}
emit('selection-change', props.data.filter(d =>
selectedRowKeys.value.has(d[props.rowKey])
));
};
const handleSelectRow = (row: Record<string, unknown>, checked: boolean) => {
const key = row[props.rowKey];
if (checked) {
selectedRowKeys.value.add(key);
} else {
selectedRowKeys.value.delete(key);
}
// 防御式:ref 修改后重新赋值触发响应式
selectedRowKeys.value = new Set(selectedRowKeys.value);
emit('selection-change', props.data.filter(d =>
selectedRowKeys.value.has(d[props.rowKey])
));
};
// 分页逻辑
const currentPage = ref(1);
const pageSize = ref(
typeof props.pagination === 'object' ? props.pagination.pageSize ?? 20 : 20
);
const handlePageChange = (page: number) => {
currentPage.value = page;
emit('page-change', page);
};
// 暴露方法给父组件
defineExpose({
clearSelection: () => { selectedRowKeys.value = new Set(); },
getSelectedRows: () => props.data.filter(d =>
selectedRowKeys.value.has(d[props.rowKey])
),
});
</script>
<template>
<div class="smart-table">
<el-table
:data="data"
:loading="loading"
@sort-change="handleSortChange"
>
<el-table-column
v-if="selectable"
type="selection"
:selectable="(row: any) => !row.disabled"
@select="handleSelectRow"
@select-all="handleSelectAll"
/>
<el-table-column
v-for="col in columns"
:key="col.key"
:prop="col.key"
:label="col.title"
:sortable="col.sortable"
:width="col.width"
:min-width="col.minWidth"
:fixed="col.fixed"
/>
<slot />
</el-table>
<el-pagination
v-if="pagination"
v-model:page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="(s: number) => emit('page-size-change', s)"
@current-change="handlePageChange"
/>
</div>
</template>
React 实现
// SmartTable.tsx
import React, { useState, useCallback, useImperativeHandle, forwardRef, useMemo } from 'react';
import { Table, Pagination } from 'antd';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
export interface ColumnConfig<T> {
key: string;
title: string;
dataIndex?: string;
width?: number;
sortable?: boolean;
fixed?: 'left' | 'right';
render?: (value: unknown, record: T, index: number) => React.ReactNode;
}
export interface SmartTableHandle {
clearSelection: () => void;
getSelectedRows: () => Record<string, unknown>[];
}
interface SmartTableProps<T extends Record<string, unknown>> {
columns: ColumnConfig<T>[];
data: T[];
rowKey?: string;
loading?: boolean;
selectable?: boolean;
pagination?: boolean;
pageSize?: number;
onSortChange?: (sort: Record<string, 'ascend' | 'descend' | null>) => void;
onPageChange?: (page: number, pageSize: number) => void;
onSelectionChange?: (selected: T[]) => void;
}
function SmartTableInner<T extends Record<string, unknown>>(
props: SmartTableProps<T>,
ref: React.Ref<SmartTableHandle>
) {
const {
columns,
data,
rowKey = 'id',
loading = false,
selectable = false,
pagination = true,
pageSize = 20,
onSortChange,
onPageChange,
onSelectionChange,
} = props;
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
useImperativeHandle(ref, () => ({
clearSelection: () => setSelectedRowKeys([]),
getSelectedRows: () => data.filter(d => selectedRowKeys.includes(d[rowKey])),
}), [data, selectedRowKeys, rowKey]);
const handleTableChange = useCallback(
(_: TablePaginationConfig, __: any, sorter: SorterResult<T> | SorterResult<T>[]) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
onSortChange?.({ [s.field as string]: s.order });
},
[onSortChange]
);
const handleSelectionChange = useCallback(
(keys: React.Key[], rows: T[]) => {
setSelectedRowKeys(keys);
// 防御式:避免触发过多重渲染
onSelectionChange?.(rows);
},
[onSelectionChange]
);
// 防御式:columns 可能为 undefined
const antdColumns: ColumnsType<T> = useMemo(() =>
columns?.map(col => ({
key: col.key,
title: col.title,
dataIndex: col.dataIndex,
width: col.width,
sorter: col.sortable,
fixed: col.fixed,
render: col.render,
})) ?? [], [columns]
);
return (
<div className="smart-table">
<Table<T>
rowKey={rowKey}
columns={antdColumns}
dataSource={data}
loading={loading}
rowSelection={selectable ? {
selectedRowKeys,
onChange: handleSelectionChange as any,
} : undefined}
onChange={handleTableChange as any}
pagination={false}
/>
{pagination && (
<Pagination
defaultPageSize={pageSize}
showSizeChanger
showQuickJumper
showTotal={(total) => `共 ${total} 条`}
onChange={onPageChange}
style={{ marginTop: 16, textAlign: 'right' }}
/>
)}
</div>
);
}
export const SmartTable = forwardRef(SmartTableInner) as <T extends Record<string, unknown>>(
props: SmartTableProps<T> & { ref?: React.Ref<SmartTableHandle> }
) => React.ReactElement;
5.2 表单组件(动态表单、校验、联动)
Vue 3 实现
<!-- DynamicForm.vue -->
<script setup lang="ts">
import { ref, computed, watch, type Ref } from 'vue';
import type { FormField, FormRules } from './types';
interface DynamicFormProps {
fields: FormField[];
initialValues?: Record<string, unknown>;
layout?: 'horizontal' | 'vertical';
labelWidth?: string;
}
const props = withDefaults(defineProps<DynamicFormProps>(), {
layout: 'horizontal',
labelWidth: '120px',
});
const emit = defineEmits<{
(e: 'values-change', values: Record<string, unknown>): void;
(e: 'submit', values: Record<string, unknown>): void;
}>();
const formRef = ref();
const formValues = ref<Record<string, unknown>>(props.initialValues ?? {});
// 动态生成校验规则
const rules = computed<FormRules>(() => {
const result: FormRules = {};
for (const field of props.fields) {
const fieldRules: any[] = [];
if (field.required) {
fieldRules.push({ required: true, message: `${field.label}不能为空`, trigger: 'blur' });
}
if (field.pattern) {
fieldRules.push({ pattern: field.pattern, message: field.message ?? '格式错误', trigger: 'blur' });
}
if (field.validator) {
fieldRules.push({ validator: field.validator, trigger: 'change' });
}
if (fieldRules.length > 0) {
result[field.key] = fieldRules;
}
}
return result;
});
// 字段联动:监听某个字段变化后更新其他字段
const fieldWatchers = new Map<string, (value: unknown, formValues: Record<string, unknown>) => void>();
const setupWatchers = () => {
for (const field of props.fields) {
if (field.watch) {
watch(() => formValues.value[field.key], (newVal) => {
field.watch!(newVal, formValues.value);
});
}
}
};
onMounted(() => setupWatchers());
const validate = async (): Promise<boolean> => {
if (!formRef.value) return false;
try {
await formRef.value.validate();
return true;
} catch {
ElMessage.warning('请检查表单填写');
return false;
}
};
const submit = async () => {
const valid = await validate();
if (valid) {
emit('submit', { ...formValues.value });
}
};
const reset = () => {
formValues.value = props.initialValues ?? {};
formRef.value?.resetFields();
};
defineExpose({ validate, submit, reset, formValues });
</script>
<template>
<el-form
ref="formRef"
:model="formValues"
:rules="rules"
:label-width="labelWidth"
:layout="layout"
>
<el-form-item
v-for="field in fields"
:key="field.key"
:prop="field.key"
:label="field.label"
:required="field.required"
>
<!-- 根据 field.type 动态渲染不同组件 -->
<el-input
v-if="field.type === 'input'"
v-model="formValues[field.key]"
:placeholder="field.placeholder"
/>
<el-select
v-else-if="field.type === 'select'"
v-model="formValues[field.key]"
:placeholder="field.placeholder"
:options="field.options"
:multiple="field.multiple"
/>
<el-date-picker
v-else-if="field.type === 'date'"
v-model="formValues[field.key]"
:type="field.dateType ?? 'date'"
:placeholder="field.placeholder"
/>
<el-switch
v-else-if="field.type === 'switch'"
v-model="formValues[field.key]"
/>
<!-- 自定义渲染插槽 -->
<slot v-else-if="field.type === 'custom'" :name="`field-${field.key}`" :value="formValues[field.key]" />
</el-form-item>
<el-form-item>
<slot name="actions" :submit="submit" :reset="reset" />
</el-form-item>
</el-form>
</template>
React 实现
// DynamicForm.tsx
import React, { useCallback, useImperativeHandle, forwardRef, useRef } from 'react';
import { Form, Input, Select, DatePicker, Switch, Button } from 'antd';
import type { FormInstance, Rule } from 'antd/es/form';
export interface FormField {
key: string;
label: string;
type: 'input' | 'select' | 'date' | 'switch' | 'custom';
required?: boolean;
placeholder?: string;
pattern?: RegExp;
message?: string;
options?: { label: string; value: unknown }[];
multiple?: boolean;
rules?: Rule[];
/** 字段联动:监听某个字段变化后执行回调 */
watch?: (value: unknown, allValues: Record<string, unknown>) => void;
/** 自定义渲染 */
render?: (field: FormField) => React.ReactNode;
}
export interface DynamicFormHandle {
submit: () => Promise<Record<string, unknown> | null>;
validate: () => Promise<boolean>;
reset: () => void;
setFieldValue: (key: string, value: unknown) => void;
}
interface DynamicFormProps {
fields: FormField[];
initialValues?: Record<string, unknown>;
layout?: 'horizontal' | 'vertical';
labelCol?: number;
onSubmit?: (values: Record<string, unknown>) => Promise<void> | void;
}
const DynamicFormInner: React.FC<DynamicFormProps & { ref?: React.Ref<DynamicFormHandle> }> = (
{ fields, initialValues, layout = 'horizontal', labelCol = 6, onSubmit },
ref
) => {
const [form] = Form.useForm();
useImperativeHandle(ref, () => ({
submit: async () => {
try {
const values = await form.validateFields();
await onSubmit?.(values);
return values;
} catch {
return null;
}
},
validate: async () => {
try {
await form.validateFields();
return true;
} catch {
return false;
}
},
reset: () => form.resetFields(),
setFieldValue: (key: string, value: unknown) => form.setFieldValue(key, value),
}), [form, onSubmit]);
const renderField = (field: FormField) => {
const commonProps = {
placeholder: field.placeholder,
};
switch (field.type) {
case 'input':
return <Input {...commonProps} />;
case 'select':
return <Select {...commonProps} options={field.options} mode={field.multiple ? 'multiple' : undefined} />;
case 'date':
return <DatePicker {...commonProps} style={{ width: '100%' }} />;
case 'switch':
return <Switch />;
case 'custom':
return field.render?.(field);
default:
return <Input {...commonProps} />;
}
};
// 防御式:fields 可能为 undefined
const formFields = fields?.map(field => {
const rules: Rule[] = [...(field.rules ?? [])];
if (field.required) {
rules.push({ required: true, message: `${field.label}不能为空` });
}
if (field.pattern) {
rules.push({ pattern: field.pattern, message: field.message ?? '格式错误' });
}
return { field, rules };
}) ?? [];
return (
<Form
form={form}
layout={layout}
labelCol={{ span: labelCol }}
initialValues={initialValues}
onValuesChange={(changedValues, allValues) => {
// 字段联动
for (const field of fields) {
if (field.watch && changedValues[field.key] !== undefined) {
field.watch(changedValues[field.key], allValues);
}
}
}}
>
{formFields.map(({ field, rules }) => (
<Form.Item
key={field.key}
name={field.key}
label={field.label}
rules={rules}
>
{renderField(field)}
</Form.Item>
))}
</Form>
);
};
export const DynamicForm = forwardRef(DynamicFormInner);
5.3 弹窗组件(Confirm / Drawer / Modal 统一管理)
Vue 3 实现
<!-- dialog-manager/index.ts - 全局弹窗管理器 -->
<script setup lang="ts">
import { createApp, defineComponent, h, reactive, type Component } from 'vue';
import { ElDialog, ElDrawer, ElButton, ElMessage } from 'element-plus';
// 弹窗配置类型
interface DialogConfig {
title?: string;
width?: string | number;
component: Component;
props?: Record<string, unknown>;
type?: 'modal' | 'drawer' | 'confirm';
confirmText?: string;
cancelText?: string;
onConfirm?: () => Promise<boolean | void>;
onCancel?: () => void;
showFooter?: boolean;
/** 关闭时销毁组件 */
destroyOnClose?: boolean;
}
// 弹窗管理 Store
const dialogState = reactive<{
stack: (DialogConfig & { id: string; visible: boolean; loading: boolean })[];
}>({
stack: [],
});
let dialogId = 0;
// 全局弹窗控制器
export const DialogManager = {
open(config: DialogConfig) {
const id = `dialog_${++dialogId}`;
const entry = { ...config, id, visible: true, loading: false };
dialogState.stack.push(entry);
return {
close: () => {
entry.visible = false;
// 延迟移除,让关闭动画完成
setTimeout(() => {
const idx = dialogState.stack.indexOf(entry);
if (idx > -1) dialogState.stack.splice(idx, 1);
}, 300);
},
update: (props: Record<string, unknown>) => {
Object.assign(entry, { props: { ...entry.props, ...props } });
},
};
},
confirm(content: string, title = '确认操作') {
return new Promise<boolean>((resolve) => {
this.open({
title,
type: 'confirm',
component: defineComponent({
setup() {
return () => h('p', { style: 'text-align: center; margin: 20px;' }, content);
},
}),
onConfirm: async () => { resolve(true); return true; },
onCancel: () => { resolve(false); },
});
});
},
closeAll() {
dialogState.stack.forEach(d => { d.visible = false; });
dialogState.stack.splice(0);
},
};
// DialogRenderer.vue - 挂载到根组件
<script setup lang="ts">
import { dialogState, DialogManager } from './index';
const handleConfirm = async (dialog: any) => {
dialog.loading = true;
try {
const result = await dialog.onConfirm?.();
if (result !== false) {
dialog.visible = false;
}
} catch (e) {
ElMessage.error('操作失败');
} finally {
dialog.loading = false;
}
};
</script>
<template>
<!-- Modal 渲染 -->
<el-dialog
v-for="d in dialogState.stack.filter(d => d.type !== 'drawer')"
:key="d.id"
v-model="d.visible"
:title="d.title"
:width="d.width ?? '520px'"
:destroy-on-close="d.destroyOnClose !== false"
>
<component :is="d.component" v-bind="d.props" />
<template v-if="d.showFooter !== false" #footer>
<el-button @click="d.onCancel?.(); d.visible = false">
{{ d.cancelText ?? '取消' }}
</el-button>
<el-button type="primary" :loading="d.loading" @click="handleConfirm(d)">
{{ d.confirmText ?? '确认' }}
</el-button>
</template>
</el-dialog>
<!-- Drawer 渲染 -->
<el-drawer
v-for="d in dialogState.stack.filter(d => d.type === 'drawer')"
:key="d.id"
v-model="d.visible"
:title="d.title"
:size="d.width ?? '400px'"
>
<component :is="d.component" v-bind="d.props" />
</el-drawer>
</template>
// 使用示例
DialogManager.confirm('确定要删除这条记录吗?').then(confirmed => {
if (confirmed) {
// 执行删除
}
});
// 打开弹窗
const modal = DialogManager.open({
title: '编辑用户',
type: 'modal',
width: '600px',
component: UserEditForm,
props: { userId: 123 },
onConfirm: async () => {
await userApi.update({ id: 123, name: 'xxx' });
// 不返回 false 则会自动关闭
},
});
React 实现
// hooks/useModalManager.ts
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import { Modal, Drawer, Button, Space, message } from 'antd';
interface ModalConfig {
title?: string;
width?: string | number;
type?: 'modal' | 'drawer' | 'confirm';
component: React.ComponentType<any>;
props?: Record<string, unknown>;
confirmText?: string;
cancelText?: string;
onConfirm?: () => Promise<boolean | void>;
onCancel?: () => void;
footer?: React.ReactNode;
}
interface ModalInstance {
id: string;
config: ModalConfig;
close: () => void;
update: (props: Record<string, unknown>) => void;
}
interface ModalContextType {
open: (config: ModalConfig) => ModalInstance;
confirm: (content: string, title?: string) => Promise<boolean>;
closeAll: () => void;
}
const ModalContext = createContext<ModalContextType | null>(null);
export const useModalManager = (): ModalContextType => {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error('useModalManager must be used within ModalProvider');
return ctx;
};
// 全局 Hook
let modalIdCounter = 0;
const modalListeners = new Set<React.Dispatch<React.SetStateAction<ModalInstance[]>>>();
export const modalManager: ModalContextType = {
open: (config) => {
const id = `modal_${++modalIdCounter}`;
const instance: ModalInstance = {
id,
config,
close: () => {
instance.config = { ...instance.config, props: { ...instance.config.props, visible: false } };
modalListeners.forEach(setter => setter(prev => [...prev]));
setTimeout(() => {
modalListeners.forEach(setter => setter(prev => prev.filter(m => m.id !== id)));
}, 300);
},
update: (props) => {
instance.config = { ...instance.config, props: { ...instance.config.props, ...props } };
modalListeners.forEach(setter => setter(prev => [...prev]));
},
};
modalListeners.forEach(setter => setter(prev => [...prev, instance]));
return instance;
},
confirm: (content: string, title = '确认操作') => {
return new Promise<boolean>((resolve) => {
Modal.confirm({
title,
content,
onOk: () => { resolve(true); },
onCancel: () => { resolve(false); },
});
});
},
closeAll: () => {
modalListeners.forEach(setter => setter([]));
},
};
// ModalProvider.tsx
export const ModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [modals, setModals] = useState<ModalInstance[]>([]);
// 注册 setter
modalListeners.add(setModals);
useEffect(() => {
return () => { modalListeners.delete(setModals); };
}, []);
const context: ModalContextType = {
open: modalManager.open,
confirm: modalManager.confirm,
closeAll: modalManager.closeAll,
};
return (
<ModalContext.Provider value={context}>
{children}
{modals.map(m => {
const Comp = m.config.component;
const isDrawer = m.config.type === 'drawer';
const isConfirm = m.config.type === 'confirm';
// 防御式:处理 onConfirm 加载状态
const [confirmLoading, setConfirmLoading] = useState(false);
const handleOk = async () => {
if (m.config.onConfirm) {
setConfirmLoading(true);
try {
const result = await m.config.onConfirm();
if (result !== false) {
m.close();
}
} catch (e) {
message.error('操作失败');
} finally {
setConfirmLoading(false);
}
} else {
m.close();
}
};
if (isDrawer) {
return (
<Drawer
key={m.id}
title={m.config.title}
width={m.config.width ?? 400}
open={true}
onClose={m.close}
>
<Comp {...m.config.props} />
</Drawer>
);
}
return (
<Modal
key={m.id}
title={m.config.title}
width={m.config.width ?? 520}
open={true}
onCancel={() => { m.config.onCancel?.(); m.close(); }}
onOk={handleOk}
confirmLoading={confirmLoading}
footer={m.config.footer ?? (
<Space>
<Button onClick={() => { m.config.onCancel?.(); m.close(); }}>
{m.config.cancelText ?? '取消'}
</Button>
<Button type="primary" loading={confirmLoading} onClick={handleOk}>
{m.config.confirmText ?? '确认'}
</Button>
</Space>
)}
>
<Comp {...m.config.props} />
</Modal>
);
})}
</ModalContext.Provider>
);
};
5.4 列表组件(虚拟滚动、无限加载)
Vue 3 实现(虚拟滚动)
<!-- VirtualList.vue -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue';
interface VirtualListProps {
/** 数据源 */
items: unknown[];
/** 每项高度(固定高度时用) */
itemHeight: number;
/** 预渲染的缓冲区数量 */
buffer?: number;
/** 唯一键 */
itemKey?: string;
}
const props = withDefaults(defineProps<VirtualListProps>(), {
buffer: 5,
itemKey: 'id',
});
const emit = defineEmits<{
(e: 'load-more'): void;
}>();
const containerRef = ref<HTMLElement>();
const scrollTop = ref(0);
const containerHeight = ref(0);
// 可视范围计算
const visibleCount = computed(() => Math.ceil(containerHeight.value / props.itemHeight));
const totalHeight = computed(() => props.items.length * props.itemHeight);
const startIndex = computed(() => {
const idx = Math.floor(scrollTop.value / props.itemHeight) - props.buffer;
return Math.max(0, idx);
});
const endIndex = computed(() => {
const idx = startIndex.value + visibleCount.value + props.buffer * 2;
return Math.min(props.items.length, idx);
});
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value));
const offsetY = computed(() => startIndex.value * props.itemHeight);
// 无限加载检测
const nearBottom = computed(() =>
scrollTop.value + containerHeight.value >= totalHeight.value - props.itemHeight * 2
);
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
scrollTop.value = target.scrollTop;
// 触底加载
if (nearBottom.value) {
emit('load-more');
}
};
// ResizeObserver 监听容器大小变化
let observer: ResizeObserver | null = null;
onMounted(() => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight;
observer = new ResizeObserver((entries) => {
containerHeight.value = entries[0]?.contentRect.height ?? 0;
});
observer.observe(containerRef.value);
}
});
onUnmounted(() => {
observer?.disconnect();
});
</script>
<template>
<div
ref="containerRef"
class="virtual-list-container"
@scroll="handleScroll"
style="overflow-y: auto; position: relative;"
>
<div class="virtual-list-phantom" :style="{ height: totalHeight + 'px' }" />
<div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="(item, idx) in visibleItems"
:key="item[itemKey] ?? idx"
class="virtual-list-item"
:style="{ height: itemHeight + 'px' }"
>
<!-- 默认插槽:接收 item 和 index -->
<slot name="default" :item="item" :index="startIndex + idx" />
</div>
</div>
<div v-if="nearBottom" class="loading-tip">
加载更多...
</div>
</div>
</template>
<style scoped>
.virtual-list-container { height: 100%; }
.virtual-list-phantom { pointer-events: none; }
.virtual-list-item { box-sizing: border-box; }
.loading-tip { text-align: center; padding: 12px; color: #999; }
</style>
React 实现(无限加载 + 虚拟滚动)
// hooks/useInfiniteScroll.ts
import { useRef, useCallback, useEffect } from 'react';
interface UseInfiniteScrollOptions {
/** 是否还有更多数据 */
hasMore: boolean;
/** 加载更多 */
onLoadMore: () => void | Promise<void>;
/** 触底阈值(px) */
threshold?: number;
/** 是否正在加载 */
loading?: boolean;
}
export function useInfiniteScroll(options: UseInfiniteScrollOptions) {
const { hasMore, onLoadMore, threshold = 200, loading } = options;
const containerRef = useRef<HTMLDivElement>(null);
const loadingRef = useRef(false);
// 避免重复加载
useEffect(() => {
loadingRef.current = loading ?? false;
}, [loading]);
const handleScroll = useCallback(() => {
const container = containerRef.current;
if (!container || !hasMore || loadingRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < threshold) {
loadingRef.current = true;
onLoadMore();
}
}, [hasMore, onLoadMore, threshold]);
// 防抖处理
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let timer: ReturnType<typeof setTimeout>;
const debouncedScroll = () => {
clearTimeout(timer);
timer = setTimeout(handleScroll, 100);
};
container.addEventListener('scroll', debouncedScroll, { passive: true });
return () => {
container.removeEventListener('scroll', debouncedScroll);
clearTimeout(timer);
};
}, [handleScroll]);
return { containerRef };
}
// VirtualList.tsx
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
interface VirtualListProps<T> {
items: T[];
itemHeight: number;
containerHeight: number;
buffer?: number;
renderItem: (item: T, index: number) => React.ReactNode;
onLoadMore?: () => void;
hasMore?: boolean;
}
function VirtualList<T>({
items,
itemHeight,
containerHeight,
buffer = 5,
renderItem,
onLoadMore,
hasMore = false,
}: VirtualListProps<T>) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const totalHeight = items.length * itemHeight;
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
const endIndex = Math.min(items.length, startIndex + visibleCount + buffer * 2);
const visibleItems = items.slice(startIndex, endIndex);
const handleScroll = useCallback(() => {
const container = containerRef.current;
if (!container) return;
setScrollTop(container.scrollTop);
// 触底加载
if (hasMore && container.scrollHeight - container.scrollTop - container.clientHeight < 200) {
onLoadMore?.();
}
}, [hasMore, onLoadMore]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let timer: ReturnType<typeof setTimeout>;
const debounced = () => {
clearTimeout(timer);
timer = setTimeout(handleScroll, 80);
};
container.addEventListener('scroll', debounced, { passive: true });
return () => {
container.removeEventListener('scroll', debounced);
clearTimeout(timer);
};
}, [handleScroll]);
return (
<div
ref={containerRef}
style={{ height: containerHeight, overflowY: 'auto', position: 'relative' }}
>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ position: 'absolute', top: startIndex * itemHeight, left: 0, right: 0 }}>
{visibleItems.map((item, idx) => (
<div key={startIndex + idx} style={{ height: itemHeight }}>
{renderItem(item, startIndex + idx)}
</div>
))}
</div>
</div>
{hasMore && (
<div style={{ textAlign: 'center', padding: 12, color: '#999' }}>
加载更多...
</div>
)}
</div>
);
}
export default VirtualList;
6. 组件设计检查清单
6.1 设计阶段
□ 是否坚持单一职责 —— 这个组件只做一件事?
□ 是否提供了足够的扩展点 —— Props / Slots / Hooks?
□ Props 是否都有合理的默认值?
□ 是否考虑了受控与非受控两种模式?
□ 是否做了类型定义(TypeScript / interface)?
□ 组件名是否语义化,一目了然?
6.2 实现阶段
□ 是否遵循单向数据流原则?
□ 组件内部是否包含防御式校验(Props 边界值、空值处理)?
□ 是否正确处理了组件卸载(取消订阅、清除定时器、断开观察者)?
□ 是否避免了不必要的重渲染(computed / useMemo / v-memo)?
□ 是否对异步操作做了 loading / error 状态处理?
□ 非必填 Props 是否标记了 undefined 可能性?
□ 列表渲染是否正确指定了 key?
□ 事件处理函数是否做了防抖/节流?
6.3 测试阶段
□ 正常渲染测试 —— 传入最小 Props 能否正常渲染?
□ 边界值测试 —— 空数据、超长文本、超大数据量?
□ 交互测试 —— 点击、输入、滚动是否正常?
□ 异步测试 —— loading 态、错误态、重试?
□ 卸载测试 —— 组件卸载后是否还会 setState?
□ 快照测试 —— UI 是否符合预期?
6.4 可维护性
□ Props 是否都有 JSDoc 注释?
□ 组件代码是否超过 200 行(建议拆分子组件 / Composables / Hooks)?
□ 是否在项目中建立了统一的组件目录规范?
□ 是否避免了组件内部的深层条件嵌套(建议不超过 3 层)?
□ 复用逻辑是否已提取为 Composables / Custom Hooks?
7. 避坑总结
7.1 常见踩坑点
| 问题 |
表现 |
原因 |
解决方案 |
| Props 直接修改 |
Vue 报错,数据不同步 |
子组件直接修改了 Props |
使用 emit + v-model |
| 丢失响应式 |
视图不更新 |
直接修改数组/对象,未使用响应式 API |
reactive / useState 不可变更新 |
| key 使用索引 |
列表错乱,性能下降 |
Vue/React 用 index 作为 key |
使用唯一 ID |
| Context 滥用 |
组件间耦合,重渲染频繁 |
把全局状态都放 Context |
按领域拆分,用 useMemo |
| useEffect 死循环 |
无限请求,浏览器卡死 |
依赖数组漏写/写错 |
仔细检查依赖,使用 lint 规则 |
| 组件卸载后 setState |
React 告警 |
异步操作在卸载后还在更新 |
使用 AbortController / isMounted 标志 |
7.2 Props 设计原则
Props 设计三问:
1. 这个 Props 是否真的需要外部传入?→ 可以内部默认吗?
2. 这个 Props 的默认值是否合理?→ 大多数场景是否符合预期?
3. 这个 Props 是否可以被其他模式替代?→ 能用 slot 替代就不要用 Props 传递 JSX
7.3 性能红线
- 列表渲染超过 1000 条 → 使用虚拟滚动
- 组件内 setState 超过每秒 30 次 → 使用防抖/节流
- Context 更新超过每秒 10 次 → 拆分为多个 Context
- 组件嵌套超过 10 层 → 考虑使用 slot/children 扁平化
最后更新:2026/06/29