React 组件设计模式:从 HOC 到 Render Props 再到 Hooks

React 组件设计模式:从 HOC 到 Render Props 再到 Hooks

组件逻辑复用是 React 开发中的永恒话题。本文从实战角度对比三种主流方案,附真实踩坑经验。

1. 为什么需要组件设计模式

写 React 代码时,你一定遇到过这种场景:

css 复制代码
┌─────────────────────────────────────┐
│  Header    │  需要相同的逻辑        │
├─────────────────────────────────────┤
│  Sidebar   │  • 用户权限判断        │
│            │  • 数据订阅            │
│  Content   │  • 日志记录           │
│            │  • 主题切换           │
└─────────────────────────────────────┘

同样的逻辑要在多个组件里重复?传统的组件复用方式有两种:

  1. Mixin(已废弃)------ React 16 之前的方式,问题太多已被移除
  2. 组件组合------ 今天要讲的三种模式,都是组合思想的体现

三种模式的演进:

scss 复制代码
HOC (2015) → Render Props (2017) → Hooks (2019)

下面从实操角度对比这三种方案。


2. HOC:曾经的主流方案

2.1 什么是 HOC

高阶组件(Higher-Order Component) 就是一个函数,接受一个组件,返回一个新组件。

javascript 复制代码
const EnhancedComponent = higherOrderComponent(WrappedComponent);

组件把 props 转成 UI,HOC 把组件转成另一个组件。

2.2 解决什么问题

HOC 主要解决**横切关注点(Cross-Cutting Concerns)**问题。比如数据订阅:

javascript 复制代码
// 不使用 HOC - 每个组件都要写一遍
class UserList extends React.Component {
  state = { users: [] };

  componentDidMount() {
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange = () => {
    this.setState({ users: DataSource.getUsers() });
  };

  render() {
    return <ul>{/* render users */}</ul>;
  }
}

class UserProfile extends React.Component {
  // 同样的逻辑又要写一遍...
  state = { user: null };

  componentDidMount() {
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange = () => {
    this.setState({ user: DataSource.getUserById(this.props.userId) });
  };

  render() {
    return <div>{/* render user */}</div>;
  }
}

用 HOC 封装这个逻辑:

javascript 复制代码
// withData HOC
function withData(getData) {
  return function(WrappedComponent) {
    return class extends React.Component {
      state = { data: null };

      componentDidMount() {
        DataSource.addChangeListener(this.handleChange);
      }

      componentWillUnmount() {
        DataSource.removeChangeListener(this.handleChange);
      }

      handleChange = () => {
        this.setState({ data: getData(DataSource, this.props) });
      };

      render() {
        return <WrappedComponent data={this.state.data} {...this.props} />;
      }
    };
  };
}

// 使用
const UserListWithData = withData(() => DataSource.getUsers())(UserList);
const UserProfileWithData = withData((ds, props) => ds.getUserById(props.userId))(UserProfile);

优点:逻辑复用、关注点分离(容器组件管数据,UI组件管渲染)

2.3 ⚠️ 踩坑经验

坑1:静态方法丢失
javascript 复制代码
WrappedComponent.staticMethod = function() { return 'hello'; };

const Enhanced = withData()(WrappedComponent);
Enhanced.staticMethod; // ❌ undefined

解决 :用 hoist-non-react-statics

javascript 复制代码
import hoistNonReactStatic from 'hoist-non-react-statics';

function withData(getData) {
  return function(WrappedComponent) {
    class WithData extends React.Component { /* ... */ }
    hoistNonReactStatic(WithData, WrappedComponent);
    return WithData;
  };
}
坑2:Refs 不传递

HOC 返回的是新组件,ref 指向的是外层容器,不是内部 WrappedComponent。

javascript 复制代码
// ❌ ref 指向 WithData,而非 UserList
<UserListWithData ref={this.userListRef} />

// ✅ 用 forwardRef(React 16.3+)
const UserListWithData = React.forwardRef(
  withData()(UserList)
);
坑3:不要在 render 内使用 HOC
javascript 复制代码
// ❌ 每次 render 都创建新组件,整个子树卸载重挂
render() {
  return <withData(UserList) />;
}

// ✅ 组件外使用
const UserListWithData = withData()(UserList);
render() {
  return <UserListWithData />;
}

教训:这个坑我踩过,导致表单输入框每次都丢失焦点。

坑4:Props 属性覆盖
javascript 复制代码
// HOC 注入了 data,但用户也传了 data prop
<UserListWithData data={customData} />
// HOC 传的 data 被覆盖了

原则:HOC 应该传递"无关"的 props 给 WrappedComponent。


3. Render Props:更灵活的共享方式

3.1 核心概念

Render Props 是带有一个函数 prop 的组件,组件调用这个函数来决定渲染什么。

javascript 复制代码
<DataProvider render={data => (
  <h1>Hello {data.target}</h1>
)}/>

其实不一定要叫 render,任何函数 prop 都可以:

javascript 复制代码
// children 作为 render prop 更常见
<Mouse>
  {mouse => (
    <div>鼠标位置: {mouse.x}, {mouse.y}</div>
  )}
</Mouse>

3.2 实战示例

用 Render Props 实现鼠标追踪:

javascript 复制代码
class Mouse extends React.Component {
  state = { x: 0, y: 0 };

  handleMouseMove = (e) => {
    this.setState({ x: e.clientX, y: e.clientY });
  };

  render() {
    return (
      <div onMouseMove={this.handleMouseMove}>
        {this.props.render(this.state)}
      </div>
    );
  }
}

// 使用 - 渲染猫
<Mouse render={mouse => <Cat mouse={mouse} />} />

// 同一个 Mouse 组件,渲染不同的东西
<Mouse render={mouse => (
  <p>坐标:{mouse.x}, {mouse.y}</p>
)} />

3.3 对比 HOC

方面 HOC Render Props
嵌套深度 多个 HOC 组合变深 嵌套在组件内部,可读性更好
Props 容易冲突(被覆盖) 无冲突,render 函数接收明确的数据
实现 需要函数+类组件 普通组件即可
组合 compose 组合 直接嵌套

3.4 ⚠️ 踩坑:与 PureComponent 冲突

这是最容易忽略的性能问题

javascript 复制代码
// ❌ 每次 render 都创建新函数,PureComponent 白费
class MouseTracker extends React.Component {
  render() {
    return (
      <Mouse render={mouse => <Cat mouse={mouse} />} />
    );
  }
}

// ✅ 解决:定义为实例方法
class MouseTracker extends React.Component {
  renderTheCat = (mouse) => {
    return <Cat mouse={mouse} />;
  };

  render() {
    return <Mouse render={this.renderTheCat} />;
  }
}

原理 :React.PureComponent 用浅比较决定要不要重新渲染。函数是对象,每次 render 创建新引用,浅比较永远 false,所以每次都重新渲染,PureComponent 的优化完全失效。


4. Hooks:终结嵌套地狱

4.1 自定义 Hook 的诞生

React 16.8 引入 Hooks,其中自定义 Hook 直接替代了 HOC 和 Render Props 的工作。

核心思想 :不是用组件来复用逻辑,而是用函数来复用带状态的逻辑。

javascript 复制代码
// useMouse Hook
function useMouse() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };

    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return position;
}

// 使用 - 太清爽了
function Cat() {
  const mouse = useMouse();
  return <div>猫在 {mouse.x}, {mouse.y}</div>;
}

4.2 用 Hook 替代 HOC/Render Props

替代 HOC:数据订阅
javascript 复制代码
// HOC 写法
const UserListWithData = withData(() => DataSource.getUsers())(UserList);

// Hook 写法
function useData(getData) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const handler = () => setData(getData(DataSource));

    DataSource.addChangeListener(handler);
    return () => DataSource.removeChangeListener(handler);
  }, [getData]);

  return data;
}

// 使用 - getData 用 useCallback 缓存,避免 effect 频繁执行
function UserList() {
  const getUsers = useCallback(() => DataSource.getUsers(), []);
  const users = useData(getUsers);
  return <ul>{/* render users */}</ul>;
}
替代 Render Props:鼠标追踪
javascript 复制代码
// Render Props 写法
<Mouse render={mouse => <Cat mouse={mouse} />} />

// Hook 写法
<Cat mouse={useMouse()} />

4.3 状态隔离原理

关键点 :每次调用 Hook,都会得到完全独立的 state。

javascript 复制代码
function Counter() {
  const [count, setCount] = useState(0); // 独立的 count
  // ...
}

function App() {
  return (
    <>
      <Counter /> {/* 第一个 count 实例 */}
      <Counter /> {/* 第二个 count 实例,互不影响 */}
    </>
  );
}

这就是为什么 Hooks "自动"解决了 HOC 的 props 冲突和嵌套问题------根本没有嵌套。

4.4 Hooks 的优势

方面 HOC/Render Props Hooks
嵌套 层层包裹 无嵌套
Props 冲突风险 无冲突
静态方法 需特殊处理 完全保留
TypeScript 一般 优秀(类型推断)
Tree Shaking 部分支持 完全支持
学习成本 需理解两个概念 统一为 Hook

5. 三种模式实战对比

场景:实现一个"带日志功能的组件"

HOC 版本
javascript 复制代码
function withLogger(WrappedComponent) {
  return class extends React.Component {
    componentDidMount() {
      console.log(`${WrappedComponent.name} mounted`);
    }

    componentDidUpdate(prevProps) {
      console.log(`${WrappedComponent.name} updated:`, prevProps, '->', this.props);
    }

    componentWillUnmount() {
      console.log(`${WrappedComponent.name} will unmount`);
    }

    render() {
      return <WrappedComponent {...this.props} />;
    }
  };
}

const EnhancedButton = withLogger(Button);
Render Props 版本
javascript 复制代码
function Logger({ children }) {
  return children({
    logMount: () => console.log('Component mounted'),
    logUnmount: () => console.log('Component will unmount')
  });
}

// 使用
<Logger>
  {({ logMount, logUnmount }) => (
    <Button onMount={logMount} onUnmount={logUnmount} />
  )}
</Logger>
Hook 版本
javascript 复制代码
function useLogger(name) {
  useEffect(() => {
    console.log(`${name} mounted`);
    return () => console.log(`${name} will unmount`);
  }, [name]);
}

function Button() {
  useLogger('Button');
  return <button>Click</button>;
}

结论:Hook 版本最简洁,没有额外的组件嵌套。


6. 何时选哪种模式

决策树

复制代码
需要复用逻辑吗?
  │
  ├─ 是 → 用 Hook(2019+ 首选)
  │         ├─ 简单状态逻辑 → useState + useEffect
  │         ├─ 复杂状态 → useReducer
  │         └─ 跨组件共享 → Context + useContext
  │
  └─ 否 → 继续用普通组件

特殊情况

场景 推荐
旧项目(React < 16.8) HOC 或 Render Props
需要劫持组件生命周期 HOC(可以访问 WrappedComponent 的生命周期)
库作者(需要兼容多种用法) Render Props(更灵活)
新项目 Hooks

为什么 Hooks 是现代首选

  1. 没有嵌套地狱withAuth(withTheme(withData(Component))) 变成 useAuth() + useTheme() + useData()
  2. 没有 this 指向问题:函数组件 + Hooks 完全不用关心 this
  3. 类型推断好:TypeScript 对 Hooks 的支持更完善
  4. 测试简单:直接调用 Hook 函数,不用 mount 组件树

7. 总结

模式 核心思想 适用场景
HOC 函数转换组件 需要转换/增强组件的场景
Render Props 函数控制渲染 需要动态决定渲染内容的场景
Hooks 函数复用带状态逻辑 现代 React 首选

记忆点

  • HOC = 包装,Render Props = 注入,Hooks = 调用
  • 遇到新场景,优先考虑 Hooks
  • 老项目逐步迁移,不用一次性重写

相关技术栈:React 16.8+, Hooks, HOC, Render Props, TypeScript

如果你觉得这篇文章有帮助,欢迎关注,我会持续输出 React 进阶内容。


参考资料:

相关推荐
敲代码的约德尔人2 小时前
React useEffect 完全指南:我在 3 个项目中踩坑后总结的血泪经验
前端·react.js
毛骗导演2 小时前
OpenClaw Auth Profile 与多 Key 冷却隔离机制深度解析:一个 API Key 是如何被选择、追踪并轮换的
前端·架构
用户9751470751362 小时前
如何在 Vite 中配置 CSS 模块,以避免全局样式被模块化隔离覆盖?
前端
我叫黑大帅2 小时前
Js常用的字符串处理
前端·javascript·面试
栀秋6662 小时前
深入浅出:手写一个迷你版 Zustand
前端·react.js·前端框架
gustt2 小时前
手写 Zustand:从零实现 React 轻量级状态管理库
前端·面试
读忆2 小时前
在前端开发中使用组件后, 若是出了bug, 应该如何排查, 怎么排查, 解决方式是什么?
前端·javascript·vue.js·bug
We་ct2 小时前
LeetCode 162. 寻找峰值:二分高效求解
前端·算法·leetcode·typescript·二分·暴力
HWL56792 小时前
uni-app的生命周期
前端·vue.js·uni-app