先说个有意思的事:今年我在 code review 里截图留档了一个新同事的 PR,里面赫然写着const Card: React.FC<CardProps> = () => { ... }。倒不是说他写错了,而是这套写法在前端社区已经被反复讨论了好几年,到了 2026 年还全项目统一用React.FC,多少有点“用着十年前的工具链,写五年前的代码,还觉得自己很规范”的意思。
这篇文章我不准备再来一遍“React.FC 能不能用”的口水仗,而是直接给你一套现在真正值得落地的 TypeScript 组件写法。会拆解React.FC的历史包袱、现阶段主流的 props 类型设计、泛型组件、组件通信的类型安全,以及从旧写法迁移到新写法时的工程化步骤。适合所有用 React + TypeScript 写业务或维护组件库的开发者,尤其是团队里还在把React.FC当“规范”的人。
1. React.FC 到底错在哪,先把它拆干净
1.1 它当年为什么能火起来
2018 年前后,React 16.8 刚带火 Hooks,大量团队从 class 组件迁移到函数组件。那时候 TypeScript 对 JSX 的支持还没现在这么顺滑,写一个函数组件如果不加类型标注,props里全是隐式的any,非常难受。React.FC(FunctionComponent 的别名)给了一个看起来很优雅的解法:
import React from 'react'; interface CardProps { title: string; } const Card: React.FC<CardProps> = ({ title }) => { return <div>{title}</div>; };这样写有几个当时很讨喜的特点:函数入参自动帮你标好类型,children会被隐式注入,返回值也会被约束成ReactElement | null。对于刚从 class 组件切换过来、对 TypeScript 还没形成肌肉记忆的人来说,React.FC就像“安全网”,让你少想几件事。
但安全网的代价,是把你的一部分类型判断能力也一起收走了。等社区把函数组件的写法吃透之后,回头再看,React.FC身上的问题就一个个暴露出来了。
1.2 现在回头看,它的问题其实很具体
第一,隐式注入children是个陷阱。React.FC<CardProps>会让Card的 props 类型里“偷偷”多一个children?: ReactNode。假设你像上面那样定义CardProps,本意是不想让Card接收任何子元素,但用的时候<Card>我在哪</Card>也不会报错,类型系统在这里是失守的。更麻烦的是,当你做 props 透传时,这个多余的children还会造成跨组件赋值不匹配,排查起来很头大。
第二,React.FC和泛型组件八字不合。React.FC是一个固定类型别名,它接受一个泛型参数传入 props 类型,但你没法基于它声明一个“组件本身是泛型”的场景。比如你写一个List<T>组件,让items和renderItem的类型跟随外部传入的T联动,用React.FC根本表达不出来——你只能写const List: React.FC<ListProps<unknown>>,然后把T硬编码成unknown,内部还得不断做类型断言,这基本等于放弃类型安全。
第三,defaultProps在React.FC上的类型推断一直有坑。函数组件的默认值正确做法是解构时直接赋初值:
function Button({ type = 'button', children }: ButtonProps) { return <button type={type}>{children}</button>; }用React.FC包裹后,type字段在类型层面仍是“可选且有默认值”,但拿到 props 的一端不会自动把type推断成必填,很多时候还得手动?? 'button',多写一层防御。说白了,React.FC做的约束和真正想要的“props 显式化”之间,存在一层隐式偏差。
第四,调试和性能上虽然差别不大,但React.FC会给函数组件额外包一层类型标注,部分场景下displayName、defaultProps的推导需要依赖FunctionComponent类型的静态属性兼容,一旦你用了 memo 或 forwardRef,这一层类型关系经常把自己绕进去。举个实际场景:
const MemoCard = React.memo<CardProps>(Card);这行代码本身没问题,但如果你一开始把Card定义成React.FC<CardProps>,而CardProps里没有显式声明children,在某些严格版本下React.memo的泛型推导会和FC自带的隐式children打架,报一个让人摸不着头脑的类型错误。为了一个“省事”的写法,反而要多写代码去绕弯,这就本末倒置了。
1.3 它还有没有存在的意义
说实话,到了 2026 年,我找不到一个“必须用 React.FC”的硬场景。如果你在维护一个极老的代码库,或者公司内部规定所有组件必须显式返回ReactElement | null,那它是一个历史兼容选择,不算错误,但也别把它当新项目的默认值。新写的业务代码、组件库代码,我建议一律不套React.FC。谁要是在你团队的新 PR 里看到React.FC,直接拿我后面这几节的理由去对线就行。
2. 现在的主流写法:回归函数本身,让类型显式化
2.1 函数声明 + 独立 props 类型,是最稳的底子
我自己现在写组件的基线是这样:
import type { ReactNode } from 'react'; type ButtonProps = { variant: 'primary' | 'secondary'; children: ReactNode; onClick?: () => void; }; function Button({ variant, children, onClick }: ButtonProps) { return ( <button className={`btn btn--${variant}`} onClick={onClick}> {children} </button> ); }这版写法的核心逻辑就一句话:把 props 当作普通函数的参数,组件就是普通函数。ButtonProps是显式定义的,children要不要、什么类型,完全由你说了算;入参解构直接得到类型,不需要FC帮你做任何隐式注入;函数的返回值交给 TypeScript 自己推导,反正 JSX 的返回类型天然就是ReactElement | null,推导不出来才叫奇怪。
从团队协作的角度看,这种写法的最大优势是“一个东西只有一个来源”。ButtonProps单独导出后,别处要复用、要继承、要用工具类型派生,都非常顺手。而React.FC的写法把组件类型和 props 类型绑死在了一个声明里,想拆开用反而不方便。
2.2 箭头函数到底能不能用,关键看要不要泛型
有些团队偏好const Xxx = () => {}的写法,这没问题。但不建议写成const Button: React.FC<Props> = () => {},而是直接:
const Button = ({ variant, children, onClick }: ButtonProps) => { return <button className={`btn btn--${variant}`}>{children}</button>; };不套FC之后,箭头函数组件和函数声明组件在“普通 props 场景”下几乎没有区别。但一旦涉及泛型组件,箭头函数就要多个心眼。在.tsx文件里,<T>会被 JSX 解析器当成标签开头,所以泛型箭头函数要写成<T,>:
const List = <T,>({ items, renderItem }: ListProps<T>) => { return <ul>{items.map((item, index) => renderItem(item, index))}</ul>; };那个逗号不是手误,是用来告诉 TypeScript“这里是泛型,不是 JSX 标签”。很多小白第一次写泛型组件就卡在这一行。如果不想记这个偏门语法,函数声明写法更省心,因为function List<T>(...)没有这个歧义。
选择函数声明还是箭头函数,我的标准是:需要泛型,优先函数声明;只是简单组件,两者都行,队内统一个偏好就行。真正的重点是别再用FC把两者都框死。
2.3 高阶组件和转发场景用什么类型
当你写 HOC、写装饰器、或者封装forwardRef组件时,直接裸函数已经不够用了,这时候需要用 React 提供的基础类型工具。
ComponentType<P>是“函数组件或类组件”的统一类型,适合做 HOC 的入参类型:
import type { ComponentType } from 'react'; type WithLoggingProps<T> = T & { logMessage?: string }; function withLogging<T extends object>( WrappedComponent: ComponentType<T> ) { return function LoggedComponent(props: WithLoggingProps<T>) { console.log(props.logMessage ?? 'component rendered'); return <WrappedComponent {...props} />; }; }注意这里的<T extends object>,是为了阻止别人传入string、number这种非对象类型当 props 基座。你不加这个约束,TypeScript 不会主动拦你,等到组件内部展开 props 时才报错,那排查成本就上去了。
至于forwardRef,React 19 已经在标准 props 里支持ref作为普通属性传入,不需要再包一层forwardRef。但如果你还在维护 React 18 及以下的代码,旧式写法里ForwardRefRenderFunction比React.FC更准确:
import { forwardRef } from 'react'; import type { ForwardRefRenderFunction } from 'react'; interface InputProps { label: string; } const InputInner: ForwardRefRenderFunction<HTMLInputElement, InputProps> = ( { label }, ref ) => <input ref={ref} aria-label={label} />; export const Input = forwardRef(InputInner);这套写法的关键是“类型意图分离”:第一个泛型参数是 ref 指向的 DOM 元素类型,第二个才是组件 props 类型。用React.FC写 forwardRef 的话,ref 类型和 props 类型会搅在一起,推导经常失真。
3. 进阶模式:泛型组件、派生 Props 与通信类型安全
3.1 泛型组件是 2026 年组件设计的及格线
现在做 UI 组件库,泛型组件几乎成了标配。最典型的场景是列表和选择器:
import type { ReactNode } from 'react'; type ListProps<T> = { items: T[]; renderItem: (item: T, index: number) => ReactNode; keyExtractor: (item: T, index: number) => string | number; }; function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { return ( <ul> {items.map((item, index) => ( <li key={keyExtractor(item, index)}>{renderItem(item, index)}</li> ))} </ul> ); }使用的时候,TypeScript 会根据items和renderItem自动推断T,如果renderItem里写了一个不存在的字段,立即报错。这比用List<any>然后靠运行时炸锅要可靠太多。
泛型组件再往后走,会遇到“多泛型联动”的需求。比如一个Select<T, K>组件,value的类型由T决定,onChange回调接收的类型由K决定:
type SelectProps<T, K> = { options: T[]; value: K; getValue: (item: T) => K; onChange: (value: K) => void; }; function Select<T, K>({ options, value, getValue, onChange }: SelectProps<T, K>) { return ( <select value={String(value)} onChange={(e) => { const matched = options.find((opt) => String(getValue(opt)) === e.target.value); if (matched) onChange(getValue(matched)); }}> {options.map((opt) => ( <option key={String(getValue(opt))} value={String(getValue(opt))}> {JSON.stringify(opt)} </option> ))} </select> ); }这类组件用React.FC是写不出来的。组件库越做越深,泛型几乎是绕不开的工具。
3.2 派生 Props:别把 HTML 原生属性重新写一遍
业务组件一个常见的需求是“包装一个 button / input,同时支持原生属性”。过去很多人会手动把onClick、disabled、className一个个复制到自己的 props 类型里,不仅累,还经常漏。正确做法是用ComponentProps或ComponentPropsWithoutRef派生:
import type { ComponentPropsWithoutRef } from 'react'; type ButtonProps = ComponentPropsWithoutRef<'button'> & { variant?: 'primary' | 'ghost' | 'danger'; loading?: boolean; }; function Button({ variant = 'primary', loading = false, disabled, children, ...rest }: ButtonProps) { return ( <button className={`btn btn--${variant}`} disabled={disabled || loading} {...rest}> {loading ? '加载中...' : children} </button> ); }这样Button天然支持type、aria-label、>type ButtonProps = Omit<ComponentPropsWithoutRef<'button'>, 'type'> & { type?: 'button' | 'submit' | 'reset'; variant?: 'primary' | 'ghost'; }; function Button({ type = 'button', ...rest }: ButtonProps) { return <button type={type} {...rest} />; }
注意Omit的顺序:先剔除再补上,才能精准替换。反过来如果先&再Omit,会把自己追加的type也删掉,这就翻车了。
3.3 组件通信的类型安全:父传子、子传父、Context 三种都要有
业务开发中组件通信绕不开“父传子、子传父、跨层级 Context”三种。
父传子最简单,就是普通的 props 类型定义。子传父本质上就是回调函数,关键是回调的参数类型要定义清楚。我建议把“事件动作”和“携带数据”拆开,避免回调里塞一个笼统的any:
type SortAction = | { type: 'sort'; field: 'name' | 'age'; direction: 'asc' | 'desc' } | { type: 'filter'; keyword: string }; type ToolbarProps = { onAction: (action: SortAction) => void; }; function Toolbar({ onAction }: ToolbarProps) { return ( <div> <button onClick={() => onAction({ type: 'sort', field: 'name', direction: 'asc' })}> 按名称排序 </button> <button onClick={() => onAction({ type: 'filter', keyword: '' })}> 清除筛选 </button> </div> ); }这种可辨识联合(discriminated union)的好处是:父组件拿到action后,用一个switch (action.type)就能让 TypeScript 精确收窄每一分支的数据类型。比直接传(field: string, direction: string) => void这种松散回调安全得多。
Context 场景,我推荐把 context 的默认值设计成“显式可空”而不是“给个空对象然后到处断言”:
import { createContext, useContext } from 'react'; interface ThemeContextValue { theme: 'light' | 'dark'; toggleTheme: () => void; } const ThemeContext = createContext<ThemeContextValue | null>(null); export function useThemeContext() { const ctx = useContext(ThemeContext); if (!ctx) { throw new Error('useThemeContext 必须在 ThemeContext.Provider 内使用'); } return ctx; }用一个自定义 hook 包一层,而不是让组件直接useContext(ThemeContext)然后手动判空,这是我在项目里踩过坑后定的规矩。否则每个消费组件都要写一遍判空逻辑,总有人会偷懒不判,等到undefined出现时又是一个诡异的运行时错误。
3.4 React 19 与 TypeScript 5.x 带来的新变化
到了 2026 年,React 19 已经是很常见的基线版本了。它最大的变化之一是ref可以直接作为普通 prop 传递,forwardRef不再必需。类型上对应的写法更清爽:
type InputProps = { label: string; ref?: React.Ref<HTMLInputElement>; }; function Input({ label, ref }: InputProps) { return <input ref={ref} aria-label={label} />; }如果你在用 React 19,新组件就别再套forwardRef了,直接用上面的方式即可。老组件迁移也不难,把forwardRef包的那层去掉,ref声明移到 props 里。
TypeScript 这方面,5.x 系列的const类型参数、更快的增量构建对大型前端项目帮助很大。有一个编译器选项我建议新项目直接开:"verbatimModuleSyntax": true。它会强制你使用import type导入纯类型,从根上避免类型被编译进产物导致的循环引用问题。开了这个选项,上面我写的import type { ReactNode }这种风格就是必须的了,而不是可选的“推荐写法”。
另外一个值得留意的选项是"erasableSyntaxOnly": true,TypeScript 5.8 引入的,它限制你使用那些需要运行时保留的语法(比如 enum、namespace、类参数属性),因为 React 组件类型大多只是“可擦除”的结构化类型,这套约束能保证类型系统和编译产物解耦。如果你在维护组件库,这个选项可以让产物体积更可控,类型也更干净。
4. 工程化落地:怎么从 React.FC 迁到新写法还不翻车
4.1 用 ESLint 把门槛卡住
代码规范靠 code review 人肉盯,迟早漏。强烈建议在 ESLint 配置里把“禁止使用 React.FC”自动化。你可以直接用@typescript-eslint配合no-restricted-syntax,或者找一个现成的规则插件。我项目里用的自定义规则类似这样:
// eslint.config.js 片段 export default [ { files: ['**/*.tsx'], rules: { 'no-restricted-syntax': [ 'error', { selector: 'TSTypeReference[typeName.name="FC"]', message: '请勿使用 React.FC,改用普通函数声明 + 显式 props 类型' } ] } } ]另外两个推荐开的规则:@typescript-eslint/consistent-type-definitions设置为'type',强制团队统一用type而不是interface定义 props,减少两种写法混用带来的认知负担(这条看团队偏好,至少统一一种);react/display-name保留,但对“不写 React.FC 的普通函数组件”它通常不误报,不用额外关。
还有一点容易被忽略:@typescript-eslint/consistent-type-imports开了之后,所有纯类型导入会被强制写成import type { ... }。这能有效防止你一边标榜“不用 FC”,一边在文件顶部import React, { FC } from 'react'。
4.2 迁移旧组件的实操步骤
我建议按下面五步走,别想着一次性全部替换,风险太大。
- 先把组件的 props 类型补完整。
React.FC<CardProps>里面的CardProps如果之前依赖隐式children,先显式加上children?: ReactNode,避免删掉 FC 后出现一堆 children 相关的类型报错。 - 把
const Xxx: React.FC<Props> = ({ ... }) => {}改成const Xxx = ({ ... }: Props) => {}。这一步只去类型标注,不动逻辑,配合 ESLint 规则后每个文件改动极小,review 成本低。 - 如果遇到泛型组件或复杂 HOC,确认原先
React.FC是否参与了对泛型的错误收窄,改完函数声明后重新检查useState、useMemo的推断是否正常。 - 跑一遍全量
tsc --noEmit,把报错集中收集,大部分是children缺失、defaultProps类型不匹配、React.memo泛型冲突这三类。逐个修复即可。 - 最后用
react-component-remove-fc之类的 codemod(或自己写的正则)做一次全局扫描,确认没有漏网之鱼。注意 codemod 只能处理简单的React.FC<Props>模式,遇到React.FC<Props> & { defaultProps... }这种组合还是得手改。
我实践下来,一个两三百个组件的项目,按这个顺序分批改,一周内能完成,前提是测试覆盖不要太差。
4.3 常见报错排查速查表
| 报错现象 | 常见原因 | 解决办法 |
|---|---|---|
Property 'children' does not exist on type 'Props' | 之前依赖React.FC隐式注入 children | 在 props 类型里显式声明children?: ReactNode |
Type 'Element' is not assignable to type 'FC<Props>' | 手写了React.FC却没和函数签名对上 | 去掉React.FC标注,保留 props 解构类型 |
Expected 2 type arguments, but got 1 | React.memo在部分版本里泛型参数数量不同 | 不用React.memo<Props>(...),直接用memo(Component)让 TS 推断 |
<T,>语法处报 JSX 错误 | 在.tsx里写泛型箭头函数少了逗号 | 改为<T,>或改用function声明 |
'ButtonProps' is missing the following properties from type 'ButtonProps' | props 里存在可选字段但使用处必须传 | 检查extends或&交叉类型里是否覆盖成了必填 |
Cannot find namespace 'React' | 新版 React 类型导入方式变了 | 使用import type { ReactNode } from 'react'显式导入,别依赖 UMD 全局 |
这张表我都是拿实际报错堆出来的,你照着排查基本够用。如果还有没覆盖到的,优先看tsc的完整报错链,大部分 TypeScript 问题都能从“第一个报错”往下推导,别被后续的连锁报错带偏。
4.4 团队落地时的一点心得
最后给个很实际的建议:不要搞“一刀切”的强制重构。我见过有团队把“禁 React.FC”写进规范后,要求一晚上全量替换,结果第二天一半组件在冒类型错误,新同事还以为是自己的问题。比较稳的做法是分两步走:存量代码只加 ESLint 规则但不自动改,新代码必须按新规范写;然后每周挑一批低风险组件做迁移,把“技术债”拆成一个个可回滚的小任务。
从长期维护角度看,后面我也建议把这些新写法沉淀成团队的组件模板。创建新组件时直接引用模板,比 review 时反复提醒有效得多。等模板用顺手后,团队里自然不会再有人想起来React.FC这回事。这比任何规范文档都管用。
我自己的体会是,React 组件的 TypeScript 写法本质上是“在类型上多花一点心思,在排查上省一大段时间”。React.FC的问题不在于它本身是错的,而在于它用一层隐式魔法把类型问题推迟到了使用端。换成显式 props 类型、泛型组件、可辨识联合之后,大部分类型问题会在写代码的当下就被 IDE 拦住。2019 年大家都在找捷径,2026 年该把路走扎实了。