React02:组件与 Props

本文深入讲解React函数组件的核心机制:props作为唯一只读入参,解构与默认值的正确用法,children与三种插槽(默认/具名/作用域)的实现方式,TS类型约束(interface、可选属性、ReactNode),以及...rest透传原生属性的最佳实践。强调单向数据流、类型安全和企业级组件设计规范。

第 02 课 · 组件与 Props

🎯 本课目标

  • 会定义函数组件:首字母大写、props 是唯一入参、组件就是「props 进、JSX 出」的函数
  • 用解构 + 默认值还原 defineProps + withDefaults 的体验,并用 interface 把类型管起来
  • 用 children 还原默认插槽,用「传 JSX 的 props」还原具名插槽,用「传函数的 props」还原作用域插槽
  • 掌握 ReactNode / 可选属性 / 联合类型这三类最常见的 props 类型写法
  • 会用 ...rest 透传属性(对应 $attrs),知道哪些场景该透传、哪些场景不该
  • 建立「单向数据流」的肌肉记忆:props 只读,改数据必须走回调(第 06 课展开)

1. 函数组件:props 是唯一入参,且只读

Vue 里你这样写

<script setup lang="ts">
const props = defineProps<{ name: string; age?: number }>()
// Vue 里「可以」偷偷改 props 引用的对象(不推荐但不报错),还能 emit 回去改
</script>

<template>
  <div>{{ props.name }}</div>
</template>

React 里这样写

// 组件就是一个函数:入参只有 props,返回值是 JSX
// 首字母必须大写,小写会被当成原生 HTML 标签
interface UserCardProps {
  name: string // 必填
  age?: number // 可选
}

function UserCard({ name, age }: UserCardProps) {
  // ❌ props.name = '李四'      ← TS 直接报错:只读
  // ❌ props.age = 18           ← TS 直接报错:只读
  return (
    <div>
      {name}({age != null ? `${age} 岁` : '年龄保密'})
    </div>
  )
}

// 使用:跟写 HTML 一样,属性就是 props
const app = <UserCard name="张三" age={28} />

关键差异与注意点

  • Vue 单向数据流是「约定不能改」,React 是「类型层面就改不了」:props 在 TS 里是只读的,赋值直接编译报错
  • React 没有 emit:子组件要通知父组件,靠父组件传下来一个回调 prop(onXxx),第 06 课展开
  • 组件必须当 JSX 用(<UserCard />),不要 UserCard({...}) 直接调用 —— 那样它就成了普通函数,没有 Hook 环境、没有组件身份,diff 也会错乱
  • props 每次渲染都是「新的一份」:不要在组件里缓存 props 到模块级变量,那是旧快照
  • 一个文件默认导出一个组件,私有子组件可以同文件共存(不导出)

2. 解构 props 与默认值

Vue 里你这样写

const props = withDefaults(defineProps<{ name: string; role?: string; tags?: string[] }>(), {
  role: '普通成员',
  tags: () => [],
})

React 里这样写

interface UserCardProps {
  name: string
  role?: string
  tags?: string[]
}

// 解构的同时写默认值:可选 prop 不传时用兜底,等价于 withDefaults
function UserCard({ name, role = '普通成员', tags = [] }: UserCardProps) {
  return (
    <div>
      {name} · {role}
      {tags.map((tag) => (
        <span key={tag} className="tag">
          {tag}
        </span>
      ))}
    </div>
  )
}

// 父组件不传可选 prop 时,显式传 undefined 也走默认值
const cardA = <UserCard name="张三" />
const cardB = <UserCard name="李四" role={undefined} />

关键差异与注意点

  • React 19 之后 props 就是普通参数,解构写法是企业绝对主流:接口定义 + 解构默认值两行搞定
  • 默认值只对 undefined 生效:父组件显式传 null 不会走默认值,类型上要么禁 null、要么在组件里判 != null
  • 对象/数组默认值直接写 [] {} 即可(函数参数默认值每次求值,没有 Vue 的「引用类型默认值要工厂函数」的坑)
  • 解构会丢失「整个 props 对象」的引用:需要 ...rest 透传时,解构里把它留到最后(见第 5 节)

3. children 与插槽的三种还原方式

Vue 里你这样写

<Panel title="项目进度">
  <!-- 默认插槽 -->
  <p>内容</p>
  <!-- 具名插槽 -->
  <template #header><span>头部</span></template>
  <!-- 作用域插槽:子组件把数据传出来 -->
  <template #item="{ course }"><b>{{ course.name }}</b></template>
</Panel>

React 里这样写

import type { ReactNode } from 'react'

interface Course {
  id: number
  name: string
}

interface PanelProps {
  title: string
  courses: Course[]
  header?: ReactNode // 具名插槽 = 传 JSX 的 props
  footer?: ReactNode
  renderItem?: (course: Course) => ReactNode // 作用域插槽 = 传「返回 JSX 的函数」
  children: ReactNode // 默认插槽 = children
}

function Panel({ title, courses, header, footer, renderItem, children }: PanelProps) {
  return (
    <div>
      {/* 没传 header 就回退到 title,等价于 <slot name="header">{{ title }}</slot> */}
      <div className="box-title">{header ?? title}</div>

      {children}

      {/* 判断插槽有没有传:直接判断 prop 是否存在,等价于 $slots.header */}
      {footer && <div className="hint">{footer}</div>}

      {/* 数据在子组件手里循环,样式交给父组件的函数 */}
      {courses.map((course) => (
        <div key={course.id}>{renderItem ? renderItem(course) : course.name}</div>
      ))}
    </div>
  )
}

// 使用侧:插槽就是「往属性里塞 JSX / 塞函数」
function App() {
  const courses: Course[] = [
    { id: 1, name: 'React 入门' },
    { id: 2, name: 'TS 实战' },
  ]
  return (
    <Panel title="项目进度" courses={courses} header={<span>头部</span>} renderItem={(course) => <b>{course.name}</b>}>
      <p>内容</p>
    </Panel>
  )
}

关键差异与注意点

  • 记三句话:默认插槽 = children;具名插槽 = 传 JSX 的 props;作用域插槽 = 传返回 JSX 的函数 props
  • children 是特殊的内置 prop:写在组件的开闭标签中间,其他都要显式写属性名
  • React 没有 useSlots:判断插槽是否传入就是 props.header 是否为 undefined
  • children 类型用 ReactNode:它涵盖字符串、数字、JSX、数组、undefined 等一切可渲染的东西
  • 作用域插槽在 React 里没有语法糖,就是一个函数 —— 「传 UI 还是传函数」由父组件决定,数据始终在子组件手里
  • 面向企业:header ?? title 这种兜底写法很常见;footer && ... 表示「没传就不渲染这块」

4. TS 类型约束:interface、可选属性、ReactNode

React 里这样写

import type { ReactNode } from 'react'

// 基础类型 + 可选属性
interface ButtonProps {
  type?: 'primary' | 'danger' | 'default' // 联合类型:合法值被钉死
  size?: 'sm' | 'md'
  disabled?: boolean
  children: ReactNode
  onClick?: () => void // 回调 prop:约定 onXxx 命名
}

function Button({ type = 'default', size = 'md', disabled, onClick, children }: ButtonProps) {
  return (
    <button className={`btn ${type === 'primary' ? 'btn-primary' : ''}`} disabled={disabled} onClick={onClick}>
      {children}
    </button>
  )
}

// 复用已有类型:交叉出新的 props 类型
interface User {
  id: number
  name: string
}
interface UserItemProps {
  user: User
  active?: boolean
  onSelect?: (id: number) => void
}

关键差异与注意点

  • props 类型用 interface XxxProps,放在组件文件顶部,导不导出都行(父组件一般不需要 import 它)
  • type?: 'primary' | 'danger' 这种联合类型是 React 项目的标配:比 string 安全,比 enum 轻量(本项目 erasableSyntaxOnly 也禁用了 enum)
  • children / 渲染函数的返回值统一用 ReactNode;事件回调用 React 提供的类型(如 React.ChangeEvent<HTMLInputElement>)或直接内联推断
  • 回调 props 命名统一 onXxx(onClick、onChange、onSelect),和原生事件保持一致的阅读习惯,第 06 课展开
  • 传错了类型、拼错了属性名,编译期就红 —— 这是 React + TS 相比 Vue SFC 体验提升最明显的地方

5. …rest 透传(对应 $attrs)

Vue 里你这样写

<!-- MyButton.vue:$attrs 自动落到根元素上 -->
<template>
  <button class="my-btn" v-bind="$attrs"><slot /></button>
</template>

React 里这样写

import type { ButtonHTMLAttributes } from 'react'

interface MyButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'default' // 自己的专属 prop
}

function MyButton({ variant = 'default', children, ...rest }: MyButtonProps) {
  return (
    // rest 里是剩下的所有原生属性:type、id、disabled、onClick、data-* …
    // 直接展开到真实 DOM 元素上,对应 v-bind="$attrs"
    <button className={`btn ${variant === 'primary' ? 'btn-primary' : ''}`} {...rest}>
      {children}
    </button>
  )
}

// 调用方:像用原生 button 一样用
const saveButton = (
  <MyButton variant="primary" type="button" onClick={() => {}} disabled>
    保存
  </MyButton>
)

关键差异与注意点

  • 解构时把「自己的 prop」挑出来,剩下的自动进 rest,{...rest} 展开到内部元素 —— 和 inheritAttrs 一个思路
  • React 没有自动透传:不写 {...rest},调用方传的 id / disabled 就会被丢掉,这是封装基础组件时最常见的遗漏
  • 基础组件的 props 类型推荐 extends React.ButtonHTMLAttributes<...> 等官方类型,白拿全部原生属性的类型提示
  • 注意顺序:{...rest} 要放在 className 等组件自己控制的属性之后或之前要有明确约定,避免调用方传 className 被覆盖;要合并 className 时用 cx(rest.className, 'btn')
  • 不是所有组件都要透传:业务组件(UserCard、StatusTag)一般不需要,通用基础组件(Button、Input、Modal)必须有

📊 Vue ↔ React 对照速查表

Vue 3React说明
defineProps<{...}>()function C(props: XxxProps) / 解构props 是唯一入参
withDefaults(...)解构默认值 { role = '普通成员' }只对 undefined 生效
默认插槽 <slot />children内置 prop
具名插槽 #headerheader={...}(ReactNode prop)传 JSX 当参数
作用域插槽 #item="{ row }"renderItem={(row) => <X />}传返回 JSX 的函数
$slots.header 判断props.header !== undefined没传就是 undefined
插槽兜底内容header ?? title / footer && ...同样的思路
$attrs / v-bind="$attrs"...rest + {...rest}React 不自动透传
emit('change')调用 props.onChange(...)传函数下去(第 06 课)
props 只读(约定)props 只读(类型层面报错)单向数据流更硬
defineExposeuseImperativeHandle很少用,先知道
组件文件 UserCard.vueUserCard.tsx 默认导出一个文件一个对外组件

🏢 企业实战要点

  1. props 类型必须显式写 interface:团队约定不用 React.FC、不用 any;interface 放组件上方,命名 XxxProps。
  2. 「数据进、事件出」:props 里只放数据和回调,不要把「一个巨大的 options 对象」塞进去;超过 7-8 个 props 就该考虑合并成对象 prop 或拆组件(review 高频意见)。
  3. 基础组件必带 ...rest 透传:Button / Input / Select 这类通用组件不透传原生属性,业务一用就卡住;业务组件不需要透传就别加,避免「什么都能传」的泥球。
  4. 回调命名 onXxx,处理函数命名 handleXxx:子组件的 prop 叫 onSelect,父组件里的实现叫 handleSelect,全队统一,读代码不用猜。
  5. 插槽(JSX props)别滥用:三五个具名插槽的组件可读性差,能用 children + props 配置解决的,不要开新插槽;插槽越多,调用方越难理解结构。
  6. children 只放「内容」,别当成参数列表:往 children 里塞一段「靠位置约定含义」的结构(比如第一个元素必须是标题)是坏味道,要传结构就用具名 props。
  7. 默认值只兜 undefined:接口返回 null 时默认值不生效,业务组件里对可空字段用 value ?? '默认' 显式兜底,不要依赖参数默认值。
  8. 不要在子组件里改 props 对象的属性(props.user.name = 'x'):类型上可能绕得过(嵌套对象),但会破坏单向数据流、让父组件数据被静默污染 —— review 直接打回,改数据交给回调。

💼 面试高频

1. 为什么 React 的 props 是只读的?

组件被描述为「props 进、JSX 出」的纯函数:同样的 props 必须渲染出同样的结果,React 才能安全地跳过、复用、并发渲染。如果 props 可以被子组件随意修改,父组件的数据就会被静默污染,渲染结果不可预测,React 的所有优化(memo、跳过重渲染)都建立在「props 不可变」之上。所以类型层面 props 被声明为 readonly,想改数据只能通过回调让父组件更新。

2. React 是怎么实现 Vue 的三种插槽的?

默认插槽就是内置的 children prop;具名插槽是传 JSX 的普通 props(header={<span/>});作用域插槽是传一个返回 JSX 的函数 props(renderItem={(row) => <X />}),子组件在循环里调用它并把数据传回去。React 没有插槽的特殊语法,本质都是「往组件里传值」:UI 是值(ReactNode),能产出 UI 的函数也是值。判断插槽是否传入就是看对应 prop 是否为 undefined。

3. 函数组件的心智模型和 Vue 组件有什么不同?

React 函数组件是「每次 props/state 变化就重新执行一遍的函数」:输入 props 和 state,输出 JSX,函数体每次都是全新执行,没有实例、没有 this、没有 data/ methods / computed 的分区。Vue 组件是有实例的:setup 只跑一次,模板通过响应式系统精准更新。所以 React 里「渲染函数必须纯净」(不写副作用、不改外部变量),状态跨渲染要靠 Hook 记住,这是两种模型最本质的差别。

4. 为什么建议基础组件用 ...rest 透传?

基础组件的价值在于「比原生多一点点能力」,调用方一定会有传 id、data-*、原生事件、aria 属性的需求。React 不会像 Vue 那样自动把 $attrs 落到根元素,不写 {...rest} 这些属性就被静默丢弃。规范做法是:props 类型 extends React 的原生属性类型(如 ButtonHTMLAttributes<HTMLButtonElement>),解构出组件专属 props,其余 {...rest} 展开到根 DOM 元素上。

5. props 里传函数和传 JSX 有什么区别?什么时候用哪个?

传 JSX(ReactNode):父组件渲染好了一段静态 UI 交给子组件摆放,适合插槽、装饰性内容。传函数(返回 ReactNode 的函数):子组件持有数据、父组件决定每一条长什么样,适合列表项、表格单元格这类「子组件循环、父组件定制」的场景。判断标准:内容是否依赖子组件内部的数据 —— 依赖就传函数,不依赖就传 JSX。

📌 小结 + 下节预告

  • 函数组件 = 「props 进、JSX 出」的函数:首字母大写、props 只读、必须当 JSX 用
  • 解构 + 默认值还原 withDefaults;interface + 可选属性 + 联合类型把 props 管起来
  • 插槽三件套:children(默认)、传 JSX 的 props(具名)、传函数的 props(作用域)
  • 基础组件用 ...rest 透传原生属性,业务组件不需要透传
  • 回调 prop 统一 onXxx —— 子改父数据只能靠它

下一课讲 useState 与不可变更新:ref / reactive 没了,状态怎么声明、怎么改?重点啃下 Vue 转 React 的第一大坑 —— 必须换新值,直接 push / obj.x=1 界面不动,以及 setState 的「快照」心智、函数式更新和批处理。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值