[TOC]
React Hooks详解
1. 概述
Hooks 是 React 16.8 的新增特性。并在React 18版本新增了一些功能。Hooks可以让函数组件也可以实现部分类组件的功能,比如state状态管理、部分React生命周期钩子、以及其他的React特性。
在Hooks出现以前,开发遇到的问题:
- 函数组件后期维护需要添加状态管理的功能
- 一些函数组件中不能处理接口请求、获取数据的逻辑处理,只能改成类组件
- 类组件随着开发功能越来越多,随着功能增强而变得越来越臃肿。在进行优化拆分时,只要有状态管理的就都需要使用类组件拆分
所以 Hooks 出现本质上原因是:
- 让函数组件也能做类组件的事,有自己的状态,可以处理一些副作用,能获取 ref ,也能做数据缓存
- 解决逻辑复用难的问题
- 放弃面向对象编程,拥抱函数式编程
关于自定义 Hooks:
自定义 hooks 是在 React Hooks 基础上的一个拓展,可以根据业务需求制定满足业务需要的组合 hooks ,更注重的是逻辑单元。通过业务场景不同,到底需要React Hooks 做什么,怎么样把一段逻辑封装起来,做到复用,这是自定义 hooks 产生的初衷。
自定义 hooks 也可以说是 React Hooks 聚合产物,其内部有一个或者多个 React Hooks 组成,用于解决一些复杂逻辑。
hooks功能概览和出现的版本:
2. hooks数据驱动更新
2.1 useState
useState 可以使函数组件像类组件一样拥有 state,函数组件通过 useState 可以让组件重新渲染,更新视图。
使用方法、参数说明:
import { useState } from 'react';
const [ state , setState ] = useState(initData)
- state:状态变量,目的提供给 UI ,作为渲染视图的数据源。
- setState:用于更新状态值的函数。当使用这个函数设置新的状态时,React 会根据新的状态重新渲染组件。
- initData:状态的初始值。分为三种情况:第一种是不传,则 state 的初始值为undefined。第二种情况是非函数,将作为 state 初始化的值。 第三种情况是函数,函数的返回值作为 useState 初始化的值。
使用例子:
const DemoState = (props) => {
/* number为此时state读取值 ,setNumber为派发更新的函数 */
let [number, setNumber] = useState(0) /* 0为初始值 */
return (<div>
<span>{ number }</span>
<button onClick={ ()=> {
setNumber(number+1) // 会在下次渲染才更新number便令
console.log(number) /* 这里的number是 旧值 */
} } ></button>
</div>)
}
useState 注意事项:
1、在函数组件一次执行上下文中,state 的值是固定不变的
function Index(){
const [ number, setNumber ] = React.useState(0)
const handleClick = () => setInterval(()=>{
setNumber(number + 1 ) // // 此时 number 一直都是 0
},1000)
return <button onClick={ handleClick } > 点击 { number }</button>
}
2、 如果两次传入相同的 state 值,那么组件就不会更新
let account =5;
export default function Index(){
const [ state , dispatchState ] = useState({ name:'alien' })
const handleClick = ()=>{ // 点击按钮,视图没有更新。
state.name = 'Alien';
dispatchState(state) // state变量是对象,存储的是对象地址,这么操作直接等于内存地址重新赋值了相同的值。而传入相同的state值,组件不会渲染,所以视图没有更新。但是对象内的属性已经修改成功了
}
return (<div>
<span>{state.name}</span>
<button onClick={handleClick}>changeName++</button>
</div>);
}
3、当执行setState
时,在当前执行上下文中获取不到最新的 state, 只有再下一次组件 rerender 中才能获取到。
4、当执行setState
设置state值时,React 将跳过子组件的渲染及 effect 的执行。需要注意的是,React 可能仍需要在跳过渲染前渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心。如果你在渲染期间执行了高开销的计算,则可以使用 useMemo
来进行优化。
官方文档:https://zh-hans.react.dev/reference/react/useState
2.2 useReducer
useReducer 是 react-hooks 提供的能够在无状态组件中运行的类似redux的功能 api 。
使用方法、参数说明:
import { useReducer } from 'react';
const [ ①state , ②dispatch ] = useReducer(③reducer, initialState, processInitState)
- state:状态变量,目的提供给 UI ,作为渲染视图的数据源。
- dispatch:用于更新状态值的函数。本质上跟 useState方法返回的 setState 方法一样。
- reducer:一个函数 reducer ,我们可以认为它就是一个 redux 中的 reducer , reducer的参数就是常规reducer里面的state和action, 返回改变后的state, 这里有一个需要注意的点就是:如果返回的 state 和之前的 state ,内存指向相同,那么组件将不会更新。
- initialState:状态的初始值。如果不传则 state 的初始值为undefined。注意不能使用函数返回值形式设置。
- processInitState:选填,处理初始值的方法,initialState 可以当这个函数的参数使用。
使用例子:
function Index({ dispatch, State }) {
return (
<button onClick={() => { dispatch({ name: 'reset', payload: 666 }); }}>子 重置 {State.number}</button>
);
}
const DemoUseReducer = ({size}) => {
/* number为更新后的state值, dispatchNumber 为当前的派发函数 */
const [number, dispatchNumber] = useReducer((state, action) => {
const { payload, name } = action;
switch (name) { // /* return的值为新的state */
case 'add':
return state + 1;
case 'sub':
return state - 1;
case 'reset':
return payload;
default:
return 50;
}
}, 10, (initialState ) => {return initialState * size});
return (
<div>
当前值:{number}
<button onClick={() => dispatchNumber({ name: 'add' })}>增加</button>
<button onClick={() => dispatchNumber({ name: 'sub' })}>减少</button>
{/* 把dispatch 和 state 传递给子组件 */}
<Index dispatch={dispatchNumber} State={{ number }} />
</div>
);
};
export default DemoUseReducer;
官方文档:https://zh-hans.react.dev/reference/react/useReducer
2.3 useSyncExternalStore(v18新增)
useSyncExternalStore 的诞生和 v18 的更新模式下外部数据的 tearing 有着十分紧密的关联。useSyncExternalStore 能够让 React 组件在 concurrent 模式下安全地有效地读取外接数据源,在组件渲染过程中能够检测到变化,并且在数据源发生变化的时候,能够调度更新。当读取到外部状态发生了变化,会触发一个强制更新,来保证结果的一致性。
使用方法、参数说明:
import { useSyncExternalStore } from 'react';
useSyncExternalStore( subscribe, getSnapshot, getServerSnapshot)
- subscribe:为订阅函数,当数据改变的时候,会触发 subscribe,在 useSyncExternalStore 会通过带有记忆性的 getSnapshot 来判别数据是否发生变化,如果发生变化,那么会强制更新数据。
- getSnapshot:可以理解成一个带有记忆功能的选择器。当 store 变化的时候,会通过 getSnapshot 生成新的状态值,这个状态值可提供给组件作为数据源使用,getSnapshot 可以检查订阅的值是否改变,改变的话那么会触发更新。
- getServerSnapshot:用于 hydration 模式下的 getSnapshot。
使用例子:
import { combineReducers , createStore } from 'redux'
/* number Reducer */
function numberReducer(state=1,action){
switch (action.type){
case 'ADD':
return state + 1
case 'DEL':
return state - 1
default:
return state
}
}
/* 注册reducer */
const rootReducer = combineReducers({ number:numberReducer })
/* 创建 store */
const store = createStore(rootReducer,{ number:1 })
function Index(){
/* 订阅外部数据源 */
const state = useSyncExternalStore(store.subscribe,() => store.getState().number)
console.log(state)
return <div>
{state}
<button onClick={() => store.dispatch({ type:'ADD' })} >点击</button>
</div>
}
点击按钮,会触发 reducer ,然后会触发 store.subscribe 订阅函数,执行 getSnapshot 得到新的 number ,判断 number 是否发生变化,如果变化,触发更新。
官方文档:https://zh-hans.react.dev/reference/react/useSyncExternalStore
2.4 useTransition(v18新增)
在 React v18 中,有一种并发模式(concurrent)
,concurrent
模式允许将UI更新标记为高优先级的或者可中断的低优先级操作。而useTransition()
方法可以将某些更新标记为可中断的
和非紧急的
-也就是所谓的transitions
。这种新特性在大量的UI更新操作中尤其有效,比如过滤一个较大的列表。打个比方如下图当点击 tab 从 tab1 切换到 tab2 的时候,本质上产生了两个更新任务。
- 第一个就是 hover 状态由 tab1 变成 tab2。
- 第二个就是内容区域由 tab1 内容变换到 tab2 内容。
这两个任务,用户肯定希望 hover 状态的响应更迅速,而内容的响应有可能还需要请求数据等操作,所以更新状态并不是立马生效,通常还会有一些 loading 效果。所以第一个任务作为立即执行任务,而第二个任务就可以视为过渡任务。
useTransition使用方法、参数说明:
import { useTransition } from 'react'
const [ isPending , startTransition ] = useTransition ()
- isPending:Boolean值,表示处于过渡状态的标志,指明这个
transition
正在加载中(pending
) - startTransition:设置过度的方法,这个方法接收一个函数作为参数,在这个参数函数里面执行一些地任务行为
useTransition 例子:
/* 模拟数据 */
const mockList1 = new Array(10000).fill('tab1').map((item,index)=>item+'--'+index )
const mockList2 = new Array(10000).fill('tab2').map((item,index)=>item+'--'+index )
const mockList3 = new Array(10000).fill('tab3').map((item,index)=>item+'--'+index )
const tab = { tab1: mockList1, tab2: mockList2, tab3: mockList3 }
export default function Index(){
const [ active, setActive ] = React.useState('tab1') //需要立即响应的任务,立即更新任务
const [ renderData, setRenderData ] = React.useState(tab[active]) //不需要立即响应的任务,过渡任务
const [ isPending,startTransition ] = React.useTransition()
const handleChangeTab = (activeItem) => {
setActive(activeItem) // 立即更新
startTransition(()=>{ // startTransition 里面的任务优先级低
setRenderData(tab[activeItem])
})
}
return <div>
<div className='tab' >
{ Object.keys(tab).map((item)=> <span className={ active === item && 'active' } onClick={()=>handleChangeTab(item)} >{ item }</span> ) }
</div>
<ul className='content' >
{ isPending && <div> loading... </div> }
{ renderData.map(item=> <li key={item} >{item}</li>) }
</ul>
</div>
}
官方文档:https://zh-hans.react.dev/reference/react/useTransition
2.5 useDeferredValue(v18新增)
useDeferredValue用来接受一个值,并返回该值的新副本,该副本将推迟到更紧急地更新之后。由于React 18 将UI更新标记为高优先级的或者可中断的低优先级操作。所以我们可以在高优先级任务结束后,再得到新的状态,而这个新的状态就称之为 DeferredValue。
useDeferredValue使用方法、参数说明:
import { useDeferredValue } from 'react';
const [value, setValue] = useState('');
const deferrredValue = React.useDeferredValue(value, initialValue?)
- value:你想延迟的值,可以是任何类型。
- initialValue:可选值,组件初始渲染时使用的值。如果省略此选项,
useDeferredValue
在初始渲染期间不会延迟,因为没有以前的版本可以渲染。 - deferrredValue:延迟的值
注意事项:
- 当更新发生在 Transition 内部时,
useDeferredValue
总是返回新的value
并且不会产生延迟渲染,因为该更新已经被延迟了。 - 传递给
useDeferredValue
的值应该是原始值(如字符串和数字)或是在渲染之外创建的对象。如果你在渲染期间创建一个新对象并立即将其传递给useDeferredValue
,它在每次渲染时都会不同,从而导致不必要的后台重新渲染。 useDeferredValue
本身不会引起任何固定的延迟。一旦 React 完成原始的重新渲染,它会立即开始使用新的延迟值处理后台重新渲染。由事件(例如输入)引起的任何更新都会中断后台重新渲染,并被优先处理。- 由
useDeferredValue
引起的后台重新渲染在提交到屏幕之前不会触发 Effect。如果后台重新渲染被暂停,Effect 将在数据加载后和 UI 更新后运行。
用法:
- 1、在新内容加载期间显示旧内容
- 2、延迟渲染 UI 的某些部分
useDeferredValue 例子:
import React from "react"
const tab = { tab1: ['1','2','3'], tab2: ['4','5','6'], tab3: ['7','8','9'] }
export default function Index(){
const [ active, setActive ] = React.useState('tab1') //需要立即响应的任务,立即更新任务
const deferActive = React.useDeferredValue(active) // 把状态延时更新,类似于过渡任务
const handleChangeTab = (activeItem) => {
setActive(activeItem) // 立即更新
}
const renderData = tab[deferActive] // 使用滞后状态
return <div>
<div className='tab' >
{ Object.keys(tab).map((item)=> <span className={ active === item && 'active' } onClick={()=>handleChangeTab(item)} >{ item }</span> ) }
</div>
<ul className='content' >
{ renderData.map(item=> <li key={item} >{item}</li>) }
</ul>
</div>
}
// 如上 active 为正常改变的状态,deferActive 为滞后的 active 状态,我们使用正常状态去改变 tab 的 active 状态,使用滞后的状态去更新视图,同样达到了提升用户体验的作用。
import React, { memo, useState, useDeferredValue } from 'react';
const SlowList = memo(function SlowList({ text }) {
// 仅打印一次。实际的减速是在 SlowItem 组件内部。
console.log('[ARTIFICIALLY SLOW] Rendering 250 <SlowItem />');
let items = [];
for (let i = 0; i < 250; i++) {
items.push(<SlowItem key={i} text={text} />);
}
return ( <ul className="items"> {items} </ul> );
});
function SlowItem({ text }) {
let startTime = performance.now();
while (performance.now() - startTime < 1) {
// 每个 item 暂停 1ms,模拟极其缓慢的代码
}
return (<li className="item">Text: {text}</li>)
}
export default function App() {
const [text, setText] = useState('');
const deferredText = useDeferredValue(text);
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<SlowList text={deferredText} />
</>
);
}
// 在这个例子中,SlowList 组件中的每个 item 都被 故意减缓了渲染速度,这样你就可以看到 useDeferredValue 是如何让输入保持响应的。当你在输入框中输入时,你会发现输入很灵敏,而列表的更新会稍有延迟。
// 例子链接:https://zh-hans.react.dev/reference/react/useDeferredValue#examples
官方文档:https://zh-hans.react.dev/reference/react/useDeferredValue
3. 执行副作用
React hooks也提供了 api ,用于弥补函数组件没有生命周期的缺陷。其本质主要是运用了 hooks 里面的 useEffect、useLayoutEffect、useInsertionEffect。其中最常用的就是 useEffect 。
注意:useInsertionEffect
方法是react 18 版本才有的,低于这个版本没有这个方法。
3.1 三个方法的区别
由于useEffect、useLayoutEffect、 useInsertionEffect三个方法的参数和用法都一样。他们唯一的区别就是执行的时机不一样。关于三个方法参数和用法下一小节会介绍。这里我们先着重介绍他们的区别。
1、三个方法执行效果:
1.1 执行顺序:useInsertionEffect 执行 -> useLayoutEffect 执行 -> useEffect 执行
1.2 在方法中修改dom:都是在这一次更新中完成。
1.2 在方法中修改状态变量:都触发了组件第二次更新。
2、三个方法执行时机(区别):
2.1 useEffect:在组件渲染到屏幕之后异步执行。这意味着它不会阻塞浏览器的绘制和更新
2.2 useLayoutEffect:在修改 DOM 之后,在浏览器进行布局和绘制之前同步执行。
2.3 useInsertionEffect:在布局副作用触发之前将元素插入到 DOM 中,在useLayoutEffect之前执行
3、三个方法在使用上说明:
- useEffect
- 特点、使用场景:由于是异步执行,不会阻塞页面的渲染,对用户交互的响应性影响较小,适用于大多数与数据获取、订阅事件、手动修改
DOM
等不会直接影响页面布局和视觉呈现的操作。 - 缺点:由于是在浏览器绘制视图之后执行,如果在这个方法里面去修改DOM元素,就可能会导致浏览器再次回流和重绘。而且由于两次绘制,视图上可能会造成闪现突兀的效果。
- 特点、使用场景:由于是异步执行,不会阻塞页面的渲染,对用户交互的响应性影响较小,适用于大多数与数据获取、订阅事件、手动修改
- useLayoutEffect
- 特点、使用场景:解决 useEffect 修改 DOM 的问题。在这个方法里面可以获取更新后的 DOM,修改 DOM,修改后马上渲染到页面中。
- 缺点:由于还在等待页面渲染呢,这个方法是同步执行的,如果在其中执行的操作耗时较长,会阻塞页面的渲染,可能导致页面卡顿,影响用户体验。尽可能使用
useEffect
。
- useInsertionEffect:是为 CSS-in-JS 库的作者特意打造的。除非你正在使用 CSS-in-JS 库并且需要注入样式,否则你应该使用
useEffect
或者useLayoutEffect
。
三个方法的执行测试例子:
import React from 'react';
let h = 30;
function Index() {
const [count, setCount] = React.useState(100)
const divRef = React.useRef(null);
// 用来测试 三个副作用方法 的执行顺序,把flag变量值改成对应的值即可
const print = (name) => {
// console.log(`${name}`);
const flag = 'useInsertionEffect';
if (divRef.current) {
if (flag === name) {
// h += 10; divRef.current.innerText = h; // 1、修改dom
if (count === 0) { setCount(Math.random() + 99); } // 2、修改状态
}
console.log(`${name}`, count, divRef.current.innerText);
}
};
React.useEffect(() => {
print('useEffect');
});
React.useLayoutEffect(() => {
print('useLayoutEffect');
});
React.useInsertionEffect(() => {
print('useInsertionEffect');
});
// console.log('函数组件体,', count);
if (divRef.current) {
console.log('函数组件体', count, divRef.current.innerText);
}
return (<div className="hook-test">
<div ref={divRef} style={{ border: '1px solid' }}>This is</div>
<h4>count: {count}</h4>
{/* 1、修改dom:<button onClick={e => setCount(count + 1)}>count</button> */}
2、修改状态:<button onClick={e => setCount(0)}>count</button>
</div>);
}
export default Index;
// 函数组件渲染步骤:
// 1. 函数组件执行,返回了 经过处理逻辑的的dom
// 2. useInsertionEffect 执行
// 3. useLayoutEffect 执行
// 4. 经过修改的dom渲染到浏览器中。
// 5. useEffect 执行,这次渲染结束
3.1 useEffect
import { useEffect } from 'react';
useEffect(setup, dependencies?);
// 真正使用的时候是这种形式的
useEffect(() => {
// 执行副作用操作
return () => {}; // 清理操作执行方法,可选
}, [dependencies]); // 依赖数组
- setup:处理 Effect 的函数。setup 函数选择性返回一个 清理(cleanup) 函数,作为下一次setup执行前调用,用于清理上一次setup执行产生的副作用。
- dependencies:可选参数,类型是数组,作为执行setup处理函数的依赖项。当依赖项改变,会触发useEffect执行。React 将使用
Object.is
来比较每个依赖项和它先前的值。这个参数分为三种情况:- 传有值的数组([dep1, dep2]):当依赖项改变,会触发useEffect执行。
- 传空数组([]):只在初始挂载后执行一次,相当于componentDidMount方法
- 不传:每次重新渲染组件后都会运行 Effect 函数
React 在必要时会调用 setup 和 cleanup,这可能会发生多次:
- 1、初次挂载到页面后,运行 setup 代码
- 2、每次重新渲染组件结束后(如果传了第二个参数,那么只有在依赖项值发生改变才执行):
- 2.1 首先,使用旧的 props 和 state 运行**清理(cleanup)**函数。
- 2.2 然后,使用新的 props 和 state 运行 setup 代码。
- 3、组件卸载之前,清理(cleanup) 函数 将运行最后一次。
一个使用例子:
import React, { useEffect, useRef, useState } from "react"
function getUserInfo(a){ // 模拟数据交互
return new Promise((resolve)=>{
setTimeout(()=>{ resolve({ name:a, age:16, }) }, 500)
})
}
const Demo = ({ a }) => {
const [ userMessage , setUserMessage ] :any= useState({})
const div= useRef()
const [number, setNumber] = useState(0)
const handleResize =()=>{} // 模拟事件监听处理函数
useEffect(()=>{
// 请求数据
getUserInfo(a).then(res=>{ setUserMessage(res) })
const timer = setInterval(()=>console.log(666),1000)
window.addEventListener('resize', handleResize) // 事件监听
return function(){ // 此函数用于清除副作用
clearInterval(timer)
window.removeEventListener('resize', handleResize)
}
// 只有当 a 和 number 改变的时候,useEffect 函数才会重新执行
},[ a ,number ]) // 这里如果不加限制 ,会是函数重复执行,陷入死循环
useEffect(() => {
console.log('useEffect1111 只执行一次');
}, []); // 数组为空[],只在初始挂载后执行一次,相当于componentDidMount方法
return (<div ref={div} >
<span>{ userMessage.name }</span> <span>{ userMessage.age }</span>
<div onClick={ ()=> setNumber(1) } >{ number }</div>
</div>)
}
官方文档:https://zh-hans.react.dev/reference/react/useEffect
3.2 useLayoutEffect
useLayoutEffect 使用例子:
import React from 'react';
let h = 30;
function Index() {
const [count, setCount] = React.useState(100)
const divRef = React.useRef(null);
React.useLayoutEffect(() => {
h += 10; divRef.current.innerText = h;
});
return (<div className="hook-test">
<div ref={divRef} style={{ border: '1px solid' }}>This is</div>
<h4>count: {count}</h4>
修改dom:<button onClick={e => setCount(count + 1)}>count</button>
</div>);
}
export default Index;
官方文档:https://zh-hans.react.dev/reference/react/useLayoutEffect
3.3 useInsertionEffect
useInsertionEffect
是为 CSS-in-JS 库的作者特意打造的。除非你正在使用 CSS-in-JS 库并且需要注入样式,否则你应该使用 useEffect
或者 useLayoutEffect
。
useInsertionEffect 使用例子:
export default function Index(){
React.useInsertionEffect(()=>{
/* 动态创建 style 标签插入到 head 中 */
const style = document.createElement('style')
style.innerHTML = `
.css-in-js{
color: red;
font-size: 20px;
}
`
document.head.appendChild(style)
},[])
return <div className="css-in-js" > hello , useInsertionEffect </div>
}
官方文档:https://zh-hans.react.dev/reference/react/useInsertionEffect
4. 状态获取与传递
4.1 useContext
可以使用 useContext ,来获取父级组件传递过来的 context 值,这个当前值就是最近的父级组件 Provider 设置的 value 值,useContext 参数一般是由 createContext 方式创建的 ,也可以父级上下文 context 传递的 ( 参数为 context )。useContext 可以代替 context.Consumer 来获取 Provider 中保存的 value 值。
useContext 接受一个参数,一般都是 context 对象,返回值为 context 对象内部保存的 value 值。
使用例子:
// contextTest.js:定义一个context 文件,所有用到这个context的组件都要引用这个文件
export const UserContext = React.createContext();
// 共同的父组件发布变量
import { UserContext } from '@views/contextTest.js'; // 引入共同的 Context
const App = () => (
const [age, setAge] = React.useState(256); // 让子组件可以修改 Context
<UserContext.Provider value={{ name: 'Alice', age: age, setAge: setAge }}>
<UserInfo />
</UserContext.Provider>
);
// 函数组件使用变量:1、通过useContext使用。
import { useContext } from 'react';
import { UserContext } from '@views/contextTest.js'; // 引入共同的 Context
export default function UserInfo() {
const user = useContext(UserContext); // 1、通过useContext获取变量
return (<div>
<p>用户名: {user.name}</p> <p>年龄: {user.age}</p>
<button onClick={() => { user.setAge(user.age + 1); }}>增加年龄</button>
</div>);
}
官方文档:https://zh-hans.react.dev/reference/react/useContext
4.2 useRef
useRef 方法主要有下面两个作用:
1、保存 DOM 引用:react内置了对它的支持。当把useRef变量传递给 JSX 中的 ref
属性时,react 会在创建DOM并渲染到页面后,把节点赋值给 ref 对象的 current
属性。
2、保存状态:只要当前组件不被销毁,那么状态就会一直存在。改变 ref.current
属性时,React 不会重新渲染组件。React 不知道它何时会发生改变,因为 ref 是一个普通的 JavaScript 对象。
使用方法、参数说明:
const ref = React.useRef(initialValue);
initialValue
:ref 对象的current
属性的初始值。可以是任意类型的值。这个参数在首次渲染后被忽略。- ref:返回一个只有一个属性的对象(ref.current),current初始值为传递的
initialValue
。之后可以将其设置为其他值。如果将 ref 对象作为一个 JSX 节点的ref
属性传递给 React,React 将为它设置current
属性。在后续的渲染中,useRef
将返回同一个对象。
注意:
1、除了 初始化 外不要在渲染期间写入或者读取 ref.current
,否则会使组件行为变得不可预测。
2、在严格模式下,React 将会 调用两次组件方法,这是为了 帮助发现意外问题。但这只是开发模式下的行为,不会影响生产模式。每个 ref 对象都将会创建两次,但是其中一个版本将被丢弃。如果使用的是组件纯函数(也应当如此),那么这不会影响其行为。
3、const ref = useRef()
等同于 const [ref, _] = useState(() => createRef(null))
。详情见:react.createRef,在实际代码中React.createRef() 跟 React.useRef() 效果一样。
使用例子:
import React from 'react';
let text = 30;
function Counter() {
const ref = React.useRef(0); // 保存变量,组件在变量在,修改不触发更新
const divRef = React.useRef(null); // 保存dom。
React.useEffect(() => { console.log('useEffect'); }); // 只执行了一次
function handleClick() {
ref.current += 1;
console.log('ref.current:', ref.current);
text += 10; divRef.current.innerText = `dom修改${text}`;
}
return (<div>
<div ref={divRef}>This is</div>
<button onClick={handleClick}>点击</button>
</div>);
}
官方文档:https://zh-hans.react.dev/reference/react/useRef
4.3 useImperativeHandle
useImperativeHandle 可以让函数组件自定义实例对象用来给父组件调用。
对于子组件,如果是 类组件,我们可以通过 ref 获取类组件的实例。但是在子组件是函数组件的情况下(函数组件没有ref属性),则可以通过 forwardRef 方法来让函数组件拥有 ref 属性。
注意:由于react规定函数组件不能有ref属性(react19版本取消这个限制了),函数组件也可以通过自定义的props属性达到 ref 的效果,详情见下面的例子。
使用方法、参数说明:
React.useImperativeHandle(ref, createHandle, dependencies?)
- ref:从
forwardRef
渲染函数 中获得的第二个参数。或者是props里面的自定义 ref 属性。父组件传过来的 - createHandle:处理函数,返回值作为暴露给父组件的 ref 对象。
- dependencies:可选参数,类型是数组,作为执行setup处理函数的依赖项。当依赖项改变,会执行createHandle生成新的 ref 对象。React 将使用
Object.is
来比较每个依赖项和它先前的值。这个参数分为三种情况:- 传有值的数组([dep1, dep2]):当依赖项改变,执行createHandle生成新的 ref 对象。
- 传空数组([]):只在初始挂载后生成新的 ref 对象,相当于componentDidMount方法。这么做会导致一个问题,详情见下面的注意说明
- 不传:每次重新渲染组件后都会执行createHandle生成新的 ref 对象
useImperativeHandle使用例子:
import React from 'react';
// 父组件
const Parent = () => {
const ChildRef = React.createRef(); // React.useRef() 也一样
return (
<div>
<button onClick={() => { ChildRef.current.func(); }}>click</button>
<Child ref={ChildRef} />
</div>
);
};
// 子组件:和 forwardRef 方法配合 暴露自定义的 ref 对象给父组件
const Child = React.forwardRef((props, ref) => {
const [count, setCount] = React.useState(100);
React.useImperativeHandle(ref, () => ({
func: () => {
setCount(count + 1);
console.log('我是子组件1,count:', count);
},
}));
return <div>子组件</div>;
});
// 子组件:父组件使用<Child onRef={ChildRef} /> 这种自定义props属性来获取函数组件的自定义 ref 对象
const Child = (props) => {
const [count, setCount] = React.useState(100);
React.useImperativeHandle(props.onRef, () => ({
func: () => {
setCount(count + 1);
console.log('我是子组件,count:', count);
},
}));
return <div>子组件</div>;
};
注意:关于useImperativeHandle 方法依赖项传空数组产生的问题:
createHandle 只会在组件挂载后执行一次,当父组件执行 ref 对象里面的方法时:读取子组件的state的值永远不会变(初始值),调用子组件的setCount方法只有第一次有效,以后都是无效的。当为了性能优化而给依赖项传空数组的时候需要特别注意这一点。详情见如下例子:
const Child = React.forwardRef((props, ref) => {
const [count, setCount] = React.useState(10);
React.useImperativeHandle(ref, () => ({
func: () => {
setCount(count + 1);
console.log('我是子组件1,count:', count);
},
}), []);
return <div>子组件:{count}</div>;
});
// 父组件执行 func 方法,console.log里面的count值永远是10, 页面count值只有在第一次点击后变成了11,后面的点击都没变化
官方文档:https://zh-hans.react.dev/reference/react/useImperativeHandle
5. 状态派生与保存
5.1 useMemo
useMemo 可以在函数组件 render 上下文中同步执行一个函数逻辑,这个函数的返回值可以作为一个新的状态缓存起来。useMemo
不会让首次渲染更快,它只会帮助你跳过不必要的更新工作。
使用方法、参数说明:
const visible = React.useMemo(calculate, deps)
- calculate:计算缓存值的函数。这个函数没有参数,并且可以返回任意类型。React 将会在首次渲染时执行该函数返回结果;在之后的渲染中会根据
deps
参数情况而定。 - deps:类型是数组,作为执行 calculate 函数的依赖项。React 将使用
Object.is
来比较每个依赖项和它先前的值。这个参数分为三种情况:- 传有值的数组([dep1, dep2]):当依赖项改变,会触发 calculate 执行并返回结果。
- 传空数组([]):只在初始挂载时执行一次,相当于componentDidMount方法。这个参数会导致 useMemo 再也不执行,也就获取不到后续更新了。失去了 useMemo 的意义
- 不传:每次重新渲染组件时都会运行 useMemo 函数。这么做等于每次都重新计算结果,失去了 useMemo 的意义
- visible:需要缓存的变量。calculate 函数执行后的返回值。
使用例子:
function Scope() {
const keeper = useKeep()
const { cacheDispatch, cacheList, hasAliveStatus } = keeper
const contextValue = useMemo(() => { /* 通过 useMemo 得到派生出来的新状态 contextValue */
return {
hasAliveStatus: hasAliveStatus.bind(keeper),
cacheDestory: (payload) => cacheDispatch.call(keeper, { type: ACTION_DESTORY, payload })
}
}, [keeper])
return <KeepaliveContext.Provider value={contextValue}></KeepaliveContext.Provider>
}
需要用到 useMemo 的地方:
1、跳过代价昂贵的重新计算:默认情况下,React 会在每次重新渲染时重新运行整个组件。如果计算速度很快,这将不会产生问题。但是,当正在过滤转换一个大型数组,或者进行一些昂贵的计算,而数据没有改变,那么可能希望跳过这些重复计算。这种缓存行为叫做 记忆化。
2、跳过组件的重新渲染:默认情况下,当一个组件重新渲染时,React 会递归地重新渲染它的所有子组件。这对于不需要太多计算来重新渲染的组件来说很好。但是如果你已经确认重新渲染很慢,你可以通过将它包装在 memo
中,这样当它的 props 跟上一次渲染相同的时候它就会跳过本次渲染。
3、防止过于频繁地触发 Effect:
function ChatRoom({ roomId }) {
const options = { serverUrl: 'https://localhost:1234', roomId: roomId };
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
}
// 1. 上面代码中,因为 Effect 中的每一个响应式值都应该声明为其依赖。 然而如果你将 options 声明为依赖,会导致在 Effect 在每次渲染后都会重新执行
// 2. 为了解决这个场景,你可以使用 useMemo 将 Effect 中使用的对象包装起来
const options = useMemo(() => {
return { serverUrl: 'https://localhost:1234', roomId: roomId };
}, [roomId]); // ✅ 只有当 roomId 改变时才会被改变
// 3. 然而,因为 useMemo 只是一个性能优化手段,而并不是语义上的保证,所以 React 在 特定场景下 会丢弃缓存值。这也会导致重新触发 Effect,因此 最好通过将对象移动到 Effect 内部来消除对函数的依赖:
useEffect(() => {
// ✅ 不需要将 useMemo 或对象作为依赖!
const options = { serverUrl: 'https://localhost:1234', roomId: roomId }
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 只有当 roomId 改变时才会被改变
4、记忆一个函数,这个跟另外一个 hooks 的 useCallback 方法一样。像 function() {}
这样的函数声明和像 () => {}
这样的表达式在每次重新渲染时都会产生一个 不同 的函数。就其本身而言,创建一个新函数不是问题。这不是可以避免的事情!但是,如果 Form
组件被记忆了,大概你想在没有 props 改变时跳过它的重新渲染。总是 不同的 props 会破坏你的记忆化。
export default function ProductPage({ productId, referrer }) {
function handleSubmit(orderDetails) {
post('/product/' + productId + '/buy', { referrer, orderDetails });
}
// 使用 useMemo 记忆 handleSubmit 函数
const handleSubmit = useMemo(() => {
return (orderDetails) => {
post('/product/' + productId + '/buy', { referrer, orderDetails });
};
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
如何衡量计算过程的开销是否昂贵?
一般来说,除非要创建或循环遍历数千个对象,否则开销可能并不大。如果你想获得更详细的信息,可以在控制台来测量花费这上面的时间:
console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');
// 使用 useMemo 测试
console.time('filter array useMemo');
const visibleTodos = useMemo(() => { return filterTodos(todos, tab); }, [todos, tab]);
console.timeEnd('filter array useMemo');
然后执行你正在监测的交互(例如,在输入框中输入文字)。你将会在控制台看到如下的日志 filter array: 0.15ms
。如果全部记录的时间加起来很长(1ms
或者更多),那么记忆此计算结果是有意义的。作为对比,你可以将计算过程包裹在 useMemo
中,以验证该交互的总日志时间是否减少了。
请记住,你的开发设备可能比用户的设备性能更强大,因此最好人为降低当前浏览器性能来测试。例如,Chrome 提供了 CPU Throttling 选项来降低浏览器性能。另外,请注意,在开发环境中测量性能无法为你提供最准确的结果(例如,当开启 严格模式 时,你会看到每个组件渲染两次而不是一次)。要获得最准确的时间,请构建用于生产的应用程序并在用户使用的设备上对其进行测试。
官方文档:https://zh-hans.react.dev/reference/react/useMemo
5.2 useCallback
useMemo 和 useCallback 接收的参数都是一样,都是在其依赖项发生变化后才执行,都是返回缓存的值,区别在于 useMemo 返回的是函数运行的结果,useCallback 返回的是函数。
使用方法、参数说明:
const cachedFn = React.useCallback(fn, deps)
// useCallback 等于 如下实现,或者 const cachedFn = useMemo(() => fn, dependencies)
function myUseCallback(fn, dependencies) {
return useMemo(() => fn, dependencies);
}
- fn:想要缓存的函数。在初次渲染时,React 将把函数返回给你。在之后的渲染中会根据
deps
参数情况而定。 - deps:类型是数组,作为执行 useCallback 的依赖项。跟 useMemo 方法的参数一样。
- cachedFn:缓存函数的名字
使用例子:
/* 用react.memo */
const DemoChildren = React.memo((props)=>{
/* 只有初始化的时候打印了 子组件更新 */
console.log('子组件更新')
useEffect(()=>{ props.getInfo('子组件') },[])
return <div>子组件</div>
})
const DemoUseCallback=({ id })=>{
const [number, setNumber] = useState(1)
const getInfo = useCallback((sonName)=>{ console.log(sonName) },[id])
return <div>
{/* 点击按钮触发父组件更新 ,但是子组件没有更新 */}
<button onClick={ ()=>setNumber(number+1) } >增加</button>
<DemoChildren getInfo={getInfo} />
</div>
}
与字面量对象 {}
总是会创建新对象类似,在 JavaScript 中,function () {}
或者 () => {}
总是会生成不同的函数。正常情况下,这不会有问题。但是在函数组件中,把函数传给子组件的props。这会导致每次渲染props都是不同的。并且 memo
对性能的优化永远不会生效。而这就是 useCallback
起作用的地方。
官方文档:https://zh-hans.react.dev/reference/react/useCallback
6. 工具 hooks
6.1 useDebugValue
可以让你在 React 开发工具 中为自定义 Hook 添加标签。注意:只有自定义hooks组件才有效,其他函数组件无效。并且需要浏览器安装 React 开发工具 插件。
使用方法、参数说明:
useDebugValue(value, date => date.toDateString());
value
:你想在 React 开发工具中显示的值。可以是任何类型。- 可选
format
:它接受一个格式化函数。当组件被检查时,React 开发工具将用value
作为参数来调用格式化函数,然后显示返回的格式化值(可以是任何类型)。如果不指定格式化函数,则会显示value
。在某些情况下,格式化值的显示可能是一项开销很大的操作。除非需要检查 Hook,否则没有必要这么做。
使用例子:
function useTestDebug(data) {
// const [age, setAge] = React.useState(11);
React.useDebugValue(data);
return data;
}
function MyApp() {
const [count, setCount] = React.useState(10);
useTestDebug(count);
return (<div>
<button onClick={() => { setCount(count + 1); }}>add count</button>
count: {count}
</div>);
}
浏览器终端显示:
官方文档:https://zh-hans.react.dev/reference/react/useDebugValue
6.2 useId
useId 也是 React v18 产生的新的 hooks , 它可以在 client 和 server 生成唯一的 id , 解决了在服务器渲染中,服务端和客户端产生 id 不一致的问题,更重要的是保障了 React v18 中 streaming renderer (流式渲染) 中 id 的稳定性。
低版本 React ssr 存在的问题:
const rid = Math.random() + '_id_' /* 生成一个随机id */
function Demo (){
return <div id={rid} ></div> // 使用 rid
}
这在纯客户端渲染中没有问题,但是在服务端渲染的时候,传统模式下需要走如下流程。在这个过程中,当服务端渲染到 html 和 hydrate 过程分别在服务端和客户端进行,但是会走两遍 id 的生成流程,这样就会造成 id不一致的情况发生。useId 的出现能有效的解决这个问题。
useId 基本用法:
function Demo (){
const rid = useId() // 生成稳定的 id
return <div id={rid} ></div>
}
6.2.1 v18 ssr
这部分转载自:https://juejin.cn/post/7118937685653192735#heading-23
在 React v18 中 对 ssr 增加了流式渲染的特性 New Suspense SSR Architecture in React 18 , 那么这个特性是什么呢?我们来看一下:
在传统 React ssr 中,如果正常情况下, hydrate 过程如下所示:
刚开始的时候,因为服务端渲染,只会渲染 html 结构,此时还没注入 js 逻辑,所以我们把它用灰色不能交互的模块表示。(如上灰色的模块不能做用户交互,比如点击事件之类的。)
hydrate js 加载之后,此时的模块可以正常交互,所以用绿色的模块展示。
但是如果其中一个模块,服务端请求数据,数据量比较大,耗费时间长,我们不期望在服务端完全形成 html 之后在渲染,那么 React 18 给了一个新的可能性。可以使用 包装页面的一部分,然后让这一部分的内容先挂起。
接下来会通过 script 加载 js 的方式 流式注入 html 代码的片段,来补充整个页面。接下来的流程如下所示:
在这个原理基础之上, React 个特性叫 Selective Hydration,可以根据用户交互改变 hydrate 的顺序。
比如有两个模块都是通过 Suspense 挂起的,当两个模块发生交互逻辑时,会根据交互来选择性地改变 hydrate 的顺序。
如上 C D 选择性的 hydrate 就是 Selective Hydration 的结果。那么回到主角 useId 上,如果在 hydrate 过程中,C D 模块 id 是动态生成的,比如如下:
let id = 0
function makeId(){
return id++
}
function Demo(){
const id = useRef( makeId() )
return <div id={id} >...</div>
}
那么如果组件是 Selective Hydration , 那么注册组件的顺序服务端和客户端有可能不统一,这样表现就会不一致了。那么用 useId 动态生成 id 就不会有这个问题产生了,所以说 useId 保障了 React v18 中 streaming renderer (流式渲染) 中 id 的稳定性。
7. 函数组件使用注意事项
7.1 关于严格模式hooks的影响
严格模式启用了以下仅在开发环境下有效的行为:
组件将 额外重新渲染一次 以查找由于非纯渲染而引起的错误。
组件将 额外重新运行一次 Effect 以查找由于缺少 Effect 清理而引起的错误。
组件将 额外重新运行一次 refs 回调 以查找由于缺少 ref 清理函数而引起的错误。
组件将被 检查是否使用了已弃用的 API。
Effect 方法:修复在开发中通过重新运行 Effect 发现的错误。React 会在实际运行 setup 之前额外运行一次 setup 和 cleanup。这是一个压力测试,用于验证 Effect 的逻辑是否正确实现。如果出现可见问题,则 cleanup 函数缺少某些逻辑。cleanup 函数应该停止或撤消 setup 函数所做的任何操作。一般来说,用户不应该能够区分 setup 被调用一次(如在生产环境中)和调用 setup → cleanup → setup 序列(如在开发环境中)。
函数组件中的方法(useMemo):React 将调用你的某些函数两次而不是一次,这是符合预期的,不应对你的代码逻辑产生影响。这种 仅限开发环境下的 行为可帮助你 保持组件纯粹。React 使用其中一次调用的结果,而忽略另一次的结果。只要你的组件和计算函数是纯函数,这就不会影响你的逻辑。但是,如果你不小心写出带有副作用的代码,这可以帮助你发现并纠正错误。
严格模式官方文档:https://zh-hans.react.dev/reference/react/useMemo
当发现hooks 某些方法执行了两次可以考虑是否开启了严格模式。
8. hooks 使用规则
参考资料:
https://juejin.cn/post/7020811068955951135
https://zh-hans.legacy.reactjs.org/docs/hooks-rules.html
在react 文档中说明了使用hooks的两个规则:
1、只在最顶层使用 Hook。不要在循环,条件或嵌套函数中调用 Hook
确保总是在你的 React 函数的最顶层调用他们。遵守这条规则,你就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。这让 React 能够在多次的
useState
和useEffect
调用之间保持 hook 状态的正确。2、只在 React 函数中调用 Hook。不要在普通的 JavaScript 函数中调用 Hook。你可以:
- ✅ 在 React 的函数组件中调用 Hook
- ✅ 在自定义 Hook 中调用其他 Hook
这些规则确保了Hooks
的调用顺序在多次渲染之间保持一致。由于React
内部没有办法追踪到底有多少个状态或副作用需要被管理,它依赖于Hooks
的调用顺序来关联状态和副作用。
8.1 为什么不能在循环、条件或嵌套函数中调用Hooks
这么做的主要原因是保证有序调用hooks。React依赖于Hooks调用的顺序来正确地关联Hook的状态。如果在循环或条件语句中调用Hooks,每次迭代或条件判断都可能产生新的Hook状态,这会导致React无法正确地关联状态和更新。
1、每次调用 hooks
都会生成 hook
对象,在函数组件中多次使用hooks
,这会产生多个 hook
对象,这些hook
对象通过对象内的next
属性连接形成链表,连接是在挂载时进行的,
2、在更新时,每个hooks对象都是调用更新函数处理生成新的hook对象并连接形成hook链以供下次更新时使用。
3、正确使用hooks时,遍历hook链,复用hook上次更新的hook,没有问题,但如果某个hooks的调用与否是有条件的,那么情况就不同了。举个简单例子,如下使用hooks:
function FunComponent(props) {
if (props.condition) {
useEffect()
}
const [counter, setCounter] = useState(0)
const memo = useMemo()
}
3.1 假设第一次更新时props.condition === true
,那么 hook链是这样的:useEffect hook
=> useState hook
=>useMemo hook
。
3.2 第二次更新时props.condition === false
,useState首先执行,由于上一次的 hooks 链第一个是useEffect,返回的是effect
对象,counter
从数字变成了对象,渲染或利用counter
计算时肯定会报错的。
8.2 为什么只能在函数组件中调用 hooks ?
我们使用的hooks函数中都没有直接实现代码,而是调用一个叫做dispatcher
的对象中相应函数。源码地址
函数组件是在 renderWithHooks 函数内执行的。
因此只有函数当组件去使用才会调用hooks相关功能去实现hooks,