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;
然而在函数式组件中,可以通过useContext
hook来访问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
子组件都只会渲染一次