【声明式UI开发实用技术学与练】第9课 派生状态与计算

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

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

本课产出: 能识别哪些是派生状态;能写出清晰的派生逻辑;能正确使用 useMemo 缓存;能测量性能并做出优化决策;能区分"派生"和"缓存"的本质;能处理链式派生、分组、分页、树形结构等复杂计算。

一句话预览: 状态放置决定"数据在哪",派生计算决定"数据怎么用"。能算出来的不要存------但"算"也要算得聪明:该缓存的缓存,该拆解的拆解,该测量的测量。


一、本课目标

学完本课,你应该能:

  1. 说清楚派生状态的定义,用一句话判断"这是状态还是派生"。
  2. 掌握派生数据的完整模式:过滤、排序、聚合、格式化、查找、分组、分页、树形结构。
  3. 理解 useMemo 的本质和代价,知道什么时候该用、什么时候不该用。
  4. 掌握性能测量的实用方法,用数据而不是直觉做优化决策。
  5. 理解派生状态的链式依赖,知道怎么组织计算顺序。
  6. 区分"派生"和"缓存"的本质区别。
  7. 识别并修复 10 种常见反模式。
  8. 处理复杂计算:分组统计、树形结构、模糊搜索、虚拟滚动。

二、从一个真实痛点说起

场景:一个任务统计面板

需求:显示任务的总数、已完成数、完成率、本周完成数。

一个开发者这样写:

jsx 复制代码
function TaskStats() {
  const [tasks, setTasks] = useState([])
  const [total, setTotal] = useState(0)
  const [completed, setCompleted] = useState(0)
  const [rate, setRate] = useState(0)
  const [thisWeek, setThisWeek] = useState(0)

  // 加载任务
  useEffect(() => {
    api.getTasks().then(data => {
      setTasks(data)
      setTotal(data.length)
      setCompleted(data.filter(t => t.completed).length)
      setRate(data.filter(t => t.completed).length / data.length)
      setThisWeek(data.filter(t => isThisWeek(t.completedAt)).length)
    })
  }, [])

  // 每次任务变化,手动同步所有统计
  function toggleTask(id) {
    const next = tasks.map(t =>
      t.id === id ? { ...t, completed: !t.completed } : t
    )
    setTasks(next)
    setTotal(next.length)
    setCompleted(next.filter(t => t.completed).length)
    setRate(next.filter(t => t.completed).length / next.length)
    setThisWeek(next.filter(t => isThisWeek(t.completedAt)).length)
  }

  // ... 还有删除、添加、编辑,每个操作都要同步四个状态
}

问题:

  1. 四个统计状态都是派生的。 它们能从 tasks 算出来,不该单独存。
  2. 每个操作都要手动同步。 添加、删除、切换、编辑,每个都要更新所有统计。
  3. 容易遗漏。 忘了更新某一个,统计就不一致。
  4. 代码冗余。 同样的计算逻辑重复多次。

正确做法:

jsx 复制代码
function TaskStats() {
  const [tasks, setTasks] = useState([])

  // 派生:一行搞定,自动同步
  const total = tasks.length
  const completed = tasks.filter(t => t.completed).length
  const rate = total > 0 ? completed / total : 0
  const thisWeek = tasks.filter(t => isThisWeek(t.completedAt)).length

  function toggleTask(id) {
    setTasks(prev => prev.map(t =>
      t.id === id ? { ...t, completed: !t.completed } : t
    ))
  }
}

四个状态精简到零个派生状态。 这就是本课的第一条核心:能算出来的,不要存。

但"算"也有讲究

jsx 复制代码
// ❌ 每次渲染都重新过滤和排序,如果 tasks 很大(比如 10000 条),性能差
function TaskList({ tasks, keyword }) {
  return (
    <ul>
      {tasks
        .filter(t => t.title.includes(keyword))
        .sort((a, b) => b.createdAt - a.createdAt)
        .map(t => <TaskItem key={t.id} task={t} />)}
    </ul>
  )
}

10000 条数据,每次输入一个字符,就重新过滤 + 排序一次。 输入 10 个字符,就是 10 次全量遍历。这就是本课要解决的第二个问题:该缓存的要缓存。

第三个问题:该拆的要拆

jsx 复制代码
// ❌ 每次都重新计算所有统计,即使只用到其中一部分
const stats = {
  total: tasks.length,
  completed: tasks.filter(t => t.completed).length,
  overdue: tasks.filter(t => isOverdue(t)).length,
  byPriority: groupBy(tasks, "priority"),
  byAssignee: groupBy(tasks, "assignee"),
  completionRate: tasks.filter(t => t.completed).length / tasks.length
}

有些计算很轻(total),有些很重(groupBy)。 全部放在一起,每次都要重算。这就是第三个问题:该拆解的要拆解。

本课要教的,就是"怎么算得对,算得好,算得巧"。


三、什么是派生状态

定义

派生状态:从其他状态计算出来的数据。它不是状态,是计算。

一句话判断

问自己:如果其他状态都定了,这个值是不是唯一确定?

  • 是 → 派生,直接算。
  • 否 → 状态,用 useState。
jsx 复制代码
// 其他状态定了,fullName 唯一确定
const fullName = `${firstName} ${lastName}`  // 派生

// 其他状态定了,isModalOpen 不唯一确定(用户可以随时打开关闭)
const [isModalOpen, setIsModalOpen] = useState(false)  // 状态

三个特征

特征 状态 派生
来源 用户、服务端、界面 计算
存储 useState 变量或 useMemo
更新 setX 自动
同步 需要手动 自动
一致性 可能不一致 永远一致

常见的派生类型

类型 例子 计算方式
过滤 可见的任务列表 filter
排序 按时间排序的任务 [...items].sort
聚合 总数、总和、平均值 reduce
格式化 日期、金额、姓名 formatXxx
查找 选中的项 find / some / every
组合 全名、地址 模板字符串
条件 是否为空、是否禁用 布尔表达式
分组 按状态分组的任务 reduce + Map

一个容易忽略的例外

如果计算代价极高,可以缓存。但缓存不是状态,是性能优化。

jsx 复制代码
// ❌ 把重计算的结果当状态,还要手动同步
const [result, setResult] = useState([])
useEffect(() => {
  setResult(expensiveCompute(data))
}, [data])

// ✅ 用 useMemo 缓存,本质还是派生
const result = useMemo(() => expensiveCompute(data), [data])

useMemo 返回的仍然是派生值,只是加了缓存。 它不是状态,因为它不需要 setX,也不会独立变化。


四、派生的完整模式

模式一:过滤

jsx 复制代码
const visibleTasks = tasks.filter(task => {
  if (filter === "active") return !task.completed
  if (filter === "completed") return task.completed
  return true
})

多条件过滤时,用链式 filter,清晰且易读:

jsx 复制代码
const visibleTasks = tasks
  .filter(t => !t.archived)
  .filter(t => t.title.includes(keyword))
  .filter(t => filter === "all" || t.status === filter)

模式二:排序

jsx 复制代码
// ❌ sort 会修改原数组
const sorted = tasks.sort((a, b) => b.createdAt - a.createdAt)

// ✅ 先复制再排序
const sorted = [...tasks].sort((a, b) => b.createdAt - a.createdAt)

多个排序字段:

jsx 复制代码
const sorted = [...tasks].sort((a, b) => {
  // 先按状态排序
  if (a.status !== b.status) return a.status.localeCompare(b.status)
  // 再按时间排序
  return b.createdAt - a.createdAt
})

模式三:聚合

jsx 复制代码
// ❌ 多次遍历
const total = tasks.length
const completed = tasks.filter(t => t.completed).length
const thisWeek = tasks.filter(t => isThisWeek(t.completedAt)).length

// ✅ 一次遍历
const stats = tasks.reduce((acc, task) => {
  acc.total += 1
  if (task.completed) acc.completed += 1
  if (isThisWeek(task.completedAt)) acc.thisWeek += 1
  return acc
}, { total: 0, completed: 0, thisWeek: 0 })

用 reduce 一次遍历算多个聚合值,比多次 filter 高效。

模式四:查找

jsx 复制代码
const selectedTask = tasks.find(t => t.id === selectedId)
const currentUser = users.find(u => u.id === currentUserId)
const hasUnread = notifications.some(n => !n.read)
const allCompleted = tasks.every(t => t.completed)

find、some、every、includes 都是常用的查找派生。

模式五:分组

jsx 复制代码
// ❌ 用对象,键是动态的,容易出错
const grouped = tasks.reduce((acc, task) => {
  if (!acc[task.status]) acc[task.status] = []
  acc[task.status].push(task)
  return acc
}, {})

// ✅ 用 Map,更清晰
const grouped = tasks.reduce((map, task) => {
  const group = map.get(task.status) ?? []
  group.push(task)
  map.set(task.status, group)
  return map
}, new Map())

模式六:分页

jsx 复制代码
const pageSize = 20
const totalPages = Math.ceil(total / pageSize)
const startIndex = (page - 1) * pageSize
const pageItems = sorted.slice(startIndex, startIndex + pageSize)
const hasPrev = page > 1
const hasNext = page < totalPages

模式七:格式化

jsx 复制代码
const formattedDate = new Date(task.createdAt).toLocaleDateString()
const formattedPrice = `¥${price.toFixed(2)}`
const displayName = user.nickname || user.name || "匿名"

格式化是纯函数,适合直接派生。

模式八:组合派生

jsx 复制代码
// 派生可以依赖派生
const visible = tasks.filter(t => t.visible)           // 第一层
const sorted = [...visible].sort((a, b) => b.order - a.order)  // 第二层
const pageItems = sorted.slice(0, pageSize)            // 第三层
const pageCount = Math.ceil(sorted.length / pageSize)  // 第二层

链式派生是正常的,只要每一层都是纯计算。


五、useMemo:深入理解

本质

useMemo 是缓存:记住上一次的计算结果,依赖不变时直接返回。

jsx 复制代码
const result = useMemo(() => compute(a, b), [a, b])
  • a 或 b 变化 → 重新计算。
  • a 和 b 不变 → 返回缓存的结果。

核心原则

useMemo 是性能优化,不是正确性保证。

  • 不加 useMemo,计算仍然正确,只是每次渲染都重新算。
  • 加了 useMemo,依赖不变时跳过计算。

所以:先保证正确,再考虑优化。

useMemo 的代价

很多人不知道:useMemo 本身有开销。

每次渲染时,React 要做两件事:

  1. 存储上一次的依赖数组。
  2. 比较当前依赖和上一次依赖(逐项 Object.is 比较)。

如果计算本身很轻(比如 a + b),这个比较开销可能比计算本身还大。

jsx 复制代码
// ❌ useMemo 的开销 > 计算本身的开销
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName])

// ✅ 直接算
const fullName = `${firstName} ${lastName}`

一个粗略的参考:

计算类型 耗时 是否值得 useMemo
字符串拼接、加减乘除 < 0.001ms 不值得
几百条数据的 filter ~0.01ms 可考虑
几千条数据的 filter + sort ~0.1-1ms 值得
几万条数据的复杂计算 > 1ms 必须

这个表只是参考,实际要用测量数据说话。

什么时候该用

信号一:计算量大。

jsx 复制代码
// 数据量大,过滤排序耗时
const visible = useMemo(
  () => hugeList.filter(...).sort(...),
  [hugeList, keyword]
)

信号二:引用需要稳定。

jsx 复制代码
// 传给 memo 子组件的对象,需要稳定引用
const config = useMemo(() => ({ theme, locale }), [theme, locale])

// 传给 useEffect 的依赖,需要稳定引用
const options = useMemo(() => ({ headers }), [headers])

信号三:作为其他 Hook 的依赖。

jsx 复制代码
const sorted = useMemo(() => [...items].sort(...), [items])

useEffect(() => {
  // sorted 作为依赖,需要稳定引用
}, [sorted])

什么时候不该用

信号一:计算简单。

jsx 复制代码
// ❌ 简单计算,useMemo 的开销比计算还大
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName])

// ✅ 直接算
const fullName = `${firstName} ${lastName}`

信号二:没有性能问题。

jsx 复制代码
// ❌ 列表很小,不需要缓存
const visible = useMemo(() => items.filter(i => i.visible), [items])

// ✅ 直接算
const visible = items.filter(i => i.visible)

信号三:依赖频繁变化。

jsx 复制代码
// ❌ keyword 每次输入都变,缓存没有意义
const filtered = useMemo(() => items.filter(...), [items, keyword])

如果依赖每次都变,useMemo 每次都重新计算,缓存失效,白费开销。

一个判断流程

text 复制代码
这个计算需要缓存吗?
  │
  ├─ 计算简单(微秒级)?→ 不用 useMemo
  │
  ├─ 数据量小(几百条)?→ 不用 useMemo
  │
  ├─ 依赖频繁变化?→ 不用 useMemo
  │
  ├─ 引用需要稳定?→ 用 useMemo
  │
  ├─ 计算量大(毫秒级)?→ 用 useMemo
  │
  └─ 作为其他 Hook 依赖?→ 用 useMemo

useMemo 的正确姿势

jsx 复制代码
// ✅ 返回新对象,依赖明确
const filtered = useMemo(
  () => items.filter(i => i.name.includes(keyword)),
  [items, keyword]
)

// ✅ 返回新数组,依赖明确
const sorted = useMemo(
  () => [...items].sort((a, b) => a.order - b.order),
  [items]
)

// ❌ 依赖不完整
const filtered = useMemo(
  () => items.filter(i => i.name.includes(keyword)),
  [items]  // 缺 keyword
)

// ❌ 在 useMemo 里做副作用
const data = useMemo(() => {
  fetch("/api/data")  // 副作用
  return items
}, [items])

useMemo 不是银弹

useMemo 不能解决所有性能问题。 它只能缓存计算结果,不能:

  • 减少渲染次数(那是 memo 的职责)。
  • 减少状态更新(那是状态设计的职责)。
  • 优化 DOM 操作(那是框架的职责)。

性能优化的顺序:

  1. 状态设计。 减少不必要的状态。
  2. 状态位置。 缩小渲染范围(第 8 课)。
  3. 组件拆分。 隔离变化(第 7 课)。
  4. memo。 跳过重渲染。
  5. useMemo / useCallback。 缓存计算和引用。

useMemo 是最后一环,不是第一环。

React 编译器:未来的方向

React 19 引入的 React 编译器(React Compiler)能自动做记忆化。这意味着:

  • 大部分 useMemo / useCallback 会自动生成。
  • 开发者不需要手动加 useMemo。
  • 但理解 useMemo 的原理仍然重要------它帮你理解编译器在做什么,以及在编译器不适用的场景下如何手动优化。

Vue 的 computed 已经自动缓存,React 编译器是 React 的答案。 两种方向,同一个目标:让开发者少操心性能,多关注逻辑。


六、派生 vs 缓存

本质区别

维度 派生 缓存
目的 计算数据 优化性能
正确性 必须 可选
实现 变量或函数 useMemo
依赖 无 有依赖数组
移除后 逻辑错误 只是变慢

一个重要的思维转变

派生是"数据是什么",缓存是"怎么算更快"。

jsx 复制代码
// 派生:定义数据
const fullName = `${firstName} ${lastName}`

// 缓存:优化计算
const fullName = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName]
)

两者计算的是同一个值。区别在于是否缓存。

实践顺序

jsx 复制代码
// 第一步:先写成派生,保证正确
const visible = items.filter(i => i.visible)

// 第二步:测量,发现性能问题

// 第三步:加缓存
const visible = useMemo(
  () => items.filter(i => i.visible),
  [items]
)

原则:先正确,后优化。先测量,再优化。

一个常见的误区

jsx 复制代码
// ❌ 把派生当成状态,用 useEffect 同步
const [fullName, setFullName] = useState("")
useEffect(() => {
  setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])

// ✅ 派生
const fullName = `${firstName} ${lastName}`

这是第 2 课讲过的反模式。 useEffect 同步派生状态,多一次渲染,还可能不一致。


七、链式派生与计算顺序

什么是链式派生

一个派生依赖另一个派生。

jsx 复制代码
const visible = items.filter(i => i.visible)              // 第一层
const sorted = [...visible].sort((a, b) => b.order - a.order)  // 第二层
const pageItems = sorted.slice(0, pageSize)                // 第三层
const totalPages = Math.ceil(sorted.length / pageSize)     // 第二层
const hasNext = page < totalPages                           // 第三层

链式派生是正常的,只要每一层都是纯计算。

组织计算顺序

原则:从基础状态出发,逐层派生。

text 复制代码
基础状态:
  items, keyword, sortBy, page

第一层派生:
  filtered = items.filter(...)

第二层派生:
  sorted = [...filtered].sort(...)

第三层派生:
  pageItems = sorted.slice(...)
  totalPages = Math.ceil(sorted.length / pageSize)

第四层派生:
  hasNext = page < totalPages
  hasPrev = page > 1

每一层只依赖上一层,不跨层依赖。 这样逻辑清晰,容易理解和修改。

一个完整的例子

jsx 复制代码
function TaskList() {
  // 基础状态
  const [tasks, setTasks] = useState([])
  const [keyword, setKeyword] = useState("")
  const [filter, setFilter] = useState("all")
  const [sortBy, setSortBy] = useState("created")
  const [page, setPage] = useState(1)
  const pageSize = 20

  // 第一层:过滤
  const filtered = useMemo(() => {
    return tasks
      .filter(t => filter === "all" || t.status === filter)
      .filter(t => t.title.toLowerCase().includes(keyword.toLowerCase()))
  }, [tasks, filter, keyword])

  // 第二层:排序
  const sorted = useMemo(() => {
    return [...filtered].sort((a, b) => {
      if (sortBy === "created") return b.createdAt - a.createdAt
      if (sortBy === "title") return a.title.localeCompare(b.title)
      return 0
    })
  }, [filtered, sortBy])

  // 第三层:分页
  const pageItems = useMemo(() => {
    const start = (page - 1) * pageSize
    return sorted.slice(start, start + pageSize)
  }, [sorted, page, pageSize])

  // 派生统计(不需要缓存,计算极轻)
  const total = sorted.length
  const totalPages = Math.ceil(total / pageSize)
  const hasPrev = page > 1
  const hasNext = page < totalPages

  return (
    <div>
      <SearchBox value={keyword} onChange={setKeyword} />
      <FilterBar value={filter} onChange={setFilter} />
      <SortBar value={sortBy} onChange={setSortBy} />

      {pageItems.map(task => (
        <TaskItem key={task.id} task={task} />
      ))}

      <Pagination
        page={page}
        totalPages={totalPages}
        hasPrev={hasPrev}
        hasNext={hasNext}
        onChange={setPage}
      />
    </div>
  )
}

注意: 只有 filtered、sorted、pageItems 用了 useMemo,因为它们是重计算。total、totalPages、hasPrev、hasNext 是轻量计算,直接算。

每层缓存 vs 一次性计算

jsx 复制代码
// 方式 A:每层缓存
const filtered = useMemo(() => ..., [tasks, filter, keyword])
const sorted = useMemo(() => ..., [filtered, sortBy])
const pageItems = useMemo(() => ..., [sorted, page])

// 方式 B:一次性计算
const { pageItems, totalPages } = useMemo(() => {
  const filtered = ...
  const sorted = ...
  const pageItems = ...
  const totalPages = ...
  return { pageItems, totalPages }
}, [tasks, filter, keyword, sortBy, page])

怎么选?

场景 选择 原因
每层依赖不同,独立变化 方式 A 只有变化的层重新计算
所有依赖总是一起变 方式 B 更少的 useMemo,更少开销
计算链很长 方式 A 每层可独立缓存
计算链短 方式 B 合并更简洁

大多数情况下,方式 A 更清晰。


八、性能测量与优化决策

为什么需要测量

"先测量,再优化"不是口号,是纪律。

不测量的后果:

  • 优化了不是瓶颈的地方,浪费时间。
  • 引入了不必要的复杂度,代码难维护。
  • 可能让性能更差(比如滥用 useMemo)。

怎么测量

方式一:console.time / console.timeEnd。

jsx 复制代码
function Component({ items }) {
  console.time("filter")
  const filtered = items.filter(...)
  console.timeEnd("filter")
  // 输出:filter: 0.5ms

  return ...
}

简单直接,适合快速验证。

方式二:Performance API。

jsx 复制代码
const start = performance.now()
const result = expensiveCompute(data)
const end = performance.now()
console.log(`计算耗时: ${end - start}ms`)

更精确,适合测量毫秒级以上的计算。

方式三:React DevTools Profiler。

React DevTools 的 Profiler 标签页能记录:

  • 每个组件的渲染次数。
  • 每次渲染的耗时。
  • 哪些组件重新渲染了、为什么。

这是生产环境调试性能问题的首选工具。

方式四:Chrome Performance 面板。

记录一段时间的 CPU 使用情况,查看火焰图,找到耗时的函数。

适合定位整体性能瓶颈。

一个实用的测量流程

text 复制代码
1. 发现性能问题(界面卡顿、输入延迟)
   │
2. 用 React DevTools Profiler 记录
   │
3. 找到渲染次数多、耗时长的组件
   │
4. 分析原因:
   ├─ 状态放太高?→ 下放状态
   ├─ 重计算?→ 加 useMemo
   ├─ 重渲染?→ 加 memo
   └─ DOM 太多?→ 虚拟滚动
   │
5. 修改后重新测量,验证效果

性能预算

给关键操作设定性能预算,超过就优化。

操作 预算 说明
输入响应 < 16ms 60fps 的一帧
列表滚动 < 16ms 每帧
页面切换 < 100ms 用户感知流畅
数据加载 < 1s 可接受,超过需 loading
复杂计算 < 50ms 超过会卡顿

这些预算是经验值,具体项目可能不同。关键是有一个明确的标准。

一个真实的决策案例

jsx 复制代码
// 场景:一个 5000 条数据的列表,支持搜索
// 测量:过滤 + 排序耗时约 5ms
// 输入时每次触发,用户感知到延迟

// 决策:值得 useMemo
const visible = useMemo(
  () => [...items].filter(...).sort(...),
  [items, keyword]
)
jsx 复制代码
// 场景:一个 50 条数据的列表,支持搜索
// 测量:过滤 + 排序耗时约 0.05ms
// 输入时无感知

// 决策:不值得 useMemo
const visible = items.filter(...).sort(...)

同样是"过滤 + 排序",数据量不同,决策不同。 这就是为什么必须测量。

测量的三个原则

  1. 测量真实场景。 用真实数据量,不是测试数据。
  2. 测量关键路径。 用户最常做的操作。
  3. 测量前后对比。 优化后重新测量,验证效果。

九、复杂计算的实战模式

模式一:多条件过滤

jsx 复制代码
const filtered = useMemo(() => {
  return tasks.filter(task => {
    if (filter.status !== "all" && task.status !== filter.status) return false
    if (filter.priority !== "all" && task.priority !== filter.priority) return false
    if (keyword && !task.title.includes(keyword)) return false
    if (filter.assignee && task.assignee !== filter.assignee) return false
    return true
  })
}, [tasks, filter, keyword])

把过滤条件集中在一个函数里,清晰易改。

模式二:多字段排序

jsx 复制代码
const sorted = useMemo(() => {
  return [...tasks].sort((a, b) => {
    for (const { field, order } of sortRules) {
      const aVal = a[field]
      const bVal = b[field]
      if (aVal < bVal) return order === "asc" ? -1 : 1
      if (aVal > bVal) return order === "asc" ? 1 : -1
    }
    return 0
  })
}, [tasks, sortRules])

支持多字段排序,规则可配置。

模式三:分组统计

jsx 复制代码
const statsByStatus = useMemo(() => {
  return tasks.reduce((acc, task) => {
    if (!acc[task.status]) {
      acc[task.status] = { count: 0, total: 0, completed: 0 }
    }
    acc[task.status].count += 1
    acc[task.status].total += task.estimate
    if (task.completed) acc[task.status].completed += 1
    return acc
  }, {})
}, [tasks])

一次遍历算多个聚合值。

模式四:树形结构

jsx 复制代码
// 扁平列表 → 树形结构
const tree = useMemo(() => {
  const map = new Map()
  const roots = []

  items.forEach(item => {
    map.set(item.id, { ...item, children: [] })
  })

  items.forEach(item => {
    const node = map.get(item.id)
    if (item.parentId) {
      map.get(item.parentId)?.children.push(node)
    } else {
      roots.push(node)
    }
  })

  return roots
}, [items])

构建索引 + 两次遍历,时间复杂度 O(n),比递归查找高效。

模式五:模糊搜索

jsx 复制代码
const results = useMemo(() => {
  if (!keyword) return items
  const lower = keyword.toLowerCase()
  return items
    .map(item => ({
      item,
      score: computeScore(item.title.toLowerCase(), lower)
    }))
    .filter(({ score }) => score > 0)
    .sort((a, b) => b.score - a.score)
    .map(({ item }) => item)
}, [items, keyword])

先算分,再过滤,再排序,最后提取。

模式六:分页 + 虚拟滚动

jsx 复制代码
// 只计算当前视口的项
const visibleItems = useMemo(() => {
  const start = Math.max(0, scrollTop - bufferSize)
  const end = Math.min(items.length, scrollTop + viewportHeight + bufferSize)
  return items.slice(start, end).map((item, i) => ({
    ...item,
    index: start + i
  }))
}, [items, scrollTop, viewportHeight, bufferSize])

虚拟滚动只渲染视口内的项,是长列表的常用优化。

模式七:依赖多个状态的派生

jsx 复制代码
const summary = useMemo(() => {
  return {
    total: tasks.length,
    completed: tasks.filter(t => t.completed).length,
    overdue: tasks.filter(t => isOverdue(t)).length,
    byPriority: groupBy(tasks, "priority"),
    byAssignee: groupBy(tasks, "assignee"),
    completionRate: tasks.length > 0
      ? tasks.filter(t => t.completed).length / tasks.length
      : 0
  }
}, [tasks])

把相关的派生打包成一个对象,避免多次遍历。

模式八:带权重的排序

jsx 复制代码
// 场景:搜索结果按相关度排序
const ranked = useMemo(() => {
  return items
    .map(item => ({
      item,
      score:
        (item.title.includes(keyword) ? 100 : 0) +
        (item.tags.some(t => t.includes(keyword)) ? 50 : 0) +
        (item.isFeatured ? 20 : 0) +
        item.popularity
    }))
    .sort((a, b) => b.score - a.score)
    .map(({ item }) => item)
}, [items, keyword])

加权评分,灵活控制排序优先级。


十、常见误区与避坑

误区 1:把派生当状态

jsx 复制代码
// ❌
const [total, setTotal] = useState(0)
const [completed, setCompleted] = useState(0)

// ✅
const total = tasks.length
const completed = tasks.filter(t => t.completed).length

误区 2:用 useEffect 同步派生

jsx 复制代码
// ❌
useEffect(() => {
  setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])

// ✅
const fullName = `${firstName} ${lastName}`

误区 3:滥用 useMemo

jsx 复制代码
// ❌ 简单计算,useMemo 反而更慢
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName])

// ✅
const fullName = `${firstName} ${lastName}`

误区 4:useMemo 依赖不完整

jsx 复制代码
// ❌ 缺 keyword
const filtered = useMemo(
  () => items.filter(i => i.name.includes(keyword)),
  [items]
)

// ✅
const filtered = useMemo(
  () => items.filter(i => i.name.includes(keyword)),
  [items, keyword]
)

开启 ESLint 的 react-hooks/exhaustive-deps 规则自动检测。

误区 5:在渲染中做重计算

jsx 复制代码
// ❌ 每次渲染都排序
function List({ items }) {
  const sorted = [...items].sort(compare)
  return <ul>{sorted.map(...)}</ul>
}

// ✅ 缓存
function List({ items }) {
  const sorted = useMemo(() => [...items].sort(compare), [items])
  return <ul>{sorted.map(...)}</ul>
}

误区 6:sort 修改原数组

jsx 复制代码
// ❌ sort 原地修改
const sorted = useMemo(() => items.sort(compare), [items])

// ✅ 复制后排序
const sorted = useMemo(() => [...items].sort(compare), [items])

误区 7:在 useMemo 里做副作用

jsx 复制代码
// ❌ 副作用
const data = useMemo(() => {
  fetch("/api/data")
  return items
}, [items])

// ✅ 副作用放 useEffect
useEffect(() => {
  fetch("/api/data")
}, [items])

误区 8:多个 useMemo 依赖同一个大对象

jsx 复制代码
// ❌ 三个 useMemo 都依赖 tasks,tasks 变化时三个都重算
const filtered = useMemo(() => tasks.filter(...), [tasks])
const sorted = useMemo(() => [...tasks].sort(...), [tasks])
const stats = useMemo(() => tasks.reduce(...), [tasks])

// ✅ 合并成一个(如果依赖相同)
const { filtered, sorted, stats } = useMemo(() => ({
  filtered: tasks.filter(...),
  sorted: [...tasks].sort(...),
  stats: tasks.reduce(...)
}), [tasks])

误区 9:缓存没有意义的值

jsx 复制代码
// ❌ keyword 频繁变化,缓存失效
const filtered = useMemo(() => items.filter(...), [items, keyword])

如果依赖频繁变化,useMemo 每次都重算,白白浪费开销。

误区 10:把 useMemo 当 useCallback 用

jsx 复制代码
// ❌ useMemo 返回函数,不如用 useCallback
const handleClick = useMemo(() => () => doSomething(), [doSomething])

// ✅
const handleClick = useCallback(() => doSomething(), [doSomething])

误区 11:依赖是对象或数组

jsx 复制代码
// ❌ 对象每次渲染都是新引用
const result = useMemo(() => compute(config), [config])

// ✅ 依赖具体值
const result = useMemo(() => compute(config), [config.a, config.b])

误区 12:派生逻辑散落在渲染中

jsx 复制代码
// ❌ 渲染中直接算,每次渲染都重新计算
return (
  <ul>
    {items
      .filter(i => i.visible)
      .sort((a, b) => a.order - b.order)
      .map(i => <li key={i.id}>{i.name}</li>)}
  </ul>
)

// ✅ 派生在渲染外,可缓存
const visible = useMemo(
  () => [...items].filter(i => i.visible).sort((a, b) => a.order - b.order),
  [items]
)

return (
  <ul>
    {visible.map(i => <li key={i.id}>{i.name}</li>)}
  </ul>
)

十一、跨框架对照

派生状态

React:

jsx 复制代码
const fullName = `${firstName} ${lastName}`
const filtered = items.filter(i => i.visible)

Vue 3:

js 复制代码
import { computed } from 'vue'

const fullName = computed(() => `${firstName.value} ${lastName.value}`)
const filtered = computed(() => items.value.filter(i => i.visible))

SwiftUI:

swift 复制代码
var fullName: String {
    "\(firstName) \(lastName)"
}

var filtered: [Item] {
    items.filter { $0.visible }
}

Flutter:

dart 复制代码
String get fullName => "$firstName $lastName";

List<Item> get filtered => items.where((i) => i.visible).toList();

Jetpack Compose:

kotlin 复制代码
val fullName = "$firstName $lastName"
val filtered = remember(items) { items.filter { it.visible } }

缓存机制

框架 机制 特点
React useMemo 手动指定依赖
Vue 3 computed 自动追踪依赖
SwiftUI 计算属性 每次访问重新计算(通常轻量)
Flutter get 属性 每次访问重新计算
Compose remember / derivedStateOf 手动缓存

Vue computed vs React useMemo

js 复制代码
// Vue:自动追踪依赖
const filtered = computed(() => {
  return items.value.filter(i => i.name.includes(keyword.value))
})
jsx 复制代码
// React:手动指定依赖
const filtered = useMemo(() => {
  return items.filter(i => i.name.includes(keyword))
}, [items, keyword])

Vue 更方便,但依赖是隐式的。React 更显式,但需要手动维护。

Compose 的 derivedStateOf

kotlin 复制代码
// 当派生依赖频繁变化的状态时,用 derivedStateOf 避免不必要的重组
val isVisible by remember {
    derivedStateOf { scrollState.firstVisibleItemIndex > 0 }
}

derivedStateOf 类似 Vue 的 computed,只在结果变化时触发重组。

React 编译器

React 19 引入的编译器能自动做记忆化。未来的 React 代码可能不需要手写 useMemo。但理解 useMemo 的原理仍然重要------它帮你理解编译器在做什么,以及在编译器不适用的场景下如何手动优化。


十二、练一练:18 道多元化习题

1. 选择题

题目: 以下哪个是派生状态?

A. 输入框的当前内容

B. 弹窗是否打开

C. 从商品列表算出的总价

D. 从服务端加载的用户信息

参考答案: C

解读: 总价可以从商品列表算出来,是派生状态。A、B、D 都是不可派生的事实,是状态。判断标准:如果其他状态都定了,这个值是不是唯一确定?是 → 派生。


2. 判断题

题目: 只要加了 useMemo,性能就会变好。

参考答案: 错误。

解读: useMemo 本身有开销(存储依赖、比较依赖)。如果计算简单,useMemo 反而更慢。只有在计算量大或引用需要稳定时,useMemo 才有价值。先测量,再优化。


3. 填空题

题目: 派生和缓存的本质区别是:派生是 ______,缓存是 ______。

参考答案: 计算数据;优化性能。

解读: 派生定义"数据是什么",缓存优化"怎么算更快"。派生必须正确,缓存可选。移除派生会导致逻辑错误,移除缓存只是变慢。


4. 代码阅读题

题目: 下面代码有什么问题?

jsx 复制代码
const [tasks, setTasks] = useState([])
const [total, setTotal] = useState(0)
const [completed, setCompleted] = useState(0)

function toggleTask(id) {
  const next = tasks.map(t =>
    t.id === id ? { ...t, completed: !t.completed } : t
  )
  setTasks(next)
  setTotal(next.length)
  setCompleted(next.filter(t => t.completed).length)
}

参考答案: total 和 completed 是派生状态,不该单独存。每次操作都要手动同步,容易遗漏。

修复:

jsx 复制代码
const [tasks, setTasks] = useState([])

const total = tasks.length
const completed = tasks.filter(t => t.completed).length

function toggleTask(id) {
  setTasks(prev => prev.map(t =>
    t.id === id ? { ...t, completed: !t.completed } : t
  ))
}

解读: 能算出来的不要存。派生自动同步,不可能不一致。


5. 找错题

题目: 下面代码有什么问题?

jsx 复制代码
const sorted = useMemo(() => items.sort(compare), [items])

参考答案: sort 会原地修改 items,导致原始数据被修改。

修复:

jsx 复制代码
const sorted = useMemo(() => [...items].sort(compare), [items])

解读: sort、reverse、splice 都会修改原数组。派生时要复制后操作。


6. useMemo 判断题

题目: 下面哪些情况该用 useMemo?

a. 计算 fullName = firstName + lastName

b. 从 10000 条数据中过滤

c. 把一个对象传给 memo 子组件

d. 计算 isEmpty = items.length === 0

e. 作为 useEffect 的依赖

参考答案:

  • a. 不该。简单计算,useMemo 开销更大。
  • b. 该。计算量大。
  • c. 该。引用需要稳定,避免 memo 失效。
  • d. 不该。极其简单。
  • e. 该。引用需要稳定,避免 Effect 频繁执行。

解读: useMemo 的使用标准:计算量大、引用需要稳定、作为 Hook 依赖。简单计算不用。


7. 派生 vs 缓存题

题目: 下面两种写法,哪个是派生,哪个是缓存?

jsx 复制代码
// 写法 A
const fullName = `${firstName} ${lastName}`

// 写法 B
const fullName = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName]
)

参考答案:

  • 写法 A 是纯派生:直接计算,不缓存。
  • 写法 B 是缓存派生:用 useMemo 缓存。

两者计算的值相同。区别在于是否缓存。 对于简单计算,写法 A 更好;对于复杂计算,写法 B 更好。

解读: 派生是"数据是什么",缓存是"怎么算更快"。先写成派生,有性能问题再加缓存。


8. 链式派生题

题目: 下面代码的派生顺序合理吗?

jsx 复制代码
const pageItems = sorted.slice(0, pageSize)
const sorted = [...filtered].sort(compare)
const filtered = items.filter(i => i.visible)

参考答案: 顺序错误。pageItems 依赖 sorted,sorted 依赖 filtered,但代码顺序反了。

修复:

jsx 复制代码
const filtered = items.filter(i => i.visible)
const sorted = [...filtered].sort(compare)
const pageItems = sorted.slice(0, pageSize)

解读: 派生顺序必须从基础到上层。先过滤,再排序,再分页。每一层只依赖上一层,不跨层依赖。


9. 性能优化题

题目: 下面代码有什么性能问题?如何优化?

jsx 复制代码
function TaskList({ tasks, keyword }) {
  return (
    <ul>
      {tasks
        .filter(t => t.title.includes(keyword))
        .sort((a, b) => b.createdAt - a.createdAt)
        .map(t => <TaskItem key={t.id} task={t} />)}
    </ul>
  )
}

参考答案: 每次渲染都过滤和排序,即使 tasks 和 keyword 没变。

修复:

jsx 复制代码
function TaskList({ tasks, keyword }) {
  const visible = useMemo(
    () => [...tasks]
      .filter(t => t.title.includes(keyword))
      .sort((a, b) => b.createdAt - a.createdAt),
    [tasks, keyword]
  )

  return (
    <ul>
      {visible.map(t => <TaskItem key={t.id} task={t} />)}
    </ul>
  )
}

解读: 派生逻辑在渲染外,可缓存。同时注意 sort 要复制后操作。


10. 多条件过滤题

题目: 实现一个多条件过滤:status、priority、keyword 三个条件都可选。

参考答案:

jsx 复制代码
const filtered = useMemo(() => {
  return tasks.filter(task => {
    if (status !== "all" && task.status !== status) return false
    if (priority !== "all" && task.priority !== priority) return false
    if (keyword && !task.title.toLowerCase().includes(keyword.toLowerCase())) {
      return false
    }
    return true
  })
}, [tasks, status, priority, keyword])

解读: 把过滤条件集中在一个函数里,清晰易改。每个条件独立判断,可组合。


11. 聚合计算题

题目: 用一次遍历计算:总数、已完成数、完成率、本周完成数。

参考答案:

jsx 复制代码
const stats = useMemo(() => {
  return tasks.reduce((acc, task) => {
    acc.total += 1
    if (task.completed) {
      acc.completed += 1
      if (isThisWeek(task.completedAt)) {
        acc.thisWeek += 1
      }
    }
    return acc
  }, { total: 0, completed: 0, thisWeek: 0 })
}, [tasks])

const rate = stats.total > 0 ? stats.completed / stats.total : 0

解读: 用 reduce 一次遍历算多个聚合值,比多次 filter 高效。rate 从 stats 派生,是第二层派生。


12. 分组派生题

题目: 把任务按 status 分组。

参考答案:

jsx 复制代码
const grouped = useMemo(() => {
  return tasks.reduce((map, task) => {
    const group = map.get(task.status) ?? []
    group.push(task)
    map.set(task.status, group)
    return map
  }, new Map())
}, [tasks])

// 用法
const todoTasks = grouped.get("todo") ?? []
const doneTasks = grouped.get("done") ?? []

解读: 用 Map 分组,查找是 O(1)。用对象也可以,但 Map 更清晰,尤其是键是动态的时候。


13. 缓存合并题

题目: 下面三个 useMemo 都依赖 tasks,如何优化?

jsx 复制代码
const filtered = useMemo(() => tasks.filter(...), [tasks])
const sorted = useMemo(() => [...tasks].sort(...), [tasks])
const stats = useMemo(() => tasks.reduce(...), [tasks])

参考答案: 合并成一个 useMemo,一次遍历完成。

jsx 复制代码
const { filtered, sorted, stats } = useMemo(() => {
  const filtered = tasks.filter(...)
  const sorted = [...tasks].sort(...)
  const stats = tasks.reduce(...)
  return { filtered, sorted, stats }
}, [tasks])

解读: 多个派生依赖同一组状态时,合并成一个 useMemo。减少 useMemo 的数量,减少依赖比较的开销。注意: 如果每个派生的依赖不同,不要合并。


14. 树形结构题

题目: 把扁平的分类列表转换成树形结构。

jsx 复制代码
const categories = [
  { id: 1, name: "电子产品", parentId: null },
  { id: 2, name: "手机", parentId: 1 },
  { id: 3, name: "电脑", parentId: 1 },
  { id: 4, name: "服装", parentId: null },
  { id: 5, name: "男装", parentId: 4 }
]

参考答案:

jsx 复制代码
const tree = useMemo(() => {
  const map = new Map()
  const roots = []

  categories.forEach(cat => {
    map.set(cat.id, { ...cat, children: [] })
  })

  categories.forEach(cat => {
    const node = map.get(cat.id)
    if (cat.parentId) {
      map.get(cat.parentId)?.children.push(node)
    } else {
      roots.push(node)
    }
  })

  return roots
}, [categories])

解读: 构建索引 + 两次遍历。第一次建 Map,第二次组装树。时间复杂度 O(n),比递归查找高效。


15. 分页派生题

题目: 实现分页的所有派生状态。

参考答案:

jsx 复制代码
const pageSize = 20

const total = sorted.length
const totalPages = Math.ceil(total / pageSize)
const startIndex = (page - 1) * pageSize
const endIndex = startIndex + pageSize
const pageItems = sorted.slice(startIndex, endIndex)
const hasPrev = page > 1
const hasNext = page < totalPages
const isEmpty = total === 0
const showPagination = total > pageSize

解读: 所有分页相关的值都是派生的。page 是唯一的状态(用户输入),其余全从 page 和 sorted 算出。


16. useMemo 依赖题

题目: 下面代码的 useMemo 依赖完整吗?

jsx 复制代码
function Component({ items, keyword, sortBy }) {
  const result = useMemo(() => {
    return items
      .filter(i => i.name.includes(keyword))
      .sort((a, b) => {
        if (sortBy === "name") return a.name.localeCompare(b.name)
        return 0
      })
  }, [items])
}

参考答案: 依赖不完整。缺少 keyword 和 sortBy。

修复:

jsx 复制代码
}, [items, keyword, sortBy])

解读: useMemo 里用到的所有外部值都必须在依赖里。缺依赖会导致闭包陷阱。开启 ESLint 的 exhaustive-deps 规则自动检测。


17. 性能测量题

题目: 一个列表有 5000 条数据,支持搜索。你如何判断是否需要 useMemo?

参考答案:

步骤:

  1. 测量计算耗时。
jsx 复制代码
function List({ items, keyword }) {
  const start = performance.now()
  const filtered = items.filter(i => i.name.includes(keyword))
  const end = performance.now()
  console.log(`过滤耗时: ${end - start}ms`)

  return ...
}
  1. 判断是否超过预算。
  • 如果 < 1ms,用户无感知,不需要 useMemo。
  • 如果 > 5ms,输入时会有延迟,需要 useMemo。
  1. 验证优化效果。
jsx 复制代码
const filtered = useMemo(
  () => items.filter(i => i.name.includes(keyword)),
  [items, keyword]
)
// 再次测量,确认输入时不再重新过滤

解读: 不要凭直觉判断,要测量。5000 条数据的 filter 通常是 0.5-2ms,具体取决于数据复杂度。 只有测量才知道。


18. 综合题

题目: 实现一个"任务看板"的派生逻辑。要求:

  • 按状态分组
  • 每组内按优先级排序
  • 每组显示数量
  • 支持关键词过滤
  • 过滤后空组不显示

参考答案:

jsx 复制代码
function useBoard(tasks, keyword) {
  return useMemo(() => {
    // 第一层:过滤
    const filtered = keyword
      ? tasks.filter(t =>
          t.title.toLowerCase().includes(keyword.toLowerCase())
        )
      : tasks

    // 第二层:分组
    const grouped = filtered.reduce((map, task) => {
      const group = map.get(task.status) ?? []
      group.push(task)
      map.set(task.status, group)
      return map
    }, new Map())

    // 第三层:组内排序 + 统计
    const columns = []
    for (const [status, items] of grouped) {
      const sorted = [...items].sort((a, b) => {
        const priorityOrder = { high: 0, medium: 1, low: 2 }
        return priorityOrder[a.priority] - priorityOrder[b.priority]
      })
      columns.push({
        status,
        items: sorted,
        count: sorted.length
      })
    }

    // 空组不显示
    return columns.filter(col => col.count > 0)
  }, [tasks, keyword])
}

// 用法
function Board({ tasks, keyword }) {
  const columns = useBoard(tasks, keyword)

  return (
    <div className="board">
      {columns.map(col => (
        <div key={col.status} className="column">
          <h3>{col.status} ({col.count})</h3>
          {col.items.map(task => (
            <TaskCard key={task.id} task={task} />
          ))}
        </div>
      ))}
    </div>
  )
}

解读: 三层派生:过滤 → 分组 → 排序统计。每层都是纯计算,用一个 useMemo 缓存整个结果。注意: 如果每层的依赖不同,可以拆成多个 useMemo;如果依赖相同,合并成一个更高效。


十三、本课检查清单

派生逻辑时,按顺序问自己:

识别派生:

  • 这个值能从其他状态算出来吗?能 → 派生。
  • 它需要独立变化吗?不需要 → 派生。
  • 它有独立的更新逻辑吗?没有 → 派生。

派生写法:

  • 在渲染外计算,不要散落在 JSX 里。
  • 过滤、排序、聚合、格式化、查找、组合。
  • 链式派生从基础到上层,不跨层依赖。

useMemo:

  • 计算量大吗?大 → 用。
  • 引用需要稳定吗?需要 → 用。
  • 作为其他 Hook 的依赖?是 → 用。
  • 计算简单?不用。
  • 数据量小?不用。
  • 依赖频繁变化?不用。

性能测量:

  • 测量过真实场景吗?
  • 超过性能预算了吗?
  • 优化后重新测量了吗?

性能:

  • 多个派生依赖同一状态?合并成一个 useMemo。
  • sort、reverse、splice 复制后操作。
  • 先正确,后优化。先测量,再优化。

反模式自查:

  • 有没有把派生当状态?
  • 有没有用 useEffect 同步派生?
  • 有没有滥用 useMemo?
  • 有没有在渲染中做重计算?
  • 有没有在 useMemo 里做副作用?

十四、课后作业

基础题


题目 1: 下面代码哪些是派生状态?如何优化?

jsx 复制代码
const [items, setItems] = useState([])
const [count, setCount] = useState(0)
const [isEmpty, setIsEmpty] = useState(true)
const [totalPrice, setTotalPrice] = useState(0)
const [keyword, setKeyword] = useState("")

参考答案:

派生状态:

  • count:从 items.length 派生。
  • isEmpty:从 items.length === 0 派生。
  • totalPrice:从 items.reduce(...) 派生。

真实状态:

  • items
  • keyword

优化:

jsx 复制代码
const [items, setItems] = useState([])
const [keyword, setKeyword] = useState("")

const count = items.length
const isEmpty = items.length === 0
const totalPrice = items.reduce((s, i) => s + i.price, 0)

解读: 5 个状态精简到 2 个。派生自动同步,不可能不一致。


题目 2: 下面代码有什么问题?

jsx 复制代码
const sorted = useMemo(() => items.sort(compare), [items])

参考答案: sort 会原地修改 items。

修复:

jsx 复制代码
const sorted = useMemo(() => [...items].sort(compare), [items])

解读: sort、reverse、splice 都会修改原数组。派生时要复制后操作。


题目 3: 下面哪些该用 useMemo?

jsx 复制代码
// A
const fullName = `${firstName} ${lastName}`

// B
const filtered = hugeList.filter(i => i.name.includes(keyword))

// C
const isEmpty = items.length === 0

// D
const config = { theme, locale }

参考答案:

  • A 不该。简单计算。
  • B 该。计算量大。
  • C 不该。极其简单。
  • D 该。对象需要稳定引用(如果传给 memo 子组件)。

解读: useMemo 的使用标准:计算量大、引用需要稳定。简单计算不用。


进阶题


题目 4: 实现一个"多条件排序"的派生。

jsx 复制代码
// 支持按 name、createdAt、priority 排序,可指定升序降序

参考答案:

jsx 复制代码
const sorted = useMemo(() => {
  return [...items].sort((a, b) => {
    for (const { field, order } of sortRules) {
      const aVal = a[field]
      const bVal = b[field]
      if (aVal < bVal) return order === "asc" ? -1 : 1
      if (aVal > bVal) return order === "asc" ? 1 : -1
    }
    return 0
  })
}, [items, sortRules])

解读: 多字段排序,按规则顺序比较。sortRules 是 [{ field: "priority", order: "asc" }, ...]。规则可配置,灵活。


题目 5: 实现一个"分页 + 过滤 + 排序"的完整派生。

参考答案:

jsx 复制代码
function useTaskList(tasks, { keyword, status, sortBy, page, pageSize = 20 }) {
  // 第一层:过滤
  const filtered = useMemo(() => {
    return tasks
      .filter(t => status === "all" || t.status === status)
      .filter(t => !keyword || t.title.includes(keyword))
  }, [tasks, status, keyword])

  // 第二层:排序
  const sorted = useMemo(() => {
    return [...filtered].sort((a, b) => {
      if (sortBy === "created") return b.createdAt - a.createdAt
      if (sortBy === "title") return a.title.localeCompare(b.title)
      return 0
    })
  }, [filtered, sortBy])

  // 第三层:分页
  const pageItems = useMemo(() => {
    const start = (page - 1) * pageSize
    return sorted.slice(start, start + pageSize)
  }, [sorted, page, pageSize])

  // 派生统计(不需要缓存,计算极轻)
  const total = sorted.length
  const totalPages = Math.ceil(total / pageSize)

  return {
    items: pageItems,
    total,
    totalPages,
    hasPrev: page > 1,
    hasNext: page < totalPages
  }
}

解读: 三层派生,每层用 useMemo 缓存。依赖明确,每层独立。


题目 6: 下面代码有什么性能问题?如何优化?

jsx 复制代码
function TaskList({ tasks }) {
  return (
    <div>
      <p>总数:{tasks.length}</p>
      <p>已完成:{tasks.filter(t => t.completed).length}</p>
      <p>完成率:{tasks.filter(t => t.completed).length / tasks.length}</p>
      <ul>
        {tasks
          .filter(t => t.completed)
          .sort((a, b) => b.completedAt - a.completedAt)
          .map(t => <li key={t.id}>{t.title}</li>)}
      </ul>
    </div>
  )
}

参考答案: tasks.filter(t => t.completed) 被调用了三次,每次遍历整个数组。

修复:

jsx 复制代码
function TaskList({ tasks }) {
  const completed = tasks.filter(t => t.completed)
  const completedCount = completed.length
  const totalCount = tasks.length
  const rate = totalCount > 0 ? completedCount / totalCount : 0
  const sortedCompleted = [...completed].sort(
    (a, b) => b.completedAt - a.completedAt
  )

  return (
    <div>
      <p>总数:{totalCount}</p>
      <p>已完成:{completedCount}</p>
      <p>完成率:{(rate * 100).toFixed(1)}%</p>
      <ul>
        {sortedCompleted.map(t => <li key={t.id}>{t.title}</li>)}
      </ul>
    </div>
  )
}

解读: 提取公共派生,一次遍历算多个值。避免重复计算。


思考题


题目 7: 为什么"先正确,后优化"?

参考答案:

因为优化的前提是正确。如果代码不正确,优化没有意义。

具体来说:

  1. 过早优化会引入 bug。 加了 useMemo,依赖维护不当,结果错误。
  2. 过早优化增加复杂度。 useMemo 让代码更难读。
  3. 性能问题需要测量。 不测量就优化,可能优化了不是瓶颈的地方。
  4. 简单代码容易修改。 先写最简单的版本,有性能问题再优化,修改成本更低。

实践:

jsx 复制代码
// 第一步:写最简单的派生
const visible = items.filter(i => i.visible)

// 第二步:测量,发现性能问题

// 第三步:加 useMemo
const visible = useMemo(() => items.filter(i => i.visible), [items])

解读: 这是软件工程的通用原则。不要过早优化,但也不要忘记优化。 先写对,再写快。


题目 8: useMemo 和 computed(Vue)有什么区别?

参考答案:

维度 React useMemo Vue computed
依赖 手动指定 自动追踪
缓存 依赖不变时缓存 依赖不变时缓存
使用 Hook 组合式 API 或选项式
失效 依赖变化时失效 依赖变化时失效

React:

jsx 复制代码
const filtered = useMemo(
  () => items.filter(i => i.name.includes(keyword)),
  [items, keyword]  // 手动指定
)

Vue:

js 复制代码
const filtered = computed(() =>
  items.value.filter(i => i.name.includes(keyword.value))
  // 自动追踪 items 和 keyword
)

Vue 更方便,但依赖是隐式的。React 更显式,但需要手动维护。

解读: 两种设计哲学:Vue 追求简洁,React 追求显式。理解依赖追踪机制,是跨框架迁移的关键。


题目 9: 什么时候该合并多个 useMemo?

参考答案:

该合并:

  • 多个派生依赖同一组状态。
  • 计算可以一次遍历完成。
  • 合并后代码更清晰。
jsx 复制代码
// ❌ 三次遍历
const filtered = useMemo(() => tasks.filter(...), [tasks])
const sorted = useMemo(() => [...tasks].sort(...), [tasks])
const stats = useMemo(() => tasks.reduce(...), [tasks])

// ✅ 一次遍历
const { filtered, sorted, stats } = useMemo(() => ({
  filtered: tasks.filter(...),
  sorted: [...tasks].sort(...),
  stats: tasks.reduce(...)
}), [tasks])

不该合并:

  • 依赖不同。
  • 某些派生只在高频变化时使用。
  • 合并后代码可读性变差。

解读: 合并 useMemo 减少依赖比较的开销,但也要权衡可读性。不要为了合并而合并。


挑战题


题目 10: 实现一个"带搜索、筛选、排序、分页、分组"的完整派生逻辑。

参考答案:

jsx 复制代码
function useTaskBoard(tasks, options) {
  const {
    keyword = "",
    status = "all",
    priority = "all",
    sortBy = "created",
    sortOrder = "desc",
    page = 1,
    pageSize = 20,
    groupBy = null
  } = options

  // 第一层:过滤
  const filtered = useMemo(() => {
    const lower = keyword.toLowerCase()
    return tasks.filter(t => {
      if (status !== "all" && t.status !== status) return false
      if (priority !== "all" && t.priority !== priority) return false
      if (keyword && !t.title.toLowerCase().includes(lower)) return false
      return true
    })
  }, [tasks, status, priority, keyword])

  // 第二层:排序
  const sorted = useMemo(() => {
    return [...filtered].sort((a, b) => {
      let cmp = 0
      if (sortBy === "created") cmp = a.createdAt - b.createdAt
      else if (sortBy === "title") cmp = a.title.localeCompare(b.title)
      else if (sortBy === "priority") {
        const order = { high: 0, medium: 1, low: 2 }
        cmp = order[a.priority] - order[b.priority]
      }
      return sortOrder === "asc" ? cmp : -cmp
    })
  }, [filtered, sortBy, sortOrder])

  // 第三层:分组(可选)
  const grouped = useMemo(() => {
    if (!groupBy) return null
    return sorted.reduce((map, task) => {
      const key = task[groupBy]
      const group = map.get(key) ?? []
      group.push(task)
      map.set(key, group)
      return map
    }, new Map())
  }, [sorted, groupBy])

  // 第四层:分页
  const total = sorted.length
  const totalPages = Math.ceil(total / pageSize)
  const start = (page - 1) * pageSize
  const end = start + pageSize

  const pageItems = useMemo(() => {
    return sorted.slice(start, end)
  }, [sorted, start, end])

  return {
    items: pageItems,
    groups: grouped,
    total,
    totalPages,
    hasPrev: page > 1,
    hasNext: page < totalPages
  }
}

解读: 四层派生,每层用 useMemo 缓存。关键设计:

  1. 每层只依赖上一层。 过滤 → 排序 → 分组 → 分页。
  2. 依赖明确。 每个 useMemo 的依赖数组完整。
  3. 可组合。 groupBy 是可选参数,不需要分组时跳过。

十五、本课小结

什么是派生状态

从其他状态计算出来的数据。它不是状态,是计算。

判断标准:如果其他状态都定了,这个值唯一确定,它就是派生。

常见的派生类型

类型 计算方式
过滤 filter
排序 [...items].sort
聚合 reduce
格式化 formatXxx
查找 find / some / every
组合 模板字符串
条件 布尔表达式
分组 reduce + Map

useMemo

缓存计算结果,依赖不变时跳过计算。

该用:

  • 计算量大(毫秒级)。
  • 引用需要稳定。
  • 作为其他 Hook 的依赖。

不该用:

  • 计算简单(微秒级)。
  • 数据量小。
  • 依赖频繁变化。

原则:先正确,后优化。先测量,再优化。

派生 vs 缓存

维度 派生 缓存
目的 计算数据 优化性能
正确性 必须 可选
实现 变量 useMemo
移除后 逻辑错误 只是变慢

链式派生

从基础状态出发,逐层派生。每一层只依赖上一层。

text 复制代码
基础状态 → 第一层派生 → 第二层派生 → 第三层派生

性能测量

  • 用 performance.now() 测计算耗时。
  • 用 React DevTools Profiler 测渲染。
  • 设定性能预算,超过就优化。
  • 测量真实场景,测量关键路径,测量前后对比。

复杂计算的模式

  • 多条件过滤:集中在一个函数里。
  • 多字段排序:按规则顺序比较。
  • 分组统计:一次遍历算多个值。
  • 树形结构:构建索引 + 组装。
  • 分页:slice + 统计。
  • 模糊搜索:算分 → 过滤 → 排序。
  • 带权重排序:加权评分。

四条铁律

  1. 能算出来的不要存。 派生自动同步。
  2. 先正确,后优化。 不要过早 useMemo。
  3. 派生逻辑在渲染外。 清晰且可缓存。
  4. 用数据说话。 先测量,再优化。

一句话记住本课

能算出来的不要存,该缓存的缓存。派生是数据,缓存是优化。

实用价值回顾

  1. 状态更少。 派生不占状态,减少同步负担。
  2. 不可能不一致。 派生自动同步,永远一致。
  3. 性能可控。 useMemo 缓存重计算。
  4. 代码清晰。 派生逻辑集中,容易理解。
  5. 决策有据。 用测量数据决定是否优化。
  6. 跨框架通用。 所有声明式框架都有派生概念。

十六、下一课预告

第 10 课 用状态机建模复杂交互

本课讲了派生计算,下一课讲状态建模的进阶------状态机。核心内容:

  • 状态机思维:有限状态 + 转移
  • 用状态机替代多个布尔
  • 不可能状态原则
  • 状态机的实现:枚举、useReducer
  • 状态机的可视化
  • 复杂交互的状态机设计

派生解决"数据怎么算",状态机解决"状态怎么组织"。两者结合,才能处理复杂交互。

相关推荐
ZARD9963 小时前
Codex 连接 Figma:让 AI 读取 Figma UI 设计稿数据
ui·figma·codex
事圆则缓4 小时前
Android 一次性事件怎么设计:Channel、SharedFlow 还是 UI State?
android·ui
我命由我123454 小时前
Photoshop - Photoshop 使用魔棒工具选择单独的区域
学习·ui·职场和发展·求职招聘·职场发展·学习方法·photoshop
特立独行的猫a4 小时前
用仓颉语言给娃写了个打字练习游戏:cj-tauri 项目实战
游戏·ui·框架·tauri·仓颉·cangjie·cj-tauri
传奇开心果编程5 小时前
【声明式UI开发实用技术学与练】第8课 状态提升与下放
学习·flutter·react native·ui·swiftui·composer
传奇开心果编程20 小时前
【Compose Multiplatform 跨端开发学与练】第7课 平台适配与互操作
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持
学习·flutter·react native·ui·swiftui·android jetpack
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第5课 网络与数据层
android·网络·学习·ui·ios·kotlin·composer
传奇开心果编程2 天前
【SwiftUI提高练中学】第8课 网络层架构:拦截器、重试、缓存与离线优先
学习·macos·ui·ios·swiftui