技术0 阅读

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

写 React 时,最容易让组件发胖的,不是网络请求,也不是动画,而是“多存了一点状态”。

一开始你只是觉得这样写更顺手:

结果写着写着,组件里就变成这样:

最后组件虽然能跑,但会有一种很典型的气质: 状态很多,计算很少,effect 一大堆。

接下来我们从“派生状态”问题入手,逐步拆解其中的设计问题与解决方式。

一、什么叫“派生状态”?

先看几个常见变量:

这些值有个共同点:

这类值就叫 派生状态(derived state),也可以把它理解成组件里的“计算属性”。

React 官方一直在强调一个原则: > 如果一个值可以从当前 props 或 state 计算出来,那通常就不应该再单独存一份 state。

为什么?

因为一旦你把它存进 state,你就得自己维护“它和原始状态永远一致”。 而人最容易出错的地方,恰恰就是“同步两份,本来应该只有一份的数据”。

二、最经典的反模式:用 useEffect 同步派生状态

先看一个非常常见的例子:

tsx
function UserCard({
  firstName,
  lastName,
}: {
  firstName: string;
  lastName: string;
}) {
  const [fullName, setFullName] = useState('');

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  return <div>{fullName}</div>;
}

这段代码的问题不是“会报错”,而是它做了一件完全没必要的事:

这就是典型的“明明只是一条表达式,却写成了两段生命周期”。

重构后的版本其实只有一句:

tsx
function UserCard({
  firstName,
  lastName,
}: {
  firstName: string;
  lastName: string;
}) {
  const fullName = `${firstName} ${lastName}`;
  return <div>{fullName}</div>;
}

这类重构看上去像是“简化代码”,但本质上是在恢复正确的数据建模:

它不该拥有自己的生命周期。

三、判断一个值该不该存:看它是不是“独立事实”

这里有一个很实用的判断法: > 一个值只有在它是“独立事实”时,才值得进入 state。 > 如果它只是别的 state 的变形结果,那它更应该是一个表达式。

比如下面这几个:

应该存的

因为这些都是用户输入、服务端结果或 UI 当前状态,它们本身就是事实。

不该存的

因为这些都可以从已有事实推出,不需要单独再保存一份。

一句更直白的话:

这个边界一旦清楚,很多组件会立刻瘦下来。

四、一个真实重构:列表过滤别再“存两份”

先看一段典型坏代码:

tsx
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>
  );
}

问题和前面一样:

更合理的版本:

tsx
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>
  );
}

这时候数据流非常清楚:

没有中间同步层,没有 effect,没有“某次漏更新”的风险。

五、那 useMemo 呢?什么时候该用,什么时候别乱用?

React 官方强调的是: 先直接计算,只有在计算真的昂贵,或者你需要稳定引用时,再考虑 useMemo。

也就是说,优先级应该是:

  1. 先问:能不能直接算?

  2. 再问:这段计算是不是足够重,值得缓存?

  3. 再问:是不是因为下游 memo 化组件需要稳定引用?

比如这段:

tsx
const fullName = `${firstName} ${lastName}`;

完全没必要 useMemo。 因为它几乎没有成本,包一层 memo 反而增加心智负担。

但像这种:

tsx
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. 状态失同步

这是最直接的。

tsx
const [items, setItems] = useState<Product[]>([]);
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(items.length);
}, [items]);

问题在于:

而如果你直接写:

tsx
const count = items.length;

这个问题根本不会出现。

2. 链式副作用

这是很多大组件后期最恶心的味道:

tsx
useEffect(() => {
  setFiltered(...);
}, [items, keyword]);

useEffect(() => {
  setSorted(...);
}, [filtered]);

useEffect(() => {
  setCount(sorted.length);
}, [sorted]);

这类代码看起来“很有层次”,但本质上是在用 effect 手工搭一条数据加工流水线。 eslint-plugin-react-you-might-not-need-an-effect 甚至专门有规则去拦这种写法,比如 no-derived-stateno-chain-state-updates

正确思路应该是把它还原成表达式链:

tsx
const filtered = ...
const sorted = ...
const count = sorted.length

这才是 React 组件应该有的数据流。

3. 多一次渲染,逻辑还更绕

这是很多人最开始感觉不到,但长期会很伤的点。

用 effect 同步派生状态,通常都会走这条路:

  1. 先按旧值渲染一次。

  2. effect 在提交后执行。

  3. 调用 setState。

  4. 再渲染一次新值。

也就是说,一个本来渲染时顺手算一下就能完成的事情,被拆成了两轮。

这不仅浪费性能,更重要的是让读代码的人更难理解:

七、什么时候“派生状态”真的值得单独存?

说到这里,容易出现另一个极端: 是不是所有 derived state 都绝对不能进 state?

也不是。

有几类情况,单独存是合理的。

1. 它虽然来自 props,但之后会被用户独立修改

比如一个编辑表单:

tsx
function Editor({ initialTitle }: { initialTitle: string }) {
  const [title, setTitle] = useState(initialTitle);
  // 用户后续会手动修改 title
}

这里 title 虽然初始来源于 props,但它后面已经成了组件自己的可变状态。 它不再只是一个纯派生值。

不过这种场景仍然要小心,不要一边让它受 props 控制,一边又在内部自由修改,否则很容易进入“双数据源”地狱。

2. 你需要记录“历史”或“过渡过程”

比如:

这些值虽然和别的状态有关,但已经不只是“当前值的计算结果”,而是一个有时间维度的独立事实。

3. 你要故意缓存某个结果,但缓存本身是业务语义

比如本地草稿、快照、撤销栈。 这时候“缓存”不是性能手段,而是业务需求。

所以这篇真正想讲的不是“派生状态绝对不能存”,而是: > 如果你只是为了让 UI 展示一个可以现算的值,那别存。 > 如果你存它,是因为它已经拥有自己的业务语义,那可以考虑存。

八、一个完整重构示例:从“状态管理”改回“数据表达”

先看坏代码:

tsx
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>
  );
}

这类代码很像很多业务项目里的现实情况:

重构后:

tsx
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 都不要:

tsx
const completedCount = visibleTodos.filter(todo => todo.done).length;
const remainingCount = visibleTodos.length - completedCount;

这一版代码最大的变化不是“更短”,而是:

九、这一篇可以带走的核心结论

  1. state 应该存“事实”,不是存“公式结果”。

  2. 如果一个值能由 props/state 直接算出,它通常不该再进入 state。

  3. 用 effect 同步派生状态,会制造额外渲染、链式更新和失同步风险。

  4. useMemo 是优化工具,不是默认写法;先直接算,再决定要不要缓存。

  5. 真正值得单独存的“派生值”,往往已经不是纯派生,而是带有业务语义、历史语义或用户编辑语义的独立事实。

SHARE

分享

分享这篇文章。