阅读视图

发现新文章,点击刷新页面。

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

前端组件化样式隔离实战:React CSS Modules、styled-components 与 Vue scoped 对比

引言:当组件遇见 CSS

在现代前端开发中,组件化已成为构建用户界面的主流方式。我们将页面拆分为独立、可复用的组件,每个组件管理自己的 HTML、CSS 和 JavaScript。然而,CSS 的设计初衷是全局作用域的 —— 样式一旦定义,就会影响整个页面,这给组件化带来了严峻挑战。

试想一个多人协作的项目:A 同学写了一个按钮组件,类名为 .button;B 同学也写了一个按钮组件,同样用了 .button。当两个组件同时出现在页面上时,后加载的样式会覆盖前者,造成意料之外的 UI 错乱。如何让组件的样式“与世隔绝”,既不影响他人,也不被他人影响?本文将深入探讨 React 和 Vue 生态中三种主流的样式隔离方案:CSS Modulesstyled-componentsVue scoped。我们将通过实际代码,由浅入深地理解它们的原理与用法。

1. CSS 的“先天不足”与组件化的冲突

在传统网页开发中,我们通常这样写 CSS:

/* global.css */
.button {
  background-color: blue;
  color: white;
}

这个 .button 样式会作用于页面上所有带有 class="button" 的元素,无论它身处哪个组件。这种全局性在小型项目中或许无伤大雅,但在组件化架构下却成了灾难。

假设我们有 Button.jsxAnotherButton.jsx 两个组件,分别引入了各自的 CSS 文件:

/* Button.css */
.button { background: blue; }

/* AnotherButton.css */
.button { background: red; }
效果图

image.png

最终页面上两个按钮都会是红色,因为后引入的 AnotherButton.css 覆盖了前者的规则。这就是样式冲突的典型场景。

为了解决这一问题,社区发展出了多种作用域隔离方案,核心思想都是将样式“限定”在组件内部。下面我们分别看看 React 和 Vue 是如何做到的。

2. React 中的 CSS Modules

2.1 什么是 CSS Modules?

CSS Modules 是一种将 CSS 文件编译为局部作用域的技术。它并不是官方的 CSS 规范,而是通过构建工具(如 webpack)在编译时给类名自动添加唯一的哈希字符串,从而实现样式隔离。在 React 项目中,使用 Create React App 或 Vite 脚手架时,开箱即支持 CSS Modules。

2.2 基本用法

我们约定 CSS 文件命名为 *.module.css。在组件中像导入一个对象一样导入样式文件,然后通过对象的属性引用类名。

Button.module.css

.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
}

.txt {
  color: red;
  background-color: orange;
  font-size: 30px;
}

Button.jsx

import styles from './Button.module.css';
console.log(styles); // 输出:{ button: "Button_button__1a2b3c", txt: "Button_txt__4d5e6f" }

export default function Button() {
  return (
    <>
      <h1 className={styles.txt}>你好,世界!!!</h1>
      <button className={styles.button}>My Button</button>
    </>
  );
}
效果图

image.png

在浏览器中,最终渲染的 HTML 类似:

<h1 class="Button_txt__4d5e6f">你好,世界!!!</h1>
<button class="Button_button__1a2b3c">My Button</button>
打开控制台我们点击元素开可以看到每个元素都有唯一的id

image.png

可以看到,原始的类名 .button.txt 被转换成了带有组件名和哈希的唯一类名,从而避免了全局污染。

2.3 多人协作的保障

再来看另一个组件 AnotherButton,它也定义了同名的 .button 样式:

anotherButton.module.css

.button {
  background-color: red;
  color: black;
  padding: 10px 20px;
}

AnotherButton.jsx

import styles from './anotherButton.module.css';

export default function AnotherButton() {
  return <button className={styles.button}>My Another Button</button>;
}

两个组件的样式互不干扰,因为编译后的类名分别是 AnotherButton_button__xxxButton_button__xxx。这正是 CSS Modules 的魅力所在——让开发者无需担心类名冲突,专注于组件本身的样式。

2.4 原理浅析

CSS Modules 的原理并不复杂:在构建阶段,webpack 的 css-loader 会解析 *.module.css 文件,将每个类名映射为一个唯一的标识符(通常是 [文件名]_[类名]__[hash]),同时生成一个映射对象(即 styles)。在 JavaScript 中,我们通过这个映射对象来引用最终的类名,而 CSS 文件中的原始类名则被替换为哈希后的类名。这样,CSS 和 JS 就通过同一份映射关系保证了样式的私有性。

3. React 中的 styled-components

如果说 CSS Modules 是在编译时通过修改类名来实现隔离,那么 styled-components 则代表了另一种思潮:CSS-in-JS,即在 JavaScript 中编写 CSS,并利用 JavaScript 的作用域来实现样式隔离。

3.1 什么是 styled-components?

styled-components 是一个流行的 React 库,它允许你使用 ES6 的模板字符串定义样式组件,这些样式组件会自动生成一个唯一的类名,并将样式注入到 <head> 中。

3.2 基本用法

首先安装 styled-components:

npm install styled-components

然后在组件中创建样式化组件:

import styled from 'styled-components';

// 定义一个带样式的 button 组件
const Button = styled.button`
  background-color: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  );
}
效果图

image.png 渲染后的 HTML 如下(每个人的截图中的真实类名可能不同):

<button class="sc-axZvf jflFSQ">默认按钮</button>
<button class="sc-axZvf efDizw">主要按钮</button>
打开控制台点开控制台元素,我们同样可以看到每个元素都有唯一id

image.png 这里的 sc-axZvf 是组件标识前缀,同一组件生成的实例共享这个前缀,而 jflFSQ 和 efDizw 则是具体的样式类名,分别对应不同的样式规则(例如一个是默认样式,一个是 primary 样式)。所有样式都被动态地生成为 <style> 标签插入页面头部。

3.3 动态样式与 props

styled-components 的一大优势是支持基于 props 的动态样式。如上例所示,通过 props.primary 可以轻松改变背景色和文字颜色。这比传统 CSS 需要额外维护多个类名要直观得多。

3.4 原理浅析

styled-components 在运行时(runtime)工作:当组件渲染时,它会解析模板字符串中的样式规则,根据 props 计算出最终的 CSS 文本,然后生成一个唯一的类名(如 jflFSQ),并将 CSS 规则以 <style> 标签的形式插入到文档头部。值得注意的是,同一组件(如 Button)的所有实例会共享一个组件级标识(sc-axZvf),而具体样式类名则每个实例或每个变体可能不同。由于每个组件实例都可能生成不同的类名,样式天然是隔离的。同时,它还能自动处理浏览器前缀、关键帧动画等,为开发者提供了良好的体验。

4. Vue 中的 scoped 样式

Vue 作为另一大前端框架,其单文件组件(SFC)提供了内置的样式隔离方案——scoped 属性。

4.1 什么是 scoped?

在 Vue 的单文件组件中,可以在 <style> 标签上添加 scoped 属性,指示该样式只作用于当前组件。它的实现方式是为组件模板中的元素添加唯一的自定义属性(如 data-v-xxxxx),然后通过属性选择器来限制样式的生效范围。每个组件会生成一个唯一的哈希 ID,该组件内的所有元素都会被打上这个 ID 作为属性。

4.2 基本用法

App.vue

<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>

<template>
  <div>
    <h1 class="txt">Hello world in App</h1>
    <h2 class="txt2">一点点</h2>
    <HelloWorld />
  </div>
</template>

<style scoped>
.txt {
  color: red;
}
.txt2 {
  color: green;
}
</style>

HelloWorld.vue

<template>
  <div>
    <h1 class="txt">你好,世界!!!</h1>
    <h2 class="txt2">一点点</h2>
  </div>
</template>

<style scoped>
.txt {
  color: blue;
}
.txt2 {
  color: orange;
}
</style>
效果图

image.png

4.3 渲染结果与原理

编译后,Vue 会为每个组件生成一个唯一的哈希 ID。假设 App 组件的 ID 为 data-v-7a7a37b1,HelloWorld 组件的 ID 为 data-v-e17ea971。最终渲染的 HTML 结构如下(来自实际截图):

html

<div data-v-7a7a37b1>
  <h1 data-v-7a7a37b1 class="txt">Hello world in App</h1>
  <h2 data-v-7a7a37b1 class="txt2">一点点</h2>
</div>

<div data-v-e17ea971 data-v-7a7a37b1>
  <h1 data-v-e17ea971 class="txt">你好,世界!!!</h1>
  <h2 data-v-e17ea971 class="txt2">一点点</h2>
</div>

仔细观察可以发现:

  • App 组件内的所有元素(包括根 div)都带有自己的 ID data-v-7a7a37b1
  • HelloWorld 组件内的所有元素(包括其根 div)都带有自己的 ID data-v-e17ea971特别地,HelloWorld 的根元素上还额外附加了父组件 App 的 ID data-v-7a7a37b1。这是 Vue 故意设计的,目的是让父组件的样式可以通过属性选择器(如 .txt[data-v-7a7a37b1])作用于子组件的根元素,从而实现父组件对子组件根节点的样式控制(如果父组件样式选择器匹配的话)。

对应的 CSS 会被编译为:

css

.txt[data-v-7a7a37b1] { color: red; }
.txt2[data-v-7a7a37b1] { color: green; }
.txt[data-v-e17ea971] { color: blue; }
.txt2[data-v-e17ea971] { color: orange; }

由于属性选择器的存在,每个组件的样式只作用于带有对应属性的元素,实现了完美的样式隔离。同时,子组件根元素拥有双重属性,使得父组件的样式能够有选择地影响子组件的最外层,保持了样式的可控性。

打开控制台元素,我们就可以看到

image.png

4.4 与 CSS Modules 的对比

Vue 的 scoped 与 React 的 CSS Modules 思路相似,都是通过给选择器附加唯一标识来实现作用域。区别在于:

  • CSS Modules 修改了类名本身,而 Vue 保留了原始类名,额外添加了属性选择器。
  • Vue 的 scoped 无需导入对象,直接在模板中使用原始类名,可读性更好。
  • CSS Modules 需要显式引用 styles 对象,略显繁琐,但胜在灵活(比如可以组合多个类名)。

4.5 原理浅析

Vue 在编译单文件组件时,会为每个组件生成一个唯一的哈希 ID。然后:

  1. 将模板中的所有元素加上该 ID 作为属性(根元素额外加上父组件的 ID,如果存在父组件)。
  2. 将 <style scoped> 中的每条 CSS 规则都加上对应的属性选择器。
  3. 最终生成带作用域的 CSS。

整个过程在构建阶段完成,没有运行时开销,性能极佳。

5. 对比与总结

方案 框架 实现原理 优点 缺点
CSS Modules React / Vue 编译时修改类名,生成哈希映射 静态样式,简单可靠;可与预处理器结合 类名需要引用,模板稍显啰嗦
styled-components React 运行时生成唯一类名,注入 <style> 动态样式能力强;完全组件化;支持 props 运行时开销;包体积较大;调试稍难
Vue scoped Vue 编译时添加唯一属性,属性选择器限制 语法简洁;无运行时开销;保留原始类名 仅适用于 Vue;深度选择器需特殊处理

如何选择?

  • 如果你的项目是 React,且偏好“传统”的 CSS 写法,CSS Modules 是最佳选择,它简单、高效,与设计工具(如 Figma)配合良好。
  • 如果你追求极致的动态样式和组件封装,或者希望将样式也作为组件逻辑的一部分,styled-components 能带来流畅的开发体验。
  • 对于 Vue 项目,scoped 是官方推荐的内置方案,开箱即用,足够满足绝大多数场景。

当然,这些方案并非互斥。在大型项目中,你可能会组合使用它们:用 CSS Modules 处理全局样式库,用 styled-components 处理高频复用的动态组件。重要的是理解每种方案的原理,以便在合适的场景做出正确的选择。

结语

从 CSS 的全局困境到组件样式的精细隔离,前端社区给出了多种优雅的解决方案。无论是 React 的 CSS Modules 和 styled-components,还是 Vue 的 scoped,它们都体现了“关注点分离”到“组件内聚”的思想演进。希望本文能帮助你更好地掌握这些工具,在项目中写出健壮、可维护的样式代码。如果你有更多关于样式隔离的思考或实践,欢迎在评论区交流讨论!

深入浅出 React 闭包陷阱:从现象到原理

深入浅出 React 闭包陷阱:从现象到原理

前言

React Hooks 的推出让函数组件焕发新生,我们可以用更简洁的代码实现状态和副作用。然而,Hooks 也带来了一些“坑”,其中 闭包陷阱 是初学者乃至有经验的开发者都容易遇到的问题。本文将从 JavaScript 闭包的基础出发,结合实际的 React 代码,一步步剖析闭包陷阱的成因、表现以及多种解决方案,帮助你彻底理解并避免它。

1. 什么是闭包?

在讨论 React 之前,我们必须先理解 JavaScript 中的闭包。闭包是指一个函数能够记住并访问它的词法作用域,即使该函数在其词法作用域之外执行。简单来说,闭包让你可以在一个内层函数中访问到外层函数的变量。

function outer() {
  let message = "Hello";
  function inner() {
    console.log(message); // inner 可以访问 outer 的变量
  }
  return inner;
}

const fn = outer();
fn(); // 输出 "Hello" —— 闭包使得 message 仍然可访问

闭包的形成需要两个条件:函数嵌套,且内部函数引用了外部函数的变量。当内部函数被返回或在其他地方被调用时,它依然持有对外部作用域的引用,这就是闭包。

2. React 函数组件中的闭包

React 函数组件每次渲染都会执行整个函数体,每次执行都会创建全新的局部变量和嵌套函数(如事件处理、useEffect 回调等)。这些嵌套函数会捕获当前渲染中的 props 和 state,形成闭包。

考虑一个简单的计数器组件:

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

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

每次渲染,handleClick 函数都是新创建的,它捕获的是本次渲染的 count 值。当用户点击按钮时,handleClick 中使用的 count点击发生时所处渲染的那一时刻的值,而不是最新的 state。这正是 React 正常工作的方式,也是每次渲染拥有独立 props 和 state 的体现。

3. 什么是闭包陷阱?

闭包陷阱(Closure Trap)通常指:在 useEffect、useCallback 等 Hook 中,由于依赖数组写得不正确,导致回调函数中捕获的是旧渲染中的状态值,从而引发 Bug

最常见的情景是在 useEffect 中启动一个定时器,并且依赖数组为空 [],期望定时器只运行一次,但定时器回调内部使用了外部的 state 或 props。由于空依赖的 Effect 只执行一次,回调函数捕获的是首次渲染时的值,后续更新后定时器依然使用旧值,导致“过期闭包”。

4. 代码演示:一个典型的闭包陷阱

来看一段示例代码:

import { useState, useEffect } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  // 打印此时的count值
  console.log("----------count:",count)

  // ❌ 闭包陷阱版本
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', count);
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 空依赖数组

  return (
    <>
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        count + 1
      </button>
    </>
  );
}

现象:点击按钮增加 count,页面显示的数字会更新,但控制台每隔一秒打印的 count 始终是 0,永远不会变化。

为什么?

  • 首次渲染时,count = 0useEffect 运行,创建定时器,定时器回调通过闭包捕获了本次渲染的 count 值(0)。
  • 点击按钮,setCount 触发重新渲染,count 变为 1。但由于依赖数组为空,useEffect 不会重新执行,定时器依然是旧的,其回调仍然持有旧的 count = 0
  • 于是每次定时器执行,都打印 0。

这就是典型的闭包陷阱:异步操作(定时器)引用了过时的状态。

效果图,可以看到尽管count已经加一,当时此时定时器打印的值仍为0

屏幕录制 2026-03-01 160044.gif

5. 原因深度剖析

要彻底理解这个问题,需要明白两件事:

5.1 每次渲染都有独立的“快照”

React 函数组件每次渲染就像是一次函数调用,参数是当前的 props 和 state。在某个特定渲染中,所有的变量(count、setCount 等)都是该渲染的常量。定时器回调是在未来某个时刻执行的,但它定义时的作用域是本次渲染,所以它捕获的是本次渲染的值。

5.2 Effect 的清理机制

useEffect 的返回函数(清理函数)会在组件卸载前执行,也会在每次 Effect 重新执行前执行(清理上一次的 Effect)。当依赖数组变化时,React 会先运行上一次的清理函数,再运行新的 Effect。

如果我们在依赖数组中包含 count

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // ✅ 依赖 count

那么每次 count 变化时,都会:

  1. 清理上一次的定时器。
  2. 重新创建新的定时器,新回调捕获最新的 count
  3. 控制台每次打印的值都是最新的。

这解决了闭包陷阱,但也意味着定时器会被频繁重置,可能不是我们想要的效果(比如我们想要一个持续运行的定时器,但能读取最新值)。

效果图,可以看到此时的count加一,定时器打印出的count也随之增加

屏幕录制 2026-03-01 160448.gif

6. 解决方案

6.1 在依赖数组中包含所有外部依赖

最简单直接的方法:将 Effect 中用到的所有响应式值(state、props)都放入依赖数组。这样每次值变化,Effect 都会重新执行,确保闭包总是新鲜的。

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]);

优点:简单、符合直觉。
缺点:如果依赖变化频繁,可能导致 Effect 频繁创建销毁,影响性能;某些场景(如定时器)可能并不希望被频繁重置。

6.2 使用 ref 保存最新值

useRef 返回一个可变对象,它的 current 属性在组件整个生命周期内保持不变,修改它不会触发重新渲染。我们可以利用 ref 来保存最新的状态,在异步回调中读取 ref.current。

const [count, setCount] = useState(0);
const countRef = useRef(count);

// 每次渲染后更新 ref 的值
useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', countRef.current); // ✅ 总是最新值
  }, 1000);
  return () => clearInterval(timer);
}, []); // 依赖数组为空,定时器不会重置

原理:ref 是一个容器,我们可以手动保持它与 state 同步。由于定时器回调通过闭包捕获的是 countRef 这个对象(而不是它的值),而对象引用不变,但 current 属性可以随时更新,因此总能访问到最新的 count。

优点:定时器只创建一次,不会因 count 变化而重启。
缺点:需要手动同步 ref 与 state(可以用一个 useEffect 来做),代码稍显啰嗦。

6.3 使用 useReducer 或函数式更新

如果定时器逻辑只需要基于当前 state 计算新值(而不需要直接读取 state 用于其他目的),可以使用 setState 的函数式更新形式,但这通常适用于更新 state 的场景,而不是读取。

例如:

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prevCount => prevCount + 1); // 基于前一个值更新
  }, 1000);
  return () => clearInterval(timer);
}, []);

这里我们不需要读取 count 的值,而是用函数式更新,因此没有闭包陷阱。但如果我们确实需要读取 count 做其他操作(比如打印),这种方法就不适用。

6.4 自定义 Hook 封装

对于常见场景,可以封装一个自定义 Hook 来简化 ref 方案。比如 useInterval

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

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

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

使用时:

useInterval(() => {
  console.log('Current count:', count);
}, 1000);

这个自定义 Hook 内部使用 ref 保存最新的回调,从而避免了闭包陷阱,且定时器不会因为依赖变化而重启(除非 delay 变化)。

7. useCallback 中的闭包陷阱

类似的问题也会出现在 useCallback 中。例如:

const handleClick = useCallback(() => {
  console.log(count); // 依赖 count
}, []); // 空依赖

handleClick 捕获了首次渲染的 count,后续无论 count 如何变化,handleClick 都不会更新,导致调用时总是旧值。

解决:在依赖数组中正确填写所有依赖。

const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

或者使用 ref 方案:

const countRef = useRef(count);
useEffect(() => {
  countRef.current = count;
}, [count]);

const handleClick = useCallback(() => {
  console.log(countRef.current);
}, []); // 依赖为空,但 ref 总是最新

8. 总结

React 闭包陷阱本质是函数式组件每次渲染的独立性异步操作持久化引用旧渲染环境之间的矛盾。理解闭包和 React 渲染机制是避免陷阱的关键。

最佳实践建议

  1. 遵守 Hooks 规则:useEffect、useCallback 等 Hook 的依赖数组必须包含所有外部依赖(即该 Effect 或回调中使用的所有 props、state 以及由它们衍生而来的值)。ESLint 插件 eslint-plugin-react-hooks 会帮助你自动检查依赖,建议开启。

  2. 合理选择解决方案

    • 如果 Effect 需要响应变化且重置成本低,直接添加依赖即可。
    • 如果 Effect 需要持久运行且必须读取最新值,考虑 ref 方案或封装自定义 Hook。
    • 对于定时器、事件监听等场景,优先考虑自定义 Hook(如 useIntervaluseEventListener)来统一处理。
  3. 理解闭包:编写 React 代码时,时刻提醒自己:函数组件每次渲染都是一次独立的“快照”,异步回调捕获的是定义时的快照。

最后,强烈推荐使用 React 官方提供的 ESLint 规则,它可以捕获绝大多数遗漏依赖的情况,是避免闭包陷阱的第一道防线。

希望本文能帮助你彻底掌握 React 闭包陷阱,从此写出更健壮的代码!

React性能优化:深入理解useMemo和useCallback

React性能优化:深入理解useMemo和useCallback

前言

在React函数组件中,每次状态更新都会导致整个组件函数重新执行。如果组件内部有复杂的计算或者传递了回调函数给子组件,可能会引发不必要的性能开销。React为我们提供了两个重要的Hook:useMemouseCallback,用于缓存计算结果和函数引用,从而优化组件性能。本文将从浅入深,结合实际代码,带你彻底理解这两个Hook的使用场景和原理。


1. 性能优化的必要性

我们先看一个简单的例子:

function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');

  const list = ['apple', 'banana', 'orange', 'pear'];

  // 每次渲染都会重新执行filter
  const filterList = list.filter(item => {
  // 测试fliter是否执行
  console.log("filter执行了")
  return item.includes(keyword) 
  });

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={() => setCount(count + 1)}>count: {count}</button>
      <ul>
        {filterList.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

这里 filterList 依赖于 keyword,但每次 count 改变导致组件重新渲染时,filterList 也会重新计算。如果 list 很大或者过滤逻辑复杂,这种不必要的计算就会影响性能。同样,如果我们将一个函数作为 prop 传递给子组件,每次父组件渲染都会生成一个新函数,导致子组件(即使使用 React.memo)也无法避免重新渲染。这正是 useMemouseCallback 要解决的问题。

效果图

屏幕录制 2026-03-01 154604.gif

2. useMemo:缓存计算结果

useMemo 用于缓存一个计算后的值,只有当依赖项发生变化时,才会重新计算。

2.1 基本用法

const cachedValue = useMemo(computeFn, dependencies);
  • computeFn:纯函数,返回需要缓存的值。
  • dependencies:依赖项数组,当任意依赖项变化时,重新执行 computeFn

2.2 优化列表过滤

改进上面的例子:

import { useState, useMemo } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');
  const list = ['apple', 'banana', 'orange', 'pear'];

  // 只有 keyword 变化时才重新过滤
  const filterList = useMemo(() => {
    console.log('filter执行了'); // 依赖变化时打印
    return list.filter(item => item.includes(keyword));
  }, [keyword]);

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={() => setCount(count + 1)}>count: {count}</button>
      <ul>
        {filterList.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

现在,点击 count 按钮不会触发 filter 重新执行,只有输入框改变时才会。

效果图

屏幕录制 2026-03-01 154745.gif

2.3 缓存昂贵计算

假设有一个非常耗时的函数 slowSum

function slowSum(n) {
  console.log('计算中...');
  let sum = 0;
  for (let i = 0; i < n * 1000000; i++) {
    sum += i;
  }
  return sum;
}

我们可以用 useMemo 将其结果缓存起来:

const [num, setNum] = useState(0);
const result = useMemo(() => slowSum(num), [num]);

这样只有当 num 变化时才会重新计算,其他状态变化不会触发 slowSum,这样做我们就可以节约成本。

2.4 小结

  • useMemo 适用于需要缓存计算结果的场景,比如过滤、排序、复杂数学运算等。
  • 依赖数组要正确填写,避免遗漏或多余依赖。
  • 不要滥用,如果计算本身不昂贵,就没有必要缓存。

3. useCallback:缓存函数引用

在React中,函数组件每次渲染都会重新创建内部定义的函数。如果这个函数作为 prop 传递给子组件,即使子组件使用了 React.memo,也会因为每次父组件传递的函数引用不同而导致子组件重新渲染。useCallback 就是用来缓存函数引用的。

3.1 基本用法

const cachedFn = useCallback(fn, dependencies);
  • fn:需要缓存的函数。
  • dependencies:依赖项数组,当依赖变化时,重新创建函数。

3.2 配合 React.memo 优化子组件

先看一个没有优化的例子:

import { useState, memo } from 'react';

const Child = memo(({ count, handleClick }) => {
  console.log('子组件渲染了');
  return <div onClick={handleClick}>子组件 count: {count}</div>;
});

export default function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);

  // 每次 App 渲染,这里都会生成一个新函数
  const handleClick = () => {
    console.log('点击了');
  };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>count+1</button>
      <button onClick={() => setNum(num + 1)}>num+1</button>
      <Child count={count} handleClick={handleClick} />
    </div>
  );
}

虽然 Childmemo 包裹,理论上只有 counthandleClick 变化时才会重新渲染。但由于 handleClick 每次都是新函数,memo 浅比较发现 handleClick 引用变了,导致子组件也会重新渲染。即使点击的是 num 按钮,子组件依然会渲染。

效果图,可以看到当我们点击nums组件时,我们的子组件依然会执行

屏幕录制 2026-03-01 155203.gif

使用 useCallback 缓存 handleClick

const handleClick = useCallback(() => {
  console.log('点击了');
}, []); // 空依赖表示函数永远不会重新创建

现在点击 num 按钮,handleClick 引用不变,子组件就不会重新渲染。

效果图,可以看到这个适合当我们点击nums组件,子组件就不会执行了

屏幕录制 2026-03-01 155340.gif

3.3 依赖项的作用

如果回调函数内部使用了某些状态,需要将这些状态添加到依赖数组中,否则函数内部会一直使用旧的闭包值。

const handleClick = useCallback(() => {
  console.log('当前count:', count);
}, [count]); // 当 count 变化时,重新生成函数

这样既能保证函数引用在 count 不变时稳定,又能在 count 变化时获取最新值。

3.4 小结

  • useCallback 主要用于将回调函数传递给经过 memo 优化的子组件,避免不必要的重绘。
  • 如果回调函数不依赖任何组件状态,依赖数组可以为空。
  • 注意不要过度优化,如果子组件很轻量或者渲染成本很低,不一定需要使用 useCallback

4. useMemo 与 useCallback 的关系

  • useMemo 缓存的是useCallback 缓存的是函数
  • 实际上,useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
  • 两者都是通过闭包和依赖追踪来实现缓存,目的都是减少不必要的计算或渲染。

5. 常见误区与注意事项

5.1 依赖数组要完整

无论是 useMemo 还是 useCallback,都要确保依赖数组中包含了所有在回调/计算中使用的响应式值(props、state、context等)。否则会因闭包捕获旧值而产生 bug。可以使用 eslint-plugin-react-hooks 自动检查依赖。

5.2 不要过早优化

只有在确实存在性能瓶颈时才使用这两个 Hook。滥用会增加代码复杂度,且缓存本身也有开销。可以通过 React DevTools 的 Profiler 来识别需要优化的组件。

5.3 缓存稳定但非“纯”的函数

如果 useCallback 的回调函数依赖于外部状态,但依赖数组为空,则函数内部的变量会一直保持初始值,可能导致 bug。一定要根据实际依赖填写数组。


6. 总结

  • useMemo 用于缓存计算值,避免每次渲染都重新执行昂贵计算。
  • useCallback 用于缓存函数引用,配合 React.memo 避免子组件不必要的重新渲染。
  • 两者都需要指定依赖项,确保缓存内容在依赖变化时更新。
  • 性能优化应当有的放矢,先测量后优化,不要盲目使用。

通过合理使用这两个 Hook,我们可以让 React 应用在复杂场景下依然保持流畅。希望本文能帮助你深入理解 useMemouseCallback,并在实际项目中正确应用它们。


本文代码示例基于 React 18,你可以在自己的项目中尝试并观察效果。如果有任何疑问或见解,欢迎在评论区讨论!

从入门到进阶:手写React自定义Hooks,让你的组件更简洁

从入门到进阶:手写React自定义Hooks,让你的组件更简洁

大家好,今天我们来聊聊React中非常实用的自定义Hooks。通过两个实际例子(鼠标位置追踪和Todo待办应用),带你从零开始封装自己的Hooks,彻底理解“逻辑复用”的魅力,并掌握如何避免常见的内存泄漏问题。

什么是Hooks?

Hooks是React 16.8引入的一种函数式编程思想,它让我们在函数组件中使用状态和生命周期等特性。React内置了useStateuseEffect等基础Hooks,而自定义Hooks则是将组件逻辑提取到可复用的函数中,以use开头,内部可以调用其他Hooks。

自定义Hooks的好处

  • 复用状态逻辑,避免重复代码
  • 让UI组件更纯粹,只关注渲染
  • 便于团队维护和共享核心逻辑

第一部分:不使用自定义Hooks实现鼠标追踪(并理解内存泄漏)

我们先从一个简单的需求开始:实时显示鼠标在页面上的位置。直接在App组件里写逻辑。

直接在组件内实现

新建App.jsx,代码如下:

import { useState, useEffect } from 'react';

function MouseMove() {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  useEffect(() => {
    // 鼠标移动时的更新函数
    const updateMouse = (e) => {
      setX(e.clientX);
      setY(e.clientY);
    };

    // 监听 mousemove 事件
    window.addEventListener('mousemove', updateMouse);
    console.log('添加事件监听');

    // 清理函数:组件卸载时移除监听,防止内存泄漏
    return () => {
      window.removeEventListener('mousemove', updateMouse);
      console.log('移除事件监听');
    };
  }, []); // 空依赖数组,只在挂载时执行一次

  return (
    <div>
      鼠标位置:{x} , {y}
    </div>
  );
}

export default function App() {
  const [count, setCount] = useState(0);

  return (
    <>
    {count}
      <button onClick={() => setCount(count => count + 1)}>
        // 点击重新挂载函数
        增加
      </button>
      {count % 2 === 0 && <MouseMove />}
    </>
  );
}

代码解析

  • useState 用来存储鼠标坐标,初始值为 0
  • useEffect 接受两个参数:第一个是副作用函数,第二个是依赖数组。这里依赖数组为 [],表示副作用只在组件挂载时执行一次,不会在每次渲染后重复执行。
  • 在副作用函数中,我们定义了 updateMouse,并通过 addEventListener 注册了 mousemove 事件。
  • 重点useEffect 可以返回一个清理函数,React 会在组件卸载时调用它。我们在清理函数中移除了事件监听,这是避免内存泄漏的关键。
⚠ 如果不清理会发生什么?

想象一下:如果组件卸载时没有移除 mousemove 监听,那么 updateMouse 函数仍然存在于内存中,并且每次鼠标移动都会尝试调用 setXsetY。但此时组件已经被销毁,这些 setState 调用是无意义的,并且会导致内存泄漏——事件处理函数持有对组件作用域的引用,垃圾回收无法释放相关内存。长时间运行的应用可能会因此变得卡顿甚至崩溃。

验证方法:注释掉 return () => {...} 这一部分,然后反复点击按钮让 MouseMove 组件挂载/卸载,观察控制台。你会发现即使组件卸载了,鼠标移动时控制台依然打印“添加事件监听”(实际上并没有重新添加,但之前添加的监听还在),说明事件处理函数依然存活。这就是内存泄漏的表现。

没清理的效果图

我们看到就算函数已经卸载,事件依然会执行 屏幕录制 2026-02-28 202959.gif

清理后的效果图

可以看到,函数卸载后,事件不会执行了 屏幕录制 2026-02-28 203448.gif

第二部分:提取自定义Hook useMouse

我们把鼠标追踪的逻辑封装成一个独立的Hook,放在 hooks/useMouse.js 中。

创建 useMouse.js

import { useState, useEffect } from 'react';

/**
 * 自定义 Hook:追踪鼠标在页面上的位置
 * @returns {{ x: number, y: number }} 包含当前鼠标坐标的对象
 */
export const useMouse = () => {
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  useEffect(() => {
    /** @param {MouseEvent} e 原生鼠标事件对象 */
    const update = (e) => {
      setX(e.clientX);
      setY(e.clientY);
    };

    window.addEventListener('mousemove', update);
    console.log('useMouse: 添加监听');

    // 清理函数:组件卸载时移除监听
    return () => {
      window.removeEventListener('mousemove', update);
      console.log('useMouse: 移除监听');
    };
  }, []); // 依赖数组为空,保证只在挂载时执行一次

  return { x, y };
};

API 详细解释

  • useMouse 是一个自定义 Hook,它内部使用了 React 的 useStateuseEffect
  • 返回值:一个包含 xy 的对象,类型均为 number,表示当前鼠标坐标。
  • 副作用:在组件挂载时添加 mousemove 监听,卸载时移除。这里的清理逻辑与前面相同,但被封装在 Hook 内部,任何使用 useMouse 的组件都会自动获得正确的生命周期管理,无需重复编写清理代码。

在组件中使用 useMouse

现在改造 App.jsx,引入 useMouse

import { useState } from 'react';
import { useMouse } from './hooks/useMouse';

function MouseMove() {
  const { x, y } = useMouse(); // 一行代码搞定!

  return (
    <div>
      鼠标位置:{x} , {y}
    </div>
  );
}

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>
        点击重新挂载 MouseMove 组件
      </button>
      {count % 2 === 0 && <MouseMove />}
    </>
  );
}

现在 MouseMove 组件变得非常简洁,只负责渲染,逻辑全在 useMouse 中。如果其他地方也需要鼠标位置,直接调用 useMouse 即可,真正做到了“一次封装,多处复用”。

第三部分:使用自定义Hooks实现Todo应用(带本地存储)

接下来,我们实现一个更复杂的例子:带本地存储的Todo待办应用。我们将创建一个 useTodos Hook,封装所有todos的状态管理和持久化。

1. 编写 useTodos.js

这个Hook负责:

  • 管理todos数组(增、删、改完成状态)
  • 自动同步到localStorage,实现数据持久化
import { useState, useEffect } from 'react';

const STORAGE_KEY = 'todos'; // 本地存储的键名,统一管理便于维护

/**
 * 从 localStorage 加载待办数据
 * @returns {Array} 存储的待办数组,如果没有则返回空数组
 */
function loadFromStorage() {
  const storedTodos = localStorage.getItem(STORAGE_KEY);
  return storedTodos ? JSON.parse(storedTodos) : [];
}

/**
 * 将待办数据保存到 localStorage
 * @param {Array} todos - 待办数组
 */
function saveToStorage(todos) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}

/**
 * 自定义 Hook:管理待办事项的所有逻辑(增删改、本地存储同步)
 * @returns {{
 *   todos: Array,
 *   addTodo: (text: string) => void,
 *   toggleTodo: (id: number|string) => void,
 *   deleteTodo: (id: number|string) => void
 * }}
 */
export const useTodos = () => {
  // 使用函数形式初始化,避免每次渲染都重新读取 localStorage
  const [todos, setTodos] = useState(loadFromStorage);

  // 每当 todos 变化,自动保存到 localStorage
  useEffect(() => {
    saveToStorage(todos);
  }, [todos]); // 依赖 todos,只有 todos 变化时才执行

  /**
   * 添加新待办
   * @param {string} text - 待办内容
   */
  const addTodo = (text) => {
    setTodos([
      ...todos,
      {
        id: Date.now(),      // 简单用时间戳作为临时唯一 ID(仅用于演示)
        text,
        completed: false
      }
    ]);
  };

  /**
   * 切换指定待办的完成状态
   * @param {number|string} id - 待办项的 ID
   */
  const toggleTodo = (id) => {
    setTodos(
      todos.map(todo =>
        todo.id === id
          ? { ...todo, completed: !todo.completed } // 切换状态,保持其他属性不变
          : todo
      )
    );
  };

  /**
   * 删除指定待办
   * @param {number|string} id - 待办项的 ID
   */
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  // 返回所有状态和操作
  return {
    todos,
    addTodo,
    toggleTodo,
    deleteTodo
  };
};

API 详细解释

  • loadFromStorage:内部辅助函数,用于读取本地存储,返回待办数组或空数组。
  • saveToStorage:内部辅助函数,将待办数组序列化后存入本地存储。
  • useTodos 返回值:
    • todos:当前待办列表,每个待办对象包含 id(number/string)、text(string)、completed(boolean)。
    • addTodo(text):接收待办文本,创建一个新待办(id为当前时间戳,completed为false),并更新状态。
    • toggleTodo(id):接收待办id,遍历todos,找到对应项并反转其 completed 属性。
    • deleteTodo(id):接收待办id,过滤掉该项,更新状态。

关于内存泄漏的再次提醒:虽然本Hook中没有显式的事件监听或定时器,但 useEffect 依赖 [todos],会在每次 todos 变化后执行保存操作。这里没有清理函数的必要,因为保存操作是安全的。但如果我们在 useEffect 中启动了定时器或订阅了外部事件,就必须返回清理函数。

2. 编写UI组件(每个组件都带有详细API注释)

TodoInput.jsx - 输入框
import { useState } from 'react';

/**
 * 待办输入表单组件
 * @param {Object} props
 * @param {Function} props.onAddTodo - 添加待办的回调函数,接收待办文本作为参数
 */
export default function TodoInput({ onAddTodo }) {
  const [text, setText] = useState('');

  /**
   * 表单提交处理函数
   * @param {Event} e - 表单提交事件对象
   */
  const handleSubmit = (e) => {
    e.preventDefault(); // 阻止页面刷新
    const trimmedText = text.trim();
    if (!trimmedText) return; // 输入为空时不添加

    onAddTodo(trimmedText); // 调用父组件传递的添加函数
    setText(''); // 清空输入框
  };

  return (
    <form className="todo-input" onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="输入待办事项..."
      />
    </form>
  );
}
TodoItem.jsx - 单个待办项
/**
 * 单个待办项组件
 * @param {Object} props
 * @param {Object} props.todo - 待办对象 { id, text, completed }
 * @param {Function} props.onDeleteTodo - 删除待办的回调,接收待办 id
 * @param {Function} props.onToggleTodo - 切换完成状态的回调,接收待办 id
 */
export default function TodoItem({ todo, onDeleteTodo, onToggleTodo }) {
  return (
    <li className="todo-item">
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggleTodo(todo.id)}
      />
      <span className={todo.completed ? 'completed' : ''}>
        {todo.text}
      </span>
      <button onClick={() => onDeleteTodo(todo.id)}>删除</button>
    </li>
  );
}
TodoList.jsx - 待办列表
import TodoItem from './TodoItem';

/**
 * 待办列表组件,渲染所有待办项
 * @param {Object} props
 * @param {Array} props.todos - 待办数组
 * @param {Function} props.onDeleteTodo - 删除待办的回调
 * @param {Function} props.onToggleTodo - 切换完成状态的回调
 */
export default function TodoList({ todos, onDeleteTodo, onToggleTodo }) {
  return (
    <ul className="todo-list">
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onDeleteTodo={onDeleteTodo}
          onToggleTodo={onToggleTodo}
        />
      ))}
    </ul>
  );
}

3. 在 App.jsx 中组装

import { useTodos } from './hooks/useTodos';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';

export default function App() {
  // 直接使用自定义Hook,获取所有状态和方法
  const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();

  return (
    <>
      <h1>Todo 待办清单</h1>
      <TodoInput onAddTodo={addTodo} />
      {todos.length > 0 ? (
        <TodoList
          todos={todos}
          onDeleteTodo={deleteTodo}
          onToggleTodo={toggleTodo}
        />
      ) : (
        <div>暂无待办事项,添加一条吧~</div>
      )}
    </>
  );
}

效果

  • 添加待办:输入文本,回车提交。
  • 勾选复选框切换完成状态,文字样式变化(可自行添加CSS,比如加删除线)。
  • 点击删除按钮移除该项。
  • 刷新页面,数据依然存在,因为已经同步到localStorage。
效果图

屏幕录制 2026-02-28 203724.gif

第四部分:深入理解内存泄漏与清理的必要性

在React函数组件中,useEffect 是处理副作用的主要场所。常见的副作用包括:

  • 订阅外部事件(如 mousemoveresize、WebSocket)
  • 设置定时器(setIntervalsetTimeout
  • 手动修改DOM
  • 数据请求(虽然请求本身不需要清理,但需要处理竞态)

所有这些副作用,如果在组件卸载后没有正确清理,都会导致内存泄漏。例如:

  • 事件监听:组件卸载后,事件处理函数仍然被全局对象(如 window)引用,导致组件内部的状态变量和函数无法被垃圾回收。
  • 定时器:即使组件卸载,定时器仍然会周期性触发回调,如果回调中使用了 setState,会报“在未挂载的组件上调用setState”的警告,并且造成内存泄漏。
  • 订阅:类似于事件监听,必须取消订阅。

如何避免?useEffect 中返回一个清理函数,React 会在组件卸载前和执行下一次副作用前调用它。这个清理函数应该:

  • 移除事件监听
  • 清除定时器
  • 取消订阅
  • 中止请求(如果支持)

示例:错误的写法(导致内存泄漏)

useEffect(() => {
  const timer = setInterval(() => {
    console.log('定时器执行');
  }, 1000);
  // 没有返回清理函数!
}, []);

组件卸载后,定时器依然运行,回调中的代码可能访问已经不存在的组件状态。

正确的写法

useEffect(() => {
  const timer = setInterval(() => {
    console.log('定时器执行');
  }, 1000);
  return () => clearInterval(timer); // 清理定时器
}, []);

在我们的 useMouse 例子中,我们正是通过返回清理函数来移除事件监听,确保了无论组件如何挂载/卸载,都不会留下残留的监听器。

总结

通过两个例子,我们见证了自定义Hooks的强大:

  1. useMouse:将副作用(事件监听)和状态封装起来,组件只需调用并渲染,同时自动处理了内存泄漏的清理逻辑。
  2. useTodos:不仅管理状态,还集成了本地存储持久化,让UI组件完全无感。

自定义Hooks让我们能够像搭积木一样组合逻辑,保持组件简洁,提升代码复用性。在实际项目中,你可以根据自己的业务封装更多通用Hooks,比如 useLocalStorageuseFetchuseWindowSize 等。

最后请记住:每当你在 useEffect 中引入持续性的副作用(事件、定时器、订阅),务必返回一个清理函数,这是React函数组件中防止内存泄漏的基本准则。

希望这篇文章能帮你打开自定义Hooks的大门,快去动手试试吧!

❌