React Hooks API精讲与使用场景分析—让代码更简单
Author:zhoulujun Date:
组件 = UI + 逻辑,长期以来我们都在考虑 UI 的复用,在逻辑这一层面却不容易很好的复用。
Hook 是 React 16.8 的新增特性,在 Hooks 出现之前,我们有两种方法可以复用组件逻辑:Render Props 和HoC(高阶组件)。
不管是 render props 还是高阶组件,他们要做的都是实现状态逻辑的复用,可这俩是完美的解决方案吗?
考虑一下,如果我们依赖了多个需要复用的状态逻辑的时候,该怎么写呢?肯定会想到深度嵌套——“回调地狱”支配的恐惧。
而组件嵌套过深之后,会给调试带来很大的麻烦。
Hooks 的出现,让组件逻辑的复用变得更简单,同时解决了「嵌套地域」的问题。
React Hooks 带来的好处不仅是 “更 FP,更新粒度更细,代码更清晰”,还有如下三个特性:
多个状态不会产生嵌套,写法还是平铺的(renderProps 可以通过 compose 解决,可不但使用略为繁琐,而且因为强制封装一个新对象而增加了实体数量)。
允许函数组件使用 state 和部分生命周期
Hooks 可以引用其他 Hooks。
更容易将组件的 UI 与状态分离。
Hook API
建议先过一遍,https://react.docschina.org/docs/hooks-reference.html
钩子名 | 作用 |
---|---|
useState | 初始化和设置状态 |
useEffect | componentDidMount/componentDidUpdate/componentWillUnmount 结合体,所以可以监听 useState 定义值的变化 |
useContext | 定义一个全局的对象,类似 Context |
useReducer | 可以增强函数提供类似 Redux 的功能 |
useCallback | 记忆作用,共有两个参数,第一个参数为一个匿名函数,就是我们想要创建的函数体,第二参数为一个数组,里面的每一项是用来判断是否需要重新创建函数体的变量,如果传入的变量值保持不变,返回记忆结果,如果任何一项改变,则返回新的结果 |
useMemo | 作用和传入参数与 useCallback 一致,useCallback 返回函数,useDemo 返回值 |
useRef | 获取 ref 属性对应的 DOM |
useImperativeMethods | 自定义使用 Ref 时公开给父组件的实例值 |
useMutationEffect | 作用与 useEffect 相同,但在更新兄弟组件之前,它在 React 执行其 DOM 改变的同一阶段同步触发 |
useLayoutEffect | 作用与 useEffect 相同,但在所有 DOM 改变后同步触发 |
我们下面来看几个平时使用频率较高的 Hook
useState
const [state, setState] = useState(initialState); 返回一个 state,以及更新 state 的函数。
在初始渲染期间,返回的状态 (state) 与传入的第一个参数 (initialState) 值相同—— 参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用。思考领悟:为什么叫做 useState 而不是 createState ?
setState 函数用于更新 state。它接收一个新的 state 值并将组件的一次重新渲染加入队列。
在后续的重新渲染中,useState 返回的第一个值将始终是更新后最新的 state。
React 会确保 setState 函数的标识是稳定的,并且不会在组件重新渲染时发生变化。这就是为什么可以安全地从 useEffect 或 useCallback 的依赖列表中省略 setState。
更新函数返回值与当前 state 完全相同,则随后的重渲染会被完全跳过
React 使用 Object.is 比较算法 来比较 state,setState() 自动具备浅合并功能,useState()更新引用需要手动浅合并。
useState提供的状态更新函数不会自动合并更新对象,而是替换它,所以你需要手动合并更新(如果是对象的话)。setState在更新时会将新的状态对象与当前状态合并。
useState可以在组件的任何地方使用,但必须位于函数的顶层(不能在循环、条件或嵌套函数中调用)。
使用多个 state 变量来保存 state
const [width, setWidth] = useState(100); const [height, setHeight] = useState(100); const [left, setLeft] = useState(0); const [top, setTop] = useState(0);
使用多个 state 变量可以让 state 的粒度更细,更易于逻辑的拆分和组合。
所有的 state 放到一个 object 中(单个state)
const [state, setState] = useState({ width: 100, height: 100, left: 0, top: 0 });
使用单个 state 变量,每次更新 state 时需要合并之前的 state。因为 useState 返回的 setState 会替换原来的值。这一点和 Class 组件的 this.setState 不同。
将完全不相关的 state 拆分为多组 state。比如 size 和 position。
如果某些 state 是相互关联的,或者需要一起发生改变,就可以把它们合并为一组 state。比如 left 和 top。
有点像Vue vuex mapState mapMutations 联合体,只是类比,推荐《一文彻底搞懂 react hooks 的原理和实现》
快照(闭包) vs 最新值(引用)
function state 保存的是快照,class state 保存的是最新值。
引用类型的情况下,class state 不需要传入新的引用,而 function state 必须保证是个新的引用。
class App extends Component { state = { count: 0 } increment = () => { setTimeout(() => { this.setState({ count: this.state.count + 1 }) }, 1000) } render() { return <h1 onClick={this.increment}>{this.state.count}</h1> } }
如果是这段代码呢?它又会是什么表现?
function App() { const [ count, setCount ] = useState(0) const increment = () => { setTimeout(() => { setCount(count + 1) }, 1000) } return <h1 onClick={increment}>{count}</h1> }
在第一个例子中,连续点击十次,页面上的数字会从0增长到10。而第二个例子中,连续点击十次,页面上的数字只会从0增长到1。
这个是为什么呢?其实这主要是引用和闭包的区别。
class 组件里面可以通过 this.state 引用到 count,所以每次 setTimeout 的时候都能通过引用拿到上一次的最新 count,所以点击多少次最后就加了多少。
在 function component 里面每次更新都是重新执行当前函数,也就是说 setTimeout 里面读取到的 count 是通过闭包获取的,而这个 count 实际上只是初始值,并不是上次执行完成后的最新值,所以最后只加了1次。
思考下,如何去改?可以将 setCount 的参数改为一个函数,这个函数会接收当前的状态作为参数,并返回新的状态值。
const increment = () => { setTimeout(() => { setCount(prevCount => prevCount + 1); }, 1000); };
也可使用 useRef 来保存最新的 count 值
const [count, setCount] = useState(0); const countRef = useRef(count); useEffect(() => { countRef.current = count; }, [count]); const increment = () => { setTimeout(() => { setCount(countRef.current + 1); }, 1000); };
useReducer
const [state, dispatch] = useReducer(reducer, initialArg, init); 跟useState是一样,只是接收的是redux的reduce
const initialState = {count: 0}; function reducer(state, action) { switch (action.type) { case 'increment': return {count: state.count + 1}; case 'decrement': return {count: state.count - 1}; default: throw new Error(); } } function Counter() { const [state, dispatch] = useReducer(reducer, initialState); return ( <> Count: {state.count} <button onClick={() => dispatch({type: 'decrement'})}>-</button> <button onClick={() => dispatch({type: 'increment'})}>+</button> </> ); }
React 不使用 state = initialState 这一由 Redux 推广开来的参数约定。有时候初始值依赖于 props,因此需要在调用 Hook 时指定。如果你特别喜欢上述的参数约定,可以通过调用 useReducer(reducer, undefined, reducer) 来模拟 Redux 的行为,但我们不鼓励你这么做。
局部状态不推荐使用 useReducer ,会导致函数内部状态过于复杂,难以阅读。 useReducer 建议在多组件间通信时,结合 useContext 一起使用。
useEffect
赋值给 useEffect 的函数会在组件渲染到屏幕之后执行。类似vue的 $nextTick,不同的是:组件卸载时需要清除 effect,初始化时候执行
赋值给 useLayoutEffect 的函数会在所有的 DOM 变更之后同步调用
useEffect(() => {//useEffect 的代码既会在初始化时候执行,也会在后续每次 rerender 时执行,而返回值在析构时执行。 const subscription = props.source.subscribe(); return () => { // 清除订阅 subscription.unsubscribe(); }; },[props.source]);//只有当第二参数里面的值改变后才会重新创建订阅。不传为任意改变都创建
第二个参数「dependency array」(依赖数组),只有当依赖数组发生变化时,才会执行 useEffect 的回调函数。但是,如果有遗漏,可能会造成 bug。这其实就是 JS 闭包问题。
接收依赖数组作为参数的 Hook 还有 useMemo、useCallback 和 useImperativeHandle。
依赖数组中千万不要遗漏回调函数内部依赖的值。
请记得 React 会等待浏览器完成画面渲染之后才会延迟调用 useEffect,因此会使得处理额外操作很方便。
useContext
const value = useContext(MyContext); 接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。当前的 context 值由上层组件中距离当前组件最近的 <MyContext.Provider> 的 value prop 决定。
当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值。即使祖先使用 React.memo 或 shouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染。
调用了 useContext 的组件总会在 context 值变化时重新渲染。如果重渲染组件的开销较大,你可以 通过使用 memoization 来优化。
useRef
const refContainer = useRef(initialValue); useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。 这个我也是懵懵懂懂的
function TextInputWithFocusButton() { const inputEl = useRef(null); const onButtonClick = () => { // `current` 指向已挂载到 DOM 上的文本输入元素 inputEl.current.focus(); }; return ( <> <input ref={inputEl} type="text" /> <button onClick={onButtonClick}>Focus the input</button> </> ); }
本质上,useRef 就像是可以在其 .current 属性中保存一个可变值的“盒子”。
useCallback和useMemo
返回一个 memoized 回调函数 和 memoized 值。
可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。
memo
在计算机领域,记忆化是一种主要用来提升计算机程序速度的优化技术方案。它将开销较大的函数调用的返回结果存储起来,当同样的输入再次发生时,则返回缓存好的数据,以此提升运算效率。
在 《JavaScript 忍者秘籍》的 3.2.2 节中「自记忆函数」中有这样的介绍:记忆化是一种构建函数的处理过程,能够记住上次计算结果。在这个果壳里,当函数计算得到结果时就将该结果按照参数存储起来。采用这种方式时,如果另外一个调用也使用相同的参数,我们则可以直接返回上次存储的结果而不是再计算一遍。像这样避免既重复又复杂的计算可以显著地提高性能。
之前只能使用 class component 来利用 PureComponent 带来的性能优势
React.memo() 是什么?
React.memo() 和 PureComponent 很相似,它帮助我们控制何时重新渲染组件。
组件仅在它的 props 发生改变的时候进行重新渲染。通常来说,在组件树中 React 组件,只要有变化就会走一遍渲染流程。但是通过 PureComponent 和 React.memo(),我们可以仅仅让某些组件进行渲染。
const MySnowyComponent = React.memo(function MyComponent(props) { // only renders if props have changed! }); // can also be an es6 arrow function const OtherSnowy = React.memo(props => { return <div>my memoized component</div>; }); // and even shorter with implicit return const ImplicitSnowy = React.memo(props => ( <div>implicit memoized component</div> )); // 包裹已有的组件 const RocketComponent = props => <div>my rocket component. {props.fuel}!</div>; // create a version that only renders on prop changes const MemoizedRocketComponent = React.memo(RocketComponent);
React.memo 适用函数式组件
useMemo本身也有开销。useMemo 会「记住」一些值,同时在后续 render 时,将依赖数组中的值取出来和上一次记录的值进行比较,如果不相等才会重新执行回调函数,否则直接返回「记住」的值。这个过程本身就会消耗一定的内存和计算资源。因此,过度使用 useMemo 可能会影响程序的性能。
要想合理使用 useMemo,我们需要搞清楚 useMemo 适用的场景:
有些计算开销很大,我们就需要「记住」它的返回值,避免每次 render 都去重新计算。
由于值的引用发生变化,导致下游组件重新渲染,我们也需要「记住」这个值。
虽然 Hooks 看起来更酷炫,更简洁。但是在实际开发中我更倾向于使用 Class 来声明组件。
参考文章:
React 16.6 之 React.memo() https://www.jianshu.com/p/9293daab4161
React Hooks 你真的用对了吗? https://zhuanlan.zhihu.com/p/85969406
一篇看懂 React Hooks https://zhuanlan.zhihu.com/p/50597236
React Hooks 不完全总结 https://www.ahonn.me/blog/react-hooks-incomplete-summary
转载本站文章《React Hooks API精讲与使用场景分析—让代码更简单》,
请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/jsBase/2020_0628_8491.html