高级概念 React 18+

自定义 Hooks:复用逻辑

自定义 Hooks 让你能够提取和复用组件逻辑,而不是复用 UI

什么是自定义 Hooks?

自定义 Hook 是一个函数,名称以 "use" 开头,可以在内部调用其他 Hook。

两个组件之间的逻辑复用

TypeScript
import { useState, useEffect } from 'react';

// ❌ 重复的逻辑
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetchUser(userId).then(data => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);

  if (loading) return <Spinner />;
  return <div>{user.name}</div>;
}

function PostList({ postId }) {
  const [post, setPost] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetchPost(postId).then(data => {
      setPost(data);
      setLoading(false);
    });
  }, [postId]);

  if (loading) return <Spinner />;
  return <div>{post.title}</div>;
}

提取自定义 Hook

TypeScript
// ✅ 自定义 Hook 复用逻辑
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then(response => response.json())
      .then(data => {
        setData(data);
        setLoading(false);
      });
  }, [url]);

  return { data, loading };
}

// 使用
function UserProfile({ userId }) {
  const { data: user, loading } = useFetch(`/api/users/${userId}`);

  if (loading) return <Spinner />;
  return <div>{user.name}</div>;
}

function PostList({ postId }) {
  const { data: post, loading } = useFetch(`/api/posts/${postId}`);

  if (loading) return <Spinner />;
  return <div>{post.title}</div>;
}
自定义 Hooks vs 普通函数

自定义 Hook 和普通函数的区别在于:

  • 自定义 Hook 可以使用其他 Hook(useState, useEffect 等)
  • 普通函数不能调用 Hook
  • 自定义 Hook 必须以 "use" 开头

命名规范

以 "use" 开头

自定义 Hook 必须以 "use" 开头,这样 React 才能识别它为 Hook,并确保 Hooks 规则被正确应用。

TypeScript
// ✅ 正确的命名
function useData() {}
function useFetch() {}
function useLocalStorage() {}
function useWindowSize() {}

// ❌ 错误的命名
function getData() {}
function fetchData() {}
function getDataFromLocalStorage() {}

清晰的命名

TypeScript
// ✅ 好: 清晰描述功能
function useWindowSize() {}
function useLocalStorage(key, initialValue) {}
function useDebounce(callback, delay) {}

// ❌ 差: 不清晰的命名
function useHook() {}
function useStuff() {}
function useData() {}

常见自定义 Hooks 示例

1. useLocalStorage - 持久化状态

TypeScript
import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // 从 localStorage 获取初始值
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  // 更新 localStorage
  const setValue = (value) => {
    try {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

// 使用
function App() {
  const [name, setName] = useLocalStorage('name', '');

  return (
    <input
      value={name}
      onChange={(e) => setName(e.target.value)}
      placeholder="输入你的名字"
    />
  );
}

2. useWindowSize - 窗口尺寸

TypeScript
import { useState, useEffect } from 'react';

function useWindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

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

  return windowSize;
}

// 使用
function ResponsiveComponent() {
  const { width, height } = useWindowSize();

  return (
    <div>
      窗口大小: {width} x {height}
    </div>
  );
}

3. useDebounce - 防抖

TypeScript
import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

// 使用
function SearchInput() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  useEffect(() => {
    // 使用防抖后的搜索词进行搜索
    if (debouncedSearchTerm) {
      console.log('搜索:', debouncedSearchTerm);
    }
  }, [debouncedSearchTerm]);

  return (
    <input
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="搜索..."
    />
  );
}

4. useToggle - 切换状态

TypeScript
import { useState, useCallback } from 'react';

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => setValue(v => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse, setValue };
}

// 使用
function Modal() {
  const { value: isOpen, toggle, setFalse: close } = useToggle(false);

  return (
    <>
      <button onClick={toggle}>打开模态框</button>
      {isOpen && (
        <div>
          <p>这是一个模态框</p>
          <button onClick={close}>关闭</button>
        </div>
      )}
    </>
  );
}

5. usePrevious - 保存之前的值

TypeScript
import { useEffect, useRef } from 'react';

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

// 使用
function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <p>当前: {count}</p>
      <p>之前: {prevCount}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  );
}

6. useInterval - 动态定时器

TypeScript
import { useEffect, useRef } from 'react';

function useInterval(callback, delay) {
  const savedCallback = useRef();

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    if (delay !== null) {
      const id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

// 使用
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useInterval(() => {
    setSeconds(seconds + 1);
  }, 1000);

  return <div>已运行: {seconds} 秒</div>;
}

7. useFetch - 数据获取

TypeScript
import { useState, useEffect, useCallback } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(url);
      const json = await response.json();
      setData(json);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  const refetch = useCallback(() => {
    fetchData();
  }, [fetchData]);

  return { data, loading, error, refetch };
}

// 使用
function UserProfile({ userId }) {
  const { data: user, loading, error, refetch } = useFetch(
    `/api/users/${userId}`
  );

  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return (
    <div>
      <h1>{user.name}</h1>
      <button onClick={refetch}>刷新</button>
    </div>
  );
}

8. useScript - 加载外部脚本

TypeScript
import { useState, useEffect } from 'react';

function useScript(src) {
  const [status, setStatus] = useState('loading');
  const [error, setError] = useState(null);

  useEffect(() => {
    let script = document.querySelector(`script[src="${src}"]`);

    if (!script) {
      script = document.createElement('script');
      script.src = src;
      script.async = true;
      script.setAttribute('data-status', 'loading');

      document.body.appendChild(script);

      const setAttributeFromEvent = (event) => {
        script.setAttribute(
          'data-status',
          event.type === 'load' ? 'ready' : 'error'
        );
      };

      script.addEventListener('load', setAttributeFromEvent);
      script.addEventListener('error', setAttributeFromEvent);
    } else {
      setStatus(script.getAttribute('data-status'));
    }

    const setStateFromEvent = (event) => {
      if (event.type === 'load') {
        setStatus('ready');
      } else {
        setStatus('error');
        setError(new Error(`Failed to load script: ${src}`));
      }
    };

    script.addEventListener('load', setStateFromEvent);
    script.addEventListener('error', setStateFromEvent);

    return () => {
      script.removeEventListener('load', setStateFromEvent);
      script.removeEventListener('error', setStateFromEvent);
    };
  }, [src]);

  return { status, error };
}

// 使用
function GoogleMaps() {
  const { status } = useScript(
    'https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY'
  );

  if (status === 'loading') return <p>加载地图...</p>;
  if (status === 'error') return <p>加载失败</p>;

  return <div id="map" />;
}

组合自定义 Hooks

自定义 Hooks 可以互相调用,构建更复杂的逻辑。

TypeScript
// 基础 Hooks
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };

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

  return size;
}

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

// 组合 Hooks
function useWindowSizeDebounced(delay) {
  const { width, height } = useWindowSize();
  return {
    width: useDebounce(width, delay),
    height: useDebounce(height, delay),
  };
}

// 使用
function ResponsiveComponent() {
  const { width, height } = useWindowSizeDebounced(200);

  return (
    <div>
      窗口大小(防抖): {width} x {height}
    </div>
  );
}

最佳实践

1. 单一职责

TypeScript
// ✅ 好: 每个 Hook 只做一件事
function useLocalStorage(key, initialValue) { /* ... */ }
function useFetch(url) { /* ... */ }
function useWindowSize() { /* ... */ }

// ❌ 差: 一个 Hook 做太多事情
function useApp() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [windowSize, setWindowSize] = useState({});

  // 混合了太多逻辑...
}

2. 清晰的参数和返回值

TypeScript
// ✅ 好: 参数和返回值清晰
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // ...

  return { data, loading, error, refetch };
}

// ❌ 差: 不清晰的返回值
function useFetch(url) {
  // ...
  return [data, loading, error, refetch]; // 数组返回,不清楚每个值的含义
}

3. 提供选项参数

TypeScript
// ✅ 好: 提供灵活的配置
function useFetch(url, options = {}) {
  const {
    method = 'GET',
    headers = {},
    body = null,
    enabled = true,
  } = options;

  useEffect(() => {
    if (!enabled) return;

    fetch(url, { method, headers, body })
      .then(res => res.json())
      .then(setData);
  }, [url, enabled, method, headers, body]);

  return { data, loading, error };
}

4. 错误处理

TypeScript
// ✅ 好: 正确处理错误
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchData() {
      try {
        setLoading(true);
        setError(null);

        const response = await fetch(url, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const json = await response.json();
        setData(json);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchData();

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

  return { data, loading, error };
}

5. 类型安全 (TypeScript)

TypeScript
import { useState, useEffect } from 'react';

// 定义类型
type UseFetchResult<T> = {
  data: T | null;
  loading: boolean;
  error: Error | null;
};

// 泛型 Hook
function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then((data: T) => {
        setData(data);
        setLoading(false);
      })
      .catch((err: Error) => {
        setError(err);
        setLoading(false);
      });
  }, [url]);

  return { data, loading, error };
}

// 使用: 类型自动推断
interface User {
  id: number;
  name: string;
}

function UserProfile() {
  const { data: user, loading, error } = useFetch<User>('/api/user');

  if (loading) return <Spinner />;
  if (error) return <Error />;
  if (user) return <div>{user.name}</div>; // user 类型为 User | null

  return null;
}

常见错误

1. 在条件语句中使用 Hook

TypeScript
// ❌ 错误: 在条件中使用
function useCustomHook(condition) {
  if (condition) {
    useState(0); // Error!
  }
}

// ✅ 正确: 条件放在 Hook 内部
function useCustomHook(condition) {
  const [value, setValue] = useState(0);

  useEffect(() => {
    if (condition) {
      setValue(1);
    }
  }, [condition]);

  return value;
}

2. 在普通函数中调用 Hook

TypeScript
// ❌ 错误:在普通函数中调用 Hook
function getData() {
  const [data, setData] = useState(null); // Error!
  return data;
}

// ✅ 正确:创建自定义 Hook
function useData() {
  const [data, setData] = useState(null);
  return data;
}

3. 循环中使用 Hook

TypeScript
// ❌ 错误:在循环中调用 Hook
function useItems(items) {
  items.forEach(item => {
    useEffect(() => { // Error!
      console.log(item);
    }, [item]);
  });
}

// ✅ 正确:使用单个 effect 处理所有 items
function useItems(items) {
  useEffect(() => {
    items.forEach(item => {
      console.log(item);
    });
  }, [items]);
}

测试自定义 Hooks

使用 @testing-library/react-hooks 测试自定义 Hooks。

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

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

test('should decrement counter', () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.decrement();
  });

  expect(result.current.count).toBe(-1);
});
测试最佳实践
  • 测试 Hook 的状态变化
  • 测试副作用是否正确执行
  • 测试错误处理
  • 测试依赖项变化时的行为

常用的第三方 Hooks 库

下一步

现在你已经了解了自定义 Hooks,可以继续学习:

这篇文章有帮助吗?