陌上人如玉
公子世无双

1.3 Props

1.3 Props

一句话:Props 是父组件传给子组件的只读数据,它是「单向数据流」的载体——子组件不能修改 props,只能通过调用父组件传下来的回调来请求变更。

一、Props 的本质

// 组件接收 props 就是一个对象
function UserCard(props) {
  console.log(props);     // { name: '张三', age: 18, children: [...] }
  return <div>{props.name}</div>;
}

// 用解构更清晰
function UserCard({ name, age, children }) {
  return (
    <div>
      <h3>{name}</h3>
      <p>{age}</p>
      {children}
    </div>
  );
}

// 使用(JSX 属性会组成 props 对象)
<UserCard name="张三" age={18}>内容</UserCard>
JSX 与编译产物的对应关系:

<UserCard name="张三" age={18}>内容</UserCard>
   ↓
jsx(UserCard, { name: '张三', age: 18, children: '内容' })
   ↓
UserCard({ name: '张三', age: 18, children: '内容' })
↑ 组件本质上就是「用 props 对象调用的函数」

二、为什么 Props 是只读的

// ❌ 不能修改 props
function Child({ user }) {
  user.name = '李四';            // 违反约定(而且对基础类型根本无效)
  return <div>{user.name}</div>;
}

// ✅ 需要改数据时:通过回调请求父组件更新
function Child({ user, onChange }) {
  return <button onClick={() => onChange('李四')}>改名</button>;
}

function Parent() {
  const [user, setUser] = useState({ name: '张三' });
  return <Child user={user} onChange={(name) => setUser({ ...user, name })} />;
}

为什么必须只读(三个理由):

理由 说明
数据流向可预测 数据只从父到子,任何一个值的来源唯一,便于调试
避免状态不同步 如果子组件能改 props,父组件的 state 与子组件的显示会不一致
让更新可追踪 状态变更集中在「拥有该状态的组件」里,容易排查问题

注意一个反直觉的点:如果 props 是对象或数组,在子组件里修改它的属性(user.name = 'x')在技术上是「生效」的(因为传递的是引用),React 也不会报错。但这破坏了单向数据流,是明确的错误做法——因为父组件不知道数据被改了,下次渲染又会被覆盖,行为难以预测。

三、默认值

// ✅ 函数组件:用「默认参数」(推荐)
function Greeting({ name = '访客', age = 0 }) {
  return <div>{name} {age}</div>;
}

// ⚠️ 对象/数组作为默认值时,注意「每次渲染都是新对象」
function List({ items = [], onSelect = () => {} }) {
  // ⚠️ 默认的 [] 与 () => {} 每次渲染都是新引用
  // → 如果有依赖它们的 useEffect / useMemo,会每次触发
}

// ✅ 更稳妥:把默认值提到模块作用域
const EMPTY_ARRAY = [];
const noop = () => {};
function List2({ items = EMPTY_ARRAY, onSelect = noop }) {
  // 引用稳定 ✓
  return null;
}
// ❌ defaultProps 已被废弃(函数组件上会警告,React 19 移除)
Greeting.defaultProps = { name: '访客' };

// ✅ 用默认参数代替
function Greeting2({ name = '访客' }) {}
写法 状态 建议
默认参数 ({ name = 'x' }) ✅ 推荐 函数组件标准写法
defaultProps ❌ 已废弃(React 18 警告、19 移除) 不要用
undefined 触发默认值 ✅ 传 null 不会触发默认值 ⚠️
function Comp({ value = 'default' }) {
  return <div>{value}</div>;
}
<Comp />                  {/* 'default' */}
<Comp value={undefined} /> {/* 'default'(undefined 触发默认值) */}
<Comp value={null} />      {/* 渲染空(null 不触发默认值)⚠️ */}
<Comp value="" />          {/* 空字符串(不触发默认值) */}

四、children 的几种形态

// ① 单个元素
<Card><p>内容</p></Card>
// children: <p>内容</p>

// ② 多个元素
<Card><h1>标题</h1><p>内容</p></Card>
// children: [<h1>, <p>]

// ③ 纯文本
<Card>纯文本</Card>
// children: '纯文本'

// ④ 函数(渲染函数 / render props)
<Card>{(data) => <div>{data}</div>}</Card>
// children: (data) => <div>{data}</div>

// ⑤ 什么都不传
<Card />
// children: undefined
// tips: children 的类型
type Props = {
  children: React.ReactNode;      // ⭐ 最常用:任何可渲染的内容
};
type Props2 = {
  children: React.ReactElement;   // 只允许单个元素(不允许文本/数组)
};
type Props3 = {
  children: (data: Data) => React.ReactNode;   // 渲染函数
};

// React.ReactNode 的定义(简化)
type ReactNode =
  | ReactElement
  | string
  | number
  | boolean
  | null
  | undefined
  | Iterable<ReactNode>
  | ReactPortal;

五、展开传递与透传

// ① 展开传递(把对象的属性一次性传给子组件)
const user = { name: '张三', age: 18, avatar: 'a.png' };
<UserCard {...user} />           // 等价于 name={user.name} age={user.age} avatar={user.avatar}

// ② 有选择地传递
const { password, ...safe } = user;      // 剔除敏感字段再传
<UserCard {...safe} />

// ③ 覆盖(后面的生效)
<UserCard {...user} name="李四" />        // name 变成「李四」

// ④ 透传其余 props(组件库常见模式)
function Input({ label, ...rest }: InputProps) {
  return (
    <label>
      {label}
      <input {...rest} />           {/* 把 className / onFocus / placeholder 都透传下去 */}
    </label>
  );
}
<Input label="用户名" placeholder="请输入" onFocus={handleFocus} />
// ⑤ 展开传递与 key 的关系
const list = [{ id: 1, name: 'a' }];
{list.map((item) => <Item key={item.id} {...item} />)}
// ⚠️ 如果 item 里也有 key 属性,会被后面的 {...item} 覆盖
// ✅ 把 key 放在最后,或确保数据里没有同名字段
{list.map((item) => <Item {...item} key={item.id} />)}

六、多个「插槽」的写法(React 没有 slot)

// React 用 props 传元素来实现「具名插槽」
interface LayoutProps {
  header: React.ReactNode;
  sidebar?: React.ReactNode;
  children: React.ReactNode;
  footer?: React.ReactNode;
}

function Layout({ header, sidebar, children, footer }: LayoutProps) {
  return (
    <div className="layout">
      <header>{header}</header>
      <div className="body">
        {sidebar && <aside>{sidebar}</aside>}
        <main>{children}</main>
      </div>
      {footer && <footer>{footer}</footer>}
    </div>
  );
}

// 使用(比 Vue 的 <template #header> 更「JS 化」)
<Layout
  header={<TopBar title="首页" />}
  sidebar={<NavMenu />}
  footer={<Footer />}
>
  <Article />
</Layout>
方案 写法 特点
children <Card>内容</Card> 单一插槽,最简洁
具名 props header={<Header/>} 多插槽,类型清晰
渲染函数 renderItem={(x) => <Item/>} 把「怎么渲染」交给使用方
children 为函数 <Card>{(d) => ...}</Card> 同 render props,但要处理非函数的情况

七、Props 的类型约束(TypeScript)

// ① 基本类型定义
interface UserCardProps {
  name: string;
  age?: number;                       // 可选
  onClick?: () => void;
  children?: React.ReactNode;
}

function UserCard({ name, age = 0, onClick, children }: UserCardProps) {
  return <div onClick={onClick}>{name}{age}{children}</div>;
}

// ② 事件的类型
interface FormProps {
  onChange: (value: string) => void;                       // 自定义回调(推荐)
  onSubmit: (e: React.FormEvent<HTMLFormElement>) => void; // 原生事件
  onClick: React.MouseEventHandler<HTMLButtonElement>;
}

// ③ 原生元素属性的扩展(组件库常见)
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary';
}
function Button({ variant, ...rest }: ButtonProps) {
  return <button className={`btn-${variant}`} {...rest} />;
}
<Button variant="primary" onClick={handle} disabled>确定</Button>;   // 原生属性都能用

// ④ 联合类型约束(枚举式 props)
interface AlertProps {
  type: 'success' | 'error' | 'warning';
  message: string;
}

// ⑤ 泛型组件(容器组件常用)
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => React.ReactNode;
  keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return <ul>{items.map((it, i) => <li key={keyExtractor(it)}>{renderItem(it, i)}</li>)}</ul>;
}

// 使用时会自动推断 T
<List items={users} renderItem={(u) => u.name} keyExtractor={(u) => u.id} />;

// ⑥ 互斥 props(要么传 a 要么传 b)
type Props =
  | { variant: 'link'; href: string }
  | { variant: 'button'; onClick: () => void };

八、常见坑

// ① 修改 props(违反单向数据流)
function Child({ list }) {
  list.push('x');              // ❌ 直接改了父组件的数组
}

// ② 依赖 props 对象引用,导致每次渲染都触发副作用
function Comp({ options = {} }) {
  useEffect(() => {
    // options 每次渲染都是新对象 → 每次都会执行
  }, [options]);
}

// ③ 传 null 不会触发默认值
function Comp2({ value = 'default' }) {}
<Comp2 value={null} />          // 渲染空,不是 'default'

// ④ 把对象直接渲染
<div>{user}</div>               // ❌ Objects are not valid as a React child

// ⑤ 展开传递时 key 被覆盖
<Item key={id} {...item} />     // ⚠️ item.key 会覆盖

// ⑥ 层层透传 props(props drilling)
<A user={user} />
  <B user={user} />
    <C user={user} />
      <D user={user} />         // ⚠️ 中间层不需要但必须传 → 用 Context

// ⑦ 在子组件里深拷贝 props 再修改(性能与语义双重问题)
const local = { ...props.user };  // 浅拷贝,嵌套对象仍是共享引用

// ⑧ 用下划线命名 props(不符合 JSX 习惯)
<Comp user_name="a" />          // ⚠️ 应该用 userName

面试延伸

  1. 「Props 为什么是只读的?」
    这是「单向数据流」的核心约束。理由有三:① 数据流向可预测——任何值的来源唯一,调试时不需要追「谁改了它」;② 避免状态不同步——如果子组件能改 props,父组件的 state 与子组件显示会不一致;③ 让更新可追踪——状态变更集中在「拥有该状态的组件」里。需要改数据时,正确做法是「父组件把回调传下去,子组件调用回调请求变更」。

  2. 「子组件想修改父组件的数据怎么办?」
    标准模式是「状态提升 + 回调下传」:把状态放在两者最近的公共父组件里,父组件把「修改函数」作为 props 传给子组件,子组件在需要时调用它。如果层级很深,用 Context 避免层层透传;如果是全局共享的数据,用状态管理库(Zustand/Redux)。

  3. 「为什么 defaultProps 被废弃了?」
    因为「默认参数」是语言原生能力,语义更清晰、更符合函数组件的直觉,而且 defaultProps 在函数组件上有额外开销(每次渲染都要合并对象)且难以做类型推导。React 18 对函数组件上的 defaultProps 发出警告,React 19 已移除。类组件仍然支持 defaultProps。

  4. 「传 null 和传 undefined 在 props 上有什么区别?」
    关键区别在默认值:undefined 会触发默认参数(value = 'default' 生效),而 null 不会(渲染出空)。另外在类型层面,如果 props 的类型是 string,传 null 会类型报错(除非类型里包含 null)。所以「想表达『没传值』」应该用 undefined。

  5. 「React 有 slot 吗?怎么做多插槽?」
    没有 slot 概念。多插槽用「传 props 元素」实现:<Layout header={<Header/>} footer={<Footer/>}>{children}</Layout>。相比 Vue 的具名 slot,这种方式类型更清晰(可以在接口里精确约束每个插槽的类型),但也更「JS 化」,需要显式写 JSX。另一种形式是渲染函数(render props):把「怎么渲染」交给使用方,如 renderItem={(item) => <Item/>}。

  6. 「为什么说『层层透传 props』是问题?」
    因为中间组件「不需要这个数据却必须传」,导致:① 中间组件被迫知道与自己无关的接口(耦合);② 改一个 prop 名要改整条链路;③ 重构困难。解法是 Context(适合跨层级共享的「主题、用户、语言」)或状态管理库(适合频繁变化或结构复杂的全局状态)。但要注意 Context 有重渲染代价,不是所有透传都该改成 Context(两三层透传其实还好)。

  7. 「props 是对象,会不会有引用问题?」
    会,而且这是 React 性能问题的主要来源之一。因为 {...}、[]、() => {} 在每次渲染时都是新引用,如果子组件用了 React.memo,比较会失败导致重渲染;如果作为 useEffect 依赖,会每次触发。解法:① 把常量提到模块作用域;② 用 useMemo / useCallback 稳定引用;③ 对「只传固定内容」的场景用 children 或 renderXxx 的模式减少依赖。

  8. 「TypeScript 里怎么写组件的 props 类型?」
    三种常见形式:① 接口(interface Props { ... })——适合大多数场景,报错信息友好;② 类型别名 + 联合——适合「互斥 props」这类复杂约束;③ 扩展原生属性(extends React.ButtonHTMLAttributes<HTMLButtonElement>)——做基础组件时必须,这样 onClick、disabled、className 等原生属性都能透传且有类型。children 一般用 React.ReactNode。

一句话速记

Props 是只读的单向数据流(子组件要改数据 → 回调 + 状态提升);默认值用默认参数(defaultProps 已废弃),undefined 触发默认值、null 不触发;children 只是普通 props 字段,多插槽用「传元素 props」或 render props;{...obj} 展开时小心覆盖 key;React 没有 slot,层层透传要改用 Context(但两三层其实可以忍);TS 里基础组件记得 extends React.XxxHTMLAttributes。

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.3 Props

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫