React 高级技巧:从熟练到精通

42 阅读7分钟

React 高级技巧:从熟练到精通

写给有一定 React 基础、想要突破瓶颈的前端开发者。本文不讲 useState 和 useEffect 的基础用法,而是聚焦那些能真正提升代码质量和应用性能的进阶模式。


一、用 useRef 做"不触发渲染的状态"

很多人只把 useRef 当作获取 DOM 节点的工具,但它真正的能力在于——持有一个跨渲染周期稳定的可变值,且修改它不会触发 re-render

典型场景:在 useEffect 的回调或定时器里需要访问"最新的 props/state",但又不想把它加进依赖数组。

function useLatest(value) {
  const ref = useRef(value);
  ref.current = value; // 每次渲染都同步最新值
  return ref;
}

function Chat({ onMessage }) {
  const onMessageRef = useLatest(onMessage);

  useEffect(() => {
    const ws = new WebSocket('/chat');
    ws.onmessage = (e) => onMessageRef.current(e.data);
    return () => ws.close();
  }, []); // 依赖数组为空,但回调永远是最新的
}

这个 useLatest 模式在社区中被广泛使用(ahooks、react-use 等库都内置了它),核心思路就是把"值的引用"和"副作用的生命周期"解耦。


二、组件拆分的"状态下沉"原则

性能优化最常见的误区是到处加 React.memo。更根本的思路是——把状态下沉到真正需要它的子树里,让无关组件压根不参与 re-render

// ❌ 整个页面因为 hover 状态频繁 re-render
function Page() {
  const [hovered, setHovered] = useState(false);
  return (
    <div>
      <HeavyHeader />
      <div onMouseEnter={() => setHovered(true)}
           onMouseLeave={() => setHovered(false)}
           style={{ background: hovered ? '#f0f0f0' : '#fff' }}>
        Hover me
      </div>
      <HeavyFooter />
    </div>
  );
}

// ✅ 把 hover 状态封装到独立组件
function HoverBox() {
  const [hovered, setHovered] = useState(false);
  return (
    <div onMouseEnter={() => setHovered(true)}
         onMouseLeave={() => setHovered(false)}
         style={{ background: hovered ? '#f0f0f0' : '#fff' }}>
      Hover me
    </div>
  );
}

function Page() {
  return (
    <div>
      <HeavyHeader />
      <HoverBox />
      <HeavyFooter />
    </div>
  );
}

这比 React.memo 更彻底——不是"渲染了再比较要不要跳过",而是从源头上缩小了渲染范围


三、用 children 模式阻断 re-render 传播

与状态下沉相反的场景:状态必须在外层,但子组件不依赖这个状态。这时可以利用 children 的稳定性。

// ❌ ScrollY 变化导致 children 全部 re-render
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  return (
    <div>
      <Navbar transparent={scrollY < 100} />
      <HeavyContent />  {/* 每次滚动都 re-render */}
    </div>
  );
}

// ✅ 把不依赖 scrollY 的部分通过 children 传入
function ScrollProvider({ children }) {
  const [scrollY, setScrollY] = useState(0);
  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  return (
    <div>
      <Navbar transparent={scrollY < 100} />
      {children}  {/* children 的引用没变,不会 re-render */}
    </div>
  );
}

// 使用
<ScrollProvider>
  <HeavyContent />
</ScrollProvider>

原理:children 是在父组件(ScrollProvider 的调用方)渲染时创建的 JSX 元素,ScrollProvider 内部状态变化不会改变 children 的引用。


四、useReducer + Context:轻量级状态管理

当多个组件需要共享一块状态时,不一定要引入 Redux 或 Zustand。useReducer + Context 可以覆盖大量场景,关键在于把 dispatch 和 state 拆成两个 Context

const StateCtx = createContext();
const DispatchCtx = createContext();

function reducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return { ...state, todos: [...state.todos, action.payload] };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((t, i) =>
          i === action.index ? { ...t, done: !t.done } : t
        ),
      };
    default:
      return state;
  }
}

function TodoProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, { todos: [] });
  return (
    <DispatchCtx.Provider value={dispatch}>
      <StateCtx.Provider value={state}>
        {children}
      </StateCtx.Provider>
    </DispatchCtx.Provider>
  );
}

为什么要拆?因为 dispatch 是稳定的引用(React 保证),而 state 每次变化都是新对象。如果只有一个 Context,那些只需要 dispatch(比如"添加按钮")的组件也会因为 state 变化而 re-render。拆开之后,只订阅 DispatchCtx 的组件完全不受 state 更新影响。


五、自定义 Hook 的组合模式

自定义 Hook 的真正威力不在于封装一个 useXxx,而在于多个 Hook 像乐高一样组合

// 基础 Hook:监听媒体查询
function useMediaQuery(query) {
  const [matches, setMatches] = useState(
    () => window.matchMedia(query).matches
  );
  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e) => setMatches(e.matches);
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, [query]);
  return matches;
}

// 基础 Hook:本地存储
function useLocalStorage(key, initial) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initial;
  });
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);
  return [value, setValue];
}

// 组合:响应式暗色模式
function useDarkMode() {
  const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
  const [override, setOverride] = useLocalStorage('theme', null);

  const isDark = override !== null ? override === 'dark' : prefersDark;
  const toggle = () => setOverride(isDark ? 'light' : 'dark');
  const reset = () => setOverride(null); // 回到跟随系统

  return { isDark, toggle, reset };
}

每个基础 Hook 只做一件事,组合 Hook 负责编排逻辑。测试和复用都变得很容易。


六、用 useSyncExternalStore 接管外部数据源

React 18 引入的 useSyncExternalStore 是订阅外部数据源的"正统"方式,解决了 useEffect + setState 模式在并发渲染下可能出现的"撕裂"问题。

import { useSyncExternalStore } from 'react';

// 封装一个极简的 store
function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();

  return {
    getState: () => state,
    setState: (fn) => {
      state = typeof fn === 'function' ? fn(state) : fn;
      listeners.forEach((l) => l());
    },
    subscribe: (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

const counterStore = createStore({ count: 0 });

function useStore(store, selector) {
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState())
  );
}

// 组件中使用
function Counter() {
  const count = useStore(counterStore, (s) => s.count);
  return (
    <button onClick={() => counterStore.setState((s) => ({ count: s.count + 1 }))}>
      {count}
    </button>
  );
}

这个模式就是 Zustand 的核心原理。理解了它,你就理解了为什么 Zustand 那么轻量,以及它在并发模式下为什么比手写 useEffect 订阅更可靠。


七、Compound Components 复合组件模式

当你在构建一个 UI 组件库时,复合组件模式可以提供极其灵活的 API 设计。

const TabsContext = createContext();

function Tabs({ children, defaultIndex = 0 }) {
  const [activeIndex, setActiveIndex] = useState(defaultIndex);
  return (
    <TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }) {
  return <div className="tab-list" role="tablist">{children}</div>;
}

function Tab({ children, index }) {
  const { activeIndex, setActiveIndex } = useContext(TabsContext);
  return (
    <button
      role="tab"
      className={activeIndex === index ? 'active' : ''}
      onClick={() => setActiveIndex(index)}
    >
      {children}
    </button>
  );
}

function TabPanels({ children }) {
  const { activeIndex } = useContext(TabsContext);
  return <div className="tab-panels">{Children.toArray(children)[activeIndex]}</div>;
}

function TabPanel({ children }) {
  return <div role="tabpanel">{children}</div>;
}

// 挂载子组件
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;

使用时像这样:

<Tabs defaultIndex={0}>
  <Tabs.List>
    <Tabs.Tab index={0}>详情</Tabs.Tab>
    <Tabs.Tab index={1}>评论</Tabs.Tab>
    <Tabs.Tab index={2}>相关</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panels>
    <Tabs.Panel>详情内容...</Tabs.Panel>
    <Tabs.Panel>评论内容...</Tabs.Panel>
    <Tabs.Panel>相关内容...</Tabs.Panel>
  </Tabs.Panels>
</Tabs>

这种模式的优势是:结构完全由使用者控制,组件之间通过 Context 隐式通信,既灵活又保持了内聚性。Radix UI、Headless UI 等库都大量使用了这个模式。


八、React.lazy + Suspense 的实战细节

代码分割大家都知道用 React.lazy,但有几个细节容易踩坑。

1. 把 lazy 声明放在模块顶层,不要放在组件里:

// ✅ 模块顶层,只执行一次
const Editor = lazy(() => import('./Editor'));

// ❌ 组件内部,每次渲染都创建新的 lazy 组件
function Page() {
  const Editor = lazy(() => import('./Editor'));
  // ...
}

2. 用工厂函数做预加载:

const importEditor = () => import('./Editor');
const Editor = lazy(importEditor);

// 鼠标移入时预加载,而不是等点击
<button onMouseEnter={importEditor} onClick={() => setShowEditor(true)}>
  打开编辑器
</button>

3. 嵌套 Suspense 做细粒度加载态:

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<ContentSkeleton />}>
    <MainContent />
  </Suspense>
  <Suspense fallback={<SidebarSkeleton />}>
    <Sidebar />
  </Suspense>
</Suspense>

外层 Suspense 兜底整个页面,内层 Suspense 让各区域独立加载,避免一个慢接口阻塞整个页面。


九、ErrorBoundary 的现代写法

类组件的 ErrorBoundary 是 React 唯一还需要类组件的地方。但我们可以用一个通用的封装让它在函数组件中好用起来。

class ErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    // 上报错误监控
    reportError(error, info.componentStack);
  }

  render() {
    if (this.state.error) {
      return this.props.fallback(this.state.error, () =>
        this.setState({ error: null })
      );
    }
    return this.props.children;
  }
}

// 使用
<ErrorBoundary
  fallback={(error, retry) => (
    <div>
      <p>出了点问题:{error.message}</p>
      <button onClick={retry}>重试</button>
    </div>
  )}
>
  <App />
</ErrorBoundary>

retry(重置错误状态)作为参数传给 fallback,用户点击"重试"后子树会重新挂载,这在网络请求失败的场景下非常实用。


十、性能排查的正确姿势

最后聊聊性能排查。与其凭直觉优化,不如用工具定位问题。

React DevTools Profiler 是第一步。录制一段交互,找到耗时最长的提交(commit),展开看哪些组件在 re-render、每次渲染耗时多少。

why-did-you-render 库可以在开发环境自动检测不必要的 re-render,帮你发现那些"props 看起来没变但引用变了"的隐蔽问题。

一个常见的"引用陷阱":

// ❌ 每次渲染都创建新的 style 对象
<div style={{ color: 'red' }}>

// ✅ 提到模块顶层或用 useMemo
const redText = { color: 'red' };
<div style={redText}>

同样的问题也出现在内联函数、内联数组上。解决方案要么提取为常量,要么用 useMemo / useCallback——但只在确认存在性能问题时才用,不要过早优化。


写在最后

React 的进阶之路不在于记住更多 API,而在于理解它的渲染模型数据流。上面这些技巧的底层逻辑其实只有两条:

  1. 控制渲染范围——让该更新的更新,不该更新的别动。
  2. 保持引用稳定——React 靠引用比较决定是否 re-render,管理好引用就管好了性能。

把这两条内化,遇到具体问题时自然能推导出解法,而不需要死记硬背每一个模式。