前端开发··1 阅读·预计 12 分钟

React 渲染性能的微观优化:从 useMemo 误用到 Fiber 调和机制的实战剖析

写在前面

useMemouseCallback 的文档读了三遍,项目里 memo 包裹了所有组件,怎么页面还是卡?因为性能优化的本质不是「多套一层缓存」,而是「让 React 少干活」。这篇文章从 Fiber 调和的角度,拆几个实际场景里容易踩的坑。

陷阱一:props 里的「新对象」

// ❌ 每次父组件渲染,style 都是新引用 → Child 必定重渲染
function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <Child style={{ color: 'red' }} /> {/* 这行就是性能杀手 */}
    </div>
  );
}

const Child = memo(({ style }: { style: React.CSSProperties }) => {
  console.log('Child rendered'); // count 变化也会触发
  return <div style={style}>I'm child</div>;
});

React 的 memoObject.is 做浅比较。{ color: 'red' } 每次渲染都是新对象 → 浅比较失败 → memo 形同虚设。

// ✅ 用 useMemo 稳定引用
function Parent() {
  const [count, setCount] = useState(0);
  const childStyle = useMemo(() => ({ color: 'red' } as const), []);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
      <Child style={childStyle} />
    </div>
  );
}

更激进的做法——如果 Child 不依赖 Parent 的任何状态,直接把它提到组件外部或拆成独立子树。

陷阱二:Context 的「核弹级重渲染」

// ❌ 一个 Context 塞了三个不相关的值
const AppContext = createContext<{
  theme: 'light' | 'dark';
  user: User | null;
  notifications: number;
}>(null!);

function AppProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [user, setUser] = useState<User | null>(null);
  const [notifications, setNotifications] = useState(0);

  return (
    <AppContext.Provider value={{ theme, user, notifications }}>
      {children}
    </AppContext.Provider>
  );
}

// NavBar 只读 notifications,但 theme 变化时它也重渲染
function NavBar() {
  const { notifications } = useContext(AppContext);
  return <span>未读: {notifications}</span>;
}

Context value 变化 → 所有 useContext(AppContext) 的消费者全部重渲染。没有中间件,没有 selector。

// ✅ 按变更频率拆分 Context
const ThemeContext = createContext<'light' | 'dark'>('light');
const UserContext = createContext<User | null>(null);
const NotificationContext = createContext(0);

function AppProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [user, setUser] = useState<User | null>(null);
  const [notifications, setNotifications] = useState(0);

  return (
    <ThemeContext.Provider value={theme}>
      <UserContext.Provider value={user}>
        <NotificationContext.Provider value={notifications}>
          {children}
        </NotificationContext.Provider>
      </UserContext.Provider>
    </ThemeContext.Provider>
  );
}

拆分后,theme 变化只触发 ThemeContext 消费者。Fiber 遍历时子树范围从全量缩小到一个分支。

陷阱三:列表 key 不只是为了消除 warning

// ❌ index 做 key,列表头插时灾难级重渲染
function TodoList() {
  const [items, setItems] = useState(['A', 'B', 'C']);

  const addFirst = () => setItems(prev => ['D', ...prev]);

  return (
    <ul>
      {items.map((item, index) => (
        <TodoItem key={index}>{item}</TodoItem> {/* index 做 key */}
      ))}
    </ul>
  );
}

['D','A','B','C'] 插入后,React 对比 Fiber:旧 index 0 → A,新 index 0 → D → 内容变了,更新 DOM。旧 index 1 → B,新 index 1 → A → 内容变了,更新 DOM。结果 4 个节点全部 DOM 更新。

// ✅ 用稳定的业务 ID 做 key
{items.map(item => (
  <TodoItem key={item.id}>{item.text}</TodoItem>
))}

id 后,React 能找到旧 Fiber 0 (A) 对应的新位置 (index 1),只需插入 D 并更新索引,其余节点复用。

陷阱四:派生状态 vs 冗余状态

// ❌ 同时维护源数据和计算结果 → 同步地狱
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<Item[]>([]);
  const [filteredCount, setFilteredCount] = useState(0); // 冗余状态

  useEffect(() => {
    fetchResults(query).then(data => {
      setResults(data);
      setFilteredCount(data.filter(item => item.active).length);
    });
  }, [query]);

  return <div>活跃: {filteredCount}</div>;
}

filteredCount 完全由 results 决定。每次更新都要同步两个 state —— 一次渲染周期内触发两次 setState,浪费一个 render。

// ✅ 计算值直接用 useMemo 派生
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<Item[]>([]);

  const filteredCount = useMemo(
    () => results.filter(item => item.active).length,
    [results]
  );

  useEffect(() => {
    fetchResults(query).then(setResults);
  }, [query]);

  return <div>活跃: {filteredCount}</div>;
}

陷阱五:事件回调的引用漂移

// ❌ 内联箭头函数 → memo 失效
function Parent() {
  const [count, setCount] = useState(0);
  return (
    <ExpensiveChild
      onAction={() => console.log(count)} // 每次渲染新函数引用
    />
  );
}

// ❌ 更隐蔽:useCallback 依赖了高频变化的状态
function Parent() {
  const [count, setCount] = useState(0);
  const onAction = useCallback(() => {
    console.log(count);
  }, [count]); // count 变化 → 回调引用变化 → 跟没 memo 一样

  return <ExpensiveChild onAction={onAction} />;
}

这里 count 每次变化都需要新的回调,useCallback 无法稳定引用。解法是用 useRef 存储最新值,用 useCallback 稳定函数引用:

// ✅ ref 存值 + callback 稳引用
function Parent() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count;

  const onAction = useCallback(() => {
    console.log(countRef.current);
  }, []); // 空依赖,引用永远不变

  return <ExpensiveChild onAction={onAction} />;
}

思考:什么时候该停止优化

并不是每个 memo 都值得加。memo 本身也有成本——浅比较遍历 props 对象的时间。如果组件的 render 开销小于浅比较开销,加 memo 反而慢了。

// 这种情况就别 memo 了
const Text = ({ text }: { text: string }) => <span>{text}</span>;

性能优化的天花板公式:你的 render 时间 × 重渲染次数 ≤ 16ms。超过这个阈值用户才感知到掉帧。React DevTools Profiler 里看到 render duration 和 commit duration 再决定优化方向,别凭直觉盲铺 memo

0 评论

评论区

登录 后参与评论