React + TypeScript 组件设计进阶:从类型约束到无状态组件的三次进化

摘要

以NameEdit三次重构拆解React+TS类型体系:React.FC泛型、interface Props、React.ChangeEvent合成事件,及从传事件到无状态纯展示的进化路径。


一、React + TypeScript:类型约束让组件"自文档化"

JavaScript 写 React 有一个隐痛:你永远不知道子组件期望什么 props,除非打开文件看源码,或者依赖注释和文档。三个月后回头看自己写的组件,onChange 接收什么参数?是 string 还是 Event?------只能靠猜。

TypeScript 解决了这个问题。React 本身就是用 TypeScript 写的,React.FCReactNodeReact.ChangeEvent 这些类型都是 React 源码中内置的类型声明。当你在 .tsx 文件中写一个组件,它的 props 接口就是一份活文档------IDE 会提示每个属性的类型,编译阶段就能拦截类型不匹配的错误。

本文以一个 NameEdit 组件为样本,展示它如何经历三次重构,每一步都体现 React + TypeScript 组件设计的核心原则。


二、React.FC:函数组件的类型签名

先看最简单的 Hello 组件:

typescript 复制代码
import * as React from 'react';

interface Props {
  userName: string;
}

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

React.FC<Props> 是函数组件的类型注解。它的源码定义是:

typescript 复制代码
type FC<P = {}> = FunctionComponent<P>;

FCFunctionComponent 的类型别名,<P = {}> 表示泛型参数 P 默认值为空对象 {}------如果你不传泛型参数,props 就是空对象。当你传了 Props,TypeScript 就用 Props 接口来约束 props 的形状。

interfacetype 都可以声明类型,为什么组件 props 推荐用 interface

typescript 复制代码
// 两种方式都可以
interface Props {
  userName: string;
}

// type Props = {
//   userName: string;
// }

interface 是"接口"------定义对象需要满足的属性和方法。type 是"类型别名"------给一个类型起别称。在组件 props 的场景中,两者功能等价,但 interface 更符合语义:组件 props 本质上是一个"对象需要满足的约束",用接口描述更自然。此外,interface 支持声明合并(同名接口自动合并),在大型项目中扩展第三方类型时非常有用。


三、第一次进化:子组件传事件 → 类型污染

最初的 NameEdit 组件设计是这样的:

typescript 复制代码
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>
  )
}

父组件这样使用:

typescript 复制代码
const App = () => {
  const [username, setUsername] = useState('initialName');
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUsername(e.target.value);
  }
  return <NameEditComponent username={username} onChange={onChange} />
}

这个版本的问题在于:事件对象穿透了组件边界React.ChangeEvent<HTMLInputElement> 这种底层 DOM 事件的类型从子组件一路传到父组件,导致父组件必须知道 HTMLInputElement 这个实现细节。父组件的可读性被拖累------它原本的使命是"持有状态和修改状态,让子组件共享",但现在被迫处理 DOM 事件对象。

React.ChangeEvent 是 React 的合成事件类型。React.ChangeEvent<HTMLInputElement> 表示"一个来自 <input> 元素的变更事件"。React 的合成事件是对原生 DOM 事件的跨浏览器封装,类型签名精确到元素级别------ChangeEvent<HTMLInputElement>ChangeEvent<HTMLTextAreaElement> 是不同的类型。


四、第二次进化:子组件私有状态 → 值传递

改进方案:把事件处理封装在子组件内部,只把值传给父组件。

typescript 复制代码
interface Props {
  initialUserName: string;
  onNameUpdated: (name: string) => void;
}

const NameEditComponent: 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 (
    <>
      <label>Update name:</label>
      <input value={editingName} onChange={onChange} />
      <button onClick={onNameSubmit}>Change</button>
    </>
  )
}

关键变化:子组件自己维护 editingName 私有状态,onChange 不再暴露给父组件,onNameUpdated 接收的是 (name: string) => void------一个干净的字符串回调,而不是 React.ChangeEvent

父组件变得简洁:

typescript 复制代码
const App: React.FC = () => {
  const [username, setUsername] = useState('initialName');
  const setUsernameState = (name: string) => {
    setUsername(name);
  }
  return (
    <div>
      <Hello userName={username} />
      <NameEditComponent
        initialUserName={username}
        onNameUpdated={setUsernameState}
      />
    </div>
  )
}

父组件不再关心 input 事件,只关心"用户最终提交了什么名字"。组件边界清晰了,类型也干净了。


五、第三次进化:状态提升 → 无状态纯展示

第二个版本还有一个问题:子组件持有 editingName 状态,关闭组件后状态丢失。如果需要在父组件中展示编辑中的内容(比如实时预览),或者需要在多个地方同步编辑状态,子组件私有状态就不够用了。

最终方案:把 editingName 提升到父组件,子组件变成纯展示组件。

typescript 复制代码
// NameEditingComponent.tsx
interface Props {
  editingName: string;
  onNameUpdated: () => void;
  onEditingNameUpdated: (newEditingName: string) => void;
  disabled: boolean;
}

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

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onEditingNameUpdated(e.target.value);
  }

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

父组件持有所有状态:

typescript 复制代码
const App = () => {
  const [name, setName] = React.useState<string>('defaultUserName');
  const [editingName, setEditingName] = React.useState('defaultEditingName');

  const loadUserName = () => {
    setTimeout(() => {
      setName('name from async call');
      setEditingName('name from async call');
    }, 2000)
  }

  React.useEffect(() => {
    loadUserName();  // 组件挂载后异步加载用户名
  }, [])

  const setUsernameState = () => {
    setName(editingName);
  }

  return (
    <div>
      名字:{name}
      <HelloComponent userName={editingName} />
      <NameEditingComponent
        editingName={editingName}
        onNameUpdated={setUsernameState}
        onEditingNameUpdated={setEditingName}
        disabled={editingName === "" || editingName === name}
      />
    </div>
  )
}

这个版本达到了组件设计的理想状态:UI = fn(props) 。子组件没有任何状态,它的输出完全由 props 决定。测试时不需要 mock 任何内部状态,只需传入不同的 props 就能覆盖所有场景。disabled 属性也是一个亮点------子组件不判断"按钮是否该禁用",父组件计算好传下来,子组件只管渲染。

useState<string> 的泛型参数显式声明了状态类型。TypeScript 能从初始值 'defaultUserName' 推断出 string 类型,但显式声明可以让意图更明确,特别是在初始值可能为 nullundefined 的场景中。


六、useEffect 与异步数据加载

useEffect 在本项目中用于异步加载用户名:

typescript 复制代码
React.useEffect(() => {
  loadUserName();  // 2秒后异步返回用户名
}, [])

[] 空依赖数组意味着只在组件挂载后执行一次。组件的第一要素是赶快显示出来------先渲染初始状态,让用户看到界面,然后异步请求数据,数据返回后更新状态触发重新渲染。这种"先渲染再加载"的模式让用户感知到的速度更快,避免了"白屏等待数据"的体验。

useEffect 的三种生命周期对应:

依赖数组 时机 用途
[] 挂载后执行一次 初始化:首次加载数据、订阅事件
[dep] 挂载后 + 依赖变化后 响应式:依赖变化时重新执行
不传 每次渲染后 极少数场景:每次更新都需要同步

七、TypeScript 配置:tsconfig 的关键选项

tsconfig.app.json 中几个与 React 密切相关的选项:

json 复制代码
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}
  • "jsx": "react-jsx":使用 React 17+ 的新 JSX 转换,不需要在每个文件中 import React from 'react',但本项目使用 import * as React 更显式
  • "moduleResolution": "bundler":告诉 TypeScript 用打包器(Vite)的方式解析模块,支持 .tsx 扩展名的直接导入
  • "allowImportingTsExtensions": true:允许 import App from './App.tsx' 带扩展名导入
  • "noUnusedLocals""noUnusedParameters":编译时检查未使用的变量和参数,保持代码整洁

typescript-eslint 插件(版本 8.59.2)配合 ESLint Flat Config,在 lint 层面提供 TypeScript 特有的规则校验,与 tsc 的类型检查形成双保险。


八、三次进化的对比

维度 版本一:传事件 版本二:私有状态 版本三:状态提升
子组件状态 有(editingName)
父组件类型负担 重(需要 React.ChangeEvent) 轻(string) 轻(string)
子组件 props 接口 onChange: (e: ChangeEvent) => void onNameUpdated: (name: string) => void 四个属性,各司其职
可测试性 一般 一般 高(纯函数)
状态跨组件共享 不可 不可
设计原则 事件透传 值封装 UI = fn(props)

版本三达到了组件设计的终极形态:子组件职责单一,只负责展示;父组件持有所有状态,通过 props 控制子组件的每一个行为。这种模式下,组件复用性最高、测试最简单、重构最安全。


九、总结

React + TypeScript 的组合,让组件开发从"约定"走向"约束"。interface 定义 props 形状,React.FC 约束函数签名,React.ChangeEvent 精确到元素级别的合成事件类型------这些类型系统不是负担,而是让组件在不同开发者之间传递时,不需要任何口头沟通就能理解其接口。

组件设计的进化路径清晰可见:从"子组件传事件污染父组件"到"子组件封装状态"再到"状态提升、子组件纯展示" 。每一步都让组件更接近 UI = fn(props) 的理想模型------输入 props,输出界面,不持有状态,不产生副作用。

这个模型不是 React 的规则,而是 React 设计哲学的自然结果。当你理解了它,写出的组件自然就是可测试、可复用、可维护的。

相关推荐
90后的晨仔1 小时前
Mac 开发 uni-app 终极方案:让安卓模拟器彻底脱离 Android Studio 独立运行
前端·vue.js
90后的晨仔1 小时前
别再搞混了!uni-app 中 node_modules 和 uni_modules 到底是什么关系?
前端
kyriewen2 小时前
面试官问"这段逻辑为什么这样写"——我才发现,这半年我写的代码,我自己都解释不了
前端·javascript·面试
谢小飞2 小时前
大文件上传很难?学会这招,GB文件秒传不是梦
前端·node.js
程序员黑豆3 小时前
Windows 系统 Java 环境变量配置全攻略:解决“不是内部或外部命令”
java·前端·ai编程
To_OC3 小时前
别只拿 useRef 绑 DOM 了,搭配 Web Worker 解决页面卡顿才是真的香
前端·react.js·dom
IT_陈寒4 小时前
Vue的响应式让我原地破防,原来问题出在这
前端·人工智能·后端
子兮曰5 小时前
Bun vs Node.js 深度对决:跑分快 4 倍,真实业务只剩 3%,2026 年到底该怎么选?
前端·后端·typescript
计算机魔术师5 小时前
终端用户
前端