深入理解 React Hooks

3,124 阅读11分钟

HOOKS

HookReact 16.8 中的新增功能。它们允许您在不编写类的情况下使用状态和其他 React 功能。HOOKS 只能在函数组件中使用

memo(reactComp, compare)

React.memo 是一个高阶的组件。它类似于 React.PureComponent 。不同的是,它只对组件接受的 props 进行浅比较,从而判断是否需要重新渲染的。 如果我们希望自己定义这个过程,这个时候我们可以借助第二个参数来实现;此方法仅作为性能优化的方式而存在。但请不要依赖它来“阻止”渲染,因为这会产生 bug。


  function Foo (props) {
    ... 
  }
  export default React.memo(Foo)

React.PureComponent 如何实现性能优化的

我们都知道类组件的中有个 shouldComponentUpdate 生命周期函数。当这个函数返回 false 时,表示组件不会重新渲染;当返回 true 时,表示组件会重新渲染。PureComponent 组件就是在 shouldComponentUpdate 函数中对组件接受的 props 进行比较(propsnextProps 进行比较)如果发生变化就返回 true。同时还有自身的 state 数据也会进行一个比较(statenextState),如果发生变化就返回 true。如果上面两种数据的个自比较都返回 false,那么组件就不会发生渲染,减少不必要的渲染,从而达到性能的优化。推荐一篇文章React PureComponent 源码解析大家自己看看。

下面是我自己实现的一个 shallowEqual 方法(理解源码的大致思路就可以了)

  function is(v1, v2) {
    if (v1 === 0) {
        return 1 / v1 === 1 / v2;
    }
    if (v1 !== v1) {
        return v2 !== v2;
    }
    return v1 === v2;
  }

  function shallowEqual(pre, next) {
    if (is(pre, next)) return true;

    if (typeof pre !== 'object' || typeof next !== 'object') return false;

    let preKeys = Object.keys(pre);
    let nextKeys = Object.keys(next);
    if (preKeys.length !== nextKeys.length) return false;
    for (let i = 0; i < preKeys.length; i++) {
        if (!next.hasOwnProperty(preKeys[i]) || pre[preKeys[i]] !== next[preKeys[i]]) return false;
    }
    return true;
  }

useState

类似于类组件中的state,不同的是 useState 接受一个任意类型的值 string, array, object, bool... 作为参数并返回一个数组,且 useState 只会在组件初始化的时候执行

  // 初始化的时候,age的值就是useState中参数的值
  const [ age, setAge ] = useState(20);
  const [ visible, setVisible ] = useState(props.visible);

数组中的第一个元素是状态值,组件在运行过程中会保留这个状态值,类似于 this.state 数组中的第二个元素是改变这个状体值的函数,类似于 this.setState()

  function Hooks(props) {
    const [ age, setAge ] = useState(20);
    const [ visible, setVisible ] = useState(props.visible);

    return (
      <div className="">
        <p>我的年龄是{age}岁</p>
        <button onClick={() => setAge(age + 1)}>点击</button>
        <p>{`${visible}`}</p>
      </div>
    );
  };

我们可以在函数组件中多次使用 useState,来创建多个状态值供我们使用。但是,必须在函数作用域的最顶层使用 useState,不能嵌套在循环内部或者其他函数作用域内部或者是块级作用域中。

useState 接受一个函数作为参数时;

  // 你应该注意到了,这是一个自定义的钩子
  function useReducer(reduce, initData) {
    // 接受一个函数
    const [state, setState] = useState(() => initData);
    function dispatch(action) {
      if (typeof action === 'function') {
        setState(preState => reduce(preState,  action(preState)));
      } else {
        setState(reduce(state, action));
      }
    }
    return [state, dispatch];
  }

上面这种情况一般出现在,initData 数据结构复杂数据量比较多时,我们可以使用函数调用的形式生成。这样,这个函数只会在组件初始化的时候调用,避免每次渲染的时候重新生成。

useEffect

对于使用过类组件的同学来说,我们可以理解为 useEffect 是类组件中 componentDidMountcomponentDidUpdate 两个生命周期的一个集合。默认情况下每次当函数组件挂载成功或者重新渲染完成后都会调用 useEffect 。 不同的地方在于,useEffect 有延迟,这个触发的时间节点大约在父组件 didMountdidUpdate 后,但在任何新渲染之前触发。所以每当 effect 运行时,DOM 都已经更新完毕。useEffect 可以在组件中使用多次, 和 useState 使用一样。

useEffect 还可以返回一个函数,并在组件即将销毁时调用这个返回函数,是的,和类组件的 componentWillUnmount 一样。我们通过它来取消在 useEffect 中绑定的事件监听等行为。

一般情况下,我们可以将一个行为事件的绑定和取消绑定放在同一个 useEffect 中,这样代码的可读性和维护行会更强一些。

  function Hooks(props, ref) {
    const boxRef = useRef(null);

    useEffect(() => {
      function handle() {
        console.log(123456)
      }
      boxRef.current.addEventListener('click', handle, false);
      return () => {
        boxRef.current.removeEventListener('click', handle, false);
      }
    }, []);
    return (
      <div ref={boxRef}>
        12344556
      </div>
    );
  }

通过下面的这张图,可以看出来 useEffect 的有一个延迟

useEffect延迟执行

useEffect也可以接收一个数组作为第二个参数

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]); // 仅在 count 更改时更新

上面这个示例中,我们传入 [count] 作为第二个参数。这个参数是什么作用呢?如果 count 的值是 5,而且我们的组件重渲染的时候 count 还是等于 5,React 将对前一次渲染的 [5] 和后一次渲染的 [5] 进行比较。因为数组中的所有元素都是相等的(5 === 5),React 会跳过这个 effect,这就实现了性能的优化。

当渲染时,如果 count 的值更新成了 6,React 将会把前一次渲染时的数组 [5] 和这次渲染的数组 [6] 中的元素进行对比。这次因为 5 !== 6,React 就会再次调用 effect。如果数组中有多个元素,即使只有一个元素发生变化,React 也会执行 effect

如果参数中有多个元素 [ age, visible ] ,组件渲染时通过比较后只要有一个元素发生变化,useEffect 就会执行。当参数是一个空数组时,那么这个时候 useEffect 就和类组件中的 componentDidMount 一样,只在组件挂载成功后调用一次。 useEffect 函数中 return 的函数,不受第二个参数的影响,仍在组件即将销毁的时候调用。

无论是否接受了第二个参数,useEffect 总会在组件挂载成功后调用一次,这一点不能忘记。

不要在循环条件或嵌套函数中调用 Hook。相反,始终在 React 函数的顶层使用 Hooks。通过遵循此规则,您可以确保每次组件呈现时都以相同的顺序调用Hook 。这就是React 允许多个 useStateuseEffect 调用之间正确保留 Hook 状态的原因。

useLayoutEffect

useEffect 使用原理相同,但是唯一的区别在于 useLayoutEffect 不会延迟触发,和类组件的 componentDidMountcomponentDidUpdate 这两个生命周期函数基本处于同步的状态。但都需要注意下面的几个问题:

useLayoutEffectuseEffect 返回的函数是会在该 effect 下次需要重新渲染之前执行一次。(这可能不是我们预期的)

  useLayoutEffect(() => {
    console.log('useLayoutEffect');
    // 当 visible 发生变化,该钩子会重新渲染一次。函数 cb 将在该钩子渲染前执行。
    return function cb () {
      console.log('@@@@@@@@@@@@@@@@@@')
    }
  }, [visible]);

在 effect 中调用了一个外部声明的函数,而且该函数依赖 props 或 state,这时会发现得不到预期的结果,怎么办???

  function Example({ someProp }) {
    function doSomething() {
      console.log(someProp);
    }

    useEffect(() => {
      doSomething();
    }, []); // 这样不安全(它调用的 `doSomething` 函数使用了 `someProp`)
  }

要记住 effect 外部的函数使用了哪些 props 和 state 很难。这也是为什么 通常你会想要在 effect 内部去声明它所需要的函数。 这样就能容易的看出那个 effect 依赖了组件作用域中的哪些值

  function Example({ someProp }) {
    useEffect(() => {
      function doSomething() {
        console.log(someProp);
      }

      doSomething();
    }, [someProp]); // 安全(我们的 effect 仅用到了 `someProp`)
  }

只有当函数(以及它所调用的函数)不引用 props、state 以及由它们衍生而来的值时,你才能放心地把它们从依赖列表中省略。

如果我的 effect 的依赖频繁变化,我该怎么办?

  function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
      const id = setInterval(() => {
        setCount(count + 1); // 这个 effect 依赖于 `count` state
      }, 1000);
      return () => clearInterval(id);
    }, []); // Bug: `count` 没有被指定为依赖

    return <h1>{count}</h1>;
  }

上面的 demo 是官网的一个案例;我觉的很经典,借鉴一下;

传入空的依赖数组 [],意味着该 hook 只在组件挂载时运行一次,并非重新渲染时。但如此会有问题,在 setInterval 的回调中,count 的值不会发生变化。因为当 effect 执行时,我们会创建一个闭包,并将 count 的值被保存在该闭包当中,且初值为 0。每隔一秒,回调就会执行 setCount(0 + 1),因此,count 永远不会超过 1。

指定 [count] 作为依赖列表就能修复这个 Bug,但会导致每次改变发生时定时器都被重置(effect 重新渲染前会执行一次它返回的函数)。事实上,每个 setInterval 在被清除前(类似于 setTimeout)都会调用一次。但这并不是我们想要的。要解决这个问题,我们可以使用 setState 的函数式更新形式。它允许我们指定 state 该如何改变而不用引用当前 state

  function Counter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
      const id = setInterval(() => {
        setCount(c => c + 1); // 在这不依赖于外部的 `count` 变量
      }, 1000);
      return () => clearInterval(id);
    }, []); // 我们的 effect 不适用组件作用域中的任何变量

    return <h1>{count}</h1>;
  }

customize hooks

自定义 Hook 是一个 JavaScript 函数,其名称以 "use" 开头,可以调用其他 Hook。构建自己的 Hook 可以将组件逻辑提取到可重用的函数中 ,确保只在自定义 Hook 的顶层无条件地调用其他 Hook。与 React 组件不同,自定义 Hook 不需要具有特定签名。我们可以决定它作为参数需要什么,以及它应该返回什么(如果有的话)

  // useVisibleStatus 是一个自定义的钩子,我们在函数中调用的useEffect
  function useVisibleStatus(isShow) {
    const [ visible, setVisible ] = useState(isShow);
    useEffect(() => {
      setVisible(isShow);
    }, [ isShow ]);
    return visible;
  };

  function Hooks(props) {
    const [ count ] = useState(props.count);
    const visible = useVisibleStatus(props.visible);

    return (
      <div className="">
        <h2>{count}</h2>
        <button onClick={() => setCount(count + 1)}>点击</button>
        <h2>{`${visible}  ${props.count}`}</h2>
      </div>
    );
  }

我们也可以将一些复杂或者重复的逻辑提取提取到自定义的 Hook 函数中,从而简化我们的代码。其实自定义 hook 和函数组件没有多大区别。

useReducer

useState 复杂的状态逻辑涉及多个子值或下一个状态取决于前一个状态时,通常 useReducer 更可取。useReduce 还可以让您优化触发深度更新的组件的性能

  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({initialState}) {
    const [state, dispatch] = useReducer(reducer, initialState);
    return (
      <>
        Count: {state.count}
        <button onClick={() => dispatch({type: 'increment'})}>+</button>
        <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      </>
    );
  }

我们看看 useReducer 具体的实现(自定义一个 useReducer 钩子):

  function useReducer(reducer, initialState) {
    const [state, setState] = useState(initialState);

    function dispatch(action) {
      // 这里我们也模拟一下 setState 的函数式更新形式
      // 这样就可以不需要在 effect 中使用时强制加上依赖
      if (typeof action === 'function') {
        setState(preState => reducer(preState, action(preState)))
      } else {
        setState(reducer(state, action));
      }
    }

    return [state, dispatch];
  }

useImperativeHandle

可以通过 useImperativeHandle ,给 ref 上绑定一些自定的事件暴露给父组件,前提是我们必须联合 forwardRef 一起使用,注意所有的事件都是绑定在 refcurrent 属性上。看下面的例子

// hook.js
  function Hooks(props, ref) {
    const [ count, setCount ] = useState(props.count);
    useImperativeHandle(ref, () => ({
      // 自定义一些事件
      click: () => {
        setCount(count + 1);
      },
    }), [count]); // 我们可以指定依赖项,也可以不指定。这和 useCallback | useMemo 等 Hooks 的依赖项作用一样

    return (
      <div className="">
        <h2>{count}</h2>
        <button onClick={() => setCount(count + 1)}>点击</button>
      </div>
    );
  };
  export default React.forwardRef(Hooks);

  // Application.js
  export default class App extends PureComponent {
    componentDidMount() {
      this.ref = React.createRef();
    }
    return (
      <div
        onClick={() => this.ref.current.click()}
      >
        // ...
        <Hooks ref={this.ref} count={this.state.count} visible={this.state.visible}/>
        // ...
      </div>
    );
  }

也可以这样

  function FancyInput(props, ref) {
    // 获取真是DOM节点
    const inputRef = React.useRef();
    useImperativeHandle(ref, () => ({
      // 自定义一些事件
      focus: () => {
        // 在DOM节点执行一些操作都可以
        inputRef.current.focus();
      }
    }));
    return <input ref={inputRef} />;
  }
  FancyInput = React.forwardRef(FancyInput);

useRef

useRef 返回一个可变的ref对象,其 current 属性值为初始化传递的参数(initialValue)。返回的对象将持续整个组件的生命周期。和类组件中的实例属性很像

  const ref = usRef(20);
  console.log(ref.current) // 20
  // 可以重新赋值
  ref.current = 200;

当然最常见的就是访问一个元素节点

  function TextInputWithFocusButton() {
    const inputEl = useRef(null);
    const onButtonClick = () => {
      // `current` points to the mounted text input element
      inputEl.current.focus();
    };
    return (
      <>
        <input ref={inputEl} type="text" />
        <button onClick={onButtonClick}>Focus the input</button>
      </>
    );
  }

useMemo

使用的场景:函数组件中,我们定义了一些方法,但是我们并不希望每次组件更新的时候都重新计算一个值,那么这个时候我们就可以使用 useMemo。有个地方需要注意点那就是,useMemo 会在组件挂载完成之前执行一次,这个时间节点类似 class 组件中的 componentWillMount 类似。

useMemo 只接受了一个参数时,那么每次函数组件更新时,useMemo 都会重新计算。看下面

  // 每次都会重新计算 count
  const count = useMemo(() => {
    return (state.count || 0).toFixed(2);
  });

useMemo 接受了两个参数时。也就是说有依赖项了,那么只要依赖项发生变化,useMemo都会重新计算

  // 当 state.count 发生变化时,从新计算 count
  const count = useMemo(() => {
    return (state.count || 0).toFixed(2);
  }, [state.count]);

如果依赖项为 []。 那么只会在组件刚开始渲染时计算一次。

可以查看的我们demo

  // 组件初始化的时候会调用 `Func`,类似 `componentWillMount``
  // 当依赖项元素中的值发生改变,那么就会调用 `Func`,这个条件 a 和 b 有一个发生变化的时候 就会触发 `useMemo`
  useMemo(() => Func(a, b), [a, b]);

在看这个带返回值的

  function Hooks(props) {
    const [ count, setCount ] = useState(props.count);
    useLayoutEffect(() => {
      console.log('useLayoutEffect 后执行');
      setCount(props.count);
    }, [ props.count ]);

    const dom = useMemo(() => {
      console.log('useMemo 优先执行');
      return <h2>{count * 10}</h2>;
    }, [count]);

    return (
      <div className="">
        <h2>{count}</h2>
        <button onClick={() => setCount(count + 1)}>点击</button>
        {dom}
      </div>
    );
  }

注意:传入 useMemo 的函数会在渲染期间执行。请不要在这个函数内部执行与渲染无关的操作,诸如副作用这类的操作属于 useEffec 的适用范畴,而不是 useMemo

useCallback

useCallback 的使用和 useMemo 是一样的,且 useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

这是我的demo

useContext

  const value = useContext(MyContext);

接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。当前的 context 值由上层组件中距离当前组件最近的 <MyContext.Provider>value prop 决定。

当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext providercontext value 值。

调用了 useContext 的组件总会在 context 值变化时重新渲染

  const Thems = {
    light: {
      color: '#f90',
    },
    dack: {
      color: '#222',
    }
  }

  const ThemsContext = React.createContext({
    them: Thems.light,
    toggleTheme: () => {},
  })

  class Detail extends PureComponent {
    state = {
      context: {
        them:  Thems.light.color,
        toggleTheme: this.handle,
      }
    }

    handle = () => {
      const { context: { them } } = this.state;
      let color = Thems.light.color;
      if (them === Thems.light.color) {
        color = Thems.dack.color
      }
      this.setState({
        context: {
          ...this.state.context,
          them: color,
        },
      });
    }

    render() {
      return (
        <ThemsContext.Provider value={this.state.context}>
          <Hooks/>
        </ThemsContext.Provider>
      );
    }
  }

  function Hooks(props, ref) {
    const themContext = React.useContext(ThemsContext);

    return (
      <div
        style={{ background: themContext.them }}
        onClick={() => {
          themContext.toggleTheme()
        }}
      >
        1234567890
      </div>
    );
  }

上面的这个 demo 中,当 ThemsContext.Providervalue props 发生变化时 Hooks 组件就会发生重新渲染。所以说这个时候我们的目的就已经达到了。

以上就是我在工作中对 Hooks 的一个总结,如有问题请麻烦纠正,谢谢。