本文总结了React(v16)中的常用Hooks,包括useState、useEffect、useMemo、useCallback、useRef等等......
useState
声明state
可以使用字面量或函数形式声明state的初始值
无论值为字面量还是函数,它都只用于初始化
建议使用函数返回值作为初始值的场景
- 初始值需要复杂计算
当初始值需要复杂计算时,如果我们将其写在组件内,那么组件每次重新渲染时,都会执行这个计算
TypeScript
// ❌
export default () => {
const initialCount = xxx * yyy
const [count, setCount] = useState(initialCount)
}
// ✅
export default () => {
const [count, setCount] = useState(() => {
return xxx * yyy
})
}
- 初始值是复杂的对象
如下第一种写法,会在组件每次重新渲染时,创建一个新的initialInfo对象
TypeScript
// ❌ Bad case
export default () => {
const initialInfo = {
name: 'xxx',
age: 15,
...
extra: {
...
}
}
const [info, setInfo] = useState(initialInfo)
}
// ✅ Good case
export default () => {
const [info, setInfo] = useState({
name: 'xxx',
age: 15,
...
extra: {
...
}
})
}
更新state
可以使用字面量或者函数形式更新state
使用函数形式时,函数的第一个参数是队列里面最新的state
TypeScript
export default () => {
const [count, setCount] = useState(0)
function update1() {
setCount(100)
}
function update2() {
setCount(count => count + 1)
}
}
使用immer更新对象类型的state
由于state是只读的、不可变的(immutable),所以只能通过setState
而不是直接给state赋值来驱动组件重新渲染
举个例子:要更新数组类型的state,需要把一个新的数组传入setState中,因此当我们需要使用如下表格中右边一列的方法
避免使用 | 推荐使用 | |
---|---|---|
添加元素 | push、unshift | concat、展开符(...) |
删除元素 | pop、shift、splice | filter、slice |
替换元素 | splice、arr[i] = ... 赋值 | map |
排序 | reverse、sort | 先复制一份:slice()或[...arr] |
使用immer可以让我们的代码更加简洁
举个例子:修改列表中特定元素的值
- 使用原生useState
- 使用map遍历列表
- 找到列表中的特定元素
- 新建一个对象并填入值
- 调用setState,传入map结果
TypeScript
import { useState } from 'react'
const initialList = [
{ id: 0, title: 'Big Bellies', seen: false },
{ id: 1, title: 'Lunar Landscape', seen: false },
{ id: 2, title: 'Terracotta Army', seen: true },
]
export default () => {
const [list, setList] = useState(initialList)
function updateListById({id, title, seen}){
setState(list => list.map(item => {
if (item.id === id) {
return {
id,
title,
seen
}
} else {
return item
}
}))
}
}
- 使用immer
- 调用immer的set方法
- 找到列表中的特定元素
- 直接修改元素的值
TypeScript
import { useState } from 'react'
import { useImmer } from 'use-immer'
const initialList = [
{ id: 0, title: 'Big Bellies', seen: false },
{ id: 1, title: 'Lunar Landscape', seen: false },
{ id: 2, title: 'Terracotta Army', seen: true },
]
export default () => {
const [list, setList] = useImmer(initialList)
function updateListById({id, title, seen}){
updateList(draft => {
const targetItem = draft.find(item => item.id === id)
if (targetItem) {
targetItem = {
id,
title,
seen
}
}
})
}
}
state更新原理
- 一个state的值永远不会在一次渲染的内部发生变化
当React重新渲染一个组件时
- React会再次调用当前函数
- 函数会返回新的JSX快照
- React会更新界面来匹配第2步生成的快照
例如:在一次事件处理函数中连续以字面量的形式、调用三次setState
TypeScript
export default function Counter() {
const [number, setNumber] = useState(0);
function onClick() {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}
return (
<>
<h1>{number}</h1>
<button onClick={onClick}>+3</button>
</>
)
}
每次点击button,最终结果只会让number
递增一次,例如在第一次点击button时,onClick
函数访问到的number
永远都是0,因此,第一次执行onClick
函数是这样的
TypeScript
function onClick() {
// React准备在下一次渲染时将number更改为1
setNumber(0 + 1)
// React准备在下一次渲染时将number更改为1
setNumber(0 + 1)
// React准备在下一次渲染时将number更改为1
setNumber(0 + 1)
}
例如:在事件处理函数中加上定时器,使得它在组件重新渲染之后才触发
TypeScript
export default function Counter() {
const [number, setNumber] = useState(0);
function onClick() {
setNumber(number + 5);
setTimeout(() => {
console.log(number)
}, 3000)
}
return (
<>
<h1>{number}</h1>
<button onClick={onClick}>+3</button>
</>
)
}
每次onClick
函数执行时,setTimeout读取的都是当前这个时刻的state值
因此,在第一次点击button后的3秒后,将会打印出number原来的值0
- 只有当事件处理函数里的所有同步代码执行完毕后,才会重新渲染 组件 ,也就是批处理
例如:在一个事件处理函数中更新多个state
TypeScript
export default function Counter() {
const [number, setNumber] = useState(0);
const [color, setColor] = useState('#fff')
function onClick() {
setNumber(number + 1);
setColor('#1664ff')
}
return (
<>
<h1>{number}</h1>
<button onClick={onClick}>+3</button>
</>
)
}
当点击button的时候,setNumber
和setColor
会在下一次渲染时一起更新
- 更新队列
- 每次setState的执行结果是:队列里面最新的state值发生改变
- 当setState使用函数形式时,函数的第一个参数是队列里面最新的state值
例如:在下次渲染前多次更新同一个state
(1)先替换,后更新
TypeScript
export default function Counter() {
const [number, setNumber] = useState(0);
function onClick() {
setNumber(number + 1)
setNumber(n => n + 1)
}
return (
<>
<h1>{number}</h1>
<button onClick={onClick}>+3</button>
</>
)
}
更新队列如下:
-
setNumber(number + 1)
- number是0,相当于执行
setNumber(1)
- 此时队列中最新的的值为1
- number是0,相当于执行
-
setNumber(n => n +1)
- 队列中最新的值为1
- 相当于执行setNumber(1 + 1)
- 此时队列中最新的值为2
因此,下一次渲染结果是:number = 2
(2)先更新,后替换
TypeScript
export default function Counter() {
const [number, setNumber] = useState(0);
function onClick() {
setNumber(n => n + 1)
setNumber(n + 10)
}
return (
<>
<h1>{number}</h1>
<button onClick={onClick}>+3</button>
</>
)
}
更新队列如下:
-
setNumber(n => n + 1)
- n是0,相当于执行
setNumber(1)
- 此时队列中最新的值为1
- n是0,相当于执行
-
setNumber(n + 10)
- n是0,相当于执行
setNumber(10)
- 此时队列中最新的值为10
- n是0,相当于执行
因此,下一次渲染结果是 number = 10
useEffect
基础用法
接收2个参数
- 第一个参数是回调函数callback
- 第二个参数是数组deps
每当数组deps中的状态发生变化的时候,就会执行一次callback
deps前后的比较是浅比较,即针对对象类型的状态,只比较引用地址是否相同
若不传第二个参数deps,那么每次组件重新渲染,都会执行一次callback
若第二个参数deps为空数组,那么只会在组件挂载时执行一次callback
回调函数callback允许返回一个回调函数cleanup
在下一次执行callback之前,会执行一次cleanup
举个例子:利用useEffect写一个每秒递增一个单位的计时器
❌错误写法:
TypeScript
function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => {
clearInterval(id)
}
}, [])
return <h1>{count}</h1>
}
实际结果:
- 进入页面后,count=0
- 设置定时器,定时器内
setCount(count + 1)
读到的count是0 - 1秒后,执行
setCount(0 + 1)
,组件重新渲染,count=1 - 1秒后,执行
setCount(0 + 1)
,组件不会重新渲染 - ...
✅正确写法:利用setState的函数形式,每次setState都基于最新的state进行递增
TypeScript
function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => {
clearInterval(id)
}
}, [])
return <h1>{count}</h1>
}
移除不必要的effect
- 根据props或state来更新state
假设组件包含2个state:firstName
和lastName
,你希望基于这2个state计算出fullName
❌错误写法:使用useEffect
监听firstName
和lastName
,在effect里面更新fullName
TypeScript
function F() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(firstName + ' ' + lastName)
}, [firstName, lastName])
}
✅正确写法:直接在渲染时计算这个值(当计算较为复杂时可以使用useMemo
包裹)
TypeScript
function F() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const fullName = firstName + ' ' + lastName
}
- 当props变化时重置所有state
假设组件接收一个props:userId
,你希望在userId
变化时,重置组件内部的所有state
❌错误写法:使用useEffect
监听userId
,在effect里面重置state
TypeScript
function F({userId}){
const [comment, setComment] = useState('')
useEffect(() => {
setComment('')
}, [userId])
}
当userId
变化时,组件首先会用旧的comment值渲染,触发effect执行setComment('')
后重新渲染
而且你可能需要在一个effect里面清除所有的state
✅正确写法:从父组件传递一个key
给子组件
TypeScript
function Parent() {
return (
<F key={userId} userId={userId} />
)
}
function F({userId}){
const [comment, setComment] = useState('')
}
当userId
变化时,React会直接销毁旧的组件F,并创建新的组件F
- 通知父组件有关state变化的信息
假设组件Toggle有一个state名为isOn
,你希望在它变化的时候通知父组件,于是组件Toggle将会接收一个onChange
的props
❌错误写法:使用useEffect
监听isOn
,在effect中调用onChange
TypeScript
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
每当isOn
更新的时候,Toggle组件会重新渲染一次,然后触发effect、调用onChange
,可能导致父组件重新渲染一次
✅正确写法:在setState更新isOn
的时候就调用onChange,让父组件和当前组件同时开启重新渲染
TypeScript
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateIsOn(value) {
setIsOn(value)
onChange(value)
}
function handleClick() {
updateIsOn(!isOn)
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateIsOn(true)
} else {
updateIsOn(false);
}
}
// ...
}
从effect中提取非响应式逻辑
假设组件ChatRoom的其中一个功能是给聊天室建立网络连接,并在聊天室roomId
变化时重新连接
那么就有如下的effect:当roomId
变化时,清除旧的连接,使用新的roomId
建立新的连接
TypeScript
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
Notification.show('Connected!');
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId])
}
假设你希望在原有代码的基础上,在展示连接通知的时候同时展示当前的主题theme
,这个主题的值从props当中获取
❌错误写法:把theme
加到effect的依赖项里
TypeScript
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
Notification.show('Connected!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme])
}
因为theme
也是一个依赖项,所以每次切换主题的时候,即使roomId
没有变化,effect也会执行,导致聊天室重新连接
换而言之,你不希望这行代码Notification.show('Connected!', theme)
是响应式的
✅写法1:封装showNotification
函数,在函数内读取theme
TypeScript
function ChatRoom({ roomId, theme }) {
function showErrorNotification(message) {
Notification.show(message, theme)
}
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showErrorNotification('connected!')
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId])
}
但这种写法会引起一个eslint的warning:缺失依赖项showErrorNotification
✅写法2:使用useEffectEvent 封装showNotification
函数
注意:截止到React17正式版,这个实验性API还没有正式发布
TypeScript
function ChatRoom({ roomId, theme }) {
const showErrorNotification = useEffectEvet((message) => {
Notification.show(message, theme)
})
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showErrorNotification('connected!')
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId])
}
showNotification
是effect逻辑的一部分,它内部的逻辑不是响应式的,而且能一直捕获到最新的props和state
需要注意的是useEffectEvent也有它的局限性
- 只能在effect内部调用它
- 不能把它传递给其他的组件或者hook
useMemo
接收2个参数,第一个参数是函数callback,第二个参数是依赖数组deps(同样是浅比较),返回某个js值
当依赖改变时,才重新执行callback计算返回值,适用于复杂计算的场景
TypeScript
function calculateNum(num: number) {
// 复杂计算
return ...
}
function App() {
const result = useMemo(() => {
return calculateNum(num)
}, [num])
}
类似的还有memo,它们之间的区别在于:
-
useMemo用于返回某个js值
-
memo用于返回一个React函数组件
memo接收2个参数,第一个参数是React函数组件,第二个参数是函数areEqual、返回值为布尔值
不传入第二个参数:会对该组件的props做浅比较,如果没有变化就不会重新渲染
传入第二个参数:用于自定义重新渲染的条件,接收旧props和新props,返回true则重新重新
TypeScript
const App = memo(({list}: {list: number[]}) => {
return xxx
}, areEqual)
function areEqual(prevProps, nextProps) {
if(prevProps.list.length === nextProps.list.length) {
return false
}
return true
}
useCallback
基础用法
接收2个参数,第一个参数是原函数callbck,第二个参数是依赖数组deps,返回一个callback的memoized版本
函数组件每次渲染时,组件内部定义的函数引用都会更新
常见的使用场景是:
- 子组件接收一个来自父组件的函数A作为入参
- 当父组件重新渲染时,函数A的引用更新,导致子组件也重新渲染
TypeScript
function Parent() {
function A() {
}
return <Child onOk={A} />
}
function Child({onOk}) {
return <div>child</div>
}
为避免这种情况,就用memo包裹子组件,用useCallback包裹函数A
TypeScript
function Parent() {
const A = useCallback(() {
}, [])
return <Child onOk={A} />
}
const Child = memo(({onOk}) {
return <div>child</div>
})
常见问题
- 隐式依赖
当useCallback的依赖项更新时
- 依赖数组deps包含了此callback的 其他callback的引用也会随之更新
- 依赖数组deps包含了此callback的 useEffect会执行
这就造成了依赖的高耦合,同时不容易被人察觉
- 依赖链路
TypeScript
const callbackC =() => {
// do something
};
const callbackB =() => {
callbackC();
// do something
};
const callbackA = useCallback(() => {
const res = xxx /xxxx
const res2 = callbackB(res);
}, [xxx, xxxx]);
当函数A调用了函数B,函数B调用了函数C的时候·
如果你想用useCallback包裹函数A,那么eslint会提示你用useCallback同时包裹函数B和函数C
最佳实践
- 没有访问组件内部的state,可以直接把函数定义在组件外面
- 访问了组件内部的state,使用ahooks的useMemoizedFn包裹函数
综上所述:我们的目的可以概括为2点
- 保持函数引用不变
- 使函数内部可以访问到最新的state
useRef
使用useRef需要注意:
- 不要在渲染过程(即jsx)中访问ref.current值
- 不要基于ref.current的值去写入ref.current值
使用ref引用值
当你需要在组件内维护某个状态,但又不希望此状态更新触发组件重新渲染时,可以使用useRef
因为使用useRef声明的值,它的引用在组件整个生命周期内都不会改变
✅推荐使用ref引用值的场景:存储timeoutID、intervalID
例如:你希望在组件挂载后,每隔1秒更新count值并打印在屏幕上
TypeScript
function App() {
const [count, setCount] = useState(0)
const intervalRef = useRef(null)
useEffect(() => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}, [])
return <h1>{count}</h1>
}
使用ref操作DOM
在单个组件内使用时,把ref挂在DOM节点上,这样就可以通过ref调用DOM的一些API
TypeScript
function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>
聚焦输入框
</button>
</>
);
}
当我们需要访问另一个组件的DOM节点时,需要配合forwardRef
和useImperativeHandle
一起使用
在编写代码前梳理清楚:
- 父组件:需要调用子组件的方法or访问子组件的数据
- 子组件:需要被调用或访问的组件
- 需要被调用的方法or需要被访问的数据是哪些
父组件App,定义并传递ref给子组件
TypeScript
export default function App() {
const inputRef = useRef(null);
const onBtnClick = () => {
console.log(inputRef.current?.data);
};
return (
<div className="App">
<h2 onClick={onBtnClick}>点击这里打印数据data</h2>
<Child ref={inputRef} />
</div>
);
}
子组件Child
- 用
forwardRef
包裹组件 - 用
useImperativeHandle
定义 需要被调用的方法或数据
TypeScript
const Child = forwardRef((props, ref) => {
const [count, setCount] = useState(0)
useImperativeHandle(ref, () => ({
data: {
count
}
}))
return <h1>{count}</h1>
})