Hook开发的好处
更灵活的状态和副作用管理
在传统的类组件中,状态的管理和生命周期方法紧密耦合在一起。这种耦合可能导致代码逻辑在不同生命周期方法之间分散,难以理解和维护。当组件逻辑变得复杂时,这种分散的状态和生命周期方法会增加代码的复杂性。
在使用 Hook 时,你可以在函数组件内部使用多个 useState 和 useEffect。每个 useState 管理一个特定的状态,而 useEffect 可以处理特定的副作用。这种分离使得每部分逻辑更加独立,易于理解和维护。
提高组件的可复用性
使用 Hook 可以将状态逻辑抽离到自定义 Hook 中,使得状态逻辑更易于复用。例如,你可以创建一个 useFetch Hook 用于处理数据获取逻辑,然后在多个组件中重复使用,而不需要每次都编写相似的逻辑。
更好的性能优化和逻辑封装
Hook 可以帮助 React 进行性能优化。例如,useMemo 和 useCallback 可以用于缓存计算结果,useMemo 可以在依赖不变的情况下避免不必要的重新计算,useCallback 可以避免函数的重复创建。
Hook开发注意事项
- 仅在 React 函数组件或自定义 Hook 中调用 Hooks,确保在函数组件的顶层使用 Hook,不要在循环、条件语句或嵌套函数中使用。
- 不要在普通的 JavaScript 函数中使用 Hook。
- 自定义 Hook 的名称必须以 use 开头,这是 React 的约定,它告诉开发者这是一个自定义 Hook。
useState
用法
useState 是 React Hooks 中的一个核心 Hook,用于在函数组件中添加状态。它返回一个状态变量和一个更新该状态的函数,通常用一个数组接收:
在这个例子中,通过 useState 定义了一个名为 count 的状态变量,初始值为 0。useState 返回一个数组,第一个元素是当前的状态值(在这里是 count),第二个元素是更新该状态的函数(在这里是 setCount)。当按钮被点击时,我们调用 setCount 函数来更新 count 的状态,使其加一。
javascript
import React, { useState } from 'react';
function ExampleComponent() {
// 定义一个名为 count 的状态变量,初始值为 0
const [count, setCount] = useState(0);
// 在函数组件中使用 count 状态变量和 setCount 函数
return (
<div>
<p>You clicked {count} times</p>
{/* 当按钮被点击时,调用 setCount 函数更新 count 状态 */}
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
useState 还可以接受一个函数作为参数,当初始化组件时,React 将调用初始化函数,并将其返回值存储为初始状态。
useEffect
副作用
React中副作用是指不直接与组件渲染相关的操作,比如数据获取、订阅、手动操作DOM。举个例子,当一个组件需要从服务器获取数据时,这个数据请求就是一个副作用。这个副作用可能会在组件渲染时触发,但它不是直接与渲染UI相关的。
Effect基本用法
useEffect 是 允许在函数组件中执行副作用操作,比如数据获取、订阅、或者手动操作 DOM。useEffect 在组件渲染到屏幕上或组件重新渲染时都会执行。
useEffect 接受两个参数:一个包含副作用操作的函数和一个依赖数组(可选)。在函数组件的每次渲染周期中,React 都会在执行完所有的 DOM 变更之后运行 useEffect 中的函数
在下面例子中,useEffect 用于更新页面标题,它接受一个函数作为第一个参数,在这个函数内部进行了副作用操作。useEffect 中的函数在每次组件渲染之后都会执行。
第二个参数是依赖数组,它指定了 useEffect 的依赖项。当依赖项发生变化时,useEffect 会被重新执行。在上面的例子中,我们传入了 [count],所以当 count 发生变化时,useEffect 会重新运行。
javascript
import React, { useEffect, useState } from 'react';
function ExampleComponent() {
const [count, setCount] = useState(0);
// 使用 useEffect 进行副作用操作
useEffect(() => {
document.title = `You clicked ${count} times`; // 更新页面标题
// 进行其他副作用操作,例如数据获取、订阅等
// 清理函数(可选),用于清理副作用
return () => {
// 在组件卸载或依赖项变化时执行清理操作
// 例如取消订阅、清除定时器等
};
}, [count]); // 依赖数组中的变量,当其中的变量发生变化时,useEffect 会重新运行
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
使用useEffect可以确保副作用操作不会影响到组件的渲染性能,而且在组件卸载或者依赖项发生变化时,可以在useEffect中返回一个清理函数,用于清理副作用。
清除Effect
使用 useEffect Hook 可以处理副作用操作。有时,当组件卸载或依赖项发生变化时,希望清理之前设置的副作用,以免引发内存泄漏或其他不必要的问题。React 的 useEffect 提供了清除 Effect 的机制。
在 useEffect 中,可以返回一个清理函数,它会在组件卸载时(或者依赖项发生变化时)执行。这个清理函数用于清除之前设置的副作用,比如取消订阅、清除定时器等。
在这个例子中,useEffect 用于监听Redux数据的变化。当组件渲染到屏幕上后,useEffect 中的回调函数会被执行。在这里,它输出 "监听redux数据变化"。同时,useEffect 的返回函数会在组件卸载时执行,可以在这里执行取消监听的相关操作。
javascript
import React, { memo, useEffect, useState } from "react";
const App = memo(() => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("监听redux数据变化");
// Effect 返回清除函数:组件重新渲染或组件卸载时执行,清除副作用
return () => {
console.log("取消redux中数据监听");
};
});
return (
<div>
<h3>计数:{count}</h3>
<button onClick={(e) => setCount(count + 1)}>+1</button>
</div>
);
});
export default App;
清除 Effect 的机制有助于确保在组件销毁时进行资源清理,避免内存泄漏和不必要的性能损耗。
多个Effect
在开发函数组件时,内部往往包含多个用途的逻辑代码 ,开发者可以同时使用多个 useEffect。每个 useEffect 包含特定用途的副作用逻辑。当在组件内使用多个 useEffect 时,React 会按照它们在代码中的顺序依次调用每个 useEffect。
这种特性使得将不同的副作用逻辑分离并组织得更好。例如,一个 useEffect 可能用于数据获取,另一个可能用于订阅外部事件。
举个例子,考虑一个在线聊天应用的组件。可以通过其中一个useEffect 订阅聊天消息,另一个 useEffect 检查用户是否在线,还有一个 useEffect 用于定时发送心跳包以保持连接。这样,你可以在组件内部根据逻辑关系将不同的副作用操作分离开来,使得代码更加清晰易读。
例如下面例子,可以考虑将不同的副作用逻辑分离在多个Effect Hook当中。
javascript
import React, { memo, useEffect, useState } from "react";
const App = memo(() => {
const [count, setCount] = useState(10);
useEffect(() => {
console.log("1.进行网络请求");
});
useEffect(() => {
console.log("2.进行事件监听");
return () => {
// 取消事件监听
};
});
useEffect(() => {
console.log("3.执行数据处理逻辑");
});
return (
<div>
<h2>计数:{count}</h2>
<button onClick={(e) => setCount(count + 1)}>+1</button>
</div>
);
});
export default App;
Effect执行机制
默认情况下,useEffect的回调函数在每次渲染时都会重新执行,然而很多副作用(例如网络请求)只希望执行一次,所以可以通过useEffect的第二个参数依赖数组来控制Effect的执行。
因此如果希望在组件第一次渲染时执行某个副作用操作(比如网络请求),但在后续的渲染中不再执行,可以将依赖项数组设置为空数组 []。这样,useEffect 就只会在组件第一次渲染时执行,之后就不再执行。
scss
useEffect(() => {
// 这里的副作用操作只在组件第一次渲染时执行
fetchData();
}, []); // 传递空数组作为依赖项
此外,如果希望某个副作用仅仅在依赖的数据发生变化时才执行,则可以为依赖数组内添加具体的值
scss
useEffect(() => {
// ...
}, [count, title]); // 如果 count 或 title 发生变化则会再次运行
useContext
在类组件中,可以通过类名.contextType = xxxContext的方式在类组件中获取context,例如如下案例:
通过HomeInfo.contextType = ThemeContext;,可以在组件中使用ThemeContext中的值,下方是类组件中Context的使用方式
scala
export class HomeInfo extends Component {
render() {
// log打印出使用Context传递的数据
console.log("context", this.context);
return (
<div>
HomeInfo: {this.context.color}
</div>
);
}
}
// 3.设置组件的contextType为某一个具体的Context
HomeInfo.contextType = ThemeContext;
export default HomeInfo;
然而在函数式组件中,可以通过useContexthook来访问context的值
1.创建上下文,使用 React.createContext() 创建上下文对象
javascript
import { createContext } from "react";
const UserContext = createContext()
const ThemeContext = createContext()
export {
UserContext,
ThemeContext
}
在某个组件的父组件中使用 MyContext.Provider 来提供context的值 。在子组件中,可以使用 useContext Hook 来获取上下文的值:
ini
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<UserContext.Provider value={{ name: "weihy", school: "ustb" }}>
<ThemeContext.Provider value={{ color: "orange", size: "22px" }}>
<Provider store={store}>
<App />
</Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
如下,通过useContext来直接在函数式组件中获取context的值。
从UserContext和ThemeContexd中分别获取了当前用户信息和主题样式。这些上下文对象应该是在组件的上层组件中(通常是在应用的根组件中)通过<UserContext.Provider>和<ThemeContext.Provider>提供的。
ini
const App = memo(() => {
const user = useContext(UserContext);
const theme = useContext(ThemeContext);
return (
<div>
<h2>
name: {user.name} school: {user.school}
</h2>
<div style={{ fontSize: theme.size, color: theme.color }}>文字样式</div>
</div>
);
});
最终运行效果如下

需要注意的是
- 如果一个组件使用了
useContext(),那么相应的<Context.Provider>必须在这个组件的上层,确保useContext()能够找到相应的context提供者。 memo不影响新的上下文值的传递:即使在组件内使用了 memo,当上下文提供的值变化时,使用了这个上下文的子组件仍然会接收到新的上下文值。memo只是帮助组件避免不必要的渲染,但并不影响新的上下文值的传递。useContext返回的值是实时的,即使上下文的值发生变化,useContext 获取的值也会随之更新。当上下文的值发生变化时,React 会自动重新渲染使用了该上下文的组件,确保它们使用的上下文值是最新的。- 如果在组件的上层没有匹配的
Provider,那么useContext(SomeContext)将返回在创建上下文时通过createContext函数传递的默认值。
useReducer
用法
useReducer 是 React Hooks 中的一个 Hook,用于处理具有复杂状态逻辑的组件。它是useState的替代方案。与useState的不同之处在于,它可以在组件外部定义一个 reducer 函数,该函数描述了状态如何更新。然后,将这个 reducer 函数传递给 useReducer,在组件内部使用返回的 dispatch 函数来触发状态的更新。
scss
const [state, dispatch] = useReducer(reducer, initialState);
reducer是一个函数,它接收当前的 state 和一个 action,并返回新的 state。action 是一个描述发生了什么事情的对象,它通常包含一个 type 属性,用于表示操作的类型,以及可选的 payload 属性,用于携带操作所需的数据。initialState是状态的初始值。state是当前的状态值。dispatch是一个触发操作的函数,它用于发送一个 action,触发 reducer 的执行,从而更新 state。
案例
下列代码演示了如何使用 useReducer 处理状态逻辑,通过 reducer 函数控制状态的变化。当点击按钮时,会触发 increment 操作,使得 count 的值逐渐增加,但不会超过指定的最大值 10,也不会小于最小值 1。
const [count, dispatch] = useReducer(reducer, 5);:使用 useReducer 创建了一个名为 count 的状态变量,初始值为 5,并且提供了上面定义的 reducer 函数来处理状态更新。dispatch 是一个触发状态更新的函数。
function reducer(state, action) { ... }:定义了一个 reducer 函数,该函数接收两个参数:state(当前状态)和 action(触发的动作)。根据 action.type 的不同,它决定了如何更新状态。
javascript
function reduer(state, action) {
switch (action.type) {
case "increment":
return state < action.max ? state + 1 : action.min;
default:
return state;
}
}
const App = memo(() => {
// 初始值设置为5
const [count, dispatch] = useReducer(reduer, 5);
return (
<div>
<h2>点击{count}次</h2>
<button onClick={(e) => dispatch({ type: "increment", max: 10, min: 1 })}>
点击
</button>
</div>
);
});
useReducer 提供了一种更结构化的方式来管理和更新组件状态,尤其适用于需要处理复杂状态逻辑或多步操作的场景。
需要注意的是:
dispatch函数是为下一次渲染而更新 state:当你调用 dispatch 函数时,它并不会立即更新状态,而是为下一次组件的渲染更新状态。因此,在调用 dispatch 函数后立即读取 state 并不会得到更新后的值,而是获取到调用 dispatch 函数之前的状态。- 相同的新值会跳过重新渲染:如果提供的新值(在 dispatch 中传入的值)与当前的 state 相同(使用 Object.is 比较),React 会跳过组件和子组件的重新渲染。这是一种优化手段,避免不必要的渲染操作,提高性能。
useCallback
useCallback 用于记忆化函数,以便在依赖项不变的情况下,避免函数的重复创建。通常用于性能优化,特别是在将函数作为 prop 传递给子组件时。
ini
const memoizedCallback = useCallback(() => {
// 这里放置你的回调函数的逻辑
}, [依赖数组]);
- 第一个参数是回调函数,即希望记忆化的函数
- 第二个参数是依赖数组。当这个数组中的任意一个依赖项发生变化时,useCallback 返回的函数就会被重新创建。如果依赖项数组为空
[],则表示回调函数永远不会重新创建。
闭包陷阱问题
在计数器中有如下案例,为button按钮绑定increment事件,点击加一。但是执行一次+1后继续点击按钮,count数值不会变大

ini
const increment = useCallback(function () {
setCount(count + 1);
}, []);
这是因为useCallback 的依赖项是一个空数组 [],这表示 increment 函数在整个组件的生命周期内只会创建一次 ,不会因为任何状态或属性的变化而重新创建。在这种情况下,count 并不是 useCallback 的依赖项,所以 increment 函数内部引用的 count 始终是创建时的那个值。
因为 increment 函数被闭包捕获了创建时的 count 的值,而不是动态获取的最新值,所以无论 count 发生多少次变化,increment 函数内部的 count 始终保持最初创建时的值,这就是闭包陷阱。
基本案例1.将函数传递给子组件时
默认情况下,当一个组件重新渲染时, React 将递归渲染它的所有子组件。当需要将一个回调函数传递给子组件时,如果该回调函数在每次渲染时都重新创建,可能会导致子组件不必要的重新渲染。
以具体的例子说明,现在拥有父组件App以及子组件Home,为子组件传递一个函数increment,Home中的button按钮绑定了这一事件,可以让App组件的count+1。
Home组件构成如下:
javascript
const Home = memo((props) => {
const { increment } = props;
console.log("Home render");
return (
<div>
<button onClick={increment}>Home组件+1</button>
</div>
);
});
同时父组件还展示message,通过父组件的按钮也可以对message进行更新

现在如果为Home组件传递的increment函数是一个普通的函数(无记忆化版本),
ini
const increment = () => setCount(count + 1);
那么在点击更新message按钮 时,页面的message发生变化,会触发App组件页面重新渲染。那么会重新生成increment函数,子组件传入的参数发生变化,因此Home组件也会重新渲染。
幸运的是,我们的Home组件不含有大量计算的操作,因此不会造成很大的影响。然而如果Home组件重新渲染需要花费很长时间的话,那么更改message导致Home组件渲染是非常不值当的。

因此可以考虑在为子组件传递函数时,采用useCallback进行记忆化。 这样即使message发生变化,父组件发生重新渲染,子组件仍然会使用相同的 increment 函数引用。子组件在比较 props 的时候,由于 increment 函数在相同的 count 下是相同的引用,子组件会认为它没有变化,因此不会重新渲染。

ini
const increment = useCallback(
function () {
setCount(count + 1);
},
[count]
);
通过useRef优化useCallback
在上面的基本案例中,通过useCallback避免了因message更新而导致Home子组件渲染的问题,然而如果对count进行更新,依然会导致Home子组件的渲染,运行如下:

在本小节,计划采用useRef进一步优化,该hook会返回一个ref对象,这个ref对象在组件的整个生命周期内保持不变。
使用了 useRef 来缓存了 count 的值,然后在 useCallback 内部,通过 countRef.current 获取到了当前的 count 值,而且依赖数组为空([])。这种做法确保了在 increment 函数内部使用的 count 始终是最新的值,但是同时也避免了 useCallback 函数的重新创建。
ini
const [count, setCount] = useState(0);
const countRef = useRef();
countRef.current = count;
const increment = useCallback(function () {
setCount(countRef.current + 1);
}, []);
因此,利用了 useRef 来保存 count 的值,既保证了函数的正常执行,又避免了不必要的组件重新渲染。

useMemo
用法
useMemo 返回一个记忆化的值,只有当依赖项数组中的值发生变化时,才会重新计算。如果依赖项数组为空,它仅在组件的初始化阶段计算一次。
scss
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
useMemo 接受两个参数:一个计算函数和一个依赖数组。它的作用是根据依赖数组中的变量,缓存计算函数的返回值,并在依赖发生变化时重新计算。
- 第一个参数是一个函数,该函数用于计算需要被记忆化的值。
- 第二个参数是依赖项数组,当数组中的任意一个值发生变化时,useMemo 重新执行计算函数。如果依赖项数组为空([]),则表示该值永远不会改变。
useMemo与useCallback的关系
两者核心思想都是在依赖项发生变化时,避免重复计算或函数创建,以提高性能。
useMemo 用于记忆化计算结果,它接受一个计算函数和一个依赖项数组。当依赖项数组中的任意值发生变化时,useMemo 会重新计算并返回计算结果。
useCallback 则是用来记忆化函数 的。它接受一个函数和一个依赖项数组,并返回一个记忆化后的函数。当依赖项数组中的任意值发生变化时,useCallback 会返回一个新的函数引用。
因此,下列两种写法是等价的。
const memorizedFn = useCallback(fn, []),通过useCallback返回一个记忆化的函数
const memorizedFn2 = useMemo(() => fn, []) ,在这种写法中,useMemo 接受一个计算函数和一个依赖项数组,计算函数返回fn,因此通过useMemo返回一个记忆化的函数返回值,只不过这个返回值是一个函数引用
scss
const memorizedFn = useCallback(fn,[])
const memorizedFn2 = useMemo(()=> fn, [])
基本案例1.避免重复执行计算操作
存在一个比较复杂的计算函数,并且要在页面中渲染该函数的计算结果。
具体计算函数如下
ini
function calcNumTotal(num) {
console.log("calcNumTotal过程调用");
let total = 0;
for (let i = 1; i <= num; i++) {
total += i;
}
return total;
}
页面结构如下
javascript
const App = memo(() => {
const [message, setMessage] = useState("hello");
const result = calcNumTotal(50);
return (
<div>
{/* 执行大量计算操作时,是否需要每次渲染都重新计算? */}
<h2>计算结果:{result}</h2>
<h2>message:{message}</h2>
<button onClick={(e) => setMessage(new Date().getTime())}>
修改message
</button>
</div>
);
});
页面效果如下

显然count的更新与计算函数无关系,但是执行+1操作时,每次都会重新渲染页面,并重新执行大量的计算操作。这样显然会对页面性能造成影响

为了避免重复进行大量的计算操作,可以使用useMemo进行优化,那么在进行与计算无关的操作触发页面重新渲染时,不会重新调用calcNumTotal函数。如下,生成了一个记忆化的calcNumTotal函数返回值。那么在进行count更新时,就不会重新执行计算操作了。
ini
const result = useMemo(() => {
return calcNumTotal(50);
}, []);

基本案例2.为子组件传递相同内容的对象时
为子组件传递相同内容的对象时,如果页面重新渲染,那么会重新生成这个对象,并且会导致子组件重新渲染
如下图,页面渲染时,会重新触发子组件渲染

Home组件代码如下
javascript
const Home = memo((props) => {
const { name, school } = props.infoObj;
console.log("Home render");
return (
<div>
Home Info {name}={school}
</div>
);
});
useMemo 中的函数返回的对象 { name: "weihy", school: "ustb" }会被记忆。如果父组件重新渲染,useMemo 不会重新计算这个对象,而是直接返回之前记忆的对象。因此,无论父组件如何重新渲染,info 对象的引用都会保持不变
ini
const infoObj = useMemo(() => {
return { name: "weihy", school: "ustb" };
}, []);
因此无论父组件如何渲染,Home子组件都只会渲染一次
