react hooks学习记录(官方hook、ahooks、自定义hook)

794 阅读17分钟

分两部分,第一部分官方hook,第二部分为各种库和自己写的自定义hooks,这个文章会持续更新。

官方Hook

官方Hook这部分不记录各Hook的基本使用方式,重点是记录一些特性。大部分内容来源于React官网

useEffect(useLayoutEffect)

React的函数组件应该是是纯函数,在函数组件主体内(这里指在 React 渲染阶段)改变 DOM、添加订阅、设置定时器、记录日志以及执行其他包含副作用的操作都是不被建议的,因为这可能会产生莫名其妙的 bug 并破坏 UI 的一致性。这些副作用任务一般交给useEffect来做。

effect执行时机

关于执行时机我一般都习惯和类组件的生命周期对比,所以先来深入的思考一下componentDidMount 到底是什么时候调用的。

componentDidMount默认为同步函数,会在组件挂载后(插入 DOM 树中)立即调用。要注意,这里不是渲染后调用,是在插入DOM树之后调用的,这是有区别的,不要把组件的生命周期看作是DOM的生命周期。如下图浏览器内核运行流程

TwfmE8.png

可以看到,浏览器内核在把HTML解析为DOM 树,又把CSS解析为CSS规则树之后,会结合进行布局操作,形成渲染树,然后才会去绘制页面。componentDidMount就是在生成DOM树之后,绘制之前这个区间内调用的。

这就代表着我们可以在这里来读取 DOM 布局并同步触发重渲染,比如可以在 componentDidMount() 里直接调用 setState()。它将触发额外渲染,此渲染会发生在浏览器更新屏幕之前。如此保证了即使在 render() 两次调用的情况下,用户也不会看到中间状态。这种情况的调用步骤为:render=>componentDidMount=>render=>componentDidUpdate。

接下来回到effect执行时机。

componentDidMountcomponentDidUpdate 不同的是,在更改作用于DOM并让浏览器绘制屏幕后才会去调用useEffect的effect。尽管如此,也一定会在任何新的渲染之前执行,并且React会在组件更新前刷新上一轮渲染的effect。

如果某些effect期望在浏览器绘制之前调用,比如dom变更,使用useLayoutEffect,在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。调用阶段和componentDidMountcomponentDidUpdate 一样,所以不多说了。

下面按步骤细说

799XDJ.png
  1. React在render阶段(diff,render函数)之后,进入pre-commit阶段,类组件触发getSnapshotBeforeUpdate生命周期,函数组件会在调度useEffect的effect和effect的清除函数。注意是调度,不是执行,只是加入一个队列中。
  2. commit阶段,React将更新的内容挂载到DOM树,挂载完成以后,类组件会同步执行componentDidMountcomponentDidUpdate,函数组件同步执行useLayoutEffect的effect,在这之前会执行useLayoutEffect上一个effect的清除函数。
  3. 由于js线程和浏览器渲染线程是互斥的,只要js还在运行,即使内存中的真实 DOM 已经变化,浏览器也不会立刻渲染到屏幕。
  4. 当js运行完成后,浏览器渲染。React只用一次重绘的代价,就把所有需要更新的 DOM 节点全部更新完成。
  5. 渲染完成后运行useEffect的effect,在这之前会执行useEffect上一次effect返回的清除函数。

清除effect

useEffect的返回函数就是清除函数,为防止内存泄漏,清除函数会在组件卸载前执行。另外如果组件多次渲染,则在执行下一个 effect 之前,上一个 effect 就已被清除,需要注意的是,和effect一样,清除函数也是在浏览器绘制屏幕后被延迟调用。例如第一次渲染id为10,第二次渲染id为20:

  1. React渲染id为20的UI
  2. 浏览器绘制,我们在屏幕上看到id为20的UI
  3. React清除id为10的effect
  4. React运行id为20的effect

effect条件执行

useEffect 的依赖项,分三种

  1. 没有,只要组件更新(包括第一次挂载)就调用。
  2. 数组,数组内的依赖项改变时,effect重新创建并调用。
  3. 空数组,仅在挂载和卸载时调用,不属于特殊情况,仍然遵循数组的运行方式,只是代表不需要响应任何值的改变。

典型问题

为什么effect不能是异步函数

async函数是使用async关键字声明的函数。 async函数是AsyncFunction构造函数的实例, 并且其中允许使用await关键字。asyncawait关键字让我们可以用一种更简洁的方式写出基于Promise的异步行为,而无需刻意地链式调用promise

async函数会返回一个AsyncFunction对象,使用隐式Promise返回它的结果。简单来说就是一定会返回一个promise对象。但是useEffect不应该返回任何内容或者只能返回清理函数。所以当我们把effect写成async函数时会收到警告⚠️。

但我们仍然可以调用异步函数,所以我们换种思路:

useEffect(() => {
  (async function fun(){
    await ...
  })()
}, []);
  
useEffect(() => {
  async function fun(){
    await ...
  };
  fun()
}, []);

在依赖列表中省略函数是否安全?

react.docschina.org/docs/hooks-…

useEffect可能带来闭包问题,怎么办?

useEffect使用了js闭包机制

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>;
}

// 第二个例子
function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setTimeout(() => {
      console.log(count)
    }, 3000);
    return () => {
      clearTimeout(timer);
    }
  }, [])

  return (
    <button onClick={() => setCount(c => c + 1)}>
      click
    </button>
  )
}

传入空的依赖数组,意味着该 hook 只在组件挂载时运行一次,并非重新渲染时。但如此会有问题,在定时器的回调中,count 的值不会发生变化。因为当 effect 执行时,会创建一个闭包,并将 count 的值被保存在该闭包当中,且初值为 0。每隔一秒,回调就会执行 setCount(0 + 1),因此,count 永远不会超过 1。第二个例子类似,不论在3秒内点多少次按钮,也输出0。

要解决该问题有两种办法:

  1. 给setCount传入一个函数, setCount(count => count+1),具体原因查看笔记《深入理解setState》

  2. 使用ref引用count,通过ref.current拿到的值永远是最新的。

    const [count, setCount] = useState(0);
    
    // 通过 ref 来记忆最新的 count
    const countRef = useRef(count);
    countRef.current = count;
    
    useEffect(() => {
      const timer = setTimeout(() => {
        console.log(countRef.current)
      }, 1000);
      return () => {
        clearTimeout(timer);
      }
    }, [])
    

事实上在一般代码里不会存在上面所说的这个问题,在异步调用(或称延迟调用)时,才一定会存在,比如setTimeoutsetIntervalPromise.thenuseEffect卸载回调函数。也很好理解,非异步代码在组件渲染后立刻执行,闭包里保存的都是最新的值,异步代码什么时候执行,谁知道这期间组件状态发生了什么变化。这时一般都可以通过上面所说的ref方法解决。

useState

这里说四点useState的注意项,以下所说的setState为useState返回值里的setState。

  1. 异步的问题,先说结论,和类组件一样,setState在React周期内是异步的(这个词在笔记《深入理解setState》里解释过),其他地方是同步的。看一个例子

    export default function FunCpn() {
      const [count, setCount] = useState(0);
      useLayoutEffect(() => {
        console.log("object");
      });
    
      const handleClick = () => {
        // Promise.resolve(1).then(() => {
          setCount(1);
          console.log(count);
          setCount(2);
          console.log(count);
        // });
      };
      return (
        <div>
          <button onClick={handleClick}>ssssssss</button>
        </div>
      );
    }
    

    上面的点击函数触发时,useLayoutEffect只触发一次,两次输出count也都是0,而且输出顺序是0 0 object 符合预期。但是当加入Promise回调时,上面说过类组件的话就会变成同步调用,但在这里输出的count都是0,感觉是异步的,但useLayoutEffect却被调用了两次,而且输出顺序为object 0 object 0,又说明是同步的。其实这里确实是同步的,count输出0是因为闭包。

  2. 在组件后续的更新渲染中,useState 并不是不会执行,只是返回的第一个值将始终是更新后最新的state,并且React 会确保setState函数的标识是稳定的,不会在重新渲染时发生变化。这就是为什么可以安全地从hook的依赖列表中省略 setState。

  3. setState也可以接受一个函数,这个函数只有一个参数,不会接收props,也不会自动合并更新对象,并且不支持state更新之后的回调函数。

    setState(prevState => {
      return {...prevState, ...updatedValues};
    });
    
  4. useState接收的参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的state,此函数只在初始渲染时被调用:

    const [state, setState] = useState(() => {
      const initialState = someExpensiveComputation(props);
      return initialState;
    });
    
    const [state, setState] = useState(new Subject()) // 每次重渲染,都会执行实例化Subject的过程,即便这个实例没用,会被扔掉(重渲染会返回最新的state)
    const [state, setState] = useState(() => new Subject()) // 传递函数,可以避免性能隐患
    

以上内容是和类组件的this.setState()做对比的形式记录,可以看另一篇笔记《深入理解setState》

典型问题

没给useState传this, React怎么知道useState对应的是哪个组件?

zh-hans.reactjs.org/docs/hooks-…

一个组件都多个useState,React怎么知道哪个state对应哪个 useState

靠的是 useState调用的顺序, useState的调用顺序在每次重新渲染时都是相同的。

useContext

接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。

当组件上层最近的 <MyContext.Provider> 更新时,该hook会触发重渲染,并使用最新传递给 MyContext provider的contextvalue 值。即使祖先使用 React.memoshouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染。

useMemo

返回一个memoized(已缓存)值,一般用作性能优化的手段,和React.memo配套使用效果最佳。

注意:传入 useMemo 的函数会在渲染期间执行,也就是早于useEffect

useCallback

返回一个memoized(已缓存)回调函数。一般用作性能优化的手段,和React.memo配套使用效果最佳。

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

useRef

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数。

ref 对象在组件的整个生命周期内保持不变,useRef 会在每次渲染时返回同一个 ref 对象。

当 ref 对象内容发生变化时,useRef 并不会通知你。变更 .current 属性不会引发组件重新渲染。

当ref用作于访问DOM时,是什么时候赋值的呢?和React更新DOM同时,在render()之后,componentDidMountcomponentDidUpdate之前。

T01K2R.png

useReducer

const [state, dispatch] = useReducer(reducer, initialArg, init);
//reducer
(state, action) => newState

和useState的setState一样,不会随着重新渲染发生改变,所以可以不写入依赖项中。

简化版本:

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

  function dispatch(action) {
    const nextState = reducer(state, action);
    setState(nextState);
  }

  return [state, dispatch];
}

可以惰性创建初始值。需要将 init 函数作为 useReducer 的第三个参数传入,这样初始state将被设置为 init(initialArg)。这么做可以将用于计算state的逻辑提取到reducer外部,这也为将来对重置state的action做处理提供了便利。

function init(initialCount) {
  return {count: initialCount};
}

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'reset':
      return init(action.payload);
    default:
      throw new Error();
  }
}

function Counter({initialCount}) {
  const [state, dispatch] = useReducer(reducer, initialCount, init);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'reset', payload: initialCount})}>
        Reset
      </button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

useImperativeHandle

useImperativeHandle(ref, createHandle, [deps])

useImperativeHandle 应当与forwardRef一起使用

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);

第三方Hook

useLatest 返回当前最新值

来自ahooks

源代码

function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}

本hook利用两个特性:1. useRef 每次渲染时返回同一个 ref 对象;2. ref对象内容发生变化不会触发渲染。

本hook逻辑很简单,但这是一个非常重要的思路,ahooks里很多hook都有用到该思路,因此记录下来,它是解决闭包问题很好的方式。

使用:

import React, { useState, useEffect } from 'react';
import { useLatest } from 'ahooks';

export default () => {
  const [count, setCount] = useState(0);

  const latestCountRef = useLatest(count);

  useEffect(() => {
    const interval = setInterval(() => {
      setCount(latestCountRef.current + 1);
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  return (
    <>
      <p>count: {count}</p>
    </>
  );
};

usePrevious 获取上一轮props或state

来自react官网

源代码

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

注意:这里应该返回ref.current,不应该返回ref,返回ref.current代表返回的是当前ref存的值,返回ref代表返回ref这个可变值对象。如果返回了ref,虽然DOM上仍然显示的是上一轮的值,但在某些地方,比如点击函数里使用ref,拿到的就是已经更新过(本轮)的值了。

该hook思路来源于下面代码,但这段代码存在一个问题,就是上面注意点所说的。

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>;
}

使用

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

  const calculation = count + 100;
  const prevCalculation = usePrevious(calculation);
  // ...

useSetState 合并改变state

来自ahooks

源代码

const useSetState = (initialState) => {
  const [state, setState] = useState(initialState);

  const setMergeState = useCallback((patch) => {
    setState((prevState) => ({ ...prevState, ...(typeof patch === 'function' ? patch(prevState) : patch) }));
  }, []);

  return [state, setMergeState];
};

class的setState改变state时会使用Object.assign进行合并,useState不行,该hook模拟setState的行为,用法和useState一致。

import React from 'react';
import { useSetState } from 'ahooks';

export default () => {
  const [state, setState] = useSetState({
    hello: '',
    count: 0,
  });

  return (
    <div>
      {/*增加属性可以只写要增加的属性*/}
      <button type="button" onClick={() => setState({ foo: 'bar' })}>
        set foo
      </button>
      {/*改变count可以只写count*/}
      <button type="button" onClick={() => setState((prev) => ({ count: prev.count + 1 }))}>
        count + 1
      </button>
    </div>
  );
};

useToggle 在两个状态值间切换

来自ahooks

源代码

function useToggle<D, R>(defaultValue: D = false as unknown as D, reverseValue?: R) {
  const [state, setState] = useState<D | R>(defaultValue);

  const actions = useMemo(() => {
    const reverseValueOrigin = (reverseValue === undefined ? !defaultValue : reverseValue) as D | R;

    const toggle = () => setState((s) => (s === defaultValue ? reverseValueOrigin : defaultValue));
    const set = (value: D | R) => setState(value);
    const setLeft = () => setState(defaultValue);
    const setRight = () => setState(reverseValueOrigin);

    return {
      toggle,
      set,
      setLeft,
      setRight,
    };
    // useToggle ignore value change
    // }, [defaultValue, reverseValue]);
  }, []);

  return [state, actions];
}

ahooks还有一个useBoolean,个人感觉没必要,与此hook类似只是两个状态值为true\false,况且直接用useState切换boolean也很方便。

使用

const [state, { toggle, set, setLeft, setRight }] = useToggle('Hello', 'World');

useMemoizedFn 持久化函数

来自ahooks

源代码

type noop = (...args: any[]) => any;

function useMemoizedFn<T extends noop>(fn: T) {

  const fnRef = useRef<T>(fn);

  // why not write `fnRef.current = fn`?
  // https://github.com/alibaba/hooks/issues/728
  fnRef.current = useMemo(() => fn, [fn]);

  const memoizedFn = useRef<T>();
  if (!memoizedFn.current) {
    memoizedFn.current = function (...args) {
      // eslint-disable-next-line @typescript-eslint/no-invalid-this
      return fnRef.current.apply(this, args);
    } as T;
  }

  return memoizedFn.current;
}

讲个故事:

子组件和父组件说:你不要总让传给我的函数变化,函数一变我就得重新渲染一遍,太麻烦了。

父组件说:函数不变你就不能用了呀(拿到的旧的state)。

子组件说:我不管,你自己想办法。

父组件绞尽脑汁:好吧,我写一个useMemoizedFn,他返回的函数一直都是不变的。

子组件说:那我能拿到新的state吗?

useMemoizedFn说:能。

子组件说:怎么做到的?

useMemoizedFn说:我里面调用的函数是变的,但我自己不变。相当于我穿了件衣服,衣服不变,但衣服里面的人会变,你能看到的只是这件衣服,所以对你来说是不变的。

子组件说:哦,我懂了。

一般使用useCallback来优化函数,仅在依赖项改变时重新生成函数,但优化的不够彻底。该hook利用ref不变的性质使函数在整个生命周期内都不变。

const [state, setState] = useState('');

// 函数使用了state,为了调用时拿到最新的state,就需要加入依赖项
// 在state变化时,func地址会变化
const func = useCallback(() => {
  console.log(state);
}, [state]);

// func地址永远不会变化,依然可以拿到最新的state
const func = useMemoizedFn(() => {
  console.log(state);
});

useCreation 创建缓存值

来自ahooks

源代码:

import type { DependencyList } from 'react';
import { useRef } from 'react';
import depsAreSame from '../utils/depsAreSame';

export default function useCreation<T>(factory: () => T, deps: DependencyList) {
  const { current } = useRef({
    deps,
    obj: undefined as undefined | T,
    initialized: false,
  });
  if (current.initialized === false || !depsAreSame(current.deps, deps)) {
    current.deps = deps;
    current.obj = factory();
    current.initialized = true;
  }
  return current.obj as T;
}

export default function depsAreSame(oldDeps: DependencyList, deps: DependencyList): boolean {
  if (oldDeps === deps) return true;
  for (let i = 0; i < oldDeps.length; i++) {
    if (!Object.is(oldDeps[i], deps[i])) return false;
  }
  return true;
}

该hook是useMemouseRef的替代品。

  1. useMemo的问题:引用React文档

    **你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。**将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们,比如为离屏组件释放内存。先编写在没有 useMemo 的情况下也可以执行的代码 —— 之后再在你的代码中添加 useMemo,以达到优化性能的目的。

  2. useRef的问题:和上面提到的useState传入默认值的问题(第4点)一样,如果初始值需要较昂贵的操作,会带来一点性能问题。

useCreation可以保证不会被意外的重新计算,也可以用来创建一些常量,创建常量时和useRef类似,但在创建比较复杂的常量时更省性能。

使用

const foo = useCreation(() => new Foo(), []);

useEventEmitter 创建事件通知实例

来自ahooks

源代码:

type Subscription<T> = (val: T) => void;

export class EventEmitter<T> {
  private subscriptions = new Set<Subscription<T>>();

  emit = (val: T) => {
    for (const subscription of this.subscriptions) {
      subscription(val);
    }
  };

  useSubscription = (callback: Subscription<T>) => {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const callbackRef = useRef<Subscription<T>>();
    callbackRef.current = callback;
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      function subscription(val: T) {
        if (callbackRef.current) {
          callbackRef.current(val);
        }
      }
      this.subscriptions.add(subscription);
      return () => {
        this.subscriptions.delete(subscription);
      };
    }, []);
  };
}

export default function useEventEmitter<T = void>() {
  const ref = useRef<EventEmitter<T>>();
  if (!ref.current) {
    ref.current = new EventEmitter();
  }
  return ref.current;
}

这里的EventEmitter本质上是一个观察者模式的具体实现。主要是用来方便组件间进行事件通知。通过 props 或者 Context ,可以将EventEmitter实例共享给其他组件。然后在其他组件中,可以调用 EventEmitteremit 方法,发送一个事件,或是调用 useSubscription 方法,订阅事件。

使用useEffectuseRef,可以保证

  1. EventEmitter实例只会创建一次。
  2. useSubscription 会在组件创建时自动注册订阅,并在组件销毁时自动取消订阅。

注意点:

  1. 不要在useRef里直接写入EventEmitter实例或callback,原因上文提到过。
  2. 父子组件没必要使用该方式,父通知子可以用foreardRef,子通知父可以用回调函数。

使用:

export default function Form() {
  const event$ = useEventEmitter();

  function handleClick() {
    event$.emit("父组件发出事件");
  }

  return (
    <>
      <h1>
        <button onClick={handleClick}>发事件</button>
        <Level1 event$={event$} />
      </h1>
    </>
  );
}

function Level1(props) {
  return (
    <div style={{ display: "flex" }}>
      <Level21 {...props} />
      <Level22 {...props} />
    </div>
  );
}

function Level21(props) {
  const [level1, setLevel1] = useState();
  props.event$.useSubscription(val => {
    setLevel1(`${val}  ${new Date()}`);
  });
  return <h1>{level1}</h1>;
}

function Level22(props) {
  const [level1, setLevel1] = useState();
  props.event$.useSubscription(val => {
    setLevel1(`${val}  ${new Date()}`);
  });
  return <h1>{level1}</h1>;
}

useRouteState 缓存前端路由state

源代码

/**
 * @param {object} props,组件的props
 * @param {string} cacheKey
 * @return {*}
 */
export const usePropsState = (cacheKey: string, props: any): any => {
  const recvState = useMemo<any>(() => {
    const location = props.location;
    if (location.state) { // 判断当前有参数
      const d = JSON.stringify(location.state);
      sessionStorage.setItem(cacheKey, d); // 存入到sessionStorage中
      return location.state;
    } else {
      const state = sessionStorage.getItem(cacheKey)
      return state ? JSON.parse(state) : null; // 当state没有参数时,取sessionStorage中的参数
    }
  }, [cacheKey, props.location.state]);
  return recvState;
};

// 使用:
const data = usePropsState('data', props);

在前端路由领域,路由栈中每个路由记录都可以持久化序列化存储一个state,例如调用history.pushState就可以往路由栈里添加记录并且可以传入state。

history库中有两种history对象

  1. browserHistory:push方法在底层会使用history.pushState,所以该对象存储的state也是持久化的。
  2. hashHistory:考虑兼容性问题,截止第4版本的history库在hashHistory上没有使用pushState的底层接口。因此不建议在hashHistory中使用state,虽然可通过location对象传递state,但是其作为页面级别的state,不具备持久化state的能力。这时,仅能从hashHistory.location.state中读取到对象{some:'state'},而不能从window.history.state中读取到。例如在浏览器中执行一次后退再前进的操作,由于window.history.state没有存储状态,这时读取hashHistory.location.state,读取到的值将为空。而browserHistory可以再次读取到state值,所以需要注意,hashHistory.location.state在导航过程中并不能如browserHistory一样其state值能得到再现。由于pushState等HTML5接口已经被广泛使用,在history库未来的第5版本中将使用pushState来模拟hashHistory,因此持久化的state设置会得到支持。此时,hashHistory设置的state也可从window.history.state中读取到。

在上面的背景下,我们在使用hashHistory时可以利用storage来存储state。该hook有两个作用:

  1. 封装获取state过程,减少重复代码。
  2. 解决hashHistory对象里state没有持久化的问题。