☰
React组件写法全指南:函数组件、类组件、HOC与自定义Hook
2026/10/9 20:09:56 网站建设 项目流程

如果你写过 React,一定会发现一个很有意思的现象:同样一个“按钮组件”,有的同事用函数组件写,有的用类组件写,有人套了一层高阶组件,有人又把逻辑抽成了自定义 Hook。组件创建的“姿势”实在太多了,面试官爱问,项目评审上也容易吵起来。这篇文章就从我自己的实践角度,专门把 React 中创建组件的多种方式从头到尾捋一遍——函数组件、类组件、高阶组件、Render Props、复合组件模式、自定义 Hook,再到 React.memo、forwardRef、lazy 这些进阶玩法,逐个说清楚它们解决什么问题、怎么用、有什么坑。

如果你是刚接触 React 的初学者,这篇文章可以帮你建立完整的“组件写法地图”;如果你已经写了几年 React,那你大概率能在里面的踩坑记录里看到自己的影子。下面不讲废话,直接进入正题。

1. 先搞清楚一件事:React 组件到底是什么

1.1 组件的本质就是一个函数

很多人学 React 容易卡在一个点上:组件到底是“HTML 模板”还是“类”?其实 React 组件最本质的形态就是一个 JavaScript 函数——接收一个对象(我们叫 props),返回一段描述界面的结构(React 元素)。不管你怎么写、用什么模式,最终都逃不开这个核心规律。

function Button({ text }) { return <button>{text}</button>; }

上面这个就是最原始的组件。它接收text这个 prop,返回一个<button>元素。JSX 看起来像 HTML,但它编译后只是React.createElement的语法糖。理解这一点特别重要,因为后面所有高级玩法——HOC 返回组件、自定义 Hook 消费组件逻辑、React.lazy 懒加载组件——本质上都是在“函数”这个基础上做文章。

我在给团队新人培训时经常打一个比方:组件函数就像餐馆后厨的出菜窗口,props 是客人下的单,React 元素是端出去的菜。你可以在下单前加权限判断(HOC),也可以把做菜过程外包给另一个函数(自定义 Hook),但窗口始终是那个窗口。

1.2 为什么会有这么多创建方式

React 从 2013 年开源到现在,组件写法的演进很有节奏。早期官方主推类组件,因为那时 JavaScript 的 class 语法正流行,而且类组件能管理内部状态、有完整的生命周期方法;2015 年前后,社区开始流行高阶组件(HOC)来做逻辑复用;接着 Render Props 因为能解决 HOC 的一些痛点又被广泛讨论;2019 年 React 16.8 正式发布 Hooks,函数组件一下子拥有了状态和副作用能力,局面才基本稳定下来。

所以你今天在项目里看到各种写法共存,并不是“别人不会写”,而是不同历史阶段留下的代码风格。维护老项目时,能看懂类组件就是基本功;新建项目时,大多数人会默认函数组件加 Hooks。理解这条演进路线,你就不会看到 class 组件就喊“垃圾代码”了。

1.3 多种创建方式的适用场景速览

为了让你先有个整体概念,我先放一张速览表。这张表不是“标准答案”,而是我根据自己的项目经验整理的选型参考:

创建方式核心思想典型适用场景我的推荐度
函数组件props 进、JSX 出展示型组件、配合 Hooks 的绝大多数组件首选
类组件class + 生命周期维护 React 16.8 之前的存量代码按需
高阶组件(HOC)包装组件并返回新组件统一注入 props、权限拦截、埋点上报少用
Render Props把函数作为 props 传入复用带状态逻辑的 UI 片段少用
自定义 Hook抽离并复用状态逻辑数据请求、表单校验、订阅逻辑首选
复合组件模式Context + 组合组件组件库、Tab/手风琴这类联动组件推荐
memo / forwardRef / lazy优化与增强函数组件性能优化、透传 ref、按需加载按需用

表格里的“少用”并不代表不要学,而是要明白它们解决了什么问题、什么时候才值得用。下面逐一展开。

2. 基础中的基础:函数组件与类组件

2.1 函数组件的三种写法与注意事项

函数组件的写法说白了就是定义函数的几种语法排列组合。最多的有这三种:

// 写法一:普通函数声明 function UserCard({ name, age }) { return <div>{name},{age}岁</div>; } // 写法二:函数表达式 const UserCard = function ({ name, age }) { return <div>{name},{age}岁</div>; }; // 写法三:箭头函数 const UserCard = ({ name, age }) => { return <div>{name},{age}岁</div>; };

三种写法最终能力一样,但有两个细节我特别提醒一下。第一,尽量让组件是个“具名函数”,不要在 React DevTools 里看到一堆Anonymous,排查问题会非常痛苦。箭头函数赋值给 const 变量时,函数名能通过变量名推断出来,这一点现代工具链处理得比较好;但如果直接写匿名箭头函数作为默认导出,调试体验会差一些。第二,函数组件返回的必须是单一根节点。如果并列返回两个<div>,React 会直接报错。不想额外包一层容器,就用 Fragment:

function List() { return ( <> <li>第一项</li> <li>第二项</li> </> ); }

函数组件时代,一条铁律是:props 是只读的。你绝对不能在里面做props.name = 'xxx'这种事。React 的单向数据流依赖这一点,直接改 props 会成为各种诡异 bug 的源头。我排查过太多线上问题,最后定位到都是有人图省事改了 props 对象里的某个字段。

2.2 类组件的结构与生命周期

类组件是 React 16.8 之前写“有状态组件”的唯一正统方式。一个完整的类组件长这样:

class Counter extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; this.handleClick = this.handleClick.bind(this); } componentDidMount() { // 组件挂载后:发请求、绑定事件、拿 DOM } componentDidUpdate(prevProps) { // 组件更新后:根据 props 变化做副作用 } componentWillUnmount() { // 组件卸载前:清理定时器、解绑事件 } handleClick() { this.setState((prev) => ({ count: prev.count + 1 })); } render() { return <button onClick={this.handleClick}>{this.state.count}</button>; } }

类组件有三个“祖传坑”:this绑定问题、setState的异步更新、生命周期方法的理解成本。this绑定问题上面用了 bind,或者你可以直接在 class 里写箭头函数属性来避免。setState里要修改基于当前状态的值时,永远传函数而不是对象,否则在连续点击场景下会读到旧值。

类组件并非一无是处。它的生命周期方法边界清晰,老项目里如果已经用类组件写好了复杂的业务逻辑,硬改成函数组件反而容易引入回归。我见过不少团队搞“技术债清零计划”,把类组件全部重写为函数组件,结果出线上事故。改写没问题,但一定要用测试覆盖原有行为。

2.3 新开发时该怎么选:我从项目里总结的规则

我现在的选择规则很简单,三条:

  1. 新写的业务代码一律用函数组件加 Hooks。代码更短、逻辑更聚合、没有 this 干扰。
  2. 维护老代码时保留类组件,只在改动区域局部重构。大面积重写性价比极低。
  3. 函数组件里如果发现某段逻辑在多个组件重复出现,毫不犹豫提取成自定义 Hook,不要硬塞进单个组件里。

还有一个很多人忽略的点:函数组件并不是“不能有生命周期”。useEffect组合起来可以覆盖componentDidMount、componentDidUpdate、componentWillUnmount的职责,但心智模型完全不同——useEffect关心的是“副作用什么时候该同步”,而不是“在第几个阶段干什么”。从类组件迁移到函数组件,这里是最容易思想打架的地方。

3. 逻辑复用:高阶组件与 Render Props

3.1 高阶组件 HOC 怎么创建

高阶组件不是 React 的 API,而是一种基于函数组合的设计模式。它接收一个组件作为参数,返回一个增强后的新组件。最常见的例子是“加载中占位”和“登录鉴权”。

function withLoading(Component) { return function WrappedComponent(props) { const [loading, setLoading] = React.useState(true); React.useEffect(() => { const timer = setTimeout(() => setLoading(false), 1000); return () => clearTimeout(timer); }, []); if (loading) return <div>加载中...</div>; return <Component {...props} />; }; } const UserListWithLoading = withLoading(UserList);

用的时候直接withLoading(UserList),就把“加载逻辑”注入到了新组件里。原始UserList不需要知道自己被包了一层,它拿到的 props 里就多了一些增强的东西,比如这里我们帮它处理好了 loading 切换。

但 HOC 的坑也非常“经典”,我一个个说:

  1. props 冲突:HOC 内部如果给被包装组件注入了name,而外部调用时也传了name,那到底听谁的?React 会“就地覆盖”,HOC 注入的 props 优先级低于外部传入的。这个覆盖顺序很多新手压根不知道。
  2. 调试困难:默认情况下 DevTools 显示的是WrappedComponent,根本看不出来是谁。必须手动设置displayName:
WrappedComponent.displayName = `withLoading(${Component.displayName || Component.name})`;
  1. ref 透传问题:函数组件没有实例,被 HOC 包一层后,外部拿到的 ref 指向的是 HOC 外层组件,不是内部真实 DOM。需要借助React.forwardRef两层配合,复杂度立刻上来了。
  2. 不要在 render 方法里创建 HOC:
// 错误:每次 render 都有新的组件类型,导致子树反复卸载重挂 render() { const Enhanced = withLoading(MyList); return <Enhanced data={this.state.data} />; }

这个错误会导致 React 每次都把组件当成“新类型”,丢掉已有子树状态,非常隐蔽。

3.2 Render Props 模式:把函数当成 props 传

Render Props 的核心一句话:组件接收一个函数类型的 prop,在组件内部调用这个函数,把状态作为参数传出去。最经典的是“鼠标位置追踪”:

function MouseTracker({ render }) { const [pos, setPos] = React.useState({ x: 0, y: 0 }); React.useEffect(() => { const handler = (e) => setPos({ x: e.clientX, y: e.clientY }); window.addEventListener('mousemove', handler); return () => window.removeEventListener('mousemove', handler); }, []); return render(pos); } // 使用方 <MouseTracker render={({ x, y }) => <div>鼠标在 {x}, {y}</div>} />

MouseTracker把追踪鼠标的复杂逻辑封装了,具体展示什么由外部函数决定。相比 HOC,Render Props 的 props 来源非常明确——参数就在函数列表里摆着,不存在“隐式注入”的魔法。缺点是嵌套地狱。如果渲染内容里还要再套一层 Render Props,很快代码就变成“回调金字塔”,可读性急剧下降。

3.3 这两种模式我为什么现在用得少了

说实话,HOC 和 Render Props 在我手里已经基本降级为“面试题库”和“遗留代码阅读能力”。原因很简单:React Hooks 提供了更优雅的复用方式。比如上面鼠标追踪的例子,用自定义 Hook 写是下面这样:

function useMousePosition() { const [pos, setPos] = React.useState({ x: 0, y: 0 }); React.useEffect(() => { const handler = (e) => setPos({ x: e.clientX, y: e.clientY }); window.addEventListener('mousemove', handler); return () => window.removeEventListener('mousemove', handler); }, []); return pos; } function Demo() { const { x, y } = useMousePosition(); return <div>鼠标在 {x}, {y}</div>; }

代码量更少,调用关系更扁平,没有 props 冲突,也没有 displayName 烦恼。所以我给团队定的规范是:这种“横切逻辑复用”的需求,默认写自定义 Hook。只有一种情况我会选择 HOC——需要基于配置批量生成组件,比如后端返回一个组件配置树,前端要分别给这些组件注入统一的行为。那种场景 HOC 的“包装器”形态非常契合。

4. 现在的主流:自定义 Hook 与复合组件模式

4.1 自定义 Hook 的创建步骤与命名规则

自定义 Hook 本质是一个普通函数,名字必须以use开头,函数体内可以调用其他 Hooks。为什么必须以use开头?不只是约定俗成——React 官方 lint 规则要靠函数名判断它是不是 Hook,从而检查 Hooks 调用是否违反规则。你如果起个getData之类的名字,useEffect写错位置时 lint 可能都帮不了你。

一个实用度非常高的示例,封装数据请求:

function useFetch(url) { const [data, setData] = React.useState(null); const [error, setError] = React.useState(null); const [loading, setLoading] = React.useState(true); React.useEffect(() => { let ignore = false; async function load() { setLoading(true); try { const res = await fetch(url); const json = await res.json(); if (!ignore) setData(json); } catch (e) { if (!ignore) setError(e); } finally { if (!ignore) setLoading(false); } } load(); return () => { ignore = true; }; }, [url]); return { data, error, loading }; }

这里有一个被我反复强调的细节:ignore标志位。它解决的是组件卸载后异步回调仍然执行的问题。如果你直接在请求完成后setData,但组件已经卸载了,React 会告警,甚至在高版本会引发内存泄漏相关的问题。在真实项目里,这个简单模式可以套在 80% 的数据请求场景上。

自定义 Hook 最大的好处是“逻辑和 UI 彻底解耦”。同一个useFetch,一个组件拿来渲染表格,另一个组件拿来渲染图表,完全没问题;同一个useLocalStorage,一个组件用作表单草稿,另一个组件用作主题切换。我维护的几个中大型前端项目里,自定义 Hook 已经成了业务逻辑复用的主力。

4.2 复合组件模式:组件与组件协同工作

复合组件模式专门解决一类问题:两个组件在视觉上是独立的,但逻辑上必须共享状态。最典型的就是 Tab 切换、折叠面板、下拉选择器。如果每个子组件都自己管状态,那就会出现“点了 Tab 标题,面板不知道要切换”的尴尬。

用 Context 实现复合组件,核心代码如下:

const TabsContext = React.createContext(null); function Tabs({ defaultIndex = 0, children }) { const [activeIndex, setActiveIndex] = React.useState(defaultIndex); return ( <TabsContext.Provider value={{ activeIndex, setActiveIndex }}> {children} </TabsContext.Provider> ); } function TabItem({ index, children }) { const { activeIndex, setActiveIndex } = React.useContext(TabsContext); return ( <div className={activeIndex === index ? 'tab active' : 'tab'} onClick={() => setActiveIndex(index)} > {children} </div> ); } function Panel({ index, children }) { const { activeIndex } = React.useContext(TabsContext); return activeIndex === index ? <div className="panel">{children}</div> : null; }

使用方可以自由编排组件结构:

<Tabs> <TabItem index={0}>详情</TabItem> <TabItem index={1}>评价</TabItem> <Panel index={0}>这里是商品详情</Panel> <Panel index={1}>这里是用户评价</Panel> </Tabs>

在这个模式里,Tabs只负责提供状态,TabItem和Panel各自从 Context 里取自己需要的部分。新增一个 Tab 也不需要改 Tabs 的内部逻辑。这个思路在写前端组件库的时候几乎是必修课,Element、Ant Design 这类库的大量组件内部都是类似的组合结构。

4.3 用好 Hooks 的几个额外心得

Hooks 虽然好用,但也不是魔法。我碰到最多的问题集中在依赖数组上——要么漏依赖导致旧数据,要么依赖随意填导致死循环。一个实用的判断标准是:所有在 effect 里使用的外部变量都应该出现在依赖数组里。如果变量是对象或函数,尽量用useCallback和useMemo保持引用稳定。React 18 之后新增的useId也很好用,生成唯一 ID 做表单htmlFor关联时比手动计数靠谱得多,服务端渲染还不会出现 ID 不一致。

提到“基于 React 构建能思考与行动的智能体”这个近期的圈内热词,虽然离常规组件开发有点距离,但底层也脱离不了组件系统——智能体界面的交互模块、流式输出组件、工具调用表单,本质都是 React 组件。把组件创建方法理解透了,上层再花哨的业务也还是这些基本功的组合。

5. 创建组件时的几个高级武器

5.1 React.memo:读懂浅比较再动手

React.memo是一个包裹函数,用来对函数组件做“props 浅比较缓存”。它的使用方式一言以蔽之:只有当 props 引用变化时,组件才会重新渲染。

const HeavyList = React.memo(function HeavyList({ items }) { return ( <ul> {items.map((item) => <li key={item.id}>{item.name}</li>)} </ul> ); });

听起来很美好,但有一个前提:items这个 prop 的引用必须稳定。如果父组件每次 render 都items={[1,2,3]}这样新建数组,那 memo 等于白写。所以正确姿势是配合useMemo:

const items = React.useMemo(() => buildItems(), [dep1, dep2]);

我的实际经验是:不要一开始就给所有组件套 memo。渲染开销小的组件套 memo 属于浪费,还会因为依赖引用不稳定引入诡异 bug。先跑性能分析,真正卡到渲染瓶颈了再上。

5.2 forwardRef 与 useImperativeHandle:父组件调子组件方法

函数组件接收 props,但默认拿不到 ref。如果你需要父组件直接调用子组件里的某个方法,比如“让子组件里的输入框聚焦”,就得用forwardRef和useImperativeHandle配合:

const InputWithFocus = React.forwardRef(function InputWithFocus(props, ref) { const inputRef = React.useRef(null); React.useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), clear: () => { if (inputRef.current) inputRef.current.value = ''; } })); return <input ref={inputRef} {...props} />; }); // 父组件 const ref = React.useRef(null); <InputWithFocus ref={ref} /> <button onClick={() => ref.current.focus()}>聚焦输入框</button>

useImperativeHandle里返回的对象就是父组件通过ref.current能访问到的东西。以前类组件的时代,父组件能直接访问子组件全量实例,现在这个 API 更安全——你只暴露对外需要的方法,内部状态完全私有化。这是我喜欢它的原因:接口清晰、不留后门。

5.3 动态组件加载:React.lazy 与 Suspense

当组件数量膨胀,或者某个页面组件体积很大时,懒加载非常实用。React.lazy能让你把某个组件的加载推迟到真正渲染时:

const Dashboard = React.lazy(() => import('./Dashboard')); function App() { return ( <React.Suspense fallback={<div>页面加载中...</div>}> <Dashboard /> </React.Suspense> ); }

import('./Dashboard')会触发 webpack 或者 Vite 的代码分割,Dashboard 组件会被单独拆成一个 chunk。首次进入页面时不会加载,等路由切到 Dashboard 才拉取。这里有个实战细节我踩过坑:React.lazy要求默认导出,如果你用的是具名导出export function Dashboard() {},就要中转一下:

const Dashboard = React.lazy(() => import('./Dashboard').then((mod) => ({ default: mod.Dashboard })) );

另外,Suspense的fallback不要展示太重的组件,否则懒加载的意义就没了。一个纯文字或轻量 loading 足够。

6. 实操避坑:组件通信、封装规范与问题排查

6.1 组件通信的几种方式与选择优先级

创建组件只是第一步,组件之间怎么通信才是日常大头。我把项目里用到的通信方式按优先级排了一下,新手可以直接参考:

  1. 父子通信:父传子直接 props;子传父通过“父组件传下来的回调函数”调用。
  2. 跨层级通信:用 Context。适合主题、用户信息、多语言包这类“全局性”的数据。
  3. 兄弟组件通信:先把共享状态提升到最近公共父组件,父组件通过 props 和回调分发。
  4. 极端跨模块通信:可以用事件总线(比如 mitt)或者直接上状态管理库(Redux、Zustand)。但这类方案会让数据流变得不透明,能用前三种就尽量别用。

举一个典型场景:表单页里,父组件要做“全表单校验”,子组件是用自定义 Hook 封装的带校验输入框。你可以在父组件里维护一个validateMap,子组件的校验方法通过useImperativeHandle暴露出去,父组件最终统一调用所有子组件的校验函数。这种“双向”通信用熟了,组件边界会非常清晰。

组件通信的快问快答我可以整理成一张表:

通信场景推荐方式不推荐原因
父组件传数据给子组件props简单直接
子组件通知父组件回调函数简单直接
深层嵌套传数据Context逐层传 props 太繁琐
多组件共享同一份状态状态提升 + props分散多处难维护
全局状态跨页面共享Zustand / Redux全局变量难排查
随意外挂事件总线谨慎数据流变黑盒

6.2 组件封装的工程化规范

组件写得多了,光靠“记得”是管不住质量的。我给自己和团队定了几个工程化层面的规矩:

  1. 一个文件一个组件。默认导出组件本身,具名导出周边类型(比如 Props 类型、辅助函数)。这样查看、测试、复用都方便。
  2. Props 类型必须写清楚。我用 TypeScript,每一个组件的 props 都定义成一个类型或 interface。复杂的组件甚至会把回调函数签名都写死,这样调用方在 IDE 里就能看到全部契约。
  3. 样式方案要统一。团队项目里 CSS Modules、Tailwind、styled-components 各有长短,但混着用会非常难受。组件库开发我推荐 CSS 变量加样式隔离;业务项目 Tailwind 效率最高。
  4. Side effect 集中处理。组件的useEffect里不要塞无关逻辑,一个 effect 只做一件事。如果挂在同一依赖上,也要用注释写明整个 effect 的完整职责。

组件库开发又是另一套玩法——边界情况特别多。比如 props 要支持透传到原生 DOM、className 要能够合并、事件处理要能叠加而不是覆盖。这类工作非常考验“组件 API 设计”的功底,建议入门者先从模仿成熟组件库的 API 开始,别上来就自己发明接口。

6.3 常见报错与表现速查

最后分享一些我在调试 React 组件时高频碰到的问题,以及排查思路:

现象最可能的原因排查方向
页面白屏,控制台无报错组件抛出异步异常,没有被错误边界捕获查网络请求、排查 undefined 属性访问
组件更新了但 DOM 没变后端数据引用未变化,memo 生效了但数据是浅拷贝检查数据序列化、深拷贝
列表渲染后莫名丢项key 使用了 index改用稳定唯一 id 作为 key
点击事件触发两次事件冒泡+父组件绑定重复监听检查 stopPropagation、effect 清理
useEffect 死循环依赖数组里的函数或对象引用不稳定用 useCallback/useMemo 稳定引用
弹窗组件打开后页面还能滚动缺少锁 body 滚动的逻辑在 effect 中设置 overflow: hidden
组件卸载后还在 setState异步回调未做卸载标记使用 ignore 标志或 AbortController 取消请求

逐个展开一下。“白屏但无报错”是最常让我头疼的问题。后来我在项目入口统一加了ErrorBoundary错误边界组件,白屏时能显示一句“页面出错,请刷新”,还能把错误信息上报到日志系统。这个习惯强烈建议养成。

列表 key 用 index 的问题,只出现在列表项会被删除、重排、插入的场景。如果你是纯展示、永远不变化,用 index 也没事;但只要涉及增删,index 做 key 就可能导致 React 复用错 DOM,出现状态串台——比如第一行有输入框,删掉第二行后,输入框里的值跑到了另一行。

关于“vant 做联级多选按照层级的组件”这类组件选型问题,我的建议是:先搞清产品要的是级联选择还是树形穿梭框。两者交互模式完全不一样,前者适合“省市县”这种路径选择,后者适合“权限分配”这种多选场景。别为了炫组件库硬套需求,给使用者制造混乱。

写在最后:我的组件开发心得

写了几年 React,我最大的收获不是学会了多少种组件创建方式,而是明白了“少即是多”。新项目里,默认函数组件加 Hooks,状态逻辑用自定义 Hook 复用,跨层级状态用 Context 控制范围,需要性能优化才上 memo 和 lazy。HOC、Render Props 这些模式我会写、能读,但不会主动往代码里塞——因为它们解决的历史问题,Hooks 已经有了更简单的答案。

最后分享一个我踩过不少次坑之后养成的习惯:创建任何组件之前,先写下它的“对外契约”——props 要什么、回调什么时候触发、组件的边界行为是什么。哪怕只写三行注释,也能提前逼你自己想清楚这个组件的职责。很多臃肿的组件,根源就是“先写代码再想接口”,最终整个组件的 props 有十几个,行为没法预测。组件创建方式再花哨,也架不住一开始契约就是模糊的。把这个习惯养成,比掌握任何高级模式都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询