回复 刷新

暂无评论

React Hook 概览与最佳实践

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

动机

Hook 解决了以下问题

在组件之间复用状态逻辑很难

  • React 没有提供将可复用性行为 “附加” 到组件的途径(例如,把组件连接到 store)。

  • 解决此类问题可以使用 render props 和 高阶组件,但是这类方案需要重新组织你的组件结构,使你的代码难以理解。

  • 由 providers,consumers,高阶组件,render props 等其他抽象层组成的组件会形成 “嵌套地狱”。

使用 Hook 从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。Hook 使你在无需修改组件结构的情况下复用状态逻辑。 这使得在组件间或社区内共享 Hook 变得更便捷。

复杂组件变得难以理解

  • class 组件的状态逻辑分散在各个生命周期内,每个生命周期内包含的都是一些不相关的逻辑。
  • 因为状态逻辑无处不在,class 组件难以被拆分为更小的粒度,增加了测试的难度。
  • 引入状态管理库可以将状态集中一处,但同时引入了很多抽象概念,使复用变得更加困难。

Hook 将组件中相互关联的部分拆分成更小的函数(比如设置订阅或请求数据),而并非强制按照生命周期划分。你还可以使用 reducer 来管理组件的内部状态,使其更加可预测。

难以理解的 class

  • 学习 class 有很大成本,而且必须要理解 Javascript 中的 this 的工作方式,与其他语言存在巨大差异。
  • 使用 class 组件会无意中鼓励开发者使用一些让优化措施无效的方案。
  • 给工具带来的问题:例如,class 不能很好的压缩。

Hook 使你在非 class 的情况下可以使用更多的 React 特性,它拥抱了函数,同时没有牺牲 React 的精神原则。无需学习复杂的函数式或响应式编程技术。

Hook 概览

- 基础 Hook

  • useState
  • useEffect —— 副作用,class 组件生命周期的映射
  • useContext

- 额外的 Hook

  • useReducer
  • useCallback —— 性能优化
  • useMemo —— 性能优化
  • useRef —— 花式玩法
  • useImperativeHandle
  • useLayoutEffect useDebugValue

为了让大家快速了解 hook,以下内容涵盖了大部分功能应用场景。

基础 Hook

useState

以下是一段 useState 的示例,一个计数器组件:

import React, { useState } from 'react'; function Example() { // 声明一个叫 "count" 的 state 变量 const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); } 复制代码

它的等价 class 示例为:

class Example extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; } render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={() => this.setState({ count: this.state.count + 1 })}> Click me </button> </div> ); } } 复制代码
  • useState 是一种新方法,它与 class 里面的 this.state 提供的功能完全相同。
  • useState 可以使用数字或字符串对其进行赋值,并不一定是对象。保存多个状态可以多次调用 useState。
  • useState 返回当前 state 以及更新 state 的函数,使用数组解构的方式定义这两个变量。
  • React 会确保 useState 返回的更新 state 的函数的标识是稳定的,并且不会在组件重新渲染时发生变化。

useEffect

Effect Hook 可以让你在函数组件中执行副作用操作(数据获取,设置订阅以及手动更改 React 组件中的 DOM 都属于副作用)

import React, { useState, useEffect } from 'react'; function Example() { const [count, setCount] = useState(0); // Similar to componentDidMount and componentDidUpdate: useEffect(() => { // Update the document title using the browser API document.title = `You clicked ${count} times`; }); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ); } 复制代码

我们为计数器增加了一个小功能:将 document 的 title 设置为包含了点击次数的消息。

  • useEffect 用于定义每次 React 更新 DOM 之后执行的副作用。
  • useEffect 接收数组作为第二个参数,通过对比数组中的参数发生改变来决定是否执行传给 useEffect 的函数。不传入第二个参数则每次渲染后都执行,传入空数组则代表只在第一次渲染后执行。
  • useEffect 接收的函数可以返回一个函数用于清除副作用。(例如,取消订阅,清理计时器)
  • useEffect 执行前会先清除上一次渲染的副作用。

useEffect 实际上涵盖了 class 组件的绝大部分生命周期,详情查看 useEffect 与class 组件生命周期映射关系 一节。

useContext

const value = useContext(MyContext); 复制代码
  • useContext 接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。
  • useContext 所在的组件在 provider 的值发生变化时会重新渲染,即便祖先元素使用了 React.memo 或 shouldComponentUpdate。
  • useContext 仅仅增加了一种使用 Context 的方式,功能上并无区别。

额外 Hook

useReducer

useState 的替代方案。

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> </> ); } 复制代码
  • useReducer 和 Redux 很像,接收 reducer 函数、初始值、初始化函数并返回一个完整的 state 和 dispatch 函数。
  • useReducer 适用于管理逻辑复杂且包含多个子值的 state,或者下一个 state 依赖之前的 state 等。
  • useReducer 能给触发深更新的组件做性能优化。(向子组件传递 dispatch 而不是回调函数)
  • React 会确保 dispatch 函数的标识是稳定的,并且不会在组件重新渲染时发生变化。

useCallback、useMemo

这两个有相似关联之处,放在一起。

const memoizedCallback = useCallback( () => { doSomething(a, b); }, [a, b], ); 复制代码
  • useCallback 接收内联回调函数和依赖项数组作为参数,它返回该函数的 memoized 版本,回调函数仅在某个依赖项改变时才会更新。

  • useCallback 返回的函数传递给使用 shouldComponentUpdate 或 React.memo 的子组件时可以避免非必要的渲染。

  • useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]); 复制代码
  • useMemo 接收 ”计算“ 函数和依赖项数组作为参数,它返回一个 memoized 值,仅在某个依赖项改变时才会重新计算
    memoized 值。

  • useMemo 返回的值传递给使用 shouldComponentUpdate 或 React.memo 的子组件时可以避免非必要的渲染。

  • useMemo 有助于避免在每次渲染时都进行高开销的计算。这意味着如果是很简单的计算请谨慎考虑是否使用。

useRef

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数。返回的 ref 对象在组件的整个生命周期内保持不变。

本质上,useRef 就像是可以在其 .current 属性中保存一个可变值的 “盒子”。

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> </> ); } 复制代码

这是一种我们比较熟悉的 ref 的使用方式,用于直接访问真实 DOM。不过以前我们更多的使用字符串 ref。然而 useRef 不止于此,它比 ref 属性更有用。详情查看 useRef 用例 一节。

useEffect 与 class 组件生命周期映射关系

实质上这是个错误的标题,仅为了更好的理解 Hook。在行为上等效,并没有实质关系。

useEffect 真正实现了让相关联的逻辑都在一处的想法,我们可以在 useEffect 中设置定时器,在返回的清理函数中清除定时器。不必像 class 组件一样将这些本应该在一起的逻辑分散在各个生命周期中。除此之外,相同的抽象逻辑可以被抽离出来在不同的函数组件内复用。

以下是生命周期对照表:

class 组件生命周期 useEffect 示例代码
componentDidMount useEffect(() => { // effect here }, [])
componentDidMount & componentDidUpdate useEffect(() => { // effect here })
componentDidUpdate useEffect(() => { if (firstRef.current) return; // effect here })
componentWillUnMount useEffect(() => () => { // clear effect here }, [])

useRef 用例

用作 DOM 的引用

这是我们最熟悉的使用方式。

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> </> ); } 复制代码

辅助实现 ComponentDidUpdate 生命周期

function LifeCycleExample() { const firstMountRef = useRef(true); useEffect(() => { if (firstMountRef.current) { firstMountRef.current = false; } else { // effect here } }) return (<p>LifeCycleExample</p>); } 复制代码

如果频繁使用,则可以包装成自定义 Hook:

function useUpdateEffect(effect) { const firstMountRef = useRef(true); useEffect(() => { if (firstMountRef.current) { firstMountRef.current = false; } else { effect(); } }); } function LifeCycleExample() { useUpdateEffect(() => { // effect here }) return (<p>LifeCycleExample</p>); } 复制代码

保存上次渲染的状态

function Counter() { const [count, setCount] = useState(0); const prevCountRef = useRef(); useEffect(() => { prevCountRef.current = count; }); const prevCount = prevCountRef.current; return <h1>Now: {count}, before: {prevCount}</h1>; } 复制代码

如果频繁使用,则可以包装成自定义 Hook:

function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current; } function Counter() { const [count, setCount] = useState(0); const prevCount = usePrevious(count); return <h1>Now: {count}, before: {prevCount}</h1>; } 复制代码

保存需要在异步回调中访问最新 state 或变化过的函数

function Timer() { const intervalRef = useRef(); useEffect(() => { intervalRef.current = setInterval(() => { // ... }); }); return ( <> <button onClick={() => clearInterval(intervalRef.current)}>停止</button> </> ); } 复制代码

以上只为举例,并不局限于这几种使用方式。

自定义 Hook

自定义 Hook 是一种自然遵循 Hook 设计的约定,而并不是 React 的特性。自定义 Hook 必须使用 use 开头的方式命名。通过自定义 Hook,可以将组件逻辑提取到可重用的函数中。实际上在上一节 useRef 用例 中已经使用了自定义 Hook。

自定义 Hook 必须以 “use” 开头吗?必须如此。这个约定非常重要。不遵循的话,由于无法判断某个函数是否包含对其内部 Hook 的调用,React 将无法自动检查你的 Hook 是否违反了 Hook 规则。

Hook 分层设计

基础层,即内置 Hook

这个不多说,不需要自己实现,官方直接提供。

简化状态更新逻辑,用 immer 等 immutable 库包装 Hook

这一层在工程层面实现,我们使用 immutable 库将官方提供的基础 Hook 包装一层,便于使用。immutable 并不是为 Hook 专门准备的,在 class 组件中我们也可以用类似的库对状态进行包装。但是 Hook 这种可以将状态逻辑和组件分离的能力,提供了更好的封装的可能性。这一层将会很优雅,不会增加开发中的理解难度。

通常我们需要这样更新深层次的状态:

setState((oldValue) => ({ ...oldValue, foo: { ...oldValue.foo, bar: { ...oldValue.foo.bar, alice: newAlice }, }, })); 复制代码

封装后,直接修改状态由 immer 保证数据不可变性:

const [state, setState] = useImmerState({foo: {bar: 1}}); setState(s => s.foo.bar++); 复制代码
const [state, dispatch] = useImmerReducer( (state, action) => { case 'ADD': state.foo.bar += action.payload; case 'SUBTRACT': state.foo.bar -= action.payload; default: return; }, {foo: {bar: 1}} ); dispatch('ADD', {payload: 2}); 复制代码

数据结构的抽象,将与业务逻辑无关的数据结构操作单独封装

这个很好理解,比如将 Array、Map、Set 等复杂数据结构封装为 hook。

这里使用一个 typescript 的接口定义来体现:

const [list, methods, setList] = useArray([]); interface ArrayMethods<T> { push(item: T): void; unshift(item: T): void; pop(): void; shift(): void; slice(start?: number, end?: number): void; splice(index: number, count: number, ...items: T[]): void; remove(item: T): void; removeAt(index: number): void; insertAt(index: number, item: T): void; concat(item: T | T[]): void; replace(from: T, to: T): void; replaceAll(from: T, to: T): void; replaceAt(index: number, item: T): void; filter(predicate: (item: T, index: number) => boolean): void; union(array: T[]): void; intersect(array: T[]): void; difference(array: T[]): void; reverse(): void; sort(compare?: (x: T, y: T) => number): void; clear(): void; } 复制代码

通用场景封装

在有了基本的数据结构后,可以对场景进行封装,如 useVirtualList 就是一个价值非常大的场景的封装。需要注意的是,场景的封装不应与组件库耦合,它应当是业务与组件之间的桥梁,不同的组件库使用相同的 Hook 实现不同的界面,这才是一个理想的模式。

业务中比较常用的场景 Hooks:

  • useSelections —— 从列表中选择项目
  • useLoadMore —— 加载更多
  • useDynamicList ——动态列表(动态添加/减少)

Hook 状态粒度

上一节中 Hook 分层设计 已经从某种程度上解决了一部分 Hook 使用的粒度问题。这里简单补充一下:

如果仅仅将 class 中的 state 平移过来当做一整个状态,那分离状态,将状态复用的好处将完全得不到体现。不相关的状态堆砌在一起,不仅完全无法复用,还会隐藏其中通用的状态。再者,如果每个 state 都被单独拆分出来,在一次触发好几个状态变更时,我们需要分别对其进行更新。代码变的难以理解,增加维护难度。

我们应从 Hook 的动机入手,实现关注点分离,将关联的逻辑和状态放在一起。以能够拆分成自定义的 Hook 达到复用的目的来设计 Hook 的状态粒度。

函数组件性能优化(使用 Hook 时)

其实在上面对各项 Hook 做介绍时,我们已经提到了几种优化方式。在此处做一下总结。

跟 class 组件类似,性能优化的思路都是通过以下两个方面入手:

  • 减少 render 的次数
  • 减少高开销计算的次数

使用 React.memo

const MyComponent = React.memo(function MyComponent(props) { /* 使用 props 渲染 */ }); 复制代码

React.memo 实际上和 Hook 关系不大,它是针对函数组件的一种性能优化方式。它于 React.PureComponent 非常相似,但只适用于函数组件。默认情况下 React.memo 只对 props 和 prevProps 做浅层比较,但我们可以通过传入第二个参数来控制比较过程。

function MyComponent(props) { /* 使用 props 渲染 */ } function areEqual(prevProps, nextProps) { /* 如果把 nextProps 传入 render 方法的返回结果与 将 prevProps 传入 render 方法的返回结果一致则返回 true, 否则返回 false */ } export default React.memo(MyComponent, areEqual); 复制代码

可以将 areEqual 理解为 React.Component 中由开发者自己控制的 shouldComponentUpdate 。

使用 useCallback

当使用 React.memo 或 shouldComponentUpdate 来决定是否进行重新渲染时,则强烈依赖外部传入的 props 的稳定性。由于函数组件每次渲染都会执行一次,其内部定义的回调函数每次都是新的。这些回调函数传递给子组件时,即便使用 React.memo 或 shouldComponentUpdate 也无法实现期望的效果。

const Child = React.memo(function (props) { return (<button onClick={props.onClick}>点击</button>) }) function Parent() { // 通过 useCallback 进行记忆 callback,并将记忆的 callback 传递给 Child const handleClick = useCallback(() => { console.log('clicked'); }, /** deps = */ []) return ( <> <p>Parent</p> <Child onClick={handleClick} /> </> ); } 复制代码

通过使用 useCallback,在依赖项没有发生变化时,React 会确保 handleClick 函数的标识是稳定的,并且不会在组件重新渲染时发生变化。这样 Child 接收到的 props 在 React.memo 对比中则没有变化,Child 不会触发重新渲染,达到性能优化的目的。

使用 useMemo

const Child = React.memo(function ({ button }) { return (<button>{button.text}</button>) }) function Parent() { const button = React.useMemo(() => { // 此处有高开销的计算过程 return { text: '保存', // ... } }, []) return ( <> <p>Parent</p> <Child button={button} /> </> ); } 复制代码

和 useCallback 相似,为了避免重新渲染,我们可以使用 useMemo 记忆计算过的值。当依赖项没有发生变化时,高开销的计算过程将会被跳过,useMemo 将返回相同的值(如果是引用值,则是相同的引用标识)。这样 Child 接收到的 props 在 React.memo 对比中则没有变化,Child 不会触发重新渲染,达到性能优化的目的。

注意事项:使用 useMemo 在每次渲染时都会有函数重新定义的过程,计算量如果很小的计算函数,也可以选择不使用 useMemo,因为这点优化并不会作为性能瓶颈的要点,反而可能使用错误还会引起一些性能问题。

  • 53
  • 0
  • 0