【声明式UI开发实用技术学与练】第8课 状态提升与下放

适用技术栈:React、Vue、SwiftUI、Flutter、Jetpack Compose。以 React 为主要教学语言,思维跨框架通用。

核心理念:不讲空理论,每个知识点都落地到能直接用于工作的实用技能。

本课产出: 能判断一个状态该放在组件树的哪个位置;能正确地提升状态、下放状态;能识别状态放错位置的四个信号;能用组合、Context、状态管理解决跨层级共享;能平衡状态位置与渲染性能。

一句话预览: 组件拆分决定"代码怎么组织",状态放置决定"数据怎么流动"。状态放对了,组件结构清晰;状态放错了,props 层层传递、状态四处同步、性能无故损耗。


一、本课目标

学完本课,你应该能:

  1. 用"最近公共父级"原则判断状态该放哪里。
  2. 掌握状态提升的完整模式:提升、传递、回调、命名。
  3. 判断什么时候该下放状态,避免不必要的提升。
  4. 识别状态放错位置的四个信号:props 层层传递、状态需要同步、组件难以复用、大范围重渲染。
  5. 用组合、Context、状态管理三种方案解决跨层级共享。
  6. 理解状态位置对渲染性能的影响,做出合理权衡。
  7. 知道"组合优于继承"在状态放置中的应用。
  8. 完成一个真实场景的状态位置重构。

二、从一个真实痛点说起

场景一:兄弟组件无法共享

需求:左侧是用户列表,右侧是用户详情。点击左侧的用户,右侧显示详情。

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 个 → 放在该组件内部
  │
  ├─ 兄弟组件 → 提升到最近公共父级
  │
  └─ 跨页面 → 考虑全局
       │
       └─ 真的跨页面吗?
            ├─ 是 → 全局
            └─ 只是同一页面不同区域 → 提升到页面级,不要全局

三个判断问题

对每一个状态,问三个问题:

  1. 谁需要这个状态? 列出所有使用它的组件。
  2. 它们的最近公共父级是谁? 那就是状态该放的位置。
  3. 能不能不放这个状态? 能派生就派生,能组合就组合。

一个反直觉的例子

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} />
    </>
  )
}

最近公共父级是"刚刚好"的位置。 放低了拿不到,放高了过度传递。

一个实用的判断技巧

问自己:如果把这个状态放在当前位置,哪些组件会被迫接收它?

  • 只有使用它的组件接收 → 位置对。
  • 有组件不使用却被迫接收 → 放太高了。
  • 有组件需要使用却拿不到 → 放太低了。

四、状态提升:完整模式

什么是状态提升

状态提升:把状态从子组件移到父组件,让多个子组件共享。

三步模式

  1. 把状态移到父组件。 在父组件里声明状态。
  2. 通过 props 向下传值。 子组件接收值,不持有状态。
  3. 通过回调向上通知。 子组件变化时,调用父组件传下来的回调。

完整示例

提升前:

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 性能的影响。

实用建议:

  1. 默认就近。 状态尽量放在使用它的组件。
  2. 需要共享才提升。 提升到最近公共父级,不要一步到顶。
  3. 频繁变化的状态隔离。 输入框的值尽量放在离使用它最近的组件。
  4. 必要时用 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 状态。这导致:

  1. 如果调用方想控制展开状态,做不到。
  2. 如果调用方想默认展开,做不到。
  3. 组件只能有一种行为。

改进:

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 输入状态下放,只在提交时通知

解读: 这道题综合了状态放置的多种考虑:

  1. 共享状态提升到 Page。 keyword、page、data、selectedIds 被多个组件使用。
  2. 输入状态下放。 SearchBox 内部的 input 只在提交时才影响外部,下放到 SearchBox,避免每次输入都触发列表重渲染。
  3. 状态联动。 搜索时重置页码,在事件处理器里同时更新两个状态。
  4. 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: 为什么状态不能一步提升到全局?

参考答案:

一步提升到全局的问题:

  1. Props 层层传递。 如果状态不跨页面,全局化后反而要多层传递。
  2. 不必要的重渲染。 全局状态变化时,所有订阅的组件都重渲染。
  3. 组件难以复用。 组件依赖全局状态,无法在不同场景独立使用。
  4. 调试困难。 全局状态的修改来源可能很多,难以追踪。
  5. 耦合增加。 组件和全局状态耦合,难以测试和重构。

正确做法: 只提升到最近公共父级。跨页面才全局。

解读: 状态放置的粒度很重要。每一步提升都要有明确的理由。 "可能以后会用到"不是理由。等到真正需要时再提升。


题目 8: 什么时候该用组合而不是 Context?

参考答案:

用组合的场景:

  • 中间组件不需要数据。
  • 组件结构相对简单。
  • 数据流需要显式,方便追踪。
  • 状态只在少数几个组件间共享。

用 Context 的场景:

  • 多个不相邻的组件共享。
  • 层级很深,组合不方便。
  • 数据是"全局性质"(如主题、语言、用户)。
  • 状态变化不频繁。

判断: 组合能解决的,优先用组合。组合更简单、更显式、无性能陷阱。

解读: 组合是解决 prop drilling 的首选方案。不要一遇到跨层级就用 Context。 先想:能不能用组合?中间组件能不能不接收数据?


题目 9: 状态位置对性能有什么影响?如何优化?

参考答案:

影响: 状态变化时,持有它的组件和所有子组件都会重渲染。状态放得越高,重渲染范围越大。

优化方式:

  1. 下放状态。 把频繁变化的状态放在使用它的最近组件。
  2. 隔离输入状态。 输入框的值尽量放在输入框组件内部,只在提交时通知父组件。
  3. 拆分 Context。 把频繁变化的值和稳定的值分开。
  4. 用 memo。 隔离不相关的子组件。
  5. 用 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: 什么是"组合优于继承"?在状态放置中如何应用?

参考答案:

"组合优于继承" 是设计原则:用组合(把对象组合起来)而不是继承(扩展类)来实现复用。

在状态放置中的应用:

  1. 通用组件不持有业务状态。 通过 props 接收数据和回调,状态由调用方管理。
  2. 用 children 传递内容。 中间组件变成纯容器,不知道数据的存在。
  3. 用插槽传递 JSX。 父组件决定放什么,子组件负责摆放。
  4. 状态从使用它的组件直接传入。 不需要中间组件转发。

例子:

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 只有侧栏用

解读: 关键设计:

  1. 全局状态只放跨页面数据。 currentUser 是唯一全局的。
  2. 文档级状态提升到 DocumentPage。 多个子组件共享。
  3. UI 状态下放。 光标位置、侧栏 Tab 就近放置。
  4. 副作用在 DocumentPage 处理。 WebSocket 连接是文档级的。
  5. 状态同步通过 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。这样:

  1. 减少重渲染。 输入关键词时,App 和其他组件不重渲染。
  2. 职责清晰。 App 只管全局状态,SearchSection 管自己的状态。
  3. 组件独立。 SearchSection 可以独立测试和复用。

十五、本课小结

核心原则

状态应该放在使用它的最近公共父级。

  • 一个组件用 → 放在该组件。
  • 兄弟组件用 → 提升到最近公共父级。
  • 跨页面用 → 全局。

状态提升

三步模式:

  1. 把状态移到父组件。
  2. 通过 props 向下传值。
  3. 通过回调向上通知。

命名约定:

  • 父组件处理函数:handle 开头。
  • 子组件回调:on 开头。

只提升到最近公共父级,不要一步到顶。

状态下放

四个信号:

  • 状态只被一个组件用。
  • 状态是纯 UI 状态。
  • 状态提升后,中间组件被迫接收不相关的 props。
  • 状态变化导致大范围重渲染。

下放缩小重渲染范围,是性能优化的第一手段。

状态放错的四个信号

信号 问题 解决
Props 层层传递 中间组件转发 组合或 Context
状态需要同步 两份数据不一致 提升到公共父级
组件难以复用 通用组件持有业务状态 状态提升到调用方
大范围重渲染 状态放太高 下放到局部

跨层级共享三种方案

text 复制代码
Props → 组合 → Context → 全局状态

从简单到复杂,够用就好。

组合优于继承

  • 组合让中间组件变成纯容器。
  • 组合比 Context 更简单、更显式。
  • 能用组合就用组合。

状态位置与性能

  • 状态放得越高,重渲染范围越大。
  • 频繁变化的状态尽量下放。
  • 输入框的值隔离在输入框组件内部。
  • 必要时用 memo 隔离无关组件。
  • Context 的 value 用 useMemo 稳定,或拆分。

五条铁律

  1. 默认就近,需要共享才提升。
  2. 只提升到最近公共父级,不要一步到顶。
  3. 能用组合就用组合,组合不够用 Context。
  4. 频繁变化的状态尽量下放。
  5. 通用组件不持有业务状态。

一句话记住本课

状态放对位置,代码自然清晰;状态放错位置,处处都是补丁。

实用价值回顾

  1. 组件结构清晰。 状态和组件职责匹配。
  2. 代码可复用。 通用组件不持有业务状态。
  3. 性能可控。 状态变化影响范围可控。
  4. 容易调试。 状态只有一个真相来源。
  5. 跨框架通用。 所有声明式框架都遵循相同的状态放置原则。

十六、下一课预告

第 9 课 派生状态与计算

本课讲了状态放在哪里,下一课讲状态怎么计算。核心内容:

  • 派生数据的完整模式
  • useMemo 的正确使用场景
  • 避免在渲染中做重计算
  • 派生 vs 缓存的区别
  • 派生状态的链式依赖
  • 何时该用 useMemo,何时不该

状态放置决定"数据在哪",派生计算决定"数据怎么用"。两者结合,才能写出高效、清晰的声明式代码。

相关推荐
东坡肘子38 分钟前
Swift Server,又多了一个 Google -- 肘子的 Swift 周报 #156
人工智能·swiftui·swift
m0_738185822 小时前
Flutter 鸿蒙化实战:open_app_settings 适配 OpenHarmony,一键跳转系统设置
flutter·华为·harmonyos·鸿蒙
m0_738185822 小时前
Flutter 鸿蒙化实战:qr_code_scanner 适配 OpenHarmony,相机扫码实时识别
数码相机·flutter·华为·harmonyos·鸿蒙
阳光九叶草LXGZXJ9 小时前
达梦数据库-报错-15-列【XXX】长度超出定义
linux·运维·数据库·sql·学习
mlidongfeng12 小时前
【学习】mKernel 分析
学习
一只小小的芙厨13 小时前
【线性DP】
学习·算法·动态规划
li星野14 小时前
DO — Auxiliary Verb (One-Page Notes)
学习
minglie114 小时前
关系的有向管道模型
学习
~kiss~15 小时前
LangChain 基础学习
学习·langchain