React提高09 Hooks
React Hooks学习笔记。
这篇文章真的写了好久,从1月多,Hooks还是实验特性时就开始看,中间断断续续,再加上一开始看英文的文档,拖到了现在。
总结的太磨叽了,又弄了一个精简版,那这个做分享吧。
心里也没底。
做了一个分享的PPT,如果有人需要的话可以拿走。
React Hooks是V16.8的新特性,是一个向后兼容的新特性(不会引入破坏性的改变)。
Hook是一种能够“侵入”React的函数组件的状态和生命周期特性的函数。Hook不能用在class
中,因为Hook的目的就是你能够抛弃class
来使用React
React提供了一些内嵌的Hooks,例如useState
。也可以创建自己的Hook,达到在不同的组件间复用有状态的行为。
引入的原因
实现比现有方案(HOC/Render Props)更优雅的代码复用,为纯组件引入状态,能够将组件划分为更细的粒度。
- 现有的React的状态组件复用方式(高阶组件、Render Props)有各自的问题, 而Hooks可以优雅的(不改变组件层次)实现代码复用
- Hooks可以将组件根据功能,将组件划分为更小的粒度,便于调试、测试和维护
- Hooks可以不使用
Class
来编写组件,提高代码性能,降低React的使用难度
Hooks的使用规定
(1)只在最顶层调用Hooks,不要在内部循环、条件语句或嵌套函数中调用Hooks(这是因为React是通过多个Hooks的调用顺序来确定多个useState
中状态变量的对应关系),如果想要有条件的运行一个useEffect
,可以将条件判断放在Hook内部
1 | useEffect(function persistForm() { |
(2)只在React函数中调用Hooks,不在普通的JavaScript函数中调用
可以通过ESLint的eslint-plugin-react-hooks
插件来检查、规范Hooks的使用,避免不规范的使用而导致的bug。
安装:
1 | npm install eslint-plugin-react-hooks -D |
ESLint的配置文件:
1 | { |
将来这个插件会默认集成在Create React App和类似工具中。
内置Hooks
React提供了一系列内置Hooks,共分为两大类,基础Hook和附加Hook。
基础Hook包括:
useState
useEffect
useContext
附加Hook包括:
useReducer
useCallback
useMemo
useRef
useImperativeHandle
useLayoutEffect
useDebugValue
useState
1 useState
- 基础用法
1 | import { useState } from 'react'; |
内置的useState
用来为纯组件添加状态变量和更新方法,可以认为是this.state
和this.setState
的简化版,以数组的形式获取状态变量,返回数组中的第一个项是一个状态,第二个是更新方法,useState
接受的参数是初始值。
当再次渲染时,React会在函数组件中获取count
的最新值,如果想要更新count
可以调用setCount
2 useState
- 手动合并
注意:useState
不会自动合并更新对象,所以需要手动进行合并,举个例子,在class组件中:
1 | class Test extends React.Component { |
点击按钮,setState
会自动将对象合并,打印结果是{a: 100, b: 2}
而在使用Hooks的组件中中:
1 | function Test() { |
useState
在更新时不会将对象合并,所以打印的结果是{a: 100}
,所以需要手动进行合并,采取函数式赋值的方式:
1 | setState(prevState => ({ ...prevState, a: 100 })) |
这样才能保证更新后的对象是我们想要的对象。
3 useState
- 延迟初始化
useState
的参数initialState
是首次渲染期间使用的状态,在后续的更新渲染过程中,它会被忽略,因为state
会采用上一次更新后最新的值,但是如果这个初始状态仍然会被计算一次。
1 | // 使用useCallback |
点击按钮,compute
函数每次都被调用。
如果这个状态是一个高开销的计算结果,可以改为提供函数,这个函数仅在初始渲染时执行,可以避免性能浪费:
1 | const [count, setCount] = useState(() => compute() + 1); |
在后续渲染时,初始状态计算就会被跳过了。
useEffect
1 useEffect
- 基础用法
1 | useEffect(didUpdate, dependencyArray); |
接受一个函数didUpdate
和依赖数组dependencyArray
(可选),默认在在每次渲染(首次渲染及后续更新)后执行didUpdate
方法。React会保证在DOM更新完成后才会调用effect。
通过使用这个Hook,React会保存传入的函数并且在每次DOM更新后进行调用。组件中的useEffect
可以获取函数的内部的所有变量和Prop,因为它已经在函数的作用域中了。
1 | useEffect(() => { |
useEffect
将Class组件的componentDidMount
/的componentDidUpdate
生命周期合并,逻辑更集中,而且可以减少因为没有在componentDidUpdate
处理更新前的状态而导致的bug。
可以再组件中使用多个useEffect
来分离关注点,这让我们能够基于代码的行为来分割代码,而不是基于生命周期。React会按照useEffect
声明的顺序,运行组件中的每一个useEffect
2 useEffect
- 销毁
如果在useEffect
中的更新函数中创建的一些事件需要在组件卸载时清理(比如定时器或者事件订阅等),
可以为didUpdate
更新函数返回一个新的函数,这个函数可以作为清理函数,用来执行销毁操作即可。和执行一样,销毁也是在每次渲染后都会执行,可以防止内存泄漏。
1 | useEffect(() => { |
3 useEffect
- 避免重复渲染
useEffect
默认的表现是在每次渲染后触发,当组件的任何一个状态发生改变时,更新函数都会执行。某些情况下,每次渲染都销毁或者应用effect会造成性能问题。在Class组件中,我们可以通过在componentDidUpdate
中对比prevProps
和prevState
来解决这个问题
如果在重复渲染时某些特定值未发生改变,你可以让React不再运行effect。具体做法是将一个数组作为可选的第二个参数传递给useEffect
。这时只有当数组中的任一一项的值发生变化,useEffect
的更新函数才会执行。
1 | useEffect(() => { |
这个数组并不会作为参数传递给更新函数内部,但是更新函数中引用的每个值都应该出现在输入数组中,这样才能避免更新函数依赖的某个值发生了变化,而函数没有重新执行(ESLint的插件会自动检测并插入这个数组,推荐使用)。
注意,如果进行这种优化,确保数组中包含了被更新函数使用的、会随时间变化的外部变量。否则你的代码从上次渲染中获取的参考值不会改变。
如果想只执行、销毁useEffect
一次(组件创建和销毁时),可以传递一个空数组作为第二个参数。这告诉了React这个useEffect
不依赖任何从props
和state
中任何变量,所以不需要重复执行。这种情况与只在componentDidMount
和componentWillUnmount
执行代码是类似的。建议谨慎使用,因为容易导致bug
4 useEffect
- 执行时机
useEffect
中的更新函数会延迟到layout
和paint
后触发,也就是说在浏览器更新屏幕之后才会触发,因为它所针对的事件是订阅等事件处理程序,不应该组织UI界面的更新。
但是有一些事件不能推迟,比如用户可见的DOM改变必须在下一次绘制之前同步触发,避免用户感觉到操作与视觉的不一致性。对于这个类型的事件需要在useLayoutEffect
中触发,它与useEffect
的不同就是在触发时机上的不同。
虽然useEffect
延迟到浏览器绘制完成之后执行,但是它保证在任何新渲染之前触发。
5 useEffect
- 在更新函数中获取本次渲染更新后的值
在useEffect
的更新函数中,拿到的state
和props
总是当次渲染的初始值,即便在更新函数中执行了setState
之后仍是这样。
看这样一个例子:
1 | export default function () { |
当我们点击按钮的时候,count
值变为1
,这个时候,useEffect1
和useEffect2
中都因为count
值变化而重新执行,打印的结果都是1
,UI界面也同步更新为1
对上面的例子稍加改造,在useEffect1
中添加setCount(100)
,再次点击按钮,看一下执行结果:
1 | export default function () { |
- 页面初始化,此时
count
值为0
,也就是说,本轮渲染的count
初始值是0
, - 执行
useEffect1
,对count
复制setCount(100)
,此时,在useEffect1
中,拿到的state
仍然是当次渲染的初始值,所以打印的结果是0 useEffect1
- 执行
useEffect2
,此时,在useEffect2
中,拿到的state
仍然是当次渲染的初始值,所以打印的结果是0 useEffect2
- 执行
return
部分,此时UI界面更新,获取到setCount(100)
后的count
值,所以此时界面展示100
- 由于
count
值有初始的0
变成了100
,所以useEffect1
和useEffect2
会再次被分别调用,和上一轮调用的唯一区别就是本次渲染count
的初始值变为了100
- 所以会分别打印
100 useEffect1
和100 useEffect2
- 由于
count
值稳定在了100
,所以useEffect
不会再被调用 - 如果点击按钮,执行过程是类似的
实际上useEffect
的执行时机与class组件的[setState
的执行时机]不完全想恶童(https://duola8789.github.io/2019/07/24/01%20%E5%89%8D%E7%AB%AF%E7%AC%94%E8%AE%B0/03%20React/React02%20%E6%8F%90%E9%AB%98/React%E6%8F%90%E9%AB%9801%20SetState%E7%9A%84%E6%89%A7%E8%A1%8C%E6%97%B6%E6%9C%BA/)类似:
setState
会不会立刻更新state
取决于调用setState
时是不是已经处于批量更新事务中。在批量更新事务中调用setState
不会立即执行,而是放到队列中等待批量更新事务结束后统一执行。组件的生命周期函数和绑定的事件回调函数都是在批量更新事务中执行的。
可以认为每次渲染时通过useState
声明的状态是不可变的(Immutable),每次渲染都会对它拍一个快照保存下来,当状态更新重新渲染时就会形成N个状态。不光是state
和props
通过快照的形式保存,组件的事件处理和useEffect
都是同样的形式。
我犯过的一个错误就是,在useEffect1
中通过setCount1
更新了count1
的值,而在useEffect2
中要使用更新后的count1
的值,这就会导致错误,因为在任何一个useEffect
中拿到的count1
的值是当次更新的count1
的初始值,而不会是在useEffect1
中更新后的值。
如何解决这个问题呢?我觉得有两个方法,一个是更好的组织useEffect
,一个useEffect
中不要完成过多的功能,更不要成为一个中间过程,为最终渲染的结果提供中间数据,而是让每个useEffect
都提供渲染需要的最终数据。
如果确实要在useEffect
的更新函数中使用更新后的state
,那么就需要使用React提供了另外一种内置Hook了,useRef
。
注意,在渲染结果中拿到的都是更新后的最新的
props
和state
,如果在渲染结果中出现了旧的props
和state
,那么很可能是遗漏了一些依赖,导致对应的useEffect
没有按照预期执行。还是推荐使用前面提到的ESLint的插件来帮助我们发现和解决问题。
useRef
1 | const refContainer = useRef(initialValue); |
useRef
返回一个可变的对象,其current
属性被初始化为传递的参数,返回的这个对象就保留在组件的生命周期中。
useRef
返回的ref
对象在所有Render过程中保持着唯一引用,如果认为state
是不可变的数据,那么ref
对象就可以认为是可变对象,对ref.current
的赋值和取值,拿到的都是同一个状态。
1 | export default function () { |
使用useRef
就可以在当次渲染获取到改变后的值,所以打印结果是:
1 | 100 "useEffect1" |
要注意,避免在渲染结果中(return
中)直接引用ref
对象,可能会导致预料之外的结果。相反,应该只在事件处理程序和useEffect
中修改、使用ref
对象。
useContext
1 | const context = useContext(MyContext); |
用来创建context
对象,参数接受一个React.createContext
的结果,返回改context
的当前值。当前的context
值由上层组件中距离当前组件最近的<MyContext.Provider>
的名为value
的Prop决定。
useContext(MyContext)
只是简化了子组件使用MyContext.Consumen
的方式,仍然需要在上层组件树中使用<MyContext.Provider>
来为下层组件提供context
。
不使用useContext
:
1 | // 创建一个 context 对象 |
使用useContext
进行简化:
1 | // 创建一个 context 对象 |
useReducer
1 | const [state, dispatch] = useReducer(reducer, initialState,initialAction); |
useState
的替代方案,当组件使用Flux架构组织管理数据时有用。
接受类型为(state, action) => newState
的Reducer,返回与dispatch
方法匹配的当前状态。initialAction
是可选的,提供初始的action
。
1 | const initialState = { count: 0 }; |
这个时候的state
由Reducer得来,更新方法dispatch
是匹配reducer的dispatch({type: 'type'})
更新方法。
用它配合useContext
可以避免在多层组件中深度传递回调的需要。
useCallback
1 | const memoizedCallback = useCallback( |
主要是用来处理在useEffect
之外的定义函数无法管理依赖,也无法成为useEffect
的依赖,每次渲染都会生成新的快照的情况,使用之后只有在函数的依赖发生变化时才会生成新的函数,有利于提高性能,依赖也更清晰。
如果一个函数依赖了组件的state
,并且由于复用的原因,不能放在useEffect
中,就将这个函数用useCallback
包装,返回的变量可以作为对应的useEffect
的依赖,当其依赖发生变化时,返回新的函数引用,同时触发对应的useEffect
重新执行。
我理解使用的原因主要出于性能优化和便于维护,例如,如果在组件中定义了一个函数:
1 | function fetch() { |
其中的useEffect
无法添加fetch
作为依赖,因为它是一个普通的函数,而且每次渲染fetch
都会生成一个快照,如果使用了useCallback
:
1 | const fetch = useCallback(() => { |
使用了useCallback
之后,依赖更清晰,并且在state
未发生变化时不会生成新的快照,有助于性能的提高。
useMemo
1 | const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]); |
与useCallback
类似,返回的是一个不生成快照的对象,而非函数。
useMemo
只会在其中一个输入发生更改时重新计算,此优化有助于避免在每个渲染上进行高开销的计算。
useLayoutEffect
前面介绍过,与useEffect
的不同点仅仅在于执行时机不同,useLayoutEffect
在绘制前同步触发,useEffect
会推迟到绘制后触发