第 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 3 | React | 说明 |
|---|---|---|
defineProps<{...}>() | function C(props: XxxProps) / 解构 | props 是唯一入参 |
withDefaults(...) | 解构默认值 { role = '普通成员' } | 只对 undefined 生效 |
默认插槽 <slot /> | children | 内置 prop |
具名插槽 #header | header={...}(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 只读(类型层面报错) | 单向数据流更硬 |
defineExpose | useImperativeHandle | 很少用,先知道 |
组件文件 UserCard.vue | UserCard.tsx 默认导出 | 一个文件一个对外组件 |
🏢 企业实战要点
- props 类型必须显式写 interface:团队约定不用
React.FC、不用 any;interface 放组件上方,命名XxxProps。 - 「数据进、事件出」:props 里只放数据和回调,不要把「一个巨大的 options 对象」塞进去;超过 7-8 个 props 就该考虑合并成对象 prop 或拆组件(review 高频意见)。
- 基础组件必带
...rest透传:Button / Input / Select 这类通用组件不透传原生属性,业务一用就卡住;业务组件不需要透传就别加,避免「什么都能传」的泥球。 - 回调命名
onXxx,处理函数命名handleXxx:子组件的 prop 叫onSelect,父组件里的实现叫handleSelect,全队统一,读代码不用猜。 - 插槽(JSX props)别滥用:三五个具名插槽的组件可读性差,能用 children + props 配置解决的,不要开新插槽;插槽越多,调用方越难理解结构。
- children 只放「内容」,别当成参数列表:往 children 里塞一段「靠位置约定含义」的结构(比如第一个元素必须是标题)是坏味道,要传结构就用具名 props。
- 默认值只兜 undefined:接口返回
null时默认值不生效,业务组件里对可空字段用value ?? '默认'显式兜底,不要依赖参数默认值。 - 不要在子组件里改 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 的「快照」心智、函数式更新和批处理。
本文深入讲解React函数组件的核心机制:props作为唯一只读入参,解构与默认值的正确用法,children与三种插槽(默认/具名/作用域)的实现方式,TS类型约束(interface、可选属性、ReactNode),以及...rest透传原生属性的最佳实践。强调单向数据流、类型安全和企业级组件设计规范。
363

被折叠的 条评论
为什么被折叠?



