适用技术栈:React、Vue、SwiftUI、Flutter、Jetpack Compose。以 React 为主要教学语言,思维跨框架通用。
核心理念:不讲空理论,每个知识点都落地到能直接用于工作的实用技能。
本课产出: 能识别哪些是派生状态;能写出清晰的派生逻辑;能正确使用 useMemo 缓存;能测量性能并做出优化决策;能区分"派生"和"缓存"的本质;能处理链式派生、分组、分页、树形结构等复杂计算。
一句话预览: 状态放置决定"数据在哪",派生计算决定"数据怎么用"。能算出来的不要存------但"算"也要算得聪明:该缓存的缓存,该拆解的拆解,该测量的测量。
一、本课目标
学完本课,你应该能:
- 说清楚派生状态的定义,用一句话判断"这是状态还是派生"。
- 掌握派生数据的完整模式:过滤、排序、聚合、格式化、查找、分组、分页、树形结构。
- 理解
useMemo的本质和代价,知道什么时候该用、什么时候不该用。 - 掌握性能测量的实用方法,用数据而不是直觉做优化决策。
- 理解派生状态的链式依赖,知道怎么组织计算顺序。
- 区分"派生"和"缓存"的本质区别。
- 识别并修复 10 种常见反模式。
- 处理复杂计算:分组统计、树形结构、模糊搜索、虚拟滚动。
二、从一个真实痛点说起
场景:一个任务统计面板
需求:显示任务的总数、已完成数、完成率、本周完成数。
一个开发者这样写:
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)
}
// ... 还有删除、添加、编辑,每个操作都要同步四个状态
}
问题:
- 四个统计状态都是派生的。 它们能从
tasks算出来,不该单独存。 - 每个操作都要手动同步。 添加、删除、切换、编辑,每个都要更新所有统计。
- 容易遗漏。 忘了更新某一个,统计就不一致。
- 代码冗余。 同样的计算逻辑重复多次。
正确做法:
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 要做两件事:
- 存储上一次的依赖数组。
- 比较当前依赖和上一次依赖(逐项
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 操作(那是框架的职责)。
性能优化的顺序:
- 状态设计。 减少不必要的状态。
- 状态位置。 缩小渲染范围(第 8 课)。
- 组件拆分。 隔离变化(第 7 课)。
memo。 跳过重渲染。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(...)
同样是"过滤 + 排序",数据量不同,决策不同。 这就是为什么必须测量。
测量的三个原则
- 测量真实场景。 用真实数据量,不是测试数据。
- 测量关键路径。 用户最常做的操作。
- 测量前后对比。 优化后重新测量,验证效果。
九、复杂计算的实战模式
模式一:多条件过滤
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?
参考答案:
步骤:
- 测量计算耗时。
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 ...
}
- 判断是否超过预算。
- 如果 < 1ms,用户无感知,不需要
useMemo。 - 如果 > 5ms,输入时会有延迟,需要
useMemo。
- 验证优化效果。
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(...)派生。
真实状态:
itemskeyword
优化:
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: 为什么"先正确,后优化"?
参考答案:
因为优化的前提是正确。如果代码不正确,优化没有意义。
具体来说:
- 过早优化会引入 bug。 加了
useMemo,依赖维护不当,结果错误。 - 过早优化增加复杂度。
useMemo让代码更难读。 - 性能问题需要测量。 不测量就优化,可能优化了不是瓶颈的地方。
- 简单代码容易修改。 先写最简单的版本,有性能问题再优化,修改成本更低。
实践:
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 缓存。关键设计:
- 每层只依赖上一层。 过滤 → 排序 → 分组 → 分页。
- 依赖明确。 每个
useMemo的依赖数组完整。 - 可组合。
groupBy是可选参数,不需要分组时跳过。
十五、本课小结
什么是派生状态
从其他状态计算出来的数据。它不是状态,是计算。
判断标准:如果其他状态都定了,这个值唯一确定,它就是派生。
常见的派生类型
| 类型 | 计算方式 |
|---|---|
| 过滤 | filter |
| 排序 | [...items].sort |
| 聚合 | reduce |
| 格式化 | formatXxx |
| 查找 | find / some / every |
| 组合 | 模板字符串 |
| 条件 | 布尔表达式 |
| 分组 | reduce + Map |
useMemo
缓存计算结果,依赖不变时跳过计算。
该用:
- 计算量大(毫秒级)。
- 引用需要稳定。
- 作为其他 Hook 的依赖。
不该用:
- 计算简单(微秒级)。
- 数据量小。
- 依赖频繁变化。
原则:先正确,后优化。先测量,再优化。
派生 vs 缓存
| 维度 | 派生 | 缓存 |
|---|---|---|
| 目的 | 计算数据 | 优化性能 |
| 正确性 | 必须 | 可选 |
| 实现 | 变量 | useMemo |
| 移除后 | 逻辑错误 | 只是变慢 |
链式派生
从基础状态出发,逐层派生。每一层只依赖上一层。
text
基础状态 → 第一层派生 → 第二层派生 → 第三层派生
性能测量
- 用
performance.now()测计算耗时。 - 用 React DevTools Profiler 测渲染。
- 设定性能预算,超过就优化。
- 测量真实场景,测量关键路径,测量前后对比。
复杂计算的模式
- 多条件过滤:集中在一个函数里。
- 多字段排序:按规则顺序比较。
- 分组统计:一次遍历算多个值。
- 树形结构:构建索引 + 组装。
- 分页:
slice+ 统计。 - 模糊搜索:算分 → 过滤 → 排序。
- 带权重排序:加权评分。
四条铁律
- 能算出来的不要存。 派生自动同步。
- 先正确,后优化。 不要过早
useMemo。 - 派生逻辑在渲染外。 清晰且可缓存。
- 用数据说话。 先测量,再优化。
一句话记住本课
能算出来的不要存,该缓存的缓存。派生是数据,缓存是优化。
实用价值回顾
- 状态更少。 派生不占状态,减少同步负担。
- 不可能不一致。 派生自动同步,永远一致。
- 性能可控。
useMemo缓存重计算。 - 代码清晰。 派生逻辑集中,容易理解。
- 决策有据。 用测量数据决定是否优化。
- 跨框架通用。 所有声明式框架都有派生概念。
十六、下一课预告
第 10 课 用状态机建模复杂交互
本课讲了派生计算,下一课讲状态建模的进阶------状态机。核心内容:
- 状态机思维:有限状态 + 转移
- 用状态机替代多个布尔
- 不可能状态原则
- 状态机的实现:枚举、
useReducer - 状态机的可视化
- 复杂交互的状态机设计
派生解决"数据怎么算",状态机解决"状态怎么组织"。两者结合,才能处理复杂交互。