React Hooks: 计算属性与派生状态的重构指南

写 React 时,最容易让组件发胖的,不是网络请求,也不是动画,而是“多存了一点状态”。
一开始你只是觉得这样写更顺手:
过滤后的列表存一下。
总价存一下。
已完成数量存一下。
当前表单是不是有效,也顺手存一下。
结果写着写着,组件里就变成这样:
原始数据一份。
派生数据再来一份。
每份数据后面跟一个 effect,负责保持同步。
最后组件虽然能跑,但会有一种很典型的气质: 状态很多,计算很少,effect 一大堆。
接下来我们从“派生状态”问题入手,逐步拆解其中的设计问题与解决方式。
一、什么叫“派生状态”?
先看几个常见变量:
fullName = firstName + ' ' + lastNamecompletedCount = todos.filter(t => t.done).lengthvisibleItems = items.filter(i => i.visible)totalPrice = cartItems.reduce(...)
这些值有个共同点:
它们不是独立事实。
它们都可以由已有的 props 或 state 计算出来。
这类值就叫 派生状态(derived state),也可以把它理解成组件里的“计算属性”。
React 官方一直在强调一个原则: > 如果一个值可以从当前 props 或 state 计算出来,那通常就不应该再单独存一份 state。
为什么?
因为一旦你把它存进 state,你就得自己维护“它和原始状态永远一致”。 而人最容易出错的地方,恰恰就是“同步两份,本来应该只有一份的数据”。
二、最经典的反模式:用 useEffect 同步派生状态
先看一个非常常见的例子:
function UserCard({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}) {
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}这段代码的问题不是“会报错”,而是它做了一件完全没必要的事:
fullName本来就是firstName + lastName。结果现在你额外开了一个 state。
又额外开了一个 effect 去维持它。
然后每次 props 变化,组件先渲染一次旧值,再跑 effect,再 setState,再渲染一次新值。
这就是典型的“明明只是一条表达式,却写成了两段生命周期”。
重构后的版本其实只有一句:
function UserCard({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}) {
const fullName = `${firstName} ${lastName}`;
return <div>{fullName}</div>;
}这类重构看上去像是“简化代码”,但本质上是在恢复正确的数据建模:
firstName、lastName是源数据。fullName是结果,不是源数据。
它不该拥有自己的生命周期。
三、判断一个值该不该存:看它是不是“独立事实”
这里有一个很实用的判断法: > 一个值只有在它是“独立事实”时,才值得进入 state。 > 如果它只是别的 state 的变形结果,那它更应该是一个表达式。
比如下面这几个:
应该存的
输入框当前文本。
当前选中的 tab。
请求返回的原始列表。
用户是否展开弹层。
因为这些都是用户输入、服务端结果或 UI 当前状态,它们本身就是事实。
不该存的
过滤后的列表。
某个条件下的统计数量。
两个字段拼接后的展示文案。
是否为空、是否命中条件、是否需要高亮。
因为这些都可以从已有事实推出,不需要单独再保存一份。
一句更直白的话:
state 存事实
变量存计算结果
这个边界一旦清楚,很多组件会立刻瘦下来。
四、一个真实重构:列表过滤别再“存两份”
先看一段典型坏代码:
function ProductList({
products,
keyword,
}: {
products: Product[];
keyword: string;
}) {
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
useEffect(() => {
const next = products.filter(product =>
product.name.toLowerCase().includes(keyword.toLowerCase())
);
setFilteredProducts(next);
}, [products, keyword]);
return (
<ul>
{filteredProducts.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}问题和前面一样:
filteredProducts不是独立状态。它只是
products + keyword的一个函数结果。你为了“缓存”它,反而引入了额外渲染和同步负担。
更合理的版本:
function ProductList({
products,
keyword,
}: {
products: Product[];
keyword: string;
}) {
const filteredProducts = products.filter(product =>
product.name.toLowerCase().includes(keyword.toLowerCase())
);
return (
<ul>
{filteredProducts.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}这时候数据流非常清楚:
products和keyword变了;filteredProducts自然跟着变。
没有中间同步层,没有 effect,没有“某次漏更新”的风险。
五、那 useMemo 呢?什么时候该用,什么时候别乱用?
React 官方强调的是: 先直接计算,只有在计算真的昂贵,或者你需要稳定引用时,再考虑 useMemo。
也就是说,优先级应该是:
先问:能不能直接算?
再问:这段计算是不是足够重,值得缓存?
再问:是不是因为下游 memo 化组件需要稳定引用?
比如这段:
const fullName = `${firstName} ${lastName}`;完全没必要 useMemo。
因为它几乎没有成本,包一层 memo 反而增加心智负担。
但像这种:
const sortedProducts = useMemo(() => {
return [...products]
.filter(p => p.visible)
.sort((a, b) => a.price - b.price);
}, [products]);就可能有意义,因为:
它涉及数组过滤和排序;
如果数据量大、父组件重渲染频繁,重复计算可能有成本。
不过也要注意,社区里对 useMemo 有个很常见的误区: 把所有数组操作都当成“昂贵计算”。
Developer Way 的观点很值得参考——很多时候真正贵的不是 JS 过滤本身,而是整棵子树的重渲染。所以 useMemo 应该是精确工具,而不是默认按钮。
所以结论是:
> useMemo 是给重计算和稳定引用准备的,不是给所有派生值准备的。
六、派生状态最容易引发的 3 类问题
1. 状态失同步
这是最直接的。
const [items, setItems] = useState<Product[]>([]);
const [count, setCount] = useState(0);
useEffect(() => {
setCount(items.length);
}, [items]);问题在于:
你现在维护了两份本来应该是一份的信息。
某一天有人改了
count,或者某条路径没走这个 effect,UI 就错了。
而如果你直接写:
const count = items.length;这个问题根本不会出现。
2. 链式副作用
这是很多大组件后期最恶心的味道:
useEffect(() => {
setFiltered(...);
}, [items, keyword]);
useEffect(() => {
setSorted(...);
}, [filtered]);
useEffect(() => {
setCount(sorted.length);
}, [sorted]);这类代码看起来“很有层次”,但本质上是在用 effect 手工搭一条数据加工流水线。
eslint-plugin-react-you-might-not-need-an-effect 甚至专门有规则去拦这种写法,比如 no-derived-state 和 no-chain-state-updates。
正确思路应该是把它还原成表达式链:
const filtered = ...
const sorted = ...
const count = sorted.length这才是 React 组件应该有的数据流。
3. 多一次渲染,逻辑还更绕
这是很多人最开始感觉不到,但长期会很伤的点。
用 effect 同步派生状态,通常都会走这条路:
先按旧值渲染一次。
effect 在提交后执行。
调用 setState。
再渲染一次新值。
也就是说,一个本来渲染时顺手算一下就能完成的事情,被拆成了两轮。
这不仅浪费性能,更重要的是让读代码的人更难理解:
到底哪个才是源数据?
为什么这里要晚一拍?
为什么一个展示值也有自己的 state?
七、什么时候“派生状态”真的值得单独存?
说到这里,容易出现另一个极端: 是不是所有 derived state 都绝对不能进 state?
也不是。
有几类情况,单独存是合理的。
1. 它虽然来自 props,但之后会被用户独立修改
比如一个编辑表单:
function Editor({ initialTitle }: { initialTitle: string }) {
const [title, setTitle] = useState(initialTitle);
// 用户后续会手动修改 title
}这里 title 虽然初始来源于 props,但它后面已经成了组件自己的可变状态。
它不再只是一个纯派生值。
不过这种场景仍然要小心,不要一边让它受 props 控制,一边又在内部自由修改,否则很容易进入“双数据源”地狱。
2. 你需要记录“历史”或“过渡过程”
比如:
上一次滚动位置。
动画过程中的中间值。
用户上一次提交时的快照。
这些值虽然和别的状态有关,但已经不只是“当前值的计算结果”,而是一个有时间维度的独立事实。
3. 你要故意缓存某个结果,但缓存本身是业务语义
比如本地草稿、快照、撤销栈。 这时候“缓存”不是性能手段,而是业务需求。
所以这篇真正想讲的不是“派生状态绝对不能存”,而是: > 如果你只是为了让 UI 展示一个可以现算的值,那别存。 > 如果你存它,是因为它已经拥有自己的业务语义,那可以考虑存。
八、一个完整重构示例:从“状态管理”改回“数据表达”
先看坏代码:
function TodoPanel({ todos, filter }: Props) {
const [visibleTodos, setVisibleTodos] = useState<Todo[]>([]);
const [completedCount, setCompletedCount] = useState(0);
const [remainingCount, setRemainingCount] = useState(0);
useEffect(() => {
const next = todos.filter(todo => {
if (filter === 'all') return true;
if (filter === 'done') return todo.done;
return !todo.done;
});
setVisibleTodos(next);
}, [todos, filter]);
useEffect(() => {
setCompletedCount(visibleTodos.filter(todo => todo.done).length);
setRemainingCount(visibleTodos.filter(todo => !todo.done).length);
}, [visibleTodos]);
return (
<div>
<div>已完成:{completedCount}</div>
<div>未完成:{remainingCount}</div>
<ul>
{visibleTodos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}这类代码很像很多业务项目里的现实情况:
逻辑没错。
但整段组件像是在“维护缓存系统”。
重构后:
function TodoPanel({ todos, filter }: Props) {
const visibleTodos = useMemo(() => {
return todos.filter(todo => {
if (filter === 'all') return true;
if (filter === 'done') return todo.done;
return !todo.done;
});
}, [todos, filter]);
const completedCount = useMemo(() => {
return visibleTodos.filter(todo => todo.done).length;
}, [visibleTodos]);
const remainingCount = visibleTodos.length - completedCount;
return (
<div>
<div>已完成:{completedCount}</div>
<div>未完成:{remainingCount}</div>
<ul>
{visibleTodos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}如果数据量不大,这里还能进一步简化,连第二个 useMemo 都不要:
const completedCount = visibleTodos.filter(todo => todo.done).length;
const remainingCount = visibleTodos.length - completedCount;这一版代码最大的变化不是“更短”,而是:
状态只剩真正的源数据。
其余都是可读、可追踪的表达式。
数据关系是一条线,不是 effect 互相接力。
九、这一篇可以带走的核心结论
state 应该存“事实”,不是存“公式结果”。
如果一个值能由 props/state 直接算出,它通常不该再进入 state。
用 effect 同步派生状态,会制造额外渲染、链式更新和失同步风险。
useMemo是优化工具,不是默认写法;先直接算,再决定要不要缓存。真正值得单独存的“派生值”,往往已经不是纯派生,而是带有业务语义、历史语义或用户编辑语义的独立事实。
在 Google 上继续关注
把 HeyBinyang 添加为 Google 首选来源
如果你愿意继续在 Google 里读到我的更新,可以把这个站点添加为 preferred source,之后更容易在相关内容场景里看到它。
SHARE
分享
分享这篇文章。