适用技术栈:React、Vue、SwiftUI、Flutter、Jetpack Compose。以 React 为主要教学语言,思维跨框架通用。
核心理念:不讲空理论,每个知识点都落地到能直接用于工作的实用技能。
本课产出: 能判断一个状态该放在组件树的哪个位置;能正确地提升状态、下放状态;能识别状态放错位置的四个信号;能用组合、Context、状态管理解决跨层级共享;能平衡状态位置与渲染性能。
一句话预览: 组件拆分决定"代码怎么组织",状态放置决定"数据怎么流动"。状态放对了,组件结构清晰;状态放错了,props 层层传递、状态四处同步、性能无故损耗。
一、本课目标
学完本课,你应该能:
- 用"最近公共父级"原则判断状态该放哪里。
- 掌握状态提升的完整模式:提升、传递、回调、命名。
- 判断什么时候该下放状态,避免不必要的提升。
- 识别状态放错位置的四个信号:props 层层传递、状态需要同步、组件难以复用、大范围重渲染。
- 用组合、Context、状态管理三种方案解决跨层级共享。
- 理解状态位置对渲染性能的影响,做出合理权衡。
- 知道"组合优于继承"在状态放置中的应用。
- 完成一个真实场景的状态位置重构。
二、从一个真实痛点说起
场景一:兄弟组件无法共享
需求:左侧是用户列表,右侧是用户详情。点击左侧的用户,右侧显示详情。
jsx
function UserList() {
const [selectedId, setSelectedId] = useState(null)
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
]
return (
<ul>
{users.map(user => (
<li
key={user.id}
onClick={() => setSelectedId(user.id)}
className={selectedId === user.id ? "active" : ""}
>
{user.name}
</li>
))}
</ul>
)
}
function UserDetail() {
const [selectedId, setSelectedId] = useState(null)
// 问题:这里怎么拿到 UserList 的 selectedId?
return <div>详情:{selectedId}</div>
}
function UserPanel() {
return (
<div>
<UserList />
<UserDetail />
</div>
)
}
问题很明显:UserList 里的 selectedId,UserDetail 拿不到。两个组件各管各的,无法协同。
场景二:状态放太高
jsx
function App() {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<div>
<Header />
<Main
onOpenModal={() => setIsModalOpen(true)}
onCloseModal={() => setIsModalOpen(false)}
/>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</div>
)
}
isModalOpen 放在了 App 顶层。但仔细想想:这个弹窗其实只有 Main 里的某个按钮会打开,Header 根本用不到。把状态放在 App 是放太高了。
场景三:状态需要手动同步
jsx
function Filter() {
const [filter, setFilter] = useState("all")
// 需要通知 List
}
function List() {
const [filter, setFilter] = useState("all")
// 需要接收 Filter 的通知
}
两个组件各持一份 filter,通过事件或其他机制同步。只要同步逻辑存在,就有不一致的风险。
三个场景的共同根源
状态放错了位置。
- 场景一:放太低,兄弟组件拿不到。
- 场景二:放太高,无关组件被迫接收。
- 场景三:放散了,需要手动同步。
本课要教的,就是找到那个"刚刚好"的位置。
三、状态放置的核心原则
原则:最近公共父级
状态应该放在使用它的最近公共父级。
- 只有一个组件用 → 放在该组件。
- 兄弟组件用 → 提升到它们的最近公共父级。
- 跨页面用 → 全局。
一个决策流程图
text
这个状态有几个组件用?
│
├─ 1 个 → 放在该组件内部
│
├─ 兄弟组件 → 提升到最近公共父级
│
└─ 跨页面 → 考虑全局
│
└─ 真的跨页面吗?
├─ 是 → 全局
└─ 只是同一页面不同区域 → 提升到页面级,不要全局
三个判断问题
对每一个状态,问三个问题:
- 谁需要这个状态? 列出所有使用它的组件。
- 它们的最近公共父级是谁? 那就是状态该放的位置。
- 能不能不放这个状态? 能派生就派生,能组合就组合。
一个反直觉的例子
jsx
// ❌ 状态放太深:兄弟组件拿不到
function SearchBox() {
const [keyword, setKeyword] = useState("")
}
function ResultList() {
// 拿不到 keyword
}
// ❌ 状态放太高:全局状态,其实只有这一个页面用
function App() {
const [keyword, setKeyword] = useState("")
return <Page keyword={keyword} setKeyword={setKeyword} />
}
// ✅ 放在最近公共父级
function Page() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
最近公共父级是"刚刚好"的位置。 放低了拿不到,放高了过度传递。
一个实用的判断技巧
问自己:如果把这个状态放在当前位置,哪些组件会被迫接收它?
- 只有使用它的组件接收 → 位置对。
- 有组件不使用却被迫接收 → 放太高了。
- 有组件需要使用却拿不到 → 放太低了。
四、状态提升:完整模式
什么是状态提升
状态提升:把状态从子组件移到父组件,让多个子组件共享。
三步模式
- 把状态移到父组件。 在父组件里声明状态。
- 通过 props 向下传值。 子组件接收值,不持有状态。
- 通过回调向上通知。 子组件变化时,调用父组件传下来的回调。
完整示例
提升前:
jsx
function SearchBox() {
const [keyword, setKeyword] = useState("")
return (
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
/>
)
}
function ResultList() {
const [keyword, setKeyword] = useState("")
// 拿不到 SearchBox 的 keyword
return <div>搜索:{keyword}</div>
}
function Page() {
return (
<>
<SearchBox />
<ResultList />
</>
)
}
提升后:
jsx
function Page() {
// 1. 状态提升到父组件
const [keyword, setKeyword] = useState("")
return (
<>
{/* 2. 通过 props 向下传值 */}
{/* 3. 通过回调向上通知 */}
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
function SearchBox({ value, onChange }) {
return (
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
)
}
function ResultList({ keyword }) {
return <div>搜索:{keyword}</div>
}
关键变化:
SearchBox不再持有状态,变成受控组件。ResultList不再持有状态,变成纯展示组件。- 状态只有一个真相来源:
Page。
提升后的命名约定
父组件定义的处理函数:handle 开头。
jsx
function Page() {
const [keyword, setKeyword] = useState("")
function handleKeywordChange(newKeyword) {
setKeyword(newKeyword)
}
return <SearchBox value={keyword} onChange={handleKeywordChange} />
}
子组件接收的回调:on 开头。
jsx
function SearchBox({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />
}
为什么要区分?
handle是"我处理这个事件",父组件的内部逻辑。on是"当这个事件发生时",子组件的接口。
这个约定让代码更清晰:看到 handle 知道是处理逻辑,看到 on 知道是回调接口。
提升后的边界处理
子组件需要支持"不传回调"的情况。
jsx
function SearchBox({ value, onChange }) {
return (
<input
value={value}
onChange={e => onChange?.(e.target.value)} // 可选调用
/>
)
}
为什么? 子组件可能被用在只读场景,不需要回调。可选调用让组件更灵活。
提升的连锁反应
有时候,提升一个状态会连带提升其他状态。
jsx
// 提升前:两个独立组件,各有状态
function Filter() {
const [filter, setFilter] = useState("all")
const [keyword, setKeyword] = useState("")
// ...
}
function List() {
const [sortBy, setSortBy] = useState("created")
// ...
}
// 提升后:Page 持有所有需要共享的状态
function Page() {
const [filter, setFilter] = useState("all")
const [keyword, setKeyword] = useState("")
const [sortBy, setSortBy] = useState("created")
const filtered = ... // 依赖 filter, keyword, sortBy
return (
<>
<Filter
filter={filter}
keyword={keyword}
onFilterChange={setFilter}
onKeywordChange={setKeyword}
/>
<List
items={filtered}
sortBy={sortBy}
onSortChange={setSortBy}
/>
</>
)
}
原则:只要两个状态被同一段派生逻辑用到,就应该提升到同一个层级。
提升多少层
只提升到最近公共父级,不要一步到位提到全局。
jsx
// ❌ 一步提到全局
function App() {
const [selectedId, setSelectedId] = useState(null)
return <UserPanel selectedId={selectedId} setSelectedId={setSelectedId} />
}
// ✅ 提到最近公共父级
function UserPanel() {
const [selectedId, setSelectedId] = useState(null)
return (
<>
<UserList selectedId={selectedId} onSelect={setSelectedId} />
<UserDetail selectedId={selectedId} />
</>
)
}
提升过度会导致:
- Props 层层传递(prop drilling)。
- 中间组件重新渲染。
- 组件难以独立复用。
提升后的性能优化
状态提升后,父组件重渲染会带动所有子组件重渲染。用 memo 隔离无关组件。
jsx
function Page() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
<HeavyComponent /> {/* 和 keyword 无关,但会重渲染 */}
</>
)
}
// 用 memo 包裹
const HeavyComponent = memo(function HeavyComponent() {
// ...
})
memo 的作用: props 不变时,跳过重渲染。HeavyComponent 没有 props,所以永远跳过。
五、状态下放:避免不必要的提升
什么是状态下放
状态下放:把状态从高层组件移到低层组件,让组件更自治。
什么时候下放
信号一:状态只被一个组件用。
jsx
// ❌ isModalOpen 放在 App,只有 Main 里的按钮用
function App() {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<>
<Header />
<Main
isModalOpen={isModalOpen}
onOpenModal={() => setIsModalOpen(true)}
onCloseModal={() => setIsModalOpen(false)}
/>
</>
)
}
// ✅ 下放到 Main
function Main() {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<>
<button onClick={() => setIsModalOpen(true)}>打开</button>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</>
)
}
信号二:状态是纯 UI 状态,不影响业务。
jsx
// ❌ 展开状态放在全局
function App() {
const [expandedId, setExpandedId] = useState(null)
return <Accordion expandedId={expandedId} onToggle={setExpandedId} />
}
// ✅ 就近放在 Accordion 内部
function Accordion() {
const [expandedId, setExpandedId] = useState(null)
// ...
}
信号三:状态提升后,中间组件被迫接收不相关的 props。
jsx
// ❌ 中间组件 Layout 不用 user,只是转发
function App() {
const [user, setUser] = useState(null)
return <Layout user={user} />
}
function Layout({ user }) {
return <Sidebar user={user} />
}
// ✅ 用组合避免传递
function App() {
const [user, setUser] = useState(null)
return (
<Layout>
<Sidebar>
<UserMenu user={user} />
</Sidebar>
</Layout>
)
}
function Layout({ children }) {
return <div>{children}</div>
}
信号四:状态变化导致大范围重渲染。
jsx
// ❌ keyword 放在 App,输入时整个应用重渲染
function App() {
const [keyword, setKeyword] = useState("")
return (
<>
<Header />
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
<Footer />
</>
)
}
// ✅ 下放到 SearchPage,只影响局部
function SearchPage() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
下放的边界
下放不等于"所有状态都放组件内部"。 判断标准:
- 只有一个组件用 → 下放到该组件。
- 兄弟组件用 → 不能下放,必须提升。
- 需要和外部同步 → 不能下放。
jsx
// ❌ 下放过度:兄弟组件拿不到
function SearchBox() {
const [keyword, setKeyword] = useState("") // 下放,但 ResultList 需要
}
function ResultList() {
// 拿不到 keyword
}
// ✅ 提升到父级
function Page() {
const [keyword, setKeyword] = useState("")
}
下放的实用场景
场景一:弹窗状态。
jsx
function DeleteButton({ onDelete }) {
const [confirming, setConfirming] = useState(false)
return (
<>
<button onClick={() => setConfirming(true)}>删除</button>
{confirming && (
<ConfirmDialog
onConfirm={() => { onDelete(); setConfirming(false) }}
onCancel={() => setConfirming(false)}
/>
)}
</>
)
}
场景二:展开/折叠状态。
jsx
function AccordionItem({ title, children }) {
const [expanded, setExpanded] = useState(false)
return (
<div>
<button onClick={() => setExpanded(e => !e)}>
{title} {expanded ? "▼" : "▶"}
</button>
{expanded && <div>{children}</div>}
</div>
)
}
场景三:输入框的临时状态。
jsx
function SearchBox({ onSearch }) {
const [input, setInput] = useState("")
return (
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && onSearch(input)}
/>
)
}
注意: 如果输入值需要实时影响其他组件(如实时搜索),就不能下放,必须提升。
下放后的受控/非受控模式
通用组件下放状态后,应该同时支持受控和非受控,让调用方决定。
jsx
function Accordion({
expanded: controlledExpanded,
defaultExpanded = false,
onExpandedChange,
children
}) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
const isControlled = controlledExpanded !== undefined
const expanded = isControlled ? controlledExpanded : internalExpanded
function handleToggle() {
const next = !expanded
if (!isControlled) setInternalExpanded(next)
onExpandedChange?.(next)
}
return (
<div>
<button onClick={handleToggle}>
{expanded ? "收起" : "展开"}
</button>
{expanded && <div>{children}</div>}
</div>
)
}
// 非受控:组件自己管
<Accordion defaultExpanded>内容</Accordion>
// 受控:调用方管
<Accordion expanded={expanded} onExpandedChange={setExpanded}>内容</Accordion>
设计原则: 简单场景用非受控,复杂场景用受控。同时支持两种是组件库的标配。
六、状态放错位置的四个信号
信号一:Props 层层传递
jsx
function App() {
const [user, setUser] = useState(null)
return <Layout user={user} />
}
function Layout({ user }) {
return <Sidebar user={user} /> // Layout 不用 user,只是转发
}
function Sidebar({ user }) {
return <UserMenu user={user} /> // Sidebar 也不用
}
function UserMenu({ user }) {
return <div>{user?.name}</div> // 只有它用
}
问题: Layout 和 Sidebar 被迫接收和转发 user。
解决:
- 用组合。 把
UserMenu作为 children 传进去。 - 用 Context。 如果中间层级太多,用 Context 跳过。
jsx
// 组合方案
function App() {
const [user, setUser] = useState(null)
return (
<Layout>
<Sidebar>
<UserMenu user={user} />
</Sidebar>
</Layout>
)
}
信号二:状态需要同步
jsx
// ❌ 两个组件各持一份相关状态,还要手动同步
function Filter() {
const [keyword, setKeyword] = useState("")
// 通知 List
}
function List() {
const [keyword, setKeyword] = useState("")
// 接收 Filter 的通知
}
问题: 两个状态是同一份数据,需要手动同步,容易不一致。
解决: 提升到共同父级,只有一个真相来源。
jsx
function Page() {
const [keyword, setKeyword] = useState("")
return (
<>
<Filter value={keyword} onChange={setKeyword} />
<List keyword={keyword} />
</>
)
}
信号三:组件难以复用
jsx
// ❌ 通用组件内部持有业务状态,无法在不同场景复用
function UserCard() {
const [isFollowing, setIsFollowing] = useState(false)
// isFollowing 是业务状态,不该内置
}
// ✅ 状态提升到调用方
function UserCard({ user, isFollowing, onFollowToggle }) {
return (
<div>
<span>{user.name}</span>
<button onClick={onFollowToggle}>
{isFollowing ? "已关注" : "关注"}
</button>
</div>
)
}
原则: 通用组件应该"笨"------只接收数据和回调,不持有业务状态。
信号四:大范围重渲染
jsx
// ❌ 输入框的值放在 App,每次输入整个应用重渲染
function App() {
const [keyword, setKeyword] = useState("")
return (
<>
<Header />
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
<Footer />
</>
)
}
// ✅ 下放到 SearchPage,只影响局部
function SearchPage() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
信号: 如果状态变化导致大量不相关的组件重渲染,说明状态放太高了。
四个信号的总结
| 信号 | 问题 | 解决 |
|---|---|---|
| Props 层层传递 | 中间组件转发不相关的 props | 组合或 Context |
| 状态需要同步 | 两份数据不一致 | 提升到公共父级 |
| 组件难以复用 | 通用组件持有业务状态 | 状态提升到调用方 |
| 大范围重渲染 | 状态放太高 | 下放到使用它的局部 |
一个快速诊断表
| 症状 | 可能的原因 | 修复方向 |
|---|---|---|
| 中间组件 props 很多但不使用 | 状态放太高 | 组合或 Context |
| 两个状态总是一起变 | 状态放散了 | 提升到公共父级 |
| 通用组件无法在不同场景复用 | 通用组件持有业务状态 | 状态提升到调用方 |
| 输入一个字符整个页面闪 | 状态放太高 | 下放或隔离输入状态 |
| 组件卸载后状态还在 | 状态放太高 | 下放到组件内部 |
七、跨层级共享的三种方案
当状态需要跨越多个层级时,有三种方案:props、组合、Context。
方案一:Props
适用: 层级浅(1-2 层),传递简单。
jsx
function App() {
const [user, setUser] = useState(null)
return <Layout user={user} />
}
function Layout({ user }) {
return <Sidebar user={user} />
}
优点: 显式,容易追踪。
缺点: 层级深时冗长。
方案二:组合
适用: 中间组件不需要数据,只是布局容器。
jsx
function App() {
const [user, setUser] = useState(null)
return (
<Layout>
<Sidebar>
<UserMenu user={user} />
</Sidebar>
</Layout>
)
}
function Layout({ children }) {
return <div>{children}</div>
}
function Sidebar({ children }) {
return <aside>{children}</aside>
}
优点: 不需要额外机制,数据流清晰。
缺点: 组件结构可能变得嵌套复杂。
方案三:Context
适用: 多个不相邻的组件共享,或跨层级很深。
jsx
const UserContext = createContext(null)
function App() {
const [user, setUser] = useState(null)
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
)
}
function UserMenu() {
const user = useContext(UserContext)
return <div>{user?.name}</div>
}
优点: 跳过中间层级,调用方不用传。
缺点: 值变化时所有消费者重渲染,容易滥用。
选择顺序
text
Props → 组合 → Context → 全局状态
从简单到复杂,够用就好。
一张对比表
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Props | 层级浅 | 显式、易追踪 | 层级深时冗长 |
| 组合 | 中间组件不消费数据 | 无需额外机制 | 结构可能嵌套 |
| Context | 多组件共享、层级深 | 跳过中间层级 | 性能陷阱、易滥用 |
| 全局 | 跨页面共享 | 完全解耦 | 复杂度最高 |
一个实用的判断
问自己:中间组件需要这个数据吗?
- 需要 → 用 props,正常传递。
- 不需要 → 用组合,避免传递。
- 层级太深,组合不方便 → 用 Context。
Context 的正确用法
按领域拆分 Context,不要把所有东西塞进一个。
jsx
// ❌ 一个大 Context
<AppContext.Provider value={{ user, theme, cart, notifications, ... }}>
<App />
</AppContext.Provider>
// ✅ 按领域拆分
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<CartContext.Provider value={cart}>
<App />
</CartContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
为什么? 一个 Context 的值变化,所有消费者重渲染。拆开后,不同领域的状态变化互不影响。
Context 的性能陷阱
jsx
// ❌ value 是对象,每次渲染都是新引用,所有消费者重渲染
<UserContext.Provider value={{ user, setUser }}>
<App />
</UserContext.Provider>
// ✅ 用 useMemo 稳定引用
const value = useMemo(() => ({ user, setUser }), [user])
<UserContext.Provider value={value}>
<App />
</UserContext.Provider>
// ✅ 或者拆开两个 Context
<UserContext.Provider value={user}>
<UserActionsContext.Provider value={setUser}>
<App />
</UserActionsContext.Provider>
</UserContext.Provider>
原则: 值变化频繁的 Context,用 useMemo 稳定引用,或拆分。
八、状态位置与渲染性能
状态位置影响渲染范围
状态变化时,持有它的组件和所有子组件都会重渲染。
jsx
// ❌ keyword 放在 App,输入时整个应用重渲染
function App() {
const [keyword, setKeyword] = useState("")
return (
<>
<Header />
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
<Footer />
</>
)
}
// ✅ 下放到 SearchPage,只影响局部
function App() {
return (
<>
<Header />
<SearchPage />
<Footer />
</>
)
}
function SearchPage() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
下放状态,缩小重渲染范围,是常见的性能优化手段。
一个经典的性能陷阱
jsx
// ❌ 输入框的值提升到 Page,每次输入整个列表重渲染
function Page() {
const [keyword, setKeyword] = useState("")
const [items, setItems] = useState([])
return (
<div>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<ExpensiveList items={items} />
</div>
)
}
// ✅ 用非受控 + 提交时读取,或把输入状态隔离到子组件
function Page() {
const [items, setItems] = useState([])
return (
<div>
<SearchBox onSearch={setItems} />
<ExpensiveList items={items} />
</div>
)
}
function SearchBox({ onSearch }) {
const [input, setInput] = useState("") // 输入状态隔离
return (
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && onSearch(input)}
/>
)
}
原则: 频繁变化的状态(如输入框),尽量放在离使用它最近的地方,避免影响无关的组件。
状态位置和性能的权衡
| 状态放的位置 | 渲染范围 | 适用 |
|---|---|---|
| 低层 | 小 | 频繁变化、只影响局部 |
| 高层 | 大 | 需要共享、变化不频繁 |
权衡: 共享的需要 vs 性能的影响。
实用建议:
- 默认就近。 状态尽量放在使用它的组件。
- 需要共享才提升。 提升到最近公共父级,不要一步到顶。
- 频繁变化的状态隔离。 输入框的值尽量放在离使用它最近的组件。
- 必要时用
memo。 如果状态必须提升,用memo包裹不相关的子组件。
一个常见的优化模式
jsx
// 把频繁变化的状态和稳定的状态分开
function Page() {
const [keyword, setKeyword] = useState("") // 频繁变化
const [data, setData] = useState([]) // 稳定
return (
<div>
<SearchBox value={keyword} onChange={setKeyword} />
<StableList data={data} />
</div>
)
}
// StableList 用 memo 包裹,keyword 变化不触发它重渲染
const StableList = memo(function StableList({ data }) {
return <ul>{data.map(...)}</ul>
})
注意: memo 只能防止 props 不变时的重渲染。如果父组件重渲染,memo 的子组件会跳过渲染。这是状态提升后的常用优化。
渲染范围的可视化
text
状态放在 App:
App ──▶ Header, SearchBox, ResultList, Footer 全部重渲染
(渲染 4 个组件)
状态放在 SearchPage:
SearchPage ──▶ SearchBox, ResultList 重渲染
(渲染 2 个组件)
状态放在 SearchBox 内部:
SearchBox ──▶ SearchBox 重渲染
(渲染 1 个组件)
状态放得越低,渲染范围越小。
九、组合优于继承
组合解决状态放置问题
继承的问题: 子类需要父类的状态,但继承关系是固定的。
组合的优势: 通过 children 和 props 灵活组合,状态可以放在需要的地方。
jsx
// ❌ 继承:通用组件被迫持有状态
class BaseCard {
// 状态在基类里,所有子类继承
}
// ✅ 组合:状态由调用方决定
function Card({ children }) {
return <div className="card">{children}</div>
}
<Card>
<UserInfo user={user} />
</Card>
组合解决 prop drilling
jsx
// ❌ prop drilling
<Layout user={user}>
<Sidebar user={user}>
<UserMenu user={user} />
</Sidebar>
</Layout>
// ✅ 组合
<Layout>
<Sidebar>
<UserMenu user={user} />
</Sidebar>
</Layout>
组合让中间组件不知道数据的存在,状态直接从使用它的组件传入。
组合 vs Context
| 维度 | 组合 | Context |
|---|---|---|
| 机制 | children / props | Provider / useContext |
| 数据流 | 显式 | 隐式 |
| 性能 | 无额外开销 | 值变化触发消费者重渲染 |
| 适用 | 层级浅、结构灵活 | 多组件共享、层级深 |
优先用组合。组合不够时,用 Context。
组合的实用模式
模式一:容器 + 内容。
jsx
<Card>
<CardHeader>标题</CardHeader>
<CardBody>内容</CardBody>
</Card>
模式二:布局 + 插槽。
jsx
<Layout
header={<Header />}
sidebar={<Sidebar />}
>
<Content />
</Layout>
模式三:渲染 props。
jsx
<List items={users}>
{(user, index) => <div>{index}. {user.name}</div>}
</List>
组合的灵活性: 调用方决定内容、顺序、结构。组件只负责容器和逻辑。
十、常见误区与避坑
误区 1:状态放太深
jsx
// ❌ 兄弟组件拿不到
function SearchBox() {
const [keyword, setKeyword] = useState("")
}
function ResultList() {
// 拿不到 keyword
}
// ✅ 提升到父级
function Page() {
const [keyword, setKeyword] = useState("")
}
误区 2:状态放太高
jsx
// ❌ isModalOpen 放在 App,只有 Main 用
function App() {
const [isModalOpen, setIsModalOpen] = useState(false)
return <Main isModalOpen={isModalOpen} onOpen={() => setIsModalOpen(true)} />
}
// ✅ 下放到 Main
function Main() {
const [isModalOpen, setIsModalOpen] = useState(false)
}
误区 3:为了"共享"把状态一步提到全局
jsx
// ❌ 一个页面的状态放到全局
const useStore = create(set => ({
keyword: "",
setKeyword: k => set({ keyword: k })
}))
// ✅ 页面级状态放页面级
function Page() {
const [keyword, setKeyword] = useState("")
}
全局状态只用于真正跨页面的数据。
误区 4:通用组件持有业务状态
jsx
// ❌ 通用卡片持有业务状态
function Card() {
const [isLiked, setIsLiked] = useState(false)
// 无法在不同场景复用
}
// ✅ 状态提升到调用方
function Card({ isLiked, onLikeToggle, children }) {
return (
<div>
{children}
<button onClick={onLikeToggle}>
{isLiked ? "已赞" : "点赞"}
</button>
</div>
)
}
误区 5:状态提升后忘记传递回调
jsx
// ❌ 提升了状态,但子组件不知道怎么改
function Page() {
const [keyword, setKeyword] = useState("")
return <SearchBox value={keyword} /> // 少了 onChange
}
function SearchBox({ value }) {
return <input value={value} /> // 输入框无法输入
}
// ✅ 传递回调
<SearchBox value={keyword} onChange={setKeyword} />
误区 6:用 Context 传递频繁变化的状态
jsx
// ❌ 每次输入都触发所有消费者重渲染
<UserContext.Provider value={{ user, keyword }}>
<App />
</UserContext.Provider>
// ✅ 频繁变化的状态就近管理
function SearchBox() {
const [keyword, setKeyword] = useState("")
}
误区 7:状态提升导致中间组件重渲染
jsx
// ❌ 状态提升到 App,所有子组件重渲染
function App() {
const [keyword, setKeyword] = useState("")
return (
<>
<Header /> {/* 无关,但会重渲染 */}
<SearchBox value={keyword} onChange={setKeyword} />
<Footer /> {/* 无关,但会重渲染 */}
</>
)
}
// ✅ 用 memo 隔离无关组件
const Header = memo(function Header() { ... })
const Footer = memo(function Footer() { ... })
误区 8:状态下放后无法与其他组件共享
jsx
// ❌ 下放过度,兄弟组件拿不到
function SearchBox() {
const [keyword, setKeyword] = useState("")
}
// ✅ 判断:有多个组件用,就不能下放
function Page() {
const [keyword, setKeyword] = useState("")
}
误区 9:用 Context 替代所有 props
jsx
// ❌ 所有数据都放 Context,组件耦合隐式
const AppContext = createContext()
<AppContext.Provider value={{ user, theme, cart, notifications, ... }}>
<App />
</AppContext.Provider>
// ✅ 按需拆开,只有真正跨层级的才用 Context
误区 10:状态位置频繁变动
状态位置一旦确定,不要频繁变动。 变动前想清楚:这个状态被谁用?应该放哪里?频繁变动会让团队混乱,代码难以维护。
误区 11:忽略状态提升后的性能问题
jsx
// ❌ 状态提升后,所有子组件重渲染
function Page() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
<HeavyChart /> {/* 重组件,和 keyword 无关但会重渲染 */}
</>
)
}
// ✅ 用 memo 隔离
const HeavyChart = memo(function HeavyChart() { ... })
误区 12:Context 的 value 每次都是新对象
jsx
// ❌ value 是新对象,所有消费者重渲染
<UserContext.Provider value={{ user, setUser }}>
<App />
</UserContext.Provider>
// ✅ 用 useMemo 稳定
const value = useMemo(() => ({ user, setUser }), [user])
<UserContext.Provider value={value}>
<App />
</UserContext.Provider>
// ✅ 或者拆开两个 Context
<UserContext.Provider value={user}>
<UserActionsContext.Provider value={setUser}>
<App />
</UserActionsContext.Provider>
</UserContext.Provider>
十一、跨框架对照
状态提升
React:
jsx
function Parent() {
const [value, setValue] = useState("")
return <Child value={value} onChange={setValue} />
}
Vue 3:
vue
<!-- Parent -->
<script setup>
const value = ref("")
</script>
<template>
<Child :value="value" @update:value="value = $event" />
</template>
SwiftUI:
swift
struct Parent: View {
@State private var value = ""
var body: some View {
Child(value: $value)
}
}
Flutter:
dart
class Parent extends StatefulWidget { ... }
class _ParentState extends State<Parent> {
String value = "";
@override
Widget build(BuildContext context) {
return Child(value: value, onChange: (v) => setState(() => value = v));
}
}
Jetpack Compose:
kotlin
@Composable
fun Parent() {
var value by remember { mutableStateOf("") }
Child(value = value, onChange = { value = it })
}
跨层级共享
- React: Context
- Vue: provide / inject
- SwiftUI: EnvironmentObject / @Environment
- Flutter: InheritedWidget / Provider
- Compose: CompositionLocal
思维一致: 跳过中间层级,让深层组件直接访问。
全局状态
- React: Redux / Zustand / Jotai
- Vue: Pinia / Vuex
- SwiftUI: @EnvironmentObject / ObservableObject
- Flutter: Provider / Riverpod / Bloc
- Compose: ViewModel + StateFlow
思维一致: 全局状态只用于跨页面数据。
十二、练一练:18 道多元化习题
1. 选择题
题目: 状态应该放在哪里?
A. 总是放在最顶层组件
B. 总是放在使用它的组件内部
C. 放在使用它的最近公共父级
D. 总是放在全局
参考答案: C
解读: 最近公共父级是平衡点。放低了兄弟组件拿不到,放高了过度传递。判断流程:一个组件用 → 它自己;兄弟组件用 → 提升到公共父级;跨页面 → 全局。
2. 判断题
题目: 状态提升到父组件后,子组件应该继续持有状态,只是同步给父组件。
参考答案: 错误。
解读: 状态提升后,子组件不持有状态,变成受控组件。它通过 props 接收值、通过回调通知变化。如果子组件还持有状态,就变成了两份真相,容易不一致。
3. 填空题
题目: 判断状态放错位置的四个信号是 、 、、。
参考答案: Props 层层传递、状态需要同步、组件难以复用、大范围重渲染。
解读: Props 层层传递说明状态放太深或太高;状态需要同步说明有冗余状态;组件难以复用说明通用组件持有了业务状态;大范围重渲染说明状态放太高。
4. 代码阅读题
题目: 下面代码有什么问题?
jsx
function App() {
const [user, setUser] = useState(null)
return <Layout user={user} />
}
function Layout({ user }) {
return <Sidebar user={user} />
}
function Sidebar({ user }) {
return <UserMenu user={user} />
}
function UserMenu({ user }) {
return <div>{user?.name}</div>
}
参考答案: Prop drilling。Layout 和 Sidebar 不使用 user,只是转发。
修复方式一:组合。
jsx
function App() {
const [user, setUser] = useState(null)
return (
<Layout>
<Sidebar>
<UserMenu user={user} />
</Sidebar>
</Layout>
)
}
function Layout({ children }) { return <div>{children}</div> }
function Sidebar({ children }) { return <aside>{children}</aside> }
修复方式二:Context。
jsx
const UserContext = createContext(null)
function App() {
const [user, setUser] = useState(null)
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
)
}
function UserMenu() {
const user = useContext(UserContext)
return <div>{user?.name}</div>
}
解读: 优先用组合,组合不够用 Context。组合更简单、数据流更清晰。
5. 找错题
题目: 下面代码有什么问题?
jsx
function App() {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<>
<Header />
<Main
onOpenModal={() => setIsModalOpen(true)}
onCloseModal={() => setIsModalOpen(false)}
/>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</>
)
}
参考答案: isModalOpen 放太高了。只有 Main 里的按钮会打开弹窗,Header 用不到。应该下放到 Main。
修复:
jsx
function Main() {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<>
<button onClick={() => setIsModalOpen(true)}>打开</button>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</>
)
}
解读: 状态只被一个组件用时,应该下放到该组件。放太高会导致不必要的 props 传递和重渲染。
6. 状态提升题
题目: 把下面两个组件的共享状态提升。
jsx
function SearchBox() {
const [keyword, setKeyword] = useState("")
return <input value={keyword} onChange={e => setKeyword(e.target.value)} />
}
function ResultList() {
const [keyword, setKeyword] = useState("")
return <div>搜索:{keyword}</div>
}
function Page() {
return (
<>
<SearchBox />
<ResultList />
</>
)
}
参考答案:
jsx
function Page() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
function SearchBox({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />
}
function ResultList({ keyword }) {
return <div>搜索:{keyword}</div>
}
解读: 三步提升:把状态移到 Page,通过 value 向下传,通过 onChange 向上通知。子组件变成受控组件,状态只有一个真相来源。
7. 状态下放题
题目: 下面状态该放在哪里?
jsx
// 一个 Tab 组件,有多个 Tab 项,每个 Tab 项有选中状态
// 但同一时刻只有一个 Tab 被选中
参考答案: 状态应该放在 Tabs 容器组件里。
jsx
function Tabs({ children }) {
const [activeTab, setActiveTab] = useState(0)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
)
}
function Tab({ index, children }) {
const { activeTab, setActiveTab } = useContext(TabsContext)
return (
<button
className={activeTab === index ? "active" : ""}
onClick={() => setActiveTab(index)}
>
{children}
</button>
)
}
解读: activeTab 被多个 Tab 共享(互斥),必须放在共同父级 Tabs。每个 Tab 不能自己管选中状态,否则会出现多个同时选中。
8. 组合 vs Context 题
题目: 下面场景该用组合还是 Context?
jsx
// 场景:一个 Layout 组件,里面有 Header、Sidebar、Main
// Header 需要 user 数据,Sidebar 也需要,Main 也需要
// 但 Layout 自己不需要 user
参考答案: 用 Context 或组合。
Context 方案:
jsx
const UserContext = createContext(null)
function App() {
const [user, setUser] = useState(null)
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
)
}
function Layout() {
return (
<div>
<Header />
<Sidebar />
<Main />
</div>
)
}
function Header() {
const user = useContext(UserContext)
return <div>{user?.name}</div>
}
组合方案:
jsx
function App() {
const [user, setUser] = useState(null)
return (
<Layout
header={<Header user={user} />}
sidebar={<Sidebar user={user} />}
main={<Main user={user} />}
/>
)
}
function Layout({ header, sidebar, main }) {
return (
<div>
{header}
{sidebar}
{main}
</div>
)
}
解读: 多个组件都需要 user,且 Layout 是纯布局容器。用 Context 更简洁,用组合更显式。如果只有这一个场景,用组合;如果有多个页面都需要,用 Context。
9. 性能优化题
题目: 下面代码有什么性能问题?如何优化?
jsx
function App() {
const [keyword, setKeyword] = useState("")
return (
<>
<Header />
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
<Footer />
</>
)
}
参考答案: keyword 变化时,Header 和 Footer 也会重渲染,虽然它们和 keyword 无关。
优化方式一:下放状态。
jsx
function App() {
return (
<>
<Header />
<SearchPage />
<Footer />
</>
)
}
function SearchPage() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
优化方式二:用 memo。
jsx
const Header = memo(function Header() { ... })
const Footer = memo(function Footer() { ... })
解读: 优先下放状态,缩小重渲染范围。如果状态必须提升,用 memo 隔离无关组件。状态位置是性能优化的第一手段,memo 是第二手段。
10. 状态分类题
题目: 判断下面状态该放在哪里。
a. 表单的邮箱、密码(多个字段,需要整体提交)
b. 弹窗是否打开(只有触发它的按钮用)
c. 搜索关键词(搜索框和结果列表共享)
d. 登录用户信息(全应用用)
e. Tab 的选中状态(同一时刻只有一个)
参考答案:
a. 表单组件内部,放在表单组件。如果表单分多个子组件,提升到表单容器。
b. 触发它的组件内部(下放)。
c. 提升到搜索框和结果列表的共同父级。
d. 全局状态。
e. Tabs 容器组件内部。
解读: 状态位置取决于使用它的组件。a 需要整体提交,放在表单容器;b 只被一个组件用,就近;c 被兄弟组件共享,提升;d 跨页面,全局;e 被多个 Tab 共享且互斥,放在 Tabs 容器。
11. 提升多少层
题目: 下面状态应该提升到哪一层?
jsx
function App() {
return (
<Layout>
<Page>
<UserList /> // 需要 selectedId
<UserDetail /> // 需要 selectedId
</Page>
</Layout>
)
}
参考答案: 提升到 Page。
jsx
function Page() {
const [selectedId, setSelectedId] = useState(null)
return (
<>
<UserList selectedId={selectedId} onSelect={setSelectedId} />
<UserDetail selectedId={selectedId} />
</>
)
}
不要提升到 App ,因为 Layout 和 App 都不需要 selectedId。
解读: 只提升到最近公共父级。提升过度会导致 props 层层传递和无关组件重渲染。
12. 状态同步题
题目: 下面代码有什么问题?
jsx
function Filter() {
const [filter, setFilter] = useState("all")
return (
<select value={filter} onChange={e => setFilter(e.target.value)}>
<option value="all">全部</option>
</select>
)
}
function List() {
const [filter, setFilter] = useState("all")
// 手动监听 Filter 的变化
}
参考答案: 两个组件各持一份 filter,需要手动同步,容易不一致。
修复: 提升到共同父级。
jsx
function Page() {
const [filter, setFilter] = useState("all")
return (
<>
<Filter value={filter} onChange={setFilter} />
<List filter={filter} />
</>
)
}
解读: 状态需要同步,是状态放错位置的典型信号。有同步逻辑,说明状态应该提升到公共父级,让只有一个真相来源。
13. 通用组件状态题
题目: 下面通用组件有什么问题?
jsx
function Card({ title, content }) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div>
<h3>{title}</h3>
<button onClick={() => setIsExpanded(e => !e)}>
{isExpanded ? "收起" : "展开"}
</button>
{isExpanded && <p>{content}</p>}
</div>
)
}
参考答案: Card 是通用组件,却内置了 isExpanded 状态。这导致:
- 如果调用方想控制展开状态,做不到。
- 如果调用方想默认展开,做不到。
- 组件只能有一种行为。
改进:
jsx
function Card({
title,
content,
expanded: controlledExpanded,
defaultExpanded = false,
onExpandedChange
}) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
const isControlled = controlledExpanded !== undefined
const expanded = isControlled ? controlledExpanded : internalExpanded
function handleToggle() {
const next = !expanded
if (!isControlled) setInternalExpanded(next)
onExpandedChange?.(next)
}
return (
<div>
<h3>{title}</h3>
<button onClick={handleToggle}>
{expanded ? "收起" : "展开"}
</button>
{expanded && <p>{content}</p>}
</div>
)
}
解读: 通用组件的状态应该支持受控/非受控两种模式。这样简单场景用非受控,复杂场景用受控。这是组件库的设计标准。
14. 组合设计题
题目: 用组合重写下面代码,消除 prop drilling。
jsx
function App() {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState("light")
return (
<Layout user={user} theme={theme} />
)
}
function Layout({ user, theme }) {
return (
<div className={theme}>
<Header user={user} />
<Main user={user} />
</div>
)
}
function Header({ user }) {
return <div>{user?.name}</div>
}
function Main({ user }) {
return <div>{user?.email}</div>
}
参考答案:
jsx
function App() {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState("light")
return (
<Layout
theme={theme}
header={<Header user={user} />}
main={<Main user={user} />}
/>
)
}
function Layout({ theme, header, main }) {
return (
<div className={theme}>
{header}
{main}
</div>
)
}
function Header({ user }) {
return <div>{user?.name}</div>
}
function Main({ user }) {
return <div>{user?.email}</div>
}
解读: Layout 需要 theme(因为要设置 className),所以 theme 通过 props 传。user 只有 Header 和 Main 用,通过插槽传。这样 Layout 不用转发 user。
15. 状态位置重构题
题目: 下面代码的状态位置有问题,请重构。
jsx
function App() {
const [keyword, setKeyword] = useState("")
const [selectedId, setSelectedId] = useState(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const [theme, setTheme] = useState("light")
const [cart, setCart] = useState([])
return (
<div className={theme}>
<Header cart={cart} />
<SearchPage
keyword={keyword}
onKeywordChange={setKeyword}
selectedId={selectedId}
onSelect={setSelectedId}
isModalOpen={isModalOpen}
onOpenModal={() => setIsModalOpen(true)}
onCloseModal={() => setIsModalOpen(false)}
/>
<Footer />
</div>
)
}
参考答案:
jsx
function App() {
const [theme, setTheme] = useState("light")
const [cart, setCart] = useState([])
return (
<div className={theme}>
<Header cart={cart} />
<SearchPage />
<Footer />
</div>
)
}
function SearchPage() {
const [keyword, setKeyword] = useState("")
const [selectedId, setSelectedId] = useState(null)
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList
keyword={keyword}
selectedId={selectedId}
onSelect={setSelectedId}
/>
<button onClick={() => setIsModalOpen(true)}>打开</button>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</>
)
}
分析:
| 状态 | 原位置 | 新位置 | 原因 |
|---|---|---|---|
theme |
App | App | 全应用用 |
cart |
App | App | Header 用,跨页面 |
keyword |
App | SearchPage | 只有 SearchPage 用 |
selectedId |
App | SearchPage | 只有 SearchPage 用 |
isModalOpen |
App | SearchPage | 只有 SearchPage 用 |
解读: 三个状态从 App 下放到 SearchPage。App 只保留真正跨页面的状态(theme、cart)。这样 SearchPage 内部的状态变化不会影响 App 和其他页面。
16. Context 设计题
题目: 设计一个用户认证的 Context,要求:
- 提供
user、login、logout - 避免不必要的重渲染
- 拆分成两个 Context
参考答案:
jsx
const UserContext = createContext(null)
const UserActionsContext = createContext(null)
function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const actions = useMemo(() => ({
login: async (credentials) => {
const user = await api.login(credentials)
setUser(user)
},
logout: () => {
setUser(null)
}
}), []) // 稳定,不随 user 变化
return (
<UserContext.Provider value={user}>
<UserActionsContext.Provider value={actions}>
{children}
</UserActionsContext.Provider>
</UserContext.Provider>
)
}
// 只关心 user 的组件
function UserMenu() {
const user = useContext(UserContext)
return <div>{user?.name}</div>
}
// 只关心操作的组件
function LogoutButton() {
const { logout } = useContext(UserActionsContext)
return <button onClick={logout}>退出</button>
}
解读: 拆分成两个 Context:
UserContext:值随用户变化,关心用户的组件重渲染。UserActionsContext:操作函数稳定,关心操作的组件不重渲染。
好处: LogoutButton 不会因为 user 变化而重渲染。这是 Context 性能优化的标准模式。
17. 状态提升的命名题
题目: 下面代码的命名有什么问题?如何改进?
jsx
function Parent() {
const [value, setValue] = useState("")
return <Child value={value} setValue={setValue} />
}
function Child({ value, setValue }) {
return (
<input
value={value}
onChange={e => setValue(e.target.value)}
/>
)
}
参考答案: 直接把 setValue 传给子组件,子组件知道父组件的状态名和更新函数。应该改成语义化的回调。
jsx
function Parent() {
const [value, setValue] = useState("")
function handleValueChange(newValue) {
setValue(newValue)
}
return <Child value={value} onChange={handleValueChange} />
}
function Child({ value, onChange }) {
return (
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
)
}
命名约定:
- 父组件的处理函数:
handle开头。 - 子组件接收的回调:
on开头。
解读: handle 是"我处理这个事件",on 是"当这个事件发生时"。这个约定让代码更清晰,子组件也更通用。
18. 综合题
题目: 设计一个"分页列表"的状态放置方案。要求:
- 列表数据从 API 加载
- 分页器可以改变页码
- 列表项可以选中,选中后显示批量操作栏
- 顶部有搜索框,搜索后重置页码
参考答案:
jsx
function Page() {
// 输入状态:提升到 Page,因为搜索框和列表都要用
const [keyword, setKeyword] = useState("")
const [page, setPage] = useState(1)
// 异步状态:提升到 Page
const [data, setData] = useState({ items: [], total: 0 })
const [status, setStatus] = useState("loading")
// UI 状态:选中项放在 Page,因为批量操作栏和列表都要用
const [selectedIds, setSelectedIds] = useState(new Set())
// 搜索时重置页码(状态联动)
function handleSearch(newKeyword) {
setKeyword(newKeyword)
setPage(1)
}
// 加载数据
useEffect(() => {
setStatus("loading")
api.getItems({ keyword, page })
.then(data => { setData(data); setStatus("success") })
.catch(() => setStatus("error"))
}, [keyword, page])
return (
<div>
<SearchBox value={keyword} onChange={handleSearch} />
{selectedIds.size > 0 && (
<BatchActions
selectedIds={selectedIds}
onClear={() => setSelectedIds(new Set())}
/>
)}
<ItemList
items={data.items}
status={status}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
/>
<Pagination
page={page}
total={data.total}
onPageChange={setPage}
/>
</div>
)
}
function SearchBox({ value, onChange }) {
const [input, setInput] = useState(value) // 输入状态下放到 SearchBox
return (
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
onChange(input)
}
}}
/>
)
}
状态放置分析:
| 状态 | 位置 | 原因 |
|---|---|---|
keyword |
Page | 搜索框和列表都需要 |
page |
Page | 分页器和列表都需要 |
data |
Page | 列表需要 |
status |
Page | 列表需要,控制四态 |
selectedIds |
Page | 批量操作栏和列表都需要 |
input(搜索框内部) |
SearchBox | 输入状态下放,只在提交时通知 |
解读: 这道题综合了状态放置的多种考虑:
- 共享状态提升到 Page。
keyword、page、data、selectedIds被多个组件使用。 - 输入状态下放。
SearchBox内部的input只在提交时才影响外部,下放到SearchBox,避免每次输入都触发列表重渲染。 - 状态联动。 搜索时重置页码,在事件处理器里同时更新两个状态。
- UI 状态和业务状态分离。
selectedIds是 UI 状态,放在 Page 因为批量操作栏和列表都需要。
十三、本课检查清单
放置状态时,按顺序问自己:
谁需要这个状态:
- 只有一个组件用吗?→ 放在该组件。
- 兄弟组件用吗?→ 提升到最近公共父级。
- 跨页面用吗?→ 全局。
有没有放太高:
- 中间组件被迫接收不相关的 props 吗?→ 放太高了。
- 状态变化导致大范围重渲染吗?→ 放太高了。
- 只有一处用却放到了全局吗?→ 放太高了。
有没有放太低:
- 兄弟组件拿不到吗?→ 放太低了。
- 状态需要手动同步吗?→ 放太低了。
- 通用组件持有业务状态吗?→ 放太低了。
跨层级共享方案:
- 层级浅(1-2 层)→ Props。
- 中间组件不消费数据 → 组合。
- 多个不相邻组件共享 → Context。
- 跨页面 → 全局。
性能考虑:
- 状态变化影响范围大吗?
- 频繁变化的状态能下放吗?
- 无关组件用
memo隔离了吗? - Context 的 value 稳定吗?
受控/非受控:
- 通用组件支持两种模式吗?
- 状态提升后子组件变成受控了吗?
- 命名遵循
on/handle约定吗?
十四、课后作业
基础题
题目 1: 下面代码有什么问题?如何修复?
jsx
function SearchBox() {
const [keyword, setKeyword] = useState("")
return <input value={keyword} onChange={e => setKeyword(e.target.value)} />
}
function ResultList() {
const [keyword, setKeyword] = useState("")
return <div>搜索:{keyword}</div>
}
参考答案: 两个组件各持一份 keyword,无法同步。应该提升到共同父级:
jsx
function Page() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
function SearchBox({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />
}
function ResultList({ keyword }) {
return <div>搜索:{keyword}</div>
}
解读: 兄弟组件需要共享状态时,提升到共同父级。子组件变成受控组件,状态只有一个真相来源。
题目 2: 下面代码有什么问题?
jsx
function App() {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<>
<Header />
<Main
onOpenModal={() => setIsModalOpen(true)}
onCloseModal={() => setIsModalOpen(false)}
/>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</>
)
}
参考答案: isModalOpen 放太高了。只有 Main 用,应该下放到 Main。
jsx
function Main() {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<>
<button onClick={() => setIsModalOpen(true)}>打开</button>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</>
)
}
解读: 状态只被一个组件用时,就近放置。放太高会导致不必要的 props 传递和重渲染。
题目 3: 用组合重写下面代码,消除 prop drilling。
jsx
function App() {
const [user, setUser] = useState(null)
return <Layout user={user} />
}
function Layout({ user }) {
return <Sidebar user={user} />
}
function Sidebar({ user }) {
return <UserMenu user={user} />
}
function UserMenu({ user }) {
return <div>{user?.name}</div>
}
参考答案:
jsx
function App() {
const [user, setUser] = useState(null)
return (
<Layout>
<Sidebar>
<UserMenu user={user} />
</Sidebar>
</Layout>
)
}
function Layout({ children }) {
return <div>{children}</div>
}
function Sidebar({ children }) {
return <aside>{children}</aside>
}
function UserMenu({ user }) {
return <div>{user?.name}</div>
}
解读: 用 children 把组件作为内容传进去,Layout 和 Sidebar 变成纯容器,不知道 user 的存在。数据直接从 App 传到 UserMenu。
进阶题
题目 4: 下面状态应该放在哪里?说明理由。
jsx
// 一个 Dashboard 页面,有:
// - 顶部:用户信息、通知
// - 中间:数据图表、数据表格
// - 底部:系统状态
// 其中,数据表格有分页、排序、筛选
// 数据图表的显示/隐藏由用户控制
参考答案:
状态放置方案:
| 状态 | 位置 | 理由 |
|---|---|---|
| 用户信息 | 全局 | 跨页面共享 |
| 通知 | 全局或 Dashboard | 如果只有 Dashboard 用,放 Dashboard |
| 数据表格分页/排序/筛选 | 数据表格组件 | 只被表格用 |
| 数据图表显示/隐藏 | 数据图表组件 | 只被图表用 |
| 系统状态 | Dashboard | 多个子组件可能用 |
代码:
jsx
function Dashboard() {
const [systemStatus, setSystemStatus] = useState("ok")
return (
<div>
<TopBar />
<DataTable /> {/* 分页/排序/筛选在 DataTable 内部 */}
<DataChart /> {/* 显示/隐藏在 DataChart 内部 */}
<SystemStatus status={systemStatus} />
</div>
)
}
function DataTable() {
const [page, setPage] = useState(1)
const [sortBy, setSortBy] = useState(null)
const [filters, setFilters] = useState({})
}
function DataChart() {
const [visible, setVisible] = useState(true)
}
解读: 每个子组件的状态就近放置,不要提升到 Dashboard。只有真正跨子组件共享的状态(如 systemStatus)才提升。
题目 5: 用 Context 改造下面的 prop drilling。
jsx
function App() {
const [theme, setTheme] = useState("light")
const [user, setUser] = useState(null)
const [locale, setLocale] = useState("zh")
return (
<Layout
theme={theme}
user={user}
locale={locale}
/>
)
}
function Layout({ theme, user, locale }) {
return (
<div className={theme}>
<Header user={user} locale={locale} />
<Content user={user} locale={locale} />
<Footer locale={locale} />
</div>
)
}
参考答案:
jsx
const AppContext = createContext(null)
function App() {
const [theme, setTheme] = useState("light")
const [user, setUser] = useState(null)
const [locale, setLocale] = useState("zh")
return (
<AppContext.Provider value={{ theme, user, locale }}>
<Layout />
</AppContext.Provider>
)
}
function Layout() {
const { theme } = useContext(AppContext)
return (
<div className={theme}>
<Header />
<Content />
<Footer />
</div>
)
}
function Header() {
const { user, locale } = useContext(AppContext)
return <div>{user?.name} - {locale}</div>
}
解读: 多个状态需要跨层传递,且层级较深,用 Context 合适。但注意:
- Context 的值变化时,所有消费者重渲染。
- 如果只有少数几个消费者,且状态变化不频繁,Context 合适。
- 如果状态频繁变化,考虑拆分多个 Context 或局部化状态。
题目 6: 下面组件设计有什么问题?如何改进?
jsx
function UserCard({ userId }) {
const [user, setUser] = useState(null)
const [isFollowing, setIsFollowing] = useState(false)
useEffect(() => {
api.getUser(userId).then(setUser)
}, [userId])
return (
<div>
<img src={user?.avatar} />
<span>{user?.name}</span>
<button onClick={() => setIsFollowing(f => !f)}>
{isFollowing ? "已关注" : "关注"}
</button>
</div>
)
}
参考答案: isFollowing 是业务状态,不应内置在 UserCard 里。
改进:
jsx
// UserCard 是受控组件
function UserCard({ user, isFollowing, onFollowToggle }) {
return (
<div>
<img src={user?.avatar} />
<span>{user?.name}</span>
<button onClick={onFollowToggle}>
{isFollowing ? "已关注" : "关注"}
</button>
</div>
)
}
// 容器组件管理状态
function UserCardContainer({ userId }) {
const [user, setUser] = useState(null)
const [isFollowing, setIsFollowing] = useState(false)
useEffect(() => {
api.getUser(userId).then(setUser)
api.getFollowStatus(userId).then(setIsFollowing)
}, [userId])
return (
<UserCard
user={user}
isFollowing={isFollowing}
onFollowToggle={() => setIsFollowing(f => !f)}
/>
)
}
解读: 通用组件应该"笨"------只接收数据和回调。业务状态(如关注状态)由容器组件管理。这样 UserCard 可以在任何场景复用,不依赖具体的数据来源。
思考题
题目 7: 为什么状态不能一步提升到全局?
参考答案:
一步提升到全局的问题:
- Props 层层传递。 如果状态不跨页面,全局化后反而要多层传递。
- 不必要的重渲染。 全局状态变化时,所有订阅的组件都重渲染。
- 组件难以复用。 组件依赖全局状态,无法在不同场景独立使用。
- 调试困难。 全局状态的修改来源可能很多,难以追踪。
- 耦合增加。 组件和全局状态耦合,难以测试和重构。
正确做法: 只提升到最近公共父级。跨页面才全局。
解读: 状态放置的粒度很重要。每一步提升都要有明确的理由。 "可能以后会用到"不是理由。等到真正需要时再提升。
题目 8: 什么时候该用组合而不是 Context?
参考答案:
用组合的场景:
- 中间组件不需要数据。
- 组件结构相对简单。
- 数据流需要显式,方便追踪。
- 状态只在少数几个组件间共享。
用 Context 的场景:
- 多个不相邻的组件共享。
- 层级很深,组合不方便。
- 数据是"全局性质"(如主题、语言、用户)。
- 状态变化不频繁。
判断: 组合能解决的,优先用组合。组合更简单、更显式、无性能陷阱。
解读: 组合是解决 prop drilling 的首选方案。不要一遇到跨层级就用 Context。 先想:能不能用组合?中间组件能不能不接收数据?
题目 9: 状态位置对性能有什么影响?如何优化?
参考答案:
影响: 状态变化时,持有它的组件和所有子组件都会重渲染。状态放得越高,重渲染范围越大。
优化方式:
- 下放状态。 把频繁变化的状态放在使用它的最近组件。
- 隔离输入状态。 输入框的值尽量放在输入框组件内部,只在提交时通知父组件。
- 拆分 Context。 把频繁变化的值和稳定的值分开。
- 用
memo。 隔离不相关的子组件。 - 用
useMemo/useCallback。 稳定引用,避免不必要的重渲染。
例子:
jsx
// ❌ 输入框的值提升到 Page,每次输入列表重渲染
function Page() {
const [keyword, setKeyword] = useState("")
const [items, setItems] = useState([])
return (
<>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<ExpensiveList items={items} />
</>
)
}
// ✅ 输入状态隔离,只在提交时通知
function Page() {
const [items, setItems] = useState([])
return (
<>
<SearchBox onSearch={setItems} />
<ExpensiveList items={items} />
</>
)
}
解读: 状态位置是性能优化的第一手段。优先考虑下放状态,缩小重渲染范围。 如果状态必须提升,用 memo 隔离无关组件。
题目 10: 什么是"组合优于继承"?在状态放置中如何应用?
参考答案:
"组合优于继承" 是设计原则:用组合(把对象组合起来)而不是继承(扩展类)来实现复用。
在状态放置中的应用:
- 通用组件不持有业务状态。 通过 props 接收数据和回调,状态由调用方管理。
- 用 children 传递内容。 中间组件变成纯容器,不知道数据的存在。
- 用插槽传递 JSX。 父组件决定放什么,子组件负责摆放。
- 状态从使用它的组件直接传入。 不需要中间组件转发。
例子:
jsx
// ❌ 继承思路:BaseCard 持有状态,子类继承
class BaseCard {
state = { expanded: false }
}
// ✅ 组合思路:Card 只负责容器,状态由调用方决定
function Card({ children, expanded, onToggle }) {
return (
<div>
<button onClick={onToggle}>{expanded ? "收起" : "展开"}</button>
{expanded && children}
</div>
)
}
解读: 组合让组件更灵活、更可复用。状态放在需要它的地方,而不是被继承关系绑定。 这是声明式 UI 的核心设计思维。
挑战题
题目 11: 设计一个"实时协作编辑器"的状态放置方案。要求:
- 文档内容由多个用户实时编辑
- 有光标位置同步
- 有在线用户列表
- 有编辑历史
- 有保存状态
参考答案:
jsx
// 全局状态:跨组件、跨页面共享
const useAppStore = create(set => ({
currentUser: null,
documents: []
}))
// 文档级状态:多个组件共享
function DocumentPage({ docId }) {
// 文档内容:被编辑器和历史面板共享
const [content, setContent] = useState("")
// 在线用户:被光标、用户列表、编辑器共享
const [onlineUsers, setOnlineUsers] = useState([])
// 保存状态:被顶部栏和编辑器共享
const [saveStatus, setSaveStatus] = useState("saved")
// 编辑历史:被历史面板和编辑器共享
const [history, setHistory] = useState([])
// 连接 WebSocket(副作用)
useEffect(() => {
const ws = new WebSocket(`ws://api/docs/${docId}`)
return () => ws.close()
}, [docId])
return (
<div>
<Toolbar saveStatus={saveStatus} />
<div className="editor-layout">
<Editor
content={content}
onChange={setContent}
onlineUsers={onlineUsers}
/>
<SidePanel>
<UserList users={onlineUsers} />
<HistoryPanel history={history} />
</SidePanel>
</div>
</div>
)
}
状态放置分析:
| 状态 | 位置 | 类型 | 原因 |
|---|---|---|---|
currentUser |
全局 | 全局 | 跨页面共享 |
content |
DocumentPage | 异步/输入 | 编辑器和历史面板共享 |
onlineUsers |
DocumentPage | 异步 | 光标、用户列表共享 |
saveStatus |
DocumentPage | 异步 | 顶部栏和编辑器共享 |
history |
DocumentPage | 异步 | 历史面板和编辑器共享 |
cursorPosition |
Editor | UI | 只有编辑器用 |
activeTab(侧栏) |
SidePanel | UI | 只有侧栏用 |
解读: 关键设计:
- 全局状态只放跨页面数据。
currentUser是唯一全局的。 - 文档级状态提升到
DocumentPage。 多个子组件共享。 - UI 状态下放。 光标位置、侧栏 Tab 就近放置。
- 副作用在
DocumentPage处理。 WebSocket 连接是文档级的。 - 状态同步通过 WebSocket 消息处理。 收到消息时更新对应状态。
注意: 实际项目中,实时协作的状态管理非常复杂,通常用专门的库(如 Yjs、Automerge)。本课的重点是理解状态放置的思维。
题目 12: 重构下面代码,优化状态放置和性能。
jsx
function App() {
const [keyword, setKeyword] = useState("")
const [theme, setTheme] = useState("light")
const [user, setUser] = useState(null)
const [cart, setCart] = useState([])
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<div className={theme}>
<Header user={user} cart={cart} />
<div>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<List keyword={keyword} />
</div>
<Footer />
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</div>
)
}
参考答案:
jsx
function App() {
// 全局状态
const [theme, setTheme] = useState("light")
const [user, setUser] = useState(null)
const [cart, setCart] = useState([])
return (
<div className={theme}>
<Header user={user} cart={cart} />
<SearchSection />
<Footer />
</div>
)
}
function SearchSection() {
// 局部状态:只被 SearchSection 用
const [keyword, setKeyword] = useState("")
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<div>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<List keyword={keyword} />
<button onClick={() => setIsModalOpen(true)}>打开</button>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
</div>
)
}
分析:
| 状态 | 原位置 | 新位置 | 原因 |
|---|---|---|---|
theme |
App | App | 全应用用 |
user |
App | App | Header 用,跨页面 |
cart |
App | App | Header 用,跨页面 |
keyword |
App | SearchSection | 只有 SearchSection 用 |
isModalOpen |
App | SearchSection | 只有 SearchSection 用 |
解读: 三个状态下放到 SearchSection。这样:
- 减少重渲染。 输入关键词时,
App和其他组件不重渲染。 - 职责清晰。
App只管全局状态,SearchSection管自己的状态。 - 组件独立。
SearchSection可以独立测试和复用。
十五、本课小结
核心原则
状态应该放在使用它的最近公共父级。
- 一个组件用 → 放在该组件。
- 兄弟组件用 → 提升到最近公共父级。
- 跨页面用 → 全局。
状态提升
三步模式:
- 把状态移到父组件。
- 通过 props 向下传值。
- 通过回调向上通知。
命名约定:
- 父组件处理函数:
handle开头。 - 子组件回调:
on开头。
只提升到最近公共父级,不要一步到顶。
状态下放
四个信号:
- 状态只被一个组件用。
- 状态是纯 UI 状态。
- 状态提升后,中间组件被迫接收不相关的 props。
- 状态变化导致大范围重渲染。
下放缩小重渲染范围,是性能优化的第一手段。
状态放错的四个信号
| 信号 | 问题 | 解决 |
|---|---|---|
| Props 层层传递 | 中间组件转发 | 组合或 Context |
| 状态需要同步 | 两份数据不一致 | 提升到公共父级 |
| 组件难以复用 | 通用组件持有业务状态 | 状态提升到调用方 |
| 大范围重渲染 | 状态放太高 | 下放到局部 |
跨层级共享三种方案
text
Props → 组合 → Context → 全局状态
从简单到复杂,够用就好。
组合优于继承
- 组合让中间组件变成纯容器。
- 组合比 Context 更简单、更显式。
- 能用组合就用组合。
状态位置与性能
- 状态放得越高,重渲染范围越大。
- 频繁变化的状态尽量下放。
- 输入框的值隔离在输入框组件内部。
- 必要时用
memo隔离无关组件。 - Context 的 value 用
useMemo稳定,或拆分。
五条铁律
- 默认就近,需要共享才提升。
- 只提升到最近公共父级,不要一步到顶。
- 能用组合就用组合,组合不够用 Context。
- 频繁变化的状态尽量下放。
- 通用组件不持有业务状态。
一句话记住本课
状态放对位置,代码自然清晰;状态放错位置,处处都是补丁。
实用价值回顾
- 组件结构清晰。 状态和组件职责匹配。
- 代码可复用。 通用组件不持有业务状态。
- 性能可控。 状态变化影响范围可控。
- 容易调试。 状态只有一个真相来源。
- 跨框架通用。 所有声明式框架都遵循相同的状态放置原则。
十六、下一课预告
第 9 课 派生状态与计算
本课讲了状态放在哪里,下一课讲状态怎么计算。核心内容:
- 派生数据的完整模式
useMemo的正确使用场景- 避免在渲染中做重计算
- 派生 vs 缓存的区别
- 派生状态的链式依赖
- 何时该用
useMemo,何时不该
状态放置决定"数据在哪",派生计算决定"数据怎么用"。两者结合,才能写出高效、清晰的声明式代码。