React 自定义 Hooks 抽象:如何提取可复用的业务逻辑并保持内部状态的封装性

各位前端界的“代码艺术家”们,大家好!

今天我们不聊框架,不聊库,也不聊什么“下一代的React”。今天我们来聊聊一个稍微有点“枯燥”,但能让你保住发际线、让你在周五下午不用加班重写代码的神器——自定义 Hooks

我知道你们在想什么:“不就是封装个函数吗?我会啊!function foo() { return 'bar' } 这种我都会。”

停!别急着打字。如果你觉得自定义 Hooks 只是“换个名字的函数”,那你离“资深架构师”还有十万八千里的距离。今天,我要带你们潜入 React Hooks 的深海,看看如何把那些乱成一团的“意大利面式代码”变成整洁、优雅、可复用的“艺术品”。

准备好了吗?咱们开讲。


第一讲:为什么要跟组件过不去?

想象一下,你正在开发一个电商网站。有一天,产品经理拍着桌子说:“老板说了,首页要加个‘最近浏览’功能!”

你当时就裂开了。为什么?因为你要去写 localStorage,要去写 useEffect 去同步状态,还要写一堆逻辑判断用户是不是登录了,还要处理数据过期……

于是,你写了一个 useRecentView Hook,把逻辑全塞进去,然后在 ProductCard 里调用它。

过了三个月,产品经理又来了:“老板说,‘收藏夹’也要加个‘最近浏览’的功能!”

你的手颤抖着打开了编辑器。你会怎么做?复制粘贴 useRecentView?还是把逻辑再写一遍?

这就是重复。重复是编程界的万恶之源,是内存泄漏的温床,是导致你掉头发的元凶。

自定义 Hooks 的核心使命,就是消除重复。它就像是一个“代码搅拌机”,你把原材料(业务逻辑)扔进去,吐出来的就是整齐划一的代码块。

但是!重点来了!

很多初学者用 Hook,就像是把家里的洗衣机和冰箱都塞进了同一个厨房里。他们直接把组件的 state 扔给 Hook,或者在 Hook 里直接去 setState。这就破坏了封装性

今天我们的目标只有一个:如何写出一个既干干净净、又能搞定复杂逻辑,还不会把组件搞成一团浆糊的 Hook。


第二讲:从“小打小闹”到“状态封装”

我们先从一个最简单的例子开始。假设我们需要一个计数器。

1. 糟糕的封装(反模式)

很多初学者会这么写:

// ❌ 反模式:直接操作组件的 State
function BadCounter() {
  const [count, setCount] = useState(0);

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);

  return (
    <div>
      <button onClick={increment}>加</button>
      <span>{count}</span>
      <button onClick={decrement}>减</button>
    </div>
  );
}

这看起来没啥问题,对吧?但这就像是你把所有衣服都堆在沙发上一团糟。如果你想把这个计数器用到“购物车”里,或者“点赞”里,你就得把 setCount 的逻辑复制过去。

2. 优雅的封装(正确姿势)

我们要提取逻辑,但要把状态藏起来。

// ✅ 好的封装:状态私有化
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);

  // 甚至可以加个“步长”控制
  const incrementBy = (amount) => setCount(c => c + amount);

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

// 使用
function GoodCounter() {
  const { count, increment, decrement } = useCounter(0);

  return (
    <div>
      <button onClick={increment}>加</button>
      <span>{count}</span>
      <button onClick={decrement}>减</button>
    </div>
  );
}

看懂了吗? 这里发生了什么?
组件 GoodCounter 完全不知道 count 是怎么存的,它是 useState 还是 useReducer,甚至是个全局变量,它都不在乎。它只关心一个契约:给我一个数字,我给你提供加减的方法。

这就是封装性。这就是可复用性


第三讲:持久化状态 —— useLocalStorage 的艺术

接下来,我们进入实战。假设我们要做一个表单,用户输入的内容需要保存到浏览器里,刷新不丢失。

1. 没有封装的痛苦

你会怎么写?

function LoginForm() {
  const [value, setValue] = useState(() => {
    return localStorage.getItem('username') || '';
  });

  useEffect(() => {
    localStorage.setItem('username', value);
  }, [value]);

  return (
    <input 
      value={value} 
      onChange={(e) => setValue(e.target.value)} 
    />
  );
}

这段代码有三个坏处:

  1. 重复:如果另一个组件也需要存名字,你得再写一遍。
  2. 耦合useEffect 直接依赖 value,每次输入都触发 localStorage,性能很差(虽然浏览器会优化,但逻辑上很乱)。
  3. 初始化逻辑复杂:还要在 useState 里写那个奇怪的 () => ...

2. 完美的 useLocalStorage

让我们把它封装起来。

function useLocalStorage(key, initialValue) {
  // 1. 初始化状态:从 LocalStorage 读,如果没有就用默认值
  const [storedValue, setStoredValue] = useState(() => {
    if (typeof window === "undefined") {
      return initialValue;
    }
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  // 2. 封装更新函数:写 LocalStorage,同时更新 State
  const setValue = (value) => {
    try {
      // 允许用户传函数(和 useState 一样)
      const valueToStore = value instanceof Function ? value(storedValue) : value;

      setStoredValue(valueToStore);
      if (typeof window !== "undefined") {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

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

  return (
    <input 
      value={name} 
      onChange={(e) => setName(e.target.value)} 
    />
  );
}

深度解析:
你看,组件 BetterLoginForm 现在干净得像张白纸。它不需要知道 JSON.stringify 是干嘛的,也不需要知道 localStorage 怎么用。它只需要调用 setName,数据就自动存好了。

这就是内部状态的封装性外部组件不需要关心“持久化”这个副作用是如何实现的。


第四讲:数据获取 —— useFetch 的进阶之路

这是业务逻辑最重的地方。我们要从后端拿数据。

1. 基础版:能用就行

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

  useEffect(() => {
    const fetchData = async () => {
      try {
        const res = await fetch(url);
        const json = await res.json();
        setData(json);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url]);

  return { data, loading, error };
}

这很好,很标准。但是,业务逻辑往往不止这些。

2. 进阶版:带参数、去抖、重试

假设我们有一个搜索框。用户每输入一个字,我们就发一次请求。这会导致大量的网络请求。

我们需要一个去抖 的逻辑。

function useSearchUsers(query) {
  const [state, setState] = useState({
    data: null,
    loading: false,
    error: null,
  });

  useEffect(() => {
    // 如果没有输入,直接返回空,不发请求
    if (!query) {
      setState({ data: null, loading: false, error: null });
      return;
    }

    let timeoutId;

    setState(prev => ({ ...prev, loading: true, error: null }));

    // 去抖逻辑:500ms 后再执行
    timeoutId = setTimeout(async () => {
      try {
        const res = await fetch(`https://api.github.com/search/users?q=${query}`);
        const json = await res.json();
        setState({ data: json, loading: false, error: null });
      } catch (err) {
        setState({ data: null, loading: false, error: err });
      }
    }, 500);

    // 清理函数:如果用户在 500ms 内又输入了,清除之前的定时器
    return () => clearTimeout(timeoutId);
  }, [query]);

  return state;
}

这里有什么玄机?
注意那个 return () => clearTimeout(timeoutId);。这是 Hooks 的生命线!
当组件卸载,或者 query 变化导致 useEffect 重新执行时,之前的定时器必须被销毁。否则,如果你快速输入 “a”, “ab”, “abc”,会有 3 个请求在排队。这就是内存泄漏的温床。

封装性体现在哪里?
组件 SearchComponent 只需要管好输入框,把 query 传给 useSearchUsers。至于怎么去抖,怎么处理错误,怎么控制 loading,全在 Hook 里搞定。组件不需要写 useEffect,不需要写 setTimeout,不需要写 AbortController(为了取消请求)。


第五讲:表单逻辑 —— useForm 的复杂封装

这是业务开发中最常见的场景。表单验证、提交、重置。

1. 简单的表单处理

如果不封装,你的组件会长这样:

function BadForm() {
  const [formData, setFormData] = useState({ name: '', email: '' });
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const validate = () => {
    const newErrors = {};
    if (!formData.name) newErrors.name = "名字不能为空";
    if (!formData.email.includes('@')) newErrors.email = "邮箱格式不对";
    return newErrors;
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const validationErrors = validate();
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }
    setIsSubmitting(true);
    // 发送 API...
    setIsSubmitting(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* ... */}
    </form>
  );
}

看,光是表单逻辑就占了组件一大半。而且 validate 函数可能很复杂,包含各种正则、异步校验。

2. 完美的 useForm Hook

让我们把这一切封装起来。为了演示,我们使用一个简单的策略模式来处理验证。

function useForm(initialValues, validate, onSubmit) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues(prev => ({ ...prev, [name]: value }));
    // 可选:输入时实时清除错误
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: null }));
    }
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const validationErrors = validate(values);
    setErrors(validationErrors);

    if (Object.keys(validationErrors).length === 0) {
      setIsSubmitting(true);
      onSubmit(values, {
        resetForm: () => setValues(initialValues),
        setErrors: (newErrors) => setErrors(newErrors),
      }).finally(() => setIsSubmitting(false));
    }
  };

  return {
    values,
    errors,
    handleChange,
    handleSubmit,
    isSubmitting,
  };
}

// 使用
const validateUserForm = (values) => {
  const errors = {};
  if (!values.username) errors.username = 'Required';
  if (!values.email) errors.email = 'Required';
  return errors;
};

const onSubmitUser = (values, helpers) => {
  console.log('Submitting:', values);
  // 模拟 API
  return new Promise(resolve => setTimeout(resolve, 1000));
};

function GoodForm() {
  const {
    values,
    errors,
    handleChange,
    handleSubmit,
    isSubmitting
  } = useForm(
    { username: '', email: '' },
    validateUserForm,
    onSubmitUser
  );

  return (
    <form onSubmit={handleSubmit}>
      <input name="username" value={values.username} onChange={handleChange} />
      {errors.username && <span>{errors.username}</span>}

      <input name="email" value={values.email} onChange={handleChange} />
      {errors.email && <span>{errors.email}</span>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '提交中...' : '提交'}
      </button>
    </form>
  );
}

这个 Hook 强在哪里?

  1. 关注点分离:组件只负责 UI 渲染,useForm 负责逻辑。
  2. 可扩展性:你可以在 useForm 里加防抖、加自动聚焦、加字段级校验。
  3. 契约明确onSubmit 接收一个 helpers 对象,允许你在提交成功后重置表单。

第六讲:保持封装性的“黄金法则”

好了,讲了这么多例子,我必须总结一下那些让你代码“不优雅”的坑。作为资深专家,我必须把这些“禁忌”写下来,贴在你的显示器上。

规则 1:不要把组件的 State 扔进 Hook

这是新手最容易犯的错误。

错误示范

function BadHook(componentState) {
  useEffect(() => {
    // 直接依赖组件的 state
    console.log(componentState.value); 
  }, [componentState.value]);
}

为什么错?因为这样 Hook 就和组件紧紧绑定了。如果你把这个 Hook 移到另一个组件,它就会失效。

正确做法:通过参数传入。

function GoodHook(value) {
  useEffect(() => {
    console.log(value);
  }, [value]);
}

规则 2:不要在 Hook 里直接 setState

这叫“越权管理”。Hook 应该返回一个函数,让组件去调用,而不是直接去动组件的内部。

错误示范

function BadHook() {
  // 危险!这会破坏 React 的状态更新队列
  // 而且如果组件重新渲染,这个副作用可能会被触发两次
  const [count, setCount] = useState(0);
  useEffect(() => {
    setCount(1); 
  }, []);
  return count;
}

正确做法:暴露控制权。

function GoodHook() {
  const [count, setCount] = useState(0);
  // Hook 不动,组件动
  const increment = useCallback(() => setCount(c => c + 1), []);
  return { count, increment };
}

规则 3:清理副作用

还记得我们在 useSearchUsers 里写的 return () => clearTimeout(...) 吗?这是必须的

如果你在 Hook 里开了定时器、订阅了事件、或者请求了数据,一定要在组件卸载或者依赖变化时清理它们

如果不清理,你的组件卸载了,数据还在后台跑,内存在泄漏,用户还在被请求骚扰。这叫“僵尸代码”。

规则 4:依赖数组要诚实

useEffect 的第二个参数是 []。这是懒人的借口,也是 Bug 的温床。

如果你在 Hook 里使用了外部变量(比如 propsstate),你必须把它放在依赖数组里。如果你不想让它变化,就用 useCallback 包裹它。

// 如果不写依赖,这个 effect 只会跑一次。
// 如果里面的逻辑依赖了 url,那这就是 Bug。
useEffect(() => {
  fetchData(url);
}, []); // 危险!

// 正确写法
useEffect(() => {
  fetchData(url);
}, [url]); // 诚实一点

第七讲:进阶封装 —— 泛型与上下文

当我们封装的东西越来越多,我们可能会遇到类型问题,或者跨组件通信问题。

1. 泛型 Hook:让类型安全

假设我们要封装一个通用的 useFetch,它可以获取任何类型的数据。

// TypeScript 版本的泛型 Hook
function useFetch<T>(url: string) {
  const [state, setState] = useState<{
    data: T | null;
    loading: boolean;
    error: string | null;
  }>({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => setState({ data: null, loading: false, error: err.message }));
  }, [url]);

  return state;
}

// 使用
interface User {
  id: number;
  name: string;
}

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

  if (loading) return <div>Loading...</div>;
  return <div>{data?.name}</div>;
}

通过泛型 T,我们在编译时就能发现数据类型错误。这就是封装的“硬核”之处。

2. 上下文封装:把状态提升变成“魔法”

有时候,你需要把状态在多层组件间传递。这很痛苦。但你可以封装一个 Context Hook。

// 1. 创建 Context
const UserContext = createContext();

// 2. 封装 Provider 和 Hook
function UserProvider({ children }) {
  const [user, setUser] = useState(null);

  const login = (userData) => setUser(userData);
  const logout = () => setUser(null);

  return (
    <UserContext.Provider value={{ user, login, logout }}>
      {children}
    </UserContext.Provider>
  );
}

// 3. 调用 Hook
function useUser() {
  const context = useContext(UserContext);
  if (!context) throw new Error("useUser must be used within UserProvider");
  return context;
}

// 使用
function Navbar() {
  const { user, logout } = useUser();
  return <button onClick={logout}>Logout</button>;
}

你看,现在任何组件只要调用 useUser(),就能拿到用户状态和方法。组件不需要知道数据是存在 App 组件里,还是存在 Context 里。这就是完全的封装


第八讲:性能优化 —— 别让 Hook 变成累赘

封装得好不代表性能就好。如果你的 Hook 太重,会导致整个组件疯狂重渲染。

1. 使用 useMemouseCallback

在 Hook 内部,如果你计算了一个很大的数组或者对象,记得用 useMemo

function useExpensiveData(data) {
  // 即使 data 没变,如果这里每次都重新计算,性能会爆炸
  const processedData = useMemo(() => {
    return data.map(item => item * 2); // 假设这是个很重的操作
  }, [data]);

  return processedData;
}

2. 避免在渲染期间创建函数

这是新手最容易犯的性能错误。

糟糕的 Hook

function BadHook() {
  const handleClick = () => console.log('clicked');
  // 每次渲染都会创建一个新的函数引用,导致子组件重渲染
  return <button onClick={handleClick}>Click</button>;
}

优秀的 Hook

function GoodHook() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []); // 空依赖,函数永远不变

  return <button onClick={handleClick}>Click</button>;
}

第九讲:终极奥义 —— 逻辑复用的边界

最后,我们要聊聊什么时候不要封装。

不要为了封装而封装。如果你只是想把一段代码稍微缩短一点,就写个 Hook,那反而会增加代码的理解成本。

什么时候应该封装?

  1. 逻辑重复:同样的代码在 3 个地方出现了。
  2. 副作用集中:一堆 useEffect 搞得你眼花缭乱。
  3. 状态管理复杂:你需要同时管理多个相互关联的状态。

什么时候不要封装?

  1. 组件太小:比如一个只有 10 行代码的 Button,没必要封装。
  2. 逻辑太具体:这个逻辑只在这个组件里有用,换了地方就不行了。
  3. 为了炫技:写一个 200 行的 Hook 试图解决所有问题。

结语(真正的结尾)

好了,朋友们,我们聊了这么多。

React 自定义 Hooks 真的只是“函数”吗?不,它是思维的转变

它强迫你思考:“这一堆逻辑,到底是为了解决什么问题?” 是为了状态同步?是为了数据获取?还是为了表单验证?

一旦你抓住了这个问题的本质,你就能把它抽离出来,封装成一个完美的 Hook。这个 Hook 就像是一个黑盒子。你把输入(参数)扔进去,它把输出(返回值)吐出来。至于它内部是怎么工作的,它怎么存储状态,怎么处理副作用,你完全不需要知道。

这种封装性,不仅让代码变得整洁,更让你的心智模型变得清晰。

当你下次再看到那个写着 500 行、逻辑混杂的 UserProfile 组件时,不要害怕,也不要愤怒。拿出你的 Hook,像手术刀一样,把那些“坏肉”切掉,换成一个个干干净净的、可复用的逻辑模块。

记住,写代码不是在砌砖头,而是在搭乐高。每一个 Hook 都是一个积木块。把积木搭好,你的应用大厦才能屹立不倒。

现在,去重构你的代码吧!让那些 useEffectsetState 见鬼去吧!

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注