从 React.FC 到 UI = fn(props):一堂 TS 组件设计实战课

0 阅读15分钟

从 React.FC 到 UI = fn(props):一堂 TS 组件设计实战课

今天这堂课的核心是回答一个问题:TypeScript 在 React 组件里到底约束了什么,以及为什么约束的方式会随着设计理念从 v1 演进到 v3。

老师先在 readme 笔记里打下了概念框架,然后打开代码文件逐版推进——从最原始的"事件对象透传",到子组件封装私有状态,最后落点到子组件零状态、纯展示的 UI = fn(props)。整个过程不是孤立的知识点堆砌,而是一条连贯的设计演进链。


阶段零:在 readme 建立类型概念框架

在实际动手前,readme 先铺开了几个核心概念。这些不是随手写的——它们构成了后续所有代码实验的理论基础。

React.FC 是什么

React.FC 是 React 内置的函数组件类型标注,它的源代码定义简化后长这样:

type FC<P = {}> = FunctionComponent<P>;

interface FunctionComponent<P = {}> {
    (props: P): ReactElement<any, any> | null;
}

泛型 P 就是你组件的 Props 类型。如果什么都不传,P 默认是 {}(空对象)。

注意 React 18 一个重要的变化:之前 React.FC 隐式包含 children 属性,React 18 之后移除了,需要 children 必须显式声明。

React.FC 同时约束输入和输出

readme 里并列写了两个类型:

() => void
() => ReactNode

这两个类型揭示了 React 组件的本质——React.FC 展开后,本质上就是一个函数:输入是 props,输出是 ReactNode(React 能渲染的任何东西)。而 () => void 则是普通回调的类型(事件处理、副作用),不返回 UI。两者对照,就把"组件的本质是返回 UI 的函数"这个事实摆在了台面上。

type 别名:FC 本身就是别名

FC 就是 FunctionComponenttype 别名,少打十来个字符而已。type 关键字的核心价值就三个:少写代码、改一处全生效、见名知义。

学到这会想问: React.FC 到底有什么实际作用?是不是所有组件都要写?为什么 App 不接收 props 也写了 React.FC?还有,Props 和 props 是不是同一个东西?

解答:React.FC 就两个实实在在的作用。第一,调用时自动检查——你在 JSX 里写 <Hello username={42} /> 而 Props 要求 string,TS 直接标红,代码还没跑就知道错了。第二,写代码时有智能提示——敲 props. 编辑器自动弹出所有可用字段,拼错马上标红。

但 React.FC 不是必须的。只有接收 props 的组件才需要在 <> 里传泛型约束,App 不接收任何 props,写 React.FC 纯粹是项目中风格统一,去掉完全没问题。社区主流甚至倾向于不写多余的 React.FC。

至于 Props(大写 P)和 props(小写 p),这是两个完全不同层面的东西。Props 是一个 TypeScript 接口——编译时存在,编译成 JavaScript 后彻底消失;props 是 React 运行时传给组件的真实 JavaScript 对象,浏览器里实实在在地存在。前者是"合同",后者是"快递包裹"。


阶段一:Hello 组件 —— 最简的 Props 传递

readme 建立概念后,老师打开了 Hello.tsxApp.tsx,演示最基础的父子组件组合。

Hello.tsx:

import * as React from 'react';

interface Props {
  userName: string
}

const Hello: React.FC<Props> = (props) => {
  return (
    <h2>Hello {props.userName}</h2>
  )
}

export default Hello;

App.tsx:

import * as React from 'react';
import Hello from './components/Hello';

const App: React.FC = () => {
  const [userName, setUserName] = React.useState('initialName');

  return (
    <div>
      <Hello userName={userName} />
    </div>
  )
}

export default App;

这里有一个学生容易混淆的点:interface Props { userName: string }React.FC<Props> 各司其职。interface 定义"合同"——规定了别人调用 Hello 时必须传什么、什么类型。React.FC<Props> 把这份合同绑定到组件上,告诉 TypeScript"这个组件对外承诺接收 Props 类型的参数"。

数据流是单向的:

App 持有 userName 状态
    │
    ▼
<Hello userName={userName} />
    │
    ▼
Hello 收到 props = { userName: "initialName" }
    │
    ▼
渲染 <h2>Hello initialName</h2>

这里引出了一个重要认知:React.FC 不仅约束参数的输入,还约束返回值的输出。组件必须返回 ReactElement | null。如果你 return 123 或者 return 'hello',TypeScript 直接报错。JSX(<h2>xxx</h2>)编译后就是一个 ReactElement 对象,包含 typeprops$$typeof 等字段,React 运行时读到这个对象就知道要渲染什么。


阶段二:深入泛型与 Props 的定义方式

Hello 组件讲完后,自然会引出两个深层问题:<> 里到底能放什么,以及除了 interface 还能怎么定义 Props。

泛型:给类型传参数

React.FC 本身是不完整的——它不知道这个组件的 props 长什么样。往 <> 里塞一个类型,它就完整了,就像函数 f(x) 给值传参,泛型 T<X> 给类型传参。同一个模板,填不同的具体类型,得到不同的版本。

React.FC<Props> 里的 Props 必须是对象类型——因为 React 的 props 永远是一个对象。你传 <Hello userName="布" />,React 内部组装成 { userName: "布" } 传给组件。理论上 <> 里可以写 string,语法不报错,但组件没法正常用——JSX 传不了裸值,一定是对象。

四种定义 Props 的方式

// 方式一:interface(最常用,字段多、需继承、需导出时首选)
interface Props {
  username: string
  age: number
}

// 方式二:type 别名(效果等同 interface,但不能重复定义)
type Props = {
  username: string
  age: number
}

// 方式三:对象字面量直接写(一两个字段、写了就扔时最方便)
const Hello: React.FC<{ username: string; age: number }> = ...

// 方式四:不用 React.FC,约束挂参数上(社区主流)
const Hello = ({ username }: { username: string }) => ...

interfacetype 在这个场景下几乎等同,唯一的细微差别是 interface 可以分两处写自动合并,type 不行(重名会报错)。日常基本碰不到这个差异,所以随便用。

接口的名字也可以随意取——Props 只是社区约定俗成的名字,写成 MyComponentPropsHelloParams 都能正常工作。

学到这会想问: 既然泛型 <Props> 约束了参数,那是不是意味着用了 React.FC 就不用在函数体里写逻辑了?还有,<> 里面只能放一个接口吗?以及这个 <> 是专门约束后面的参数的?

解答:React.FC 只是一个类型标签,贴在组件上告诉 TypeScript"这个函数的 props 长这样"。标签不替你写逻辑,花括号里该写什么还得写。它和内部逻辑完全是两码事,TypeScript 的类型在编译后会被全部擦除,浏览器里根本没有它存在过。

至于 <>,泛型参数确实只接受一个类型。但如果你需要多个约束可以组合——用 & 交叉类型(React.FC<Base & Extra>)或用 extends 合并多个接口。不管怎么组合,塞进 <> 里的始终只是一个整体类型。

这个 <> 里的类型同时做了两件事:对外约束调用者必须传什么(<Hello /> 缺少 username 直接标红),对内约束 props 能点出什么属性(敲 props. 时弹出正确提示)。一个类型,双向约束。


阶段三:React 合成事件 —— 当 Props 里出现函数

readme 里有一条关键记录:interface 自定义事件。这里"自定义事件"指的不是 DOM 事件,而是 Props 里的函数类型字段

打开 NameEditComponent 的第一版(现在被注释掉了),Props 里出现了函数:

interface Props {
  username: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

onChange 的类型不是 string、不是 number,而是一个函数。React 组件间的通信,数据往下流、回调往上流——数据走的是 string 通道,回调走的是函数通道。但从 TypeScript 的角度看,它们在 Props 接口里的地位完全平等:都是一种类型约束。

React.ChangeEvent 拆解

e: React.ChangeEvent<HTMLInputElement>
│  └──────┬──────┘ └────┬────┘
│     React 合成事件    泛型:事件发生在哪个元素上
│
参数名

这个类型声明做了两件事。第一,React.ChangeEvent 告诉 TypeScript 这是 React 包装过的合成事件——React 把浏览器的原生事件统一包了一层,抹平了 Chrome、Firefox、Safari 之间的差异,提供一致的 API。第二,<HTMLInputElement> 泛型标注了事件发生在 <input> 元素上,所以 e.target.value 的类型被自动推导为 string,可以直接用,不用 as any

不同事件类型有固定的内置名称:

React 事件类型对应行为
React.ChangeEvent<input> <select> <textarea> 值改变
React.MouseEvent点击、双击、鼠标移入移出
React.KeyboardEvent按键按下、抬起
React.FormEvent表单提交

不同元素的泛型也不同:HTMLInputElementHTMLSelectElementHTMLTextAreaElementHTMLButtonElement。TS 根据泛型知道 e.target 是什么元素,从而决定有哪些属性可用。比如 <input>e.target.value 也有 .checked(checkbox 场景),两个都会提示。

学到这会想问: React.ChangeEvent 是固定的吗?如果是 checkbox 怎么办?还有,Props 里这个函数类型的声明,里面的参数名是干什么的?为什么要写 onNameUpdate 而不是直接复用 useStatesetUserName

解答React.ChangeEvent 是 React 内置的固定类型,不用自己定义,直接拿来用。React 提供了一整套事件类型——React.MouseEventReact.KeyboardEventReact.FormEvent 等等,用的时候选对应类型套上元素泛型就行,记不住就输入 React. 等编辑器提示。Checkbox 的场景仍然用 React.ChangeEvent<HTMLInputElement>,因为 checkbox 也是 <input> 标签,只是取值从 e.target.value 变成 e.target.checked

类型声明里的参数名(如 newEditingName)本身不影响类型检查,TypeScript 只看类型不看名字。它的作用是给读代码的人看——(newEditingName: string) => void(string) => void 更能表达意图,同时 IDE 也会用这个参数名做智能提示。

至于 onNameUpdatesetUserName 的关系,它们本质上是同一个函数,只是换了个名字。父组件把 setUserName 传给 <NameEditComponent onNameUpdate={setUserName} />,子组件通过 props.onNameUpdate 拿到它。子组件只知道这个函数叫 onNameUpdate,不知道也不关心它在父组件里原名叫什么——就像一个电话号码存进别人手机,显示名叫"老张"但打的还是同一个号。


阶段四:三版组件演进 —— 从事件透传到纯展示

readme 里有一段关键的版本变迁记录,这也是今天最深度的教学内容:

版本的变迁:
1. 把子组件的 event 对象传给父组件,导致两边都要 React.ChangeEvent
2. 在子组件之中添加私有状态 editingName,onChange 自己修改,提交时只给值
3. 将私有状态提升到父组件,通过 props 传过来,子组件没有状态
   UI = fn(props),子组件职责单一,只负责展示

这三个版本不是随手改的——它们代表了对"状态应该放在哪里"这个问题越来越成熟的理解。

版本 1:事件对象透传(原始版)

子组件代码(已被注释):

interface Props {
  username: string
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}

const NameEditComponent: React.FC<Props> = (props) => {
  return (
    <div>
      <label>Update name:</label>
      <input value={props.username} onChange={props.onChange} />
    </div>
  )
}

父组件代码:

const setUsernameState = (event: React.ChangeEvent<HTMLInputElement>) => {
  setUserName(event.target.value);
}

<NameEditComponent username={username} onChange={setUsernameState} />

版本 1 的核心特征:子组件是一个透明管子。用户在输入框敲字,React 生成合成事件对象,子组件不做任何处理,直接把事件对象整个丢给父组件。父组件拿到事件对象后,得自己从 event.target.value 里把字符串抠出来。

问题很明显:React.ChangeEvent<HTMLInputElement> 这个复杂的类型同时出现在子组件的 Props 和父组件的回调参数里,两处耦合。父组件本来只管业务逻辑("名字变了,存新名字"),现在被迫处理 DOM 细节("事件对象的 target 属性上有一个 value 字段")。

版本 2:子组件私有状态(App1 + NameEditComponent1)

子组件 NameEditComponent1:

interface Props {
  initialUsername: string
  onNameUpdated: (newName: string) => void    // 注意:管子变细了,只传 string
}

const NameEditComponent1: React.FC<Props> = (props) => {
  const [editingName, setEditingName] = React.useState(props.initialUsername);

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEditingName(e.target.value);    // 更新自己的私有状态
  };

  const onNameSubmit = () => {
    props.onNameUpdated(editingName);  // 点按钮才把最终值传给父组件
  };

  return (
    <div>
      <label>Update name:</label>
      <input value={editingName} onChange={onChange} />
      <button onClick={onNameSubmit}>Update</button>
    </div>
  )
}

父组件 App1:

const [username, setUserName] = React.useState('initialName');

<NameEditComponent1
  initialUsername={username}
  onNameUpdated={setUserName}
/>

版本 2 做了一个关键设计决策:子组件拥有自己的私有状态 editingName。它用 useState(props.initialUsername) 拿一次初始值,之后 editingName 就和 props.initialUsername 彻底脱钩——props 变了,editingName 不会自动更新。

这个设计的巧妙之处在于保护了用户的临时编辑。假设用户在输入框里打了半截字,如果外部 props 突然更新导致输入框内容被覆盖,用户编辑了一半的内容就会丢失。useState 只在组件出生时读一次初始值,之后完全由子组件自己管理,直到用户主动点按钮,才通过 props.onNameUpdated(editingName) 把最终值提交给父组件。

管子变细了:onNameUpdated: (newName: string) => voidReact.ChangeEvent 被锁在了子组件内部,父组件收到的是一个干净的 string,它的签名恰好和 useState 的 setter((value: string) => void)完全匹配,所以可以直接传 onNameUpdated={setUserName},不需要包一层中间函数。

版本 3:受控组件 + useEffect(App2 + NameEditComponent2)

子组件 NameEditComponent2:

interface Props {
  initialUsername: string
  editingName: string                           // 编辑值也从父组件来
  onNameUpdated: () => void                     // 确认——不传值了
  onEditingNameUpdated: (newName: string) => void  // 每次按键都通知父组件
  disabled: boolean                             // 禁用也由父组件决定
}

const NameEditComponent2: React.FC<Props> = (props) => {
  const { editingName, onEditingNameUpdated, onNameUpdated, disabled } = props;

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onEditingNameUpdated(e.target.value);   // 每次按键,把值传给父组件
  };

  const onNameSubmit = () => {
    onNameUpdated();    // 只发通知,不带值,因为父组件自己就有 editingName
  };

  return (
    <>
      <label>Update name</label>
      <input value={editingName} onChange={onChange} />
      <button disabled={disabled} onClick={onNameSubmit}>Update</button>
    </>
  )
}

父组件 App2:

const [name, setName] = React.useState<string>('defaultUserName');
const [editingName, setEditingName] = React.useState<string>('defaultUserName');

const setUsernameState = () => {
  setName(editingName);   // editingName 就在自己身上,直接拿
};

// useEffect:组件先渲染,再异步拉数据
const loadUserName = () => {
  setTimeout(() => {
    setName('name from async call');
    setEditingName('name from async call');
  }, 2000);
};

React.useEffect(() => {
  loadUserName();
}, []);   // 空数组 = 只在组件挂载后执行一次

<NameEditComponent2
  initialUsername={name}
  editingName={editingName}
  onNameUpdated={setUsernameState}
  onEditingNameUpdated={setEditingName}
  disabled={editingName === "" || editingName === name}
/>

版本 3 是最激进的:子组件完全没有任何 useState,所有状态全部提升到父组件

这带来了一个数据闭环。用户每次按键,onEditingNameUpdated(e.target.value) 直接把值传给父组件的 setEditingName,父组件更新状态后重渲染,再把新的 editingName 通过 props 灌回子组件:

用户按键
  → onChange 触发
    → onEditingNameUpdated(e.target.value)  // 从子流向父
      → 父 setEditingName(...)              // 父更新状态
        → App2 重渲染
          → <NameEditComponent2 editingName={...} />  // 从父流回子input.value 更新              // 用户看到最新文字

点按钮时,onNameUpdated() 连值都不传了——因为 editingName 本身就在父组件手里,父组件的 setUsernameState 直接从自己身上拿就行。这和版本 2 形成鲜明对比:版本 2 是子组件拿着草稿说"给你值",版本 3 是父组件说"不用给我,我自己有"。

App2 还引入了 useEffect,演示真实项目里数据是从后端异步加载的这个事实。useEffect 的第二个参数是空数组 [],表示只在组件挂载后执行一次。这体现了 React 的核心设计哲学:组件的第一要素是赶快显示出来,让用户看到。页面先渲染初始值,2 秒后数据回来,自动更新 UI。用户感知是"快"的。

disabled 是版本 3 的一个典型体现——连按钮能不能点都由父组件算好:

disabled={editingName === "" || editingName === name}
//        ──────────────────    ──────────────────
//        输入框为空,不让你存                            没改动,也不需要存

子组件不判断、不算逻辑,它只知道 props 里来了一个 disabledtrue 就灰掉,false 就亮着。以后要改禁用规则,只改 App2 这一行,子组件不用动。

三个版本对照总表

版本 1版本 2版本 3
子组件状态有 useState
编辑状态存哪父(但父只存展示值)子组件自己父(存展示值+编辑值)
每次按键父组件重渲染子组件自己重渲染父组件重渲染,再灌回子组件
子传父传什么事件对象最终 string每次按键传 string
子组件职责透传封装内部逻辑纯展示

收束:回到 readme,理解 UI = fn(props)

readme 在版本变迁记录的最后写了一行字:

UI = fn(props),子组件职责非常单一,就是负责展示,不负责修改状态。

这不是一句口号,而是三个版本演进之后的必然结论。版本 3 的子组件就是一个纯函数:输入 props,输出 UI。输入什么就显示什么,自己不存状态也不会做决策;用户做了什么,立刻通知外面,让外面决定。disabled 这种逻辑也由父组件算好,子组件只管"你让我灰我就灰"。

这并不是说版本 3 永远比版本 2 好。版本 2 把编辑状态封装在子组件内部,父组件更简洁,适合输入过程不需要被外部感知的场景。版本 3 把所有状态提升到父组件,子组件成为纯傀儡,适合需要对外部状态变化做细粒度控制的场景。选哪个,取决于"状态的控制权应该归谁"。


今日知识清单

类型系统基础:

  • React.FC<P> 同时约束了组件的输入(Props)和输出(必须返回 ReactElement | null
  • FC 是 FunctionComponent 的 type 别名
  • 泛型 <> 给类型传参,和函数传参对等

Props 接口:

  • interface 和 type 都可以声明 Props 类型,效果几乎等同
  • 四种定义方式:interfacetype、对象字面量、参数直接标注
  • Props 里既有数据(stringnumber),也有函数(() => void(e: XxxEvent) => void
  • Props(接口,编译时存在)和 props(运行时对象)是两个层面的东西

React 合成事件:

  • React 把浏览器原生事件包成合成事件,跨浏览器统一 API
  • React.ChangeEvent<HTMLInputElement> 中,ChangeEvent 是事件类型,<HTMLInputElement> 是泛型标注事件在哪发生
  • e.target.valuee.target.checked 的类型由泛型自动推导
  • 事件类型是 React 内置的固定类型,不用自己定义

useEffect:

  • 组件先快速渲染,副作用在挂载后触发,让用户感知"快"
  • 第二个参数 [] 表示只在挂载后执行一次

三版组件演进:

  • v1 事件对象透传:子组件是透明管子,父组件被迫处理 DOM 细节
  • v2 私有状态封装:useState 只读一次初始值,编辑过程对外部隔离,提交时才通知父
  • v3 受控组件:子组件零状态,UI = fn(props),一切由父组件管理
  • disabled 作为受控 prop,体现了"父组件算逻辑,子组件只展示"的设计原则

代码演进路线: NameEditComponent 经历了 v1(事件透传)→ v2(添加私有 useState + 按钮)→ v3(移除 useState,改为 editingNameonEditingNameUpdateddisabled 三个受控 prop),每一步都在把状态管理的职责往更合理的位置移动。


后续延伸方向: 版本 3 的受控模式进一步可以引出 React 状态管理库(Context / Redux / Zustand)的讨论——当多个组件需要共享同一份编辑状态时,状态应该放在哪里。useEffect 的依赖数组和清理函数也是下一个值得深入的话题。


#前端 #TypeScript #React #学习笔记