Hooks 各个击破
大纲链接 §
[toc]
React内置的Hooks主要有以下几个:
useState组件状态useEffect副作用useLayoutEffect
useContext上下文useMemo缓存useCallback回调
useReducer统一状态管理(替代Redux)useRef引用useImperativeHandle
useDebugValue自定义
1. useState ⇧
使用组件状态
const [n, setN] = React.useState(0)原始类型const [user, setUser] = React.useState({name: 'F'})复杂类型
注意事项1:不可局部更新属性,不自动合并 ⇧
- 如果
state是一个复杂类型(对象等),禁止部分setState,示例代码- 因为
setState不会自动合并属性,缺失没有写入的属性 useReducer也不会自动合并
- 因为
- 解决方法:使用
...,在对象中拷贝原状态的属性,再局部覆盖同名属性
|
|
注意事项2:地址要变 ⇧
setState(obj)如果obj不变,那么React中数据不变React只侦测对象地址变化,不侦测同对象的属性变化
|
|
注意事项3 接受函数 ⇧
useState接受函数:const [x, setX] = useState(()=>{})
const [state, setState] = useState( () => {return initialValue} )const [state, setState] = useState( () => ({/*initialValue*/}) )- 该函数返回初始
state,且只执行一次- 区别于
useState传对象,JS每次会解析该对象 useState传工厂函数,减少内部多余的计算过程
- 区别于
setState接受函数:setState(()=>{})
setN(i => i + 1)- 代码示例
|
|
setN(i => i + 1)并不直接传值,而是传一个函数来操作,形参占位符i命名随便- 如果需要对
setN多次操作,更推荐使用函数
2. useReducer ⇧
用来践行
Flux/Redux的思想
- 代码示例,共分4步走
- 1.创建初始值
initialValue - 2.创建所有操作
reducer(state, action) - 3.传给
useReducer(所有操作, 初始数据),获取 读 和 写 的API - 4.调用 写
dispatch({type: '操作类型'}),不是直接操作state,而是通过reducer来操作
- 1.创建初始值
- 总的来说
useReducer就是useState的复杂版
|
|
- 不可以直接操作属性
state.n += 1; return state同useState的规则 - 对多个状态做整体操作
- 将所有操作聚拢在函数
const reducer = (state, action) => {}中- 通过
action的不同属性,来调用对应不同的分支操作 - 通过
action还可以传额外属性
- 通过
- 每次用户操作更新数据,
App就重新render一遍
如何判断使用
useReducer还是useState
- 事不过三原则:有多个数据可以在一起放入同一个对象里,就适用
useReducer,对这个对象做整体操作 - 例如表单中的数据,见 用
useReducer表单代码示例:
|
|
3. 用 useReducer 代替 redux ⇧
辅助函数和基本结构 ⇧
辅助函数,假的
AJAX获取数据,调用时传入参数url,返回对应数据
|
|
基本结构
|
|
复杂useReducer使用步骤 ⇧
- 将数据几种在一个
store对象中 - 将所有操作集中在
reducer方法中 - 创建一个
Context - 创建对数据的读写
API - 将第四步的内容
{state, dispatch}放到第三步的上下文数据提供组件中 - 用
Context.Provider将Context提供给所有子组件
<Context.Provider value={{state, dispatch}}>{/* 里面是各个子组件 */}</Context.Provider>
- 各个组件用
useContext获取读写API:const {state, dispatch} = useContext(Context)(注意是获取对象使用花括号)
- 请求获取
mock数据,ajax('/user').then(user => {dispatch({type: 'setUser', user})}(ES6对象属性简写) - 每次刷新组件时,都会再去请求一次
AJAX,为了防止每次渲染就请求一次,可以使用useEffect只在进入页面时请求一次useEffect(() => {}, []),第二个参数传一个空数组,那么只会在第一次进入页面时运行,之后永远不会再执行(之后有更好的方式)
|
|
模块化处理 ⇧
目录
|
|
main.jsx
|
|
- 注意使用策略模式(表驱动)重构
reducer代码。如下,之后可拆分为模块文件,分别导入,方便增加其他接口
|
|
|
|
use_reducer
|
|
books_reducer
|
|
movies_reducer
|
|
User.jsx
|
|
Books.jsx
|
|
Movies.jsx
|
|
Context.js
|
|
ajax.js
|
|
- 小结:使用
useReduce和useContext/createContext就可以代替Redux - 但更复杂的场景,可使用
Redux-Saga等 - 在自定义hooks中可实现完全代替
4. useContext ⇧
上下文
- 全局变量 是 全局的 上下文
- 上下文 是 局部的 全局变量
使用方法,可分为三步
- 使用
const Ctx = createContext(initialValue)创建上下文- 初始值
initialValue可以为null
- 初始值
- 使用
<Context.Provider></Context.Provider>圈定作用域范围,在标签内部写子组件- 在此标签上添加需要传入的初始属性
value,一般是一个包含读和写接口的对象 <Context.Provider value={{state, dispatch}}></Context.Provider>- 也可声明中间变量
const contextValue = { state, dispatch }直接传这个对象<Context.Provider value={contextValue}></Context.Provider>
- 在此标签上添加需要传入的初始属性
- 在作用域内,使用
useContext(Ctx),来使用上下文- 获取读和写的接口
const {state, dispatch} = useContext(Ctx)
- 获取读和写的接口
|
|
useContext将数据传递给圈定的作用域内所有子孙代组件- 可以配合
useState或者useReducer来获取读写接口
使用useContext注意事项 ⇧
区别于
Vue,数据更新不是响应式的,而只是一个重新渲染的过程(从上而下逐级通知setN重新渲染)
- 在一个模块中,将
Ctx里的值改变(动态Context) - 在另一个模块不会感知到这个变化
- 在某个子组件中触发
setN变更数据,通知根组件<App />变更,然后 自顶向下,逐级 通知使用到数据的组件去更新数据 - 数据流向始终是单向的
5. useEffect & useLayoutEffect ⇧
useEffect ⇧
副作用(函数式概念)
- 对环境的改变即为 副作用,例如修改
document.title - 但不一定非要把副作用放在
useEffect里 - 实际上,称为
useAfterRender更好,即每次render后运行
用途
- 模拟组件生命周期的三个钩子函数
- 作为
componentDidMount使用,将空数组[]作为第二个参数 - 作为
componentDidUpdate使用,第二个参数指定依赖[n]放在数组中 - 作为
componentWillUnmount使用,通过return () => {}- 路由中常用,在此钩子中做清理工作,防止内存泄漏
- 对回京做的任何变动,组件卸载时都需要清理
- 作为
- 第二个参数 和 返回的回调 决定了回调函数什么时候再次执行
- 以上三种用途可同时存在
特点
- 如果同时存在多个
useEffect,会按照 出现次序 执行
|
|
useLayoutEffect ⇧
useEffect在浏览器渲染完成后执行,代码示例
|
|
- 页面渲染时有一个闪烁,
value从0迅速变为1000
useLayoutEffect称为 “布局副作用”, 在浏览器渲染前执行
|
|
- 页面没有出现闪烁问题
- 有很多
DOM操作,浏览器会等DOM操作完了再渲染useEffect在浏览器渲染改变屏幕像素之后useLayoutEffect在DOM操作完了,紧接着执行回调(对Layout有影响的操作),然后再去渲染(改变屏幕像素之前)
useEffect V.S. useLayoutEffect ⇧
- 通过时间点来侧面证明,代码示例
|
|
- 全局API
performance.now()可以记录当前时间 - 从第二次点击开始计算
useEffect总是比useLayoutEffect慢,是因为useEffect在改变外观之后执行,而useLayoutEffect在改变外观之前执行
特点
useLayoutEffect总是比useEffect先执行,代码示例useLayoutEffect里的回调最好时改变Layout,否则没有意义
经验
- 虽然可以避免屏幕闪烁,但大部分时候不会去改变
DOM,所以并不是总是使用useLayoutEffect更好 - 为了用户体验,优先使用
useEffect(优先渲染)useLayoutEffect操作的优先级,但会影响(延长)用户看到画面变化的时间
6. useMemo ⇧
使用useMemo前,先需要理解 React.memo ⇧
React默认总是有多余的render,即使渲染的数据没有发生改变,代码示例
|
|
Child只依赖了{m},而当数据{n}改变了,重新渲染App,连带着也会重新渲染Child,即使{m}未发生改变- 如果
props不变,就没有必要再次执行一个函数组件 - 代码中的
Child用React.memo(Child)代替,封装一层
变更后的效果,见示例代码
|
|
React.memo(Child)使得一个组件只有在依赖(例如props)变化时,才会再次执行渲染
简化代码
|
|
React.memo的bug ⇧
|
|
- 在监听了函数之后,一秒破功,
Child2居然又执行了 - 因为
App每次重新运行时,都会在执行const onClickChild = () => {}处,生成新的函数 - 新旧函数虽然功能一样,但是内存地址不一样
配合使用useMemo解决React.memo的问题
useMemo可以实现函数的缓存,比对函数内容,相同的话使用之前的缓存的函数useMemo的返回值就是需要缓存的函数- 使用
useMemo来返回出需要传入子组件的函数,并给定依赖作为第二个参数
|
|
useMemo特点
- 第一个参数是
() => value,见源码useMemo的TS类型声明 - 第二个参数是依赖
[m, n] - 只有当依赖变化时,才会计算出新的
value - 如果依赖不变,那么就重用之前的
value - 这与
Vue的computed相类似,用来缓存依赖一些不变化的数据
useMemo注意事项
- 如果
value传的是函数,那么就要写成useMemo(() => (x) => {}),这是一个返回函数的函数(高阶函数/组件)
形式上复杂难用,于是就有了
useCallback
useCallback是useMemo的语法糖
用法
- 使用
useCallback(x => consolog(x), [m]) - 等价于
useMemo(() => x => console.log(x), [m])
7. useRef ⇧
useRef用途: ⇧
- 如果需要一个值,在组件不断
render时保持不变,可以使用useRef- 初始化:
const count = useRef(0) - 读取值:
count.current
- 初始化:
- 区别于
setN,n每次渲染都会变更
需求:要知道组件渲染了几次,使用一个变量记录(全局变量会污染命名环境)
- 使用
useRef,每次渲染时更新useEffect(() => {count.current += 1}),得到一个新的count,而count.value在内存中的引用不变 - 渲染
count对象地址不变,其中的current值被记录在useRef对应的一个fiberNode上,示例代码
|
|
useRef读取值为什么需要.current? ⇧
- 为了保持新旧组件更新前后两次
useRef是同一个引用对象(只有引用能做到,保持引用地址不变)
对比三个数据变化的钩子函数
useState/useReducer:n每次都变useMemo/useCallback:在依赖[n]变更时,对应的函数fn改变,有条件地变useRef:n永远不变
延伸
Vue3的ref()- 初始化:
const count = ref(0) - 读取值:
count.value
- 初始化:
- 不同点:
- 当
count.value变化时,Vue3会自动render,帮你实现了UI更新 - 而
React中使用useRef不会自动更新UI,代码示例
- 当
|
|
useRef不能做到变化时自动render刷新UI,需要额外步骤 ⇧
- 因为不符合
React的函数式理念:UI = render(data)- 每次
data变化时,使用函数(例如setState)去渲染成新的虚拟DOM - 将新的虚拟
DOM与页面上旧的虚拟DOM做DOM Diff,得到一个patch补丁 - 根据这个
patch补丁去重渲染DOM
- 每次
- 如果想要变化时自动
render,可以自定义去实现(使用setN),React不管 - 监听
ref,当ref.current变化时,再加一个依赖,调用setX即可,示例代码
|
|
8. useRef配合forwardRef用在函数组件中 ⇧
基本用法:让函数组件
<Button2 />支持ref,获取其虚拟DOM的引用
- 代替原生使用
id,和document.getElementById来获取元素
函数组件中会警告无法直接使用
useRef代码示例1:props无法传递ref属性
|
|
- 报警告
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()? useRef作为元素的引用是在类组件中使用的,即只有类组件可以直接接受ref元素引用- 提示函数组件去使用
forwardRef - 组件内打印
console.log("props", props);得到object {children: "按钮"},只把按钮传过去了,其他属性都忽略了
代码示例2:函数组件中使用
React.forwardRef包裹一层组件,可声明第二个形参ref,实现函数组件ref的组件间传递
|
|
- 函数组件需要接受外部传来的
ref参数,必须用React.forwardRef包起来<Button ref={buttonRef}>...</Button>const Button = React.forwardRef((props, ref) => (<button ref={ref} {...props} />))
代码示例3:两次
ref传递得到button的引用,实现可移动的按钮(高阶组件封装)(仍有bug,待修复)
- 功能包装:
const MovableButton = movable(Button2) - 通过
ref引用到Button2里面的按钮:const buttonRef = useRef(null),需要做两次传递- 第一次 将
<MovableButton name="email" ref={buttonRef}>传给movable(Component)中返回的<Component ref={ref} {...props} />- 即
movable(Button2)中返回的<Button2 ref={ref} {...props} />
- 即
- 第二次
Button2组件声明时传递React.forwardRef((props, ref) => { return <button ref={ref} {...props} />; });
- 第一次 将
|
|
useRefV.S.forwardRef
- 由于大部分时候不需要,所以
props不包含ref - 由于函数组件的参数
props不包含ref,所以需要React.forwardRef包裹一层,才可以传递ref React.forwardRef就是在函数组件中用来转发ref的
9. useImperativeHandle ⇧
Imperative重要的- 命名太长:其实意思就是
setRef:用来变更ref - 作用是自定义
ref的属性
不用
useImperativeHandle的 代码示例
|
|
- 如果希望得到的
ref是一个对button的自定义封装对象,则需要使用useImperativeHandle
使用
useImperativeHandle的 代码示例
|
|
10. 自定义 Hook ⇧
- 使用自定义
Hook来封装数据操作
简单代码示例
- 直接获取处理过的数据
const { list /*, setList */ } = useList(); - 处理数据的逻辑封装在自定义
Hook中,只暴露读和写的接口 - 初始化状态
const [list, setList] = useState(null) DidMount时 请求数据- 返回出读和写的接口
- 处理相关的逻辑都写在一个钩子函数中,运行时暴露出读和写的接口
|
|
useList.js
|
|
功能更全的代码示例
- 不仅提供了读和写的借口,增删改查等其他功能
|
|
useList.js
|
|
分析
- 还可以在自定义
Hook里使用Context useState只说了不能在if里,没说不能在函数里运行- 只要这个函数在函数组件里运行即可
- 实际业务中,使用良好的组件封装,更抽象的自定义
Hook封装,来代替页面组件中直接使用useState、uesEffect等钩子 - 使用函数组件,就没有必要使用
Redux了
11. Stale Closure过时的闭包 ⇧
- 期待打印自增的
count - 结果每次打印都是相同的
count - 打印的
count是过时的闭包,而不是后续操作产生的新的count
|
|
- 把依赖
[count]放入useEffect,更新DidUpdate时同时更新闭包 - 同时卸载
WillUnmount时(return () => {}) 清除上一次组件卸载时的id,来干掉旧的闭包 - 代码中的两处
id调用时机不同
|
|
- 避免保留下过时的闭包,每次更新前需要重新获取新的闭包
- 或者在返回的函数中 读取最新的闭包,而不是在返回的函数外
题外话
JS中有许多需要避免的bug或者反模式- 隐式类型转换,用
=== - 过时的闭包
- 空值判断,用
?? - 异步
- 过长的条件分支,用策略模式(表驱动)
- 隐式类型转换,用
12. 总结 ⇧
|
|
参考文章 ⇧
相关文章 ⇧
- 作者: Joel
- 文章链接:
- 版权声明
- 非自由转载-非商用-非衍生-保持署名