深入探究 React Hooks 你一直在写却从未写对的最佳实践

深入探究 React Hooks 你一直在写却从未写对的最佳实践

你打开编辑器,新建一个组件,熟练地敲下 useStateuseEffect,写完业务逻辑,跑起来没报错,提交代码,完事。

这套流程你可能每天重复十几次。但你有没有想过,为什么你的组件越写越臃肿?嗯......也不完全是,有想过,为什么你的组件越写越臃肿。为什么一个简单的列表页,useEffect 的依赖数组能写到七八个?为什么你明明只改了一个输入框的值,整个页面却在疯狂 re-render?

React Hooks 从 2019 年发布到现在,已经七年了。

一、你写的 useEffect,有一半可能不该存在

这不是危言耸听。打开你手头任意一个 React 项目,搜索 useEffect,数一下有多少个是在做"根据 state A 计算 state B"这件事。比如这种代码:

tsx 复制代码
//  经典反模式:用 useEffect 同步派生状态
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
const [keyword, setKeyword] = useState('');

useEffect(() => {
  setFilteredItems(items.filter(item => item.name.includes(keyword)));
}, [items, keyword]);

看起来没毛病对吧?itemskeyword 变了,就重新过滤一遍。逻辑上完全正确。

但问题在于,这段代码会让你的组件渲染两次 。第一次是 itemskeyword 变化触发的渲染,第二次是 useEffect 里调用 setFilteredItems 又触发了一次渲染。用户看不出来,但你的组件树在默默承受双倍的压力。

正确的做法简单到让人觉得不值一提:

tsx 复制代码
//  直接在渲染过程中计算
const [items, setItems] = useState([]);
const [keyword, setKeyword] = useState('');

const filteredItems = items.filter(item => item.name.includes(keyword));

没了。不需要 useEffect,不需要额外的 useState。等等,其实useEffect,不需要额外的 useStatefilteredItems 就是一个从已有 state 派生出来的值,每次渲染时重新计算就行。

"但是过滤操作开销很大怎么办?"------这时候才轮到 useMemo 出场:

tsx 复制代码
//  计算量大时用 useMemo 缓存
const filteredItems = useMemo(
  () => items.filter(item => item.name.includes(keyword)),
  [items, keyword]
);

React 官方文档里有一句话说得很直白:"You might not need an effect." 这句话应该贴在每个 React 开发者的显示器上。

1.1 那什么时候该用 useEffect

一条简单的判断标准:只有当你需要跟 React 渲染系统之外的东西打交道时,才需要 useEffect

这些"外部的东西"包括:浏览器 DOM API、定时器、网络请求、第三方库实例、WebSocket 连接等。

tsx 复制代码
//  正当的 useEffect 用途:与外部系统同步
useEffect(() => {
  const map = new MapboxGL.Map({ container: mapRef.current });
  return () => map.remove();
}, []);

//  正当的 useEffect 用途:订阅浏览器事件
useEffect(() => {
  const handler = () => setWidth(window.innerWidth);
  window.addEventListener('resize', handler);
  return () => window.removeEventListener('resize', handler);
}, []);

如果你发现 useEffect 里做的事情是"state A 变了,所以设置 state B",十有八九可以去掉它。

1.2 从 useEffect 链条中脱身

比双重渲染更可怕的是 useEffect 链条------A 变了触发 effect 设置 B,B 变了又触发另一个 effect 设置 C。代码一多,你完全搞不清一个状态变化会引起什么连锁反应。

tsx 复制代码
//  useEffect 瀑布流,调试噩梦
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

useEffect(() => {
  setGreeting(`Hello, ${fullName}`);
}, [fullName]);

useEffect(() => {
  document.title = greeting;
}, [greeting]);

重构的思路很清晰------把能在渲染时计算的全部提出来,只保留真正需要副作用的部分:

tsx 复制代码
//  派生值直接算,只保留真正的副作用
const fullName = `${firstName} ${lastName}`;
const greeting = `Hello, ${fullName}`;

useEffect(() => {
  document.title = greeting;
}, [greeting]);

三、自定义 Hook 的边界感

3.1 一个好的自定义 Hook 长什么样

先看一个我在几乎每个项目里都会写的 Hook:

tsx 复制代码
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

用起来极其自然:

tsx 复制代码
function SearchBox() {
  const [keyword, setKeyword] = useState('');
  const debouncedKeyword = useDebounce(keyword, 300);

  useEffect(() => {
    if (debouncedKeyword) {
      fetchSearchResults(debouncedKeyword);
    }
  }, [debouncedKeyword]);

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

这个 Hook 好在哪?它满足三个条件:独立的关注点 (只做防抖)、可复用 (任何需要防抖的值都能用)、接口简洁(入参和返回值一目了然)。

3.2 过度抽象的信号

再看一个反面案例:

tsx 复制代码
//  过度抽象:把一个组件的全部逻辑塞进 Hook
function useUserProfile(userId: string) {
  const [user, setUser] = useState(null);
  const [editing, setEditing] = useState(false);
  const [formData, setFormData] = useState({});
  const [saving, setSaving] = useState(false);
  const [avatarFile, setAvatarFile] = useState(null);
  const [showCropModal, setShowCropModal] = useState(false);

  useEffect(() => { /* 加载用户数据 */ }, [userId]);

  const handleSave = async () => { /* 保存逻辑 */ };
  const handleAvatarChange = (file: File) => { /* 头像处理 */ };
  const handleCrop = (area: CropArea) => { /* 裁剪逻辑 */ };

  return {
    user, editing, formData, saving, avatarFile, showCropModal,
    setEditing, setFormData, handleSave, handleAvatarChange,
    handleCrop, setShowCropModal,
  };
}

这个 Hook 返回了十二个东西。它不是"逻辑复用",它只是"把代码从组件搬到了另一个文件"。组件变短了,但复杂度一点没少,反而多了一层间接性------你读组件的时候还得跳到 Hook 里才能理解逻辑。判断标准很简单:如果这个 Hook 只被一个组件使用,而且返回值超过 4 个,大概率是过度抽象了。

3.3 更合理的拆分方式

与其做一个"大而全"的 Hook,不如按关注点拆分成几个小 Hook:

tsx 复制代码
//  按关注点拆分
function useUser(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

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

  return { user, loading };
}

function useImageCrop() {
  const [file, setFile] = useState<File | null>(null);
  const [showModal, setShowModal] = useState(false);
  const [croppedUrl, setCroppedUrl] = useState<string | null>(null);

  const startCrop = (f: File) => { setFile(f); setShowModal(true); };
  const confirmCrop = (area: CropArea) => {
    setCroppedUrl(cropImage(file!, area));
    setShowModal(false);
  };

  return { showModal, file, croppedUrl, startCrop, confirmCrop };
}

每个 Hook 职责单一,useUser 可以在任何需要获取用户信息的地方复用,useImageCrop 可以在任何需要图片裁剪的场景复用。

四、实战场景:那些年你一定踩过的坑

4.1 闭包陷阱:useEffect 里拿到的永远是旧值

这可能是 Hooks 里最让人抓狂的问题了。来看一个计数器:

tsx 复制代码
//  经典闭包陷阱
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log('current count:', count); // 永远是 0
      setCount(count + 1); // 永远设置成 1
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <div>{count}</div>;
}

useEffect 的依赖数组是 [],意味着回调函数只在首次渲染时创建一次。继续。这个回调"记住"了创建时的 count 值------也就是 0。之后无论组件渲染多少次,定时器里的 count 永远是 0

解决方案有两种,看场景选择:

tsx 复制代码
//  方案一:使用函数式更新,不依赖外部变量
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

//  方案二:用 useRef 持有最新值(适合需要读取但不需要触发渲染的情况)
const countRef = useRef(count);
countRef.current = count;

useEffect(() => {
  const id = setInterval(() => {
    console.log('current count:', countRef.current); // 永远是最新值
  }, 1000);
  return () => clearInterval(id);
}, []);

setCount(prev => prev + 1) 的函数式更新不依赖闭包里的 count,而是拿到 React 内部最新的 state 计算一下。

4.2 请求竞态:快速切换 Tab 时数据错乱

后台管理系统里很常见的场景:Tab 切换加载不同分类的数据。用户快速点击 Tab A -> Tab B -> Tab C,三个请求几乎同时发出(这个说法其实不太严谨)。如果 Tab A 的请求最后才返回,页面上显示的就是 Tab A 的数据,但用户当前停在 Tab C 上。

tsx 复制代码
//  没有处理竞态,可能展示错误数据
useEffect(() => {
  setLoading(true);
  fetchData(activeTab).then(data => {
    setData(data);
    setLoading(false);
  });
}, [activeTab]);

解法是利用 useEffect 的清理函数:

tsx 复制代码
//  用 cleanup 标记过期请求
useEffect(() => {
  let cancelled = false;
  setLoading(true);

  fetchData(activeTab).then(data => {
    if (!cancelled) {
      setData(data);
      setLoading(false);
    }
  });

  return () => {
    cancelled = true;
  };
}, [activeTab]);

activeTab 变化时,React 先执行上一次 effect 的清理函数(把 cancelled 设为 true),再执行新的 effect。上一次请求即使返回了,也会因为 cancelledtrue 而被忽略。

如果你的项目使用 AbortController,可以做得更彻底------直接取消请求本身:

tsx 复制代码
//  用 AbortController 真正取消请求
useEffect(() => {
  const controller = new AbortController();
  setLoading(true);

  fetchData(activeTab, { signal: controller.signal })
    .then(data => {
      setData(data);
      setLoading(false);
    })
    .catch(err => {
      if (err.name !== 'AbortError') {
        setError(err.message);
        setLoading(false);
      }
    });

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

4.3 useCallbackuseMemo 的滥用问题

"为了性能优化,所有函数都包一层 useCallback,所有计算都包一层 useMemo"------这是另一种常见的误区。

tsx 复制代码
//  无意义的 useCallback
const handleClick = useCallback(() => {
  console.log('clicked');
}, []);

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

useCallback 本身有开销------它需要在每次渲染时比较依赖数组。如果你包裹的函数传给的是一个普通的 HTML 元素(<button><div>),这层缓存完全没有意义,因为原生 DOM 元素不会因为 props 引用变化而跳过渲染。

useCallback 只在两种场景下有意义:

tsx 复制代码
//  场景一:传给用 React.memo 包裹的子组件
const MemoizedList = React.memo(({ onItemClick }: Props) => {
  // 渲染很重的列表
});

function Parent() {
  const handleItemClick = useCallback((id: string) => {
    navigate(`/items/${id}`);
  }, [navigate]);

  return <MemoizedList onItemClick={handleItemClick} />;
}

//  场景二:作为其他 Hook 的依赖
function useSearch(fetchFn: () => Promise<Data[]>) {
  useEffect(() => {
    fetchFn().then(setData);
  }, [fetchFn]); // fetchFn 引用不稳定会导致无限请求
}

useMemo 同理。对一个 20 条数据的数组做 .filter() 用不着 useMemo,JavaScript 引擎处理这种量级的数据快得你根本感知不到。只有当你处理成百上千条数据的复杂计算,或者需要保持引用稳定时,useMemo 才值得引入。

六、避坑清单:8 个高频错误

6.1 依赖数组相关

不要对依赖数组撒谎。 ESLint 的 exhaustive-deps 规则提示你缺少依赖时,不要随手加一个 // eslint-disable-next-line。那个警告在保护你。

tsx 复制代码
//  压制 lint 警告,埋下定时炸弹
useEffect(() => {
  fetchData(userId, filters);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]); // filters 变了不会重新请求

6.3 在循环/条件语句中调用 Hooks

这是 React 的铁律------Hooks 必须在组件顶层调用 ,不能放在 iffor、或嵌套函数里。React 靠调用顺序来识别每个 Hook,顺序一乱,state 就全串了。

tsx 复制代码
//  条件调用 Hook,React 会直接报错
if (isLoggedIn) {
  const [profile, setProfile] = useState(null);
}

//  Hook 无条件调用,在内部处理条件逻辑
const [profile, setProfile] = useState(null);
useEffect(() => {
  if (isLoggedIn) {
    fetchProfile().then(setProfile);
  }
}, [isLoggedIn]);

6.4 用 useState 存放不需要触发渲染的值

定时器 ID、上一次请求的参数、DOM 测量值------这些数据变化时你并不想让组件重新渲染。useRef 才是正确的工具:

tsx 复制代码
//  用 useState 存定时器 ID,每次清除/设置都多一次渲染
const [timerId, setTimerId] = useState<number | null>(null);

//  用 useRef,修改不触发渲染
const timerRef = useRef<number | null>(null);

6.5 useEffect 缺少清理函数

6.6 在 useEffect 里直接用 async

tsx 复制代码
//  useEffect 不接受 async 函数
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

//  在内部定义 async 函数
useEffect(() => {
  async function loadData() {
    const data = await fetchData();
    setData(data);
  }
  loadData();
}, []);

useEffect 期望返回值是一个清理函数或 undefined,而 async 函数返回的是 Promise------类型对不上。

6.7 状态初始化的性能陷阱

tsx 复制代码
//  每次渲染都执行 expensiveComputation
const [data, setData] = useState(expensiveComputation());

//  传入函数,只在首次渲染时执行
const [data, setData] = useState(() => expensiveComputation());

区别就是一对括号。

6.8 Context 导致的不必要渲染

useContext 没有选择器机制------只要 Context 的值变了,所有消费这个 Context 的组件都会重新渲染,哪怕它们只用了其中一个字段。

tsx 复制代码
//  一个巨大的 Context,任何字段变化都会触发全量渲染
const AppContext = createContext({
  user: null,
  theme: 'light',
  locale: 'zh-CN',
  notifications: [],
});

//  按变化频率拆分成多个 Context
const UserContext = createContext(null);
const ThemeContext = createContext('light');
const NotificationContext = createContext([]);

变化频率不同的数据放在同一个 Context 里,是性能问题的常见根源。notifications 每隔几秒更新一次,但 theme 可能整个会话都不会变------把它们拆开,theme 的消费者就不会被 notifications 的更新波及。

相关推荐
coder_Eight2 小时前
吃透JS深拷贝:从原理到实战(含Symbol全场景+性能对比)
javascript
程序员阿峰2 小时前
【JavaScript面试题-this 绑定】请说明 `this` 在不同场景下的指向(默认、隐式、显式、new、箭头函数)。
前端·javascript·面试
玉米Yvmi3 小时前
给 JS穿上铠甲:TypeScript 基础核心概念详解(类型/接口/泛型)
前端·javascript·typescript
牛马1114 小时前
Flutter CustomPaint
开发语言·前端·javascript
biubiuibiu4 小时前
JavaScript核心概念深度解析:位运算与短路逻辑
开发语言·javascript·ecmascript
紫_龙5 小时前
最新版vue3+TypeScript开发入门到实战教程之watch详解
前端·javascript·typescript
okra-5 小时前
Axure RP 10 进阶指南:从全局变量到JavaScript语法,打造高效原型设计!
javascript·axure·photoshop
lxh01136 小时前
记忆函数 II 题解
前端·javascript