Skip to content

高级组件模式

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 提供的 Context

4.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 的区别?

特性HOCHooks
复用方式包装组件在组件内调用
嵌套问题可能形成嵌套地狱无嵌套
Props 命名冲突可能不存在
调试体验组件树较深更扁平
TypeScript类型复杂类型简单

Q2: 什么时候用 Portal?

  1. Modal / Dialog:避免 z-index 和 overflow 问题
  2. Tooltip / Popover:确保定位正确
  3. Toast 通知:渲染到专门的容器
  4. 全屏组件:脱离父组件样式限制

Q3: forwardRef 的作用?

  • 将 ref 转发到子组件的 DOM 节点
  • 在 React 19 之前,函数组件不能直接接收 ref prop
  • React 19 后可以直接将 ref 作为 prop

Q4: 受控和非受控组件如何选择?

受控组件:需要实时访问/验证输入值时 非受控组件:简单场景、与第三方库集成、文件输入

Q5: Compound Components 的优势?

  1. 灵活的组合:使用者可以自由组合子组件
  2. 隐式状态共享:无需手动传递 props
  3. 清晰的 API:组件结构一目了然
  4. 关注点分离:每个子组件职责单一

前端面试知识库