首页 / 资讯中心 / 文章详情

React组件创建方式全解析:从基础形态到高阶模式

React组件创建方式全解析:从基础形态到高阶模式 ★ FEATURED ARTICLE
前两天社区有人问了我一道很常见的面试题在 React 中创建组件的方式到底有几种。大多数人的第一反应是“函数组件、类组件还有……嗯”。这个回答不算错但恰好漏掉了 React 生态里真正在用的另一大片领域——React.memo、forwardRef、高阶组件、render props、复合组件这些 API 和模式本质上都在“创建组件”。要回答好这个问题得先想清楚 React 到底是怎么看待一个组件的然后再把各种创建方式按照“基础形态、包装拓展、组合模式”三层拆开看你会发现自己对组件的理解瞬间清晰不少。1. 组件到底是什么先看清 React 的底层约束1.1 组件的最小形态一个会返回 React 元素的代码单元React 源码里有一个很朴素的概念组件就是一段能返回 React 元素的代码。渲染引擎拿到你的组件后会先看它的 type 是什么类型再决定怎么处理。如果 type 是字符串比如div、span直接映射成原生 DOM。如果 type 是函数React 会调用它把 props 作为参数传进去然后继续渲染函数返回的元素。如果 type 是一个类React 会先new出一个实例再调用实例的render()方法。所以无论是函数组件还是类组件它们表达的其实都是同一个东西给一堆 props给我一段 UI。函数组件把这件事写在函数体里类组件把这件事写在render方法里。两者的差别只是 React 内部的执行路径不同。很多人不知道的一个细节是组件的名字必须大写开头。JSX 编译器会把div当原生标签却会把Header当作变量解析如果你定义了一个叫button的函数组件然后写button /那执行的是原生 HTML button你的函数永远不会被调用。这个约束很基础但每年都有新人踩进去。1.2 为什么说“多种创建方式”是真实的理解了“组件最终都收敛成函数或类两种形态”之后再看所谓“多种创建方式”就顺了。真实项目里你不会只写朴素的函数声明你经常要做这些事用memo包一层生成一个只有 props 浅比较变化才重新渲染的新组件用forwardRef包一层让函数组件能接住 ref用高阶组件withAuth、withLoading包装原始组件返回一个增强了能力的新组件用 render props 或 children 函数把 UI 的创建权交给调用方用复合组件模式把一个完整小组件族的协作关系设计成一套对外 API。这些 API 和模式最终产出的都是“组件”。只不过有的直接声明有的包装生成。我的建议是建立这样一个心智模型创建组件 直接声明 包装拓展 组合设计。后续章节全部围绕这个模型展开你出去面试时这样答层次就比只背两个名词的人高出不少。2. 函数组件默认选择但三种写法背后有讲究2.1 函数声明、箭头函数、直接赋值Fast Refresh 教我的事函数组件是现在 React 的默认选择团队里新人上手第一课基本也是写函数组件。但它至少有三种写法。// 方式 A函数声明 export function Profile({ name }: { name: string }) { return div{name}/div; } // 方式 B箭头函数赋值给具名常量 export const Profile ({ name }: { name: string }) { return div{name}/div; }; // 方式 C匿名箭头函数直接默认导出 export default () divHello/div;三种写法都能跑但有一个实际工程问题值得注意Fast Refresh 对匿名默认导出极不友好。Vite 和 CRA 的热更新机制遇到匿名导出的组件经常无法定位到具体组件编辑一次整个页面的 state 全部重置或者干脆整页刷新。我一开始用箭头函数默认导出写一个小工具页面每次改样式状态就丢排查半天才意识到是匿名函数导致的。我的建议很简单日常优先用方式 A因为函数声明有提升特性同文件内顺序无关DevTools 里名字也清晰如果团队偏好箭头函数务必备一个具名 const别贪图省事直接export default () ...。2.2 hooks 让函数组件不再是“没有状态的组件”早期的函数组件确实只能叫“无状态组件”因为不能用 state、不能碰生命周期。React 16.8 之后 hooks 出现函数组件才算真正完整。function SearchBox({ onSearch }: { onSearch: (keyword: string) void }) { const [keyword, setKeyword] useState(); const [debounced, setDebounced] useState(); useEffect(() { const timer setTimeout(() setDebounced(keyword), 300); return () clearTimeout(timer); }, [keyword]); useEffect(() { if (debounced) onSearch(debounced); }, [debounced, onSearch]); return input value{keyword} onChange{(e) setKeyword(e.target.value)} /; }这个例子里有两个很典型的使用逻辑第一个useEffect是防抖依赖数组定了状态第二个useEffect是状态变化后的通知。函数组件加 hooks组合起来比类组件里手动对比prevProps要直观得多。但 hooks 也有自己的边界最容易被忽略的一条是不要在渲染过程中创建嵌套组件。function Parent() { // 这是反面教材 const Child () divChild/div; return Child /; }每次Parent渲染Child都是一个全新的函数类型React 在做组件 diff 时发现类型变了就会把旧Child卸载、再挂载一个新Child。子组件的状态全丢性能也会劣化。正确的做法是把Child提到组件外部或者用 render props 这类方案把“UI 创建权”作为值传递而不是作为组件类型传递。2.3 TypeScript 声明组件 propsReact.FC 并不是银弹函数组件在 TypeScript 里的声明方式也有讲究。旧项目里很常见的是React.FC泛型const Header: React.FC{ title: string } ({ title }) h1{title}/h1;React.FC的问题在于在老版本types/react中它会隐式给组件加上一个可选的children属性。也就是说一个根本不想接收 children 的组件在类型层面却允许别人往里塞 children这给代码埋了看不见的隐患。React 18 的类型定义里已经取消了隐式 children但很多老项目的类型版本没升级坑依然在。我现在的写法是直接标注 props 参数不包React.FCfunction Header({ title }: { title: string }) { return h1{title}/h1; }这样的好处是 props 类型来源一目了然组件接受什么不接受什么完全由参数决定。想要 children 时显式用PropsWithChildrenfunction Card({ title, children }: PropsWithChildren{ title: string }) { return ( section h2{title}/h2 {children} /section ); }这个习惯对团队协作很友好新人看代码时不会误以为每个组件天生就能接收 children。3. 类组件还活着因为这三个场景绕不开3.1 类组件的标准骨架constructor、state、render 一次讲清如果你在维护老项目类组件是躲不开的。类组件的标准写法有一套固定格式interface CounterState { count: number; } class Counter extends React.Component{ initial?: number }, CounterState { state: CounterState { count: this.props.initial ?? 0, }; handleClick () { this.setState({ count: this.state.count 1 }); }; render() { return button onClick{this.handleClick}{this.state.count}/button; } }有几个细节容易出错。第一constructor里必须调用super(props)否则this.props是 undefined。老代码里还能看到在 constructor 里手动 bind 事件处理函数的写法constructor(props) { super(props); this.handleClick this.handleClick.bind(this); }现代类组件直接用 class field 箭头函数handleClick () {}更省事它天然绑定了 this。第二类组件的生命周期和函数组件 hooks 并不是一一对应的。componentDidMount、componentDidUpdate、componentWillUnmount三个生命周期组合起来约等于useEffect的几种依赖写法但getSnapshotBeforeUpdate这种在 DOM 变更前抓快照的能力函数组件至今没有完全等价的 hooks 实现通常只能靠useLayoutEffect模拟。3.2 Error BoundaryReact 目前唯一的“专属特权”有一个场景到现在依然必须写类组件那就是 Error Boundary。React 官方文档写得很清楚componentDidCatch和getDerivedStateFromError这两个生命周期目前没有 hooks 版本。class ErrorBoundary extends React.Component{ fallback: ReactNode; children: ReactNode }, { hasError: boolean } { state { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error: Error, info: ErrorInfo) { console.error(渲染异常, error, info); } render() { if (this.state.hasError) return this.props.fallback; return this.props.children; } }使用方式很直接包一层就能兜住整个子树的渲染错误ErrorBoundary fallback{p页面出错了/p} Dashboard / /ErrorBoundary这个能力短期内不会被 hooks 替代。所以你哪怕整个项目全是函数组件也得留一个类组件 ErrorBoundary 当全局兜底。3.3 从类组件迁到函数组件时需要注意的 this 与 ref 陷阱类组件迁移到函数组件最常见的问题就是 put this 的地方全部报错。我整理三条经验类字段this.state.count改成const [count, setCount] useState()事件处理器里所有this.xxx改成闭包内的普通变量/函数类组件里通过this.myRef访问的 ref在函数组件里用useRef声明但注意函数组件内部的 ref 对象不会像类实例那样随生命周期自动 attach 到 DOM必须显式绑到元素上。还有一点很反直觉函数组件的useEffect默认每次渲染后都会执行而类组件的componentDidMount只执行一次。迁移时不要漏掉依赖数组否则可能把一次性请求写成无限循环。4. memo 与 forwardRef面向渲染效率的组件工厂4.1 React.memo 是“创建新组件”的 API不只是性能优化React.memo是一个高阶组件它接收一个组件作为参数返回一个新组件。新组件会对比前后两次 props如果浅比较结果一致就跳过重新渲染。const ExpensiveItem memo(function ExpensiveItem({ data }: { data: Item }) { return li{data.name}/li; });很多人把memo当成一个“优化开关”这是误解。它更像是一个“可控的渲染工厂”没有它函数组件只要父组件渲染就会跟着渲染有了它只有当 props 引用变化时才触发渲染。配合useCallback和useMemo让传给子组件的函数和对象引用稳定才是完整的组合。反例我见过太多次// 父组件每次渲染都生成新对象memo 形同虚设 ExpensiveItem data{{ name: props.name }} /解决方式很简单把对象缓存起来const data useMemo(() ({ name: props.name }), [props.name]); ExpensiveItem data{data} /4.2 forwardRef函数组件接住 ref 的唯一姿势函数组件默认不接收 ref因为 ref 和 props 是分开的。想要把 ref 透传给内部的 DOM 节点就得用forwardRef创建一层新的组件壳。type FancyInputProps { label: string }; const FancyInput forwardRefHTMLInputElement, FancyInputProps((props, ref) { return ( label {props.label} input ref{ref} / /label ); });父组件拿到 ref 后能直接聚焦到内部的 input 节点这是封装表单控件常见的需求。顺便提一句React 19 开始允许把 ref 当作普通 prop 传给函数组件理论上可以不用 forwardRef但现在的存量代码和面试题里 forwardRef 依然是主流答案而且很多第三方库的类型签名还是按 forwardRef 写的所以这个 API 必须掌握。4.3 组合使用时淹死的细节memo 包外层forwardRef 包内层实际开发里memo和forwardRef经常一起出现。推荐写法是外层 memo、内层 forwardRefconst FancyInput memo( forwardRefHTMLInputElement, FancyInputProps((props, ref) { return input ref{ref} {...props} /; }) );这里有个容易忽略的点forwardRef返回的是一个比较特殊的组件对象类型是ForwardRefExoticComponent直接对它再套memo时类型推断偶尔会出问题。稳妥的做法是先声明一个普通组件再分别用 forwardRef 和 memo 包必要时手动标注类型。另一个坑是如果你在类组件里用 refReact.memo包裹后的组件 ref 行为不会受影响但如果你包的是函数组件且没做 forwardRefref 就会拿不到东西。5. 高阶组件以组件为参数、返回组件的工厂函数5.1 属性代理 vs 反向继承两种实现姿势要分清高阶组件HOC的定义很好记一个函数接收组件作为参数返回一个新组件。type ComponentTypeP {} React.ComponentTypeP; function withLoadingP extends object(WrappedComponent: ComponentTypeP) { return function WithLoading(props: P) { const loading useLoading(); if (loading) return div加载中.../div; return WrappedComponent {...props} /; }; }这个例子是属性代理模式HOC 不修改原组件只是替调用方渲染它并在渲染前控制 props 或增加 UI。这是 99% 业务场景用的方案。还有一种是反向继承function withLoggingP extends object(WrappedComponent: ComponentTypeP) { return class Enhanced extends WrappedComponentP { render() { return super.render(); } }; }反向继承让 HOC 类继承被包装的组件从而可以访问原组件的 state 和生命周期。听着很强大但隐患极大它要求被包装组件必须是类组件会破坏封装边界一改原组件内部实现可能就出问题。面试偶尔会问实际项目我基本不推荐用。5.2 用 HOC 封装权限、埋点与加载状态的通用模式HOC 最适合的场景是横向能力的注入权限校验、埋点上报、加载态、错误兜底。权限控制是我用得最多的。假设路由组件必须登录才能看function withAuthP extends object(Component: ComponentTypeP) { return function WithAuth(props: P) { const { user } useAuth(); if (!user) return Navigate to/login replace /; return Component {...props} /; }; } const AdminPage withAuth(Admin);这样封装的好处是页面组件本身不关心“有没有权限”只要在路由配置里套一层withAuth即可。埋点也一样function withTrackP extends object(Component: ComponentTypeP) { return function WithTrack(props: P) { useEffect(() { track(page_view, { path: location.pathname }); }, []); return Component {...props} /; }; }HOC 的抽象级别比 hooks 高它通常承载“组件级”的横切能力而 hooks 更适合承载“逻辑级”的复用。两者不冲突经常配合使用。5.3 HOC 最容易踩的坑displayName、ref、内联创建写 HOC 时最容易被骂的坑有三个。第一个是displayName。HOC 返回的新组件名字默认是WithLoadingDevTools 里看不到原始组件名排查问题非常痛苦。规范做法是手动拼接function getDisplayName(WrappedComponent: ComponentType) { return WrappedComponent.displayName || WrappedComponent.name || Component; } Enhanced.displayName withLoading(${getDisplayName(WrappedComponent)});第二个是ref。ref不会出现在 props 里属性代理如果不处理withAuth(Admin)外面传的 ref 会丢。要么配合forwardRef把 ref 转发给被包装组件要么约定业务上通过innerRef之类的 prop 传。第三个也是最隐蔽的不要在渲染方法里创建 HOC。function Parent() { const Enhanced withAuth(Admin); // 错误示范每次渲染都是新组件 return Enhanced /; }因为每次渲染都会生成一个新的Enhanced函数类型React 会认为这是不同类型组件导致加载状态重置、动效闪烁、性能雪崩。HOC 的创建要放在模块顶层只在模块初始化时执行一次。6. render props 与 children 函数把 UI 创建权交给调用方6.1 render props 的核心结构一个 prop 渲染一段 UIrender props 是一种反常规的组合方式。普通组件是“父组件决定子组件渲染什么”render props 则是“组件内部决定什么时候渲染但渲染内容由调用方通过函数 prop 决定”。interface MouseProps { render: (state: { x: number; y: number }) ReactNode; } class Mouse extends React.ComponentMouseProps { state { x: 0, y: 0 }; handleMove (event: MouseEvent) { this.setState({ x: event.clientX, y: event.clientY }); }; render() { return div onMouseMove{this.handleMove}{this.props.render(this.state)}/div; } }使用方可以完全按自己的需求绘制 UI而鼠标监听逻辑被封装在 Mouse 里Mouse render{({ x, y }) img src{cursor} style{{ position: absolute, left: x, top: y }} /} /严格来说 render props 不是“创建组件的新函数”它是在创建组件时把部分 UI 的生成委托给调用方。这是一种值得记住的组合思想。6.2 children 函数另一种“槽位”设计children 作为函数是 render props 的变体。区别只在于函数通过children传入而不是某个具名 prop。function Toggle({ children }: { children: (state: { on: boolean; toggle: () void }) ReactNode }) { const [on, setOn] useState(false); return children({ on, toggle: () setOn((v) !v) }); }使用Toggle {({ on, toggle }) ( button onClick{toggle}{on ? 开 : 关}/button )} /Toggle这种方式特别适合表达“组件只管行为不管长相”的场景。社区很多表单库、动画库的早期版本就大量使用 children 函数。6.3 为什么现在的 render props 大多被 hooks 替代hooks 出现后render props 的很多场景都被替代了。因为 render props 一旦多个叠加JSX 会变成“回调地狱”Mouse {({ x, y }) ( Theme {({ theme }) ( User {({ user }) div{user.name} at {x},{y}/div} /User )} /Theme )} /Mouse这种嵌套可读性极差。换成 hooks 之后const { x, y } useMouse(); const { theme } useTheme(); const { user } useUser();逻辑上一目了然。但 render props 并不会完全消失它依然适合那些“需要把渲染内容作为参数传入”的场景比如React Router的某些 API、Suspense的 fallback 设计、组件库中的虚拟列表渲染项。理解它不是为了多用而是为了看懂老代码和设计复杂组件接口。7. 复合组件创建组件簇而不是单个组件7.1 从 Modal 头身脚引出复合组件的设计动机有时候一个 UI 模块不是一个组件能搞定的而是一族组件。比如 Tabs、Modal、DropDown它们天然分头、身、脚多个部分。如果用一个大配置对象硬写调用方会痛苦Tabs items{[ { id: home, label: 首页, render: Home / }, { id: settings, label: 设置, render: Settings / }, ]} /配置写法一旦复杂起来可扩展性很差。复合组件则更像写 HTML 结构Tabs TabList Tab首页/Tab Tab设置/Tab /TabList TabPanel首页内容/TabPanel TabPanel设置内容/TabPanel /Tabs这种 API 对使用者极其友好因为结构和最终渲染 UI 完全映射不用记忆配置字段。7.2 用 Context 串起父组件与内部子组件复合组件的“灵魂”是父组件通过 Context 给子组件传递共享状态。父组件维护一个activeId和切换函数子组件从 context 里读取自己被激活与否。const TabsContext createContext{ activeId: string; setActiveId: (id: string) void; } | null(null); function Tabs({ children }: { children: ReactNode }) { const activeIdRef useState(0); // 简化起见实际会存 id 而不是索引 ... }标准实现为了避免 props 逐层下传直接提供一个 Provider 包住 children所有 Tab 内部通过useContext取状态。这样做的好处是父组件只管状态子组件只管展示协作关系在 context 层约定对外 API 非常干净。7.3 cloneElement 动态拼接子组件的限制与正确用法复合组件还有一种“给 children 注入 props”的实现方式叫cloneElementconst Buttons ({ children, size }: { children: ReactNode; size: sm | md }) ( div {React.Children.map(children, (child, index) React.isValidElement(child) ? React.cloneElement(child, { size, key: index }) : child )} /div );cloneElement适合做“浅层注入”给子组件批量加 props不用手动传。但它有三个限制要记住child必须用React.isValidElement判断否则 children 里混入纯文本、数组会出错只能加 props不能改组件类型cloneElement 会浅合并 props如果你要覆盖原来的 props顺序要小心避免把子组件内部已经写的 props 误改了。在复杂复合组件里我越来越倾向于用 Context 而不是 cloneElement因为 cloneElement 的隐式修改很难追踪而 Context 的意图更明确。cloneElement 更适合做按钮组、表单控件这类“统一加样式或行为”的场景。8. 工程与面试视角创建组件时怎么选才不踩坑8.1 选型决策清单我把这么多年实际项目里的选型标准整理成一张表可以直接当 checklist 用场景推荐形态理由常规页面、普通 UI函数组件 hooks简洁、好测、类型友好需要捕获渲染错误类组件 ErrorBoundaryhooks 目前没有对应能力老项目维护类组件、函数组件并存优先读懂现状再谈迁移列表/高频渲染且 props 稳定React.memouseCallback/useMemo减少不必要的子组件渲染需要把 ref 传给 DOMforwardRef或 React 19 的 ref prop函数组件不自带 ref 能力需要组件级权限、埋点、加载态HOC横向能力统一注入需要复用鼠标位置、订阅、表单等逻辑自定义 hooks可组合、无嵌套需要 UI 创建权交给调用方render props / children 函数组件管行为调用方管长相内部状态复杂且结构上可拆成多个子组件复合组件 Context对外 API 贴合 JSX 习惯8.2 我踩过的组件创建坑内联 HOC、对象 props、children 误用复盘我自己的项目踩得最深的坑集中在下面几个。内联 HOC 造成卸载重挂。有一个通知列表页面我在 render 里调用了withReadStatus(NoticeItem)结果每次刷新列表所有 NoticeItem 都被 React 判定为新组件全部重新挂载展开状态、已读状态全丢接口请求也翻了几倍。后来把 HOC 调用移到模块顶层问题立刻消失。这种错误极难排查因为渲染结果看起来是对的只有状态和性能在悄悄崩。memo 被对象 props 刺穿。子组件明明包了memo但父组件里写了一个对象字面量Row item{{ id: row.id }} /memo 的浅比较发现对象引用每次都变了照样重渲染。后来团队约定凡是传给 memo 子组件的对象一律用useMemo生成凡是传给 memo 子组件的回调一律用useCallback。这个约定要写进 code review 的 checklist。children 误用导致类型失控。团队里有人把React.FC当成标配结果一个纯图标组件没阻止 children调用时塞了文本到组件里整个布局乱了。后来类型签名改成显式 props才把这类隐患从编辑器层面堵住。8.3 面试官问“创建组件的多种方式”时怎么组织答案如果这道题出现在面试里我的建议是不要只背“函数、类”两个词按三层结构答会让面试官明显感受到你有体系。先讲本质React 只认函数和类两种组件形态前者被调用后者被实例化后调render再讲包装层的 APIReact.memo生成按浅比较决定是否渲染的新组件forwardRef生成能接住 ref 的组件壳HOC 是“组件进、组件出”的组件工厂最后讲组合层的模式render props / children 函数把 UI 创建权委托给调用方复合组件Context 把一组组件编排成一个小型 DSL补一句现代趋势自定义 hooks 正在替代 render props 和老一部分 HOC 逻辑但 memo、forwardRef、ErrorBoundary 依然是绕不开的基础设施。用这个结构答代码不用写太长讲清楚每个模式解决了什么问题、有什么代价即可面试官追问概率会明显下降。我自己在实际项目里的排序其实是能用函数组件就绝不用类组件能写 hooks 就绝不叠 HOC必须做性能控制时才上 memo必须拿 DOM 节点时才加 forwardRef。所谓“多种创建方式”不是选择题而是工具箱里的不同抽屉知道每个抽屉装什么、什么时候该拉开比记住所有写法更重要。
阅读完成 · 觉得有帮助?
咨询建站