高级组件模式
HOC、Render Props、Portal、Ref 转发等高级模式
1. 高阶组件 (HOC)
1.1 什么是 HOC?
高阶组件 (Higher-Order Component) 是一个接收组件并返回新组件的函数。
jsx
// HOC 定义
function withLogger(WrappedComponent) {
return function EnhancedComponent(props) {
useEffect(() => {
console.log('Component mounted:', WrappedComponent.name);
}, []);
return <WrappedComponent {...props} />;
};
}
// 使用
const EnhancedButton = withLogger(Button);
<EnhancedButton onClick={...} />1.2 常见 HOC 模式
jsx
// 1. 注入 props
function withUser(Component) {
return function WithUser(props) {
const user = useContext(UserContext);
return <Component {...props} user={user} />;
};
}
// 2. 条件渲染
function withAuth(Component) {
return function WithAuth(props) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Redirect to="/login" />;
}
return <Component {...props} />;
};
}
// 3. 数据获取
function withData(Component, fetchData) {
return function WithData(props) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData(props).then(data => {
setData(data);
setLoading(false);
});
}, [props.id]);
if (loading) return <Spinner />;
return <Component {...props} data={data} />;
};
}1.3 HOC 的注意事项
jsx
// ❌ 不要在 render 中创建 HOC
function Parent() {
// 每次渲染都创建新组件,导致完全重新挂载
const EnhancedChild = withLogger(Child);
return <EnhancedChild />;
}
// ✅ 在组件外部创建
const EnhancedChild = withLogger(Child);
function Parent() {
return <EnhancedChild />;
}
// 复制静态方法
function withLogger(WrappedComponent) {
function Enhanced(props) { ... }
// 复制静态方法
hoistNonReactStatics(Enhanced, WrappedComponent);
// 设置 displayName 便于调试
Enhanced.displayName = `withLogger(${WrappedComponent.displayName || WrappedComponent.name})`;
return Enhanced;
}2. Render Props
2.1 基本概念
Render Props 是一种通过 prop 传递渲染函数来共享代码的技术。
jsx
// Render Props 组件
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return render(position);
}
// 使用
<MouseTracker
render={({ x, y }) => (
<div>Mouse position: {x}, {y}</div>
)}
/>2.2 children 作为函数
jsx
// 使用 children 而不是 render prop
function MouseTracker({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
// ... 事件监听逻辑
return children(position);
}
// 使用
<MouseTracker>
{({ x, y }) => <Cursor x={x} y={y} />}
</MouseTracker>2.3 Render Props vs Hooks
jsx
// Render Props 方式
<MouseTracker render={({ x, y }) => <Cursor x={x} y={y} />} />
// Hook 方式(更推荐)
function Cursor() {
const { x, y } = useMouse();
return <div style={{ left: x, top: y }}>🐭</div>;
}建议:在新代码中优先使用 Hooks,Render Props 主要用于需要灵活控制渲染输出的场景。
3. Compound Components
3.1 基本模式
复合组件通过隐式共享状态来协作工作。
jsx
// 组件定义
const TabsContext = createContext(null);
function Tabs({ children, defaultIndex = 0 }) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div className="tab-list" role="tablist">{children}</div>;
}
function Tab({ index, children }) {
const { activeIndex, setActiveIndex } = useContext(TabsContext);
return (
<button
role="tab"
aria-selected={activeIndex === index}
onClick={() => setActiveIndex(index)}
>
{children}
</button>
);
}
function TabPanels({ children }) {
return <div className="tab-panels">{children}</div>;
}
function TabPanel({ index, children }) {
const { activeIndex } = useContext(TabsContext);
if (activeIndex !== index) return null;
return <div role="tabpanel">{children}</div>;
}
// 组合导出
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;3.2 使用方式
jsx
<Tabs defaultIndex={0}>
<Tabs.List>
<Tabs.Tab index={0}>Tab 1</Tabs.Tab>
<Tabs.Tab index={1}>Tab 2</Tabs.Tab>
<Tabs.Tab index={2}>Tab 3</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel index={0}>Content 1</Tabs.Panel>
<Tabs.Panel index={1}>Content 2</Tabs.Panel>
<Tabs.Panel index={2}>Content 3</Tabs.Panel>
</Tabs.Panels>
</Tabs>4. Portal
4.1 基本用法
Portal 可以将子节点渲染到父组件 DOM 树之外的 DOM 节点中。
jsx
import { createPortal } from 'react-dom';
function Modal({ children, isOpen }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay">
<div className="modal-content">
{children}
</div>
</div>,
document.body // 渲染到 body
);
}
// 使用
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => setShowModal(true)}>Open Modal</button>
<Modal isOpen={showModal}>
<h2>Modal Title</h2>
<button onClick={() => setShowModal(false)}>Close</button>
</Modal>
</div>
);
}4.2 Portal 的特性
jsx
// 1. 事件冒泡仍然按 React 树结构,而非 DOM 树
// 点击 Modal 内部,事件会冒泡到 App 组件
<App onClick={() => console.log('App clicked')}>
<Modal>
<button>Click me</button> {/* 点击会触发 App 的 onClick */}
</Modal>
</App>
// 2. Context 仍然可用
// Modal 内部可以访问 App 提供的 Context4.3 常见用途
jsx
// 1. Modal / Dialog
const Modal = ({ children }) => createPortal(
<div className="modal">{children}</div>,
document.body
);
// 2. Tooltip
const Tooltip = ({ children, target }) => createPortal(
<div className="tooltip" style={getPosition(target)}>
{children}
</div>,
document.body
);
// 3. Toast 通知
const Toast = ({ message }) => createPortal(
<div className="toast">{message}</div>,
document.getElementById('toast-container')
);5. Ref 转发
5.1 forwardRef (React 18)
jsx
// React 18 需要 forwardRef
const FancyButton = forwardRef(function FancyButton(props, ref) {
return (
<button ref={ref} className="fancy-button">
{props.children}
</button>
);
});
// 使用
function App() {
const buttonRef = useRef(null);
useEffect(() => {
buttonRef.current.focus();
}, []);
return <FancyButton ref={buttonRef}>Click me</FancyButton>;
}5.2 React 19 简化
jsx
// React 19: ref 作为普通 prop
function FancyButton({ ref, children }) {
return (
<button ref={ref} className="fancy-button">
{children}
</button>
);
}
// 使用方式相同
<FancyButton ref={buttonRef}>Click me</FancyButton>5.3 useImperativeHandle
jsx
// 自定义暴露给父组件的实例值
const FancyInput = forwardRef(function FancyInput(props, ref) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
// 只暴露特定方法
focus: () => inputRef.current.focus(),
clear: () => { inputRef.current.value = ''; },
getValue: () => inputRef.current.value,
}), []);
return <input ref={inputRef} {...props} />;
});
// 使用
function App() {
const inputRef = useRef(null);
return (
<>
<FancyInput ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>Focus</button>
<button onClick={() => inputRef.current.clear()}>Clear</button>
</>
);
}6. 受控与非受控组件
6.1 受控组件
jsx
// 状态由 React 完全控制
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
value={value}
onChange={e => setValue(e.target.value)}
/>
);
}6.2 非受控组件
jsx
// 状态由 DOM 管理
function UncontrolledInput() {
const inputRef = useRef(null);
const handleSubmit = () => {
console.log(inputRef.current.value);
};
return (
<>
<input ref={inputRef} defaultValue="initial" />
<button onClick={handleSubmit}>Submit</button>
</>
);
}6.3 何时使用
| 场景 | 推荐方式 |
|---|---|
| 表单验证 | 受控 |
| 实时响应输入 | 受控 |
| 简单的一次性表单 | 非受控 |
| 与第三方库集成 | 非受控 |
| 文件输入 | 非受控 |
7. State Reducer 模式
7.1 基本实现
jsx
function useToggle({ reducer = (state, action) => action.changes } = {}) {
const [on, setOn] = useState(false);
const toggle = () => {
const action = { type: 'toggle', changes: !on };
const changes = reducer({ on }, action);
setOn(changes);
};
return { on, toggle };
}
// 使用:自定义行为
function App() {
const { on, toggle } = useToggle({
reducer: (state, action) => {
// 可以覆盖默认行为
if (action.type === 'toggle' && state.clickCount >= 4) {
return state.on; // 超过 4 次点击后禁止切换
}
return action.changes;
}
});
return <button onClick={toggle}>{on ? 'ON' : 'OFF'}</button>;
}8. 面试高频问题
Q1: HOC 和 Hooks 的区别?
| 特性 | HOC | Hooks |
|---|---|---|
| 复用方式 | 包装组件 | 在组件内调用 |
| 嵌套问题 | 可能形成嵌套地狱 | 无嵌套 |
| Props 命名冲突 | 可能 | 不存在 |
| 调试体验 | 组件树较深 | 更扁平 |
| TypeScript | 类型复杂 | 类型简单 |
Q2: 什么时候用 Portal?
- Modal / Dialog:避免 z-index 和 overflow 问题
- Tooltip / Popover:确保定位正确
- Toast 通知:渲染到专门的容器
- 全屏组件:脱离父组件样式限制
Q3: forwardRef 的作用?
- 将 ref 转发到子组件的 DOM 节点
- 在 React 19 之前,函数组件不能直接接收 ref prop
- React 19 后可以直接将 ref 作为 prop
Q4: 受控和非受控组件如何选择?
受控组件:需要实时访问/验证输入值时 非受控组件:简单场景、与第三方库集成、文件输入
Q5: Compound Components 的优势?
- 灵活的组合:使用者可以自由组合子组件
- 隐式状态共享:无需手动传递 props
- 清晰的 API:组件结构一目了然
- 关注点分离:每个子组件职责单一