第 10 章

自定义 Hook:抽象、复用与设计模式

第10章:自定义 Hook:抽象、复用与设计模式

自定义 Hook 是 React 组合性的核心表达形式——它们让有状态逻辑脱离渲染树,成为可独立测试的逻辑单元。

本章核心问题:自定义 Hook 的规则从何而来?什么时候该提取,什么时候不该? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

什么时候应该提取自定义 Hook?

提取自定义 Hook 的信号:

  1. 同样的 Hook 组合在多个组件里重复出现
  2. 一个组件里有大量 Hook 逻辑,让渲染逻辑难以看清
  3. 想给一段有状态的逻辑命名,让意图更清晰
  4. 需要测试一段有状态的逻辑(自定义 Hook 比组件更容易测试)

不应该提取的情况:只被一个组件使用,且逻辑足够简单。过度抽象和抽象不足一样有害。

useFetch:生产级别的数据获取 Hook

这是最常见的自定义 Hook 需求,也是最容易出错的:

type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function useFetch<T>(url: string | null): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ status: 'idle' });

  useEffect(() => {
    if (!url) {
      setState({ status: 'idle' });
      return;
    }

    setState({ status: 'loading' });
    const controller = new AbortController();

    fetch(url, { signal: controller.signal })
      .then(response => {
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.json() as Promise<T>;
      })
      .then(data => setState({ status: 'success', data }))
      .catch(error => {
        if (error.name !== 'AbortError') {
          setState({ status: 'error', error });
        }
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

// 使用示例
function UserProfile({ userId }: { userId: string }) {
  const result = useFetch<User>(`/api/users/${userId}`);

  if (result.status === 'loading') return <Skeleton />;
  if (result.status === 'error') return <ErrorMessage error={result.error} />;
  if (result.status === 'idle' || result.status !== 'success') return null;

  return <div>{result.data.name}</div>;
}

为什么用判别联合类型(Discriminated Union)?

// 不好的设计:data、loading、error 三个独立字段
type BadState = {
  data: T | null;
  loading: boolean;
  error: Error | null;
};
// 存在不可能的组合:{ data: user, loading: true, error: someError }
// 消费者需要写复杂的条件判断

// 好的设计:判别联合类型,每种状态互斥
type GoodState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }  // data 只在 success 时存在
  | { status: 'error'; error: Error }; // error 只在 error 时存在
// TypeScript 会在类型层面保证不可能的组合不存在

useLocalStorage:序列化、SSR 安全与同步

function useLocalStorage<T>(
  key: string,
  defaultValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  // 懒初始化:只在挂载时读一次 localStorage
  const [storedValue, setStoredValue] = useState<T>(() => {
    // SSR 安全:服务端没有 window 对象
    if (typeof window === 'undefined') return defaultValue;

    try {
      const item = window.localStorage.getItem(key);
      return item !== null ? (JSON.parse(item) as T) : defaultValue;
    } catch {
      // JSON.parse 失败、localStorage 被禁用等情况
      return defaultValue;
    }
  });

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      setStoredValue(prev => {
        const nextValue = typeof value === 'function'
          ? (value as (prev: T) => T)(prev)
          : value;

        try {
          window.localStorage.setItem(key, JSON.stringify(nextValue));
        } catch {
          // localStorage 满了或被禁用(隐私模式)
          console.warn(`Failed to save to localStorage: ${key}`);
        }

        return nextValue;
      });
    },
    [key]
  );

  // 跨标签页同步:监听 storage 事件
  useEffect(() => {
    function handleStorageChange(event: StorageEvent) {
      if (event.key === key && event.newValue !== null) {
        try {
          setStoredValue(JSON.parse(event.newValue) as T);
        } catch {
          // ignore invalid JSON from other tabs
        }
      }
    }

    window.addEventListener('storage', handleStorageChange);
    return () => window.removeEventListener('storage', handleStorageChange);
  }, [key]);

  return [storedValue, setValue];
}

// 使用示例
function Settings() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');

  return (
    <select value={theme} onChange={e => setTheme(e.target.value as 'light' | 'dark')}>
      <option value="light">浅色</option>
      <option value="dark">深色</option>
    </select>
  );
}

useWindowSize:响应式尺寸检测

type WindowSize = { width: number; height: number };

function useWindowSize(): WindowSize {
  const [size, setSize] = useState<WindowSize>(() => ({
    width: typeof window !== 'undefined' ? window.innerWidth : 0,
    height: typeof window !== 'undefined' ? window.innerHeight : 0,
  }));

  useEffect(() => {
    // ResizeObserver 比 resize 事件更精准,但对 window 监听仍需 addEventListener
    function handleResize() {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    }

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// 在实际应用中,配合 useDebounce 避免频繁更新
function useWindowSizeDebounced(delayMs = 100): WindowSize {
  const size = useWindowSize();
  return useDebounce(size, delayMs);
  // 注意:这里传了一个对象给 useDebounce,
  // 每次 resize 都是新对象,需要在 useDebounce 内部处理引用问题
  // 实际生产中可能需要比较对象内容而非引用
}

useIntersectionObserver:懒加载与无限滚动

type IntersectionOptions = {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
};

function useIntersectionObserver(
  ref: React.RefObject<Element | null>,
  options: IntersectionOptions = {}
): boolean {
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(
      ([entry]) => setIsIntersecting(entry.isIntersecting),
      options
    );

    observer.observe(element);
    return () => observer.disconnect();
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ref, options.root, options.rootMargin, options.threshold]);
  // 注意:options 对象本身不稳定,需要解构原始值作为依赖

  return isIntersecting;
}

// 图片懒加载
function LazyImage({ src, alt }: { src: string; alt: string }) {
  const ref = useRef<HTMLDivElement>(null);
  const isVisible = useIntersectionObserver(ref, { threshold: 0.1 });

  return (
    <div ref={ref} className="lazy-image-container">
      {isVisible ? (
        <img src={src} alt={alt} />
      ) : (
        <div className="placeholder" />
      )}
    </div>
  );
}

// 无限滚动加载更多
function InfiniteList() {
  const [items, setItems] = useState<Item[]>([]);
  const [page, setPage] = useState(1);
  const sentinelRef = useRef<HTMLDivElement>(null);
  const isSentinelVisible = useIntersectionObserver(sentinelRef, { threshold: 1 });

  useEffect(() => {
    if (isSentinelVisible) {
      setPage(prev => prev + 1);
    }
  }, [isSentinelVisible]);

  useEffect(() => {
    fetchItems(page).then(newItems => setItems(prev => [...prev, ...newItems]));
  }, [page]);

  return (
    <div>
      {items.map(item => <ItemCard key={item.id} item={item} />)}
      <div ref={sentinelRef} /> {/* 哨兵元素 */}
    </div>
  );
}

测试自定义 Hook

自定义 Hook 的可测试性是提取它的重要理由之一。用 @testing-library/react-hooks(或 React Testing Library 的 renderHook)测试:

import { renderHook, act } from '@testing-library/react';

describe('useCounter', () => {
  it('initializes with the given value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increments correctly', () => {
    const { result } = renderHook(() => useCounter(0));
    act(() => {
      result.current.increment();
    });
    expect(result.current.count).toBe(1);
  });

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });
    expect(result.current.count).toBe(5);
  });
});

describe('useDebounce', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  it('returns initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('initial', 300));
    expect(result.current).toBe('initial');
  });

  it('debounces value updates', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 300),
      { initialProps: { value: 'initial' } }
    );

    rerender({ value: 'updated' });
    expect(result.current).toBe('initial'); // 还没到 300ms

    act(() => jest.advanceTimersByTime(300));
    expect(result.current).toBe('updated'); // 300ms 后更新
  });
});

Level 2 · 它是怎么运行的(3-5年经验)

Hooks 规则的深层原因

在理解自定义 Hook 之前,必须先理解为什么 Hooks 有使用规则——只在函数组件顶层调用,不能在条件语句、循环、嵌套函数中调用。

答案藏在 React 的 Fiber 架构里。每个组件实例在 Fiber 节点上维护一个Hook 链表。第一次调用 useState 创建链表的第一个节点,第二次调用创建第二个,依此类推。每次重渲染时,React 按顺序遍历这个链表,把第 n 次调用的结果返回给第 n 个 Hook。

渲染 1:
useState(0)     → 链表节点 1: { state: 0, queue: [] }
useState('')    → 链表节点 2: { state: '', queue: [] }
useEffect(fn)   → 链表节点 3: { effect: fn, deps: undefined }

渲染 2(依赖链表顺序来匹配状态):
useState(0)     → 读取节点 1 → 返回 0(或更新后的值)
useState('')    → 读取节点 2 → 返回 ''(或更新后的值)
useEffect(fn)   → 读取节点 3 → 比较 deps

如果你在条件语句中调用 Hook,某次渲染可能跳过了它,导致后续 Hook 与链表节点错位——React 会读取错误的状态值,产生难以追踪的 bug。

自定义 Hook 只是把这个调用链提取到一个函数里,规则自然延伸到其中。

useDebounce:防抖的正确实现

function useDebounce<T>(value: T, delayMs: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delayMs);

    return () => clearTimeout(timer); // cleanup 取消上一个定时器
  }, [value, delayMs]);

  return debouncedValue;
}

// 配合 useFetch 实现搜索防抖
function SearchPage() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  const results = useFetch<SearchResult[]>(
    debouncedQuery ? `/api/search?q=${encodeURIComponent(debouncedQuery)}` : null
  );

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="搜索..."
      />
      {results.status === 'loading' && <Spinner />}
      {results.status === 'success' && (
        <ul>{results.data.map(r => <li key={r.id}>{r.title}</li>)}</ul>
      )}
    </div>
  );
}

useDebounce 的关键在于 cleanup:每次 value 变化时,Effect 先取消上一个定时器,再设置新的。这确保只有最后一次变化(经过 delayMs 静止后)才会更新 debouncedValue


Level 3 · 规范怎么定义的(资深)

自定义 Hook 的设计原则

原则一:单一职责

每个自定义 Hook 应该只做一件事:

// 错误:一个 Hook 做了太多事
function useUserDashboard(userId: string) {
  const user = useFetch<User>(`/api/users/${userId}`);
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const windowSize = useWindowSize();
  // 这三件事完全不相关,应该在组件里分别调用
}

// 正确:各自独立,在组件中组合
function Dashboard({ userId }: { userId: string }) {
  const user = useFetch<User>(`/api/users/${userId}`);
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const { width } = useWindowSize();
}

原则二:返回一致的 API

// 不好:返回值结构不稳定,有时是数组,有时是对象
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  return [count, () => setCount(c => c + 1), () => setCount(c => c - 1)]; // 位置语义弱
}

// 好:具名返回值,语义清晰
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);

  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initial), [initial]);

  return { count, increment, decrement, reset };
}

命名约定:遵循社区惯例,use 前缀是必须的(让 React lint 规则识别),返回值用具名对象(方便解构和扩展)。

原则三:正确处理依赖

// 问题:options 对象作为依赖会每次都变化
function useEventListener(
  event: string,
  handler: (e: Event) => void,
  options?: EventListenerOptions
) {
  useEffect(() => {
    document.addEventListener(event, handler, options);
    return () => document.removeEventListener(event, handler, options);
  }, [event, handler, options]); // options 是对象,每次新建
}

// 修复:使用 ref 稳定化 handler,解构 options 的原始值
function useEventListener(
  event: string,
  handler: (e: Event) => void,
  options?: EventListenerOptions
) {
  const handlerRef = useRef(handler);
  handlerRef.current = handler; // 在渲染时同步更新

  const capture = options?.capture;
  const passive = options?.passive;
  const once = options?.once;

  useEffect(() => {
    function stableHandler(e: Event) {
      handlerRef.current(e);
    }
    document.addEventListener(event, stableHandler, { capture, passive, once });
    return () => document.removeEventListener(event, stableHandler, { capture, passive, once });
  }, [event, capture, passive, once]);
}

Level 4 · 边界与陷阱(所有人)

生产环境常见问题

在实际项目中,本章涵盖的概念最常见的问题包括:

  1. 忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。

  2. 过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。

  3. 文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。

本章评分
4.5  / 5  (34 评分)

💬 留言讨论