All articles

Getting started with React Hooks

Jan 08, 2023

本篇为基础篇,简单谈论下 16.8 引入的一些原生 HookuseEffect 的注意事项以及 useCallback 的闭包陷阱问题,如何使用 useCallback + React.memo + useRef 解决性能优化问题。

如内容存在争议,欢迎指正。

Why

Hook 作为 React16.8 新增特性,它出现的目的是为了可以让我们在不编写 class 的情况下使用 state 以及其他的 React 特性。首先可以思考一下在没有引入 Hook 之前,React 是如何定义一个组件?

通常是定义一个类组件,在类组件中,通过 constructor 初始化一些 state,在不同的生命周期中处理自己的逻辑,最后通过 render 来渲染。最初写的类组件中,逻辑往往比较简单,但随着业务的增多,类组件会变得越来越复杂,各种生命周期中包含着大量的逻辑代码,并且对于类组件很难拆分一些逻辑,状态的复用则需要通过高阶组件实现(类似于在 redux 中的 connect)。其次对于类组件而言,class 作为声明的关键字并且其是 ES6 中新增的特性,需要具有前导知识,还必须去搞清楚 this 的各种指向,对于初学者而言并不友好。

再来看看函数组件,在函数组件中,每次调用都会产生临时的变量不利于记录组件内部的状态,并且重新渲染时,函数都会被执行,里面的一些发送网络请求等逻辑都会被再次执行。其没有生命周期以及 this 作为技术壁垒是函数组件最显著的特点。

因此在没有 Hook 概念之前我们通常都是编写 class 组件,因为函数无法记录组件内部的状态。引入了 Hook 的概念,目的就是为了将两者的优点集合起来,在**不编写 class 组件的情况下也可以使用 state 以及其他 React 特性。**有了 Hook 之后基本上可以代替之前只用 class 组件的所有地方,这里也不再例举两者的区别,总而言之,Hook 可以让我们的代码更加精简,不在考虑 this 等问题,可以在函数组件中使用 React 特性。

What

下面将分别梳理下官方常见的 Hook

useState

useState 应该算是最常用的 Hook 之一,前面提到了,函数每次 render 都会重新调用一次函数,对应的内部的状态将会被重置,因此需要 Hook 来记录其内部的状态值。

useState 作为一个 hook 函数,需要传入一个参数,并且会返回一个数组,一般是通过数组解构获取里面的数据。需要传入的参数是作为初始化 state 的值,返回的数组中第一个参数包含当前状态的值以及设置状态值的函数。

由于 useState 使用简单,这里便不做举例说明(想了解 useState 的实现原理可以看之前的 mirco-react hook)。

由于是第一个 Hook 函数,简单说下对于 Hook 有三个规则:

  1. 只能在函数的最外层使用 Hook,不能在循环条件判断子函数中调用。
  2. 只能在 React 的函数组件中调用 Hook,不要在其他 JS 函数中调用(除非自定义 Hook 的时候)。
  3. 规定 Hook 函数需要以 use 作为函数开头命名,这样才能被 React 识别。

useEffect

首先从一个简单的 class 组件入手。

export class App extends PureComponent {
  constructor() {
    super();
    this.state = {
      counter: 100,
    };
  }

  componentDidMount() {
    $axios.get("localhost:7777").then(() => {});
  }

  render() {
    const { counter } = this.state;
    return (
      <div>
        <h2>{counter}</h2>
        <button
          onClick={(e) =>
            this.setState({
              counter: counter + 1,
            })
          }
        >
          Add
        </button>
      </div>
    );
  }
}

上面的代码中,可以看到在类组件中,对于网络请求的操作可以放在对应的生命周期中进行。反观函数组件,如果直接把一些副作用的代码放在了函数体内部,会导致在每次重新 render 的时候都会去执行这些副作用。

const App = memo(() => {
  const [count, setCount] = useState(200);

  $axios.get("localhost:7777").then(() => {});

  return (
    <div>
      <h2>{count}</h2>
      <button
        onClick={(e) => setCount(count + 1)}
      >
        Add
      </button>
    </div>
  );
});

在函数组件主体内(React 的渲染阶段),对于改变 DOM、添加订阅、设置定时器和执行包含副作用的操作都是不被允许的,因为会产生一些奇怪的 Bug 并且破坏 UI 的一致性。因此有了 useEffectHook,来接收一个包含命令式且可能有副作用的函数,通过这个 Hook 函数,可以告诉 React 需要在渲染后执行哪些操作。

useEffect 参数需要传入一个 callback,在 React 执行完 DOM 操作后,就会回调这个函数。无论是第一次渲染之后,还是每次更新之后都会调用这个 callback。上述情况会导致出现问题:某些代码只需要执行一次,类似于 componentDidMount 和 componentWillUnmount 中完成的事情(网络请求、订阅或者取消订阅等)

针对这问题,需要注意 useEffect 的第二个参数,一个数组,在哪些 state 发生变化时,才会重新执行。

useEffect(() => {
  console.log(count);
}, [count]);

useEffect(() => {
  // do somethings.
}, []);

如上代码,当 count 发现变化时,才会重新执行 callback,并且引入了一个特殊的用法,第二个参数传入一个空数组,此时不会依赖于其他的 state,只有在第一次调用函数组件,渲染完成之后执行,因此类似于类组件的 componentDidMount。主要注意的是如果传入了空数组,effect 内部的 propsstate 会一直持有其初始值。官方给出了一些更好的方式去避免频繁的重复调用 effect

const Example = ({ someProp }) => {
  function doSomething() {
    console.log(someProp);
  }
  useEffect(() => {
    doSomething();
  }, []);
};

effect 外部的函数使用了 someProp,但是没有依赖于 someProp 这个状态,这便对应了一个难点:需要记住 effect 外部函数使用了哪些 props 和 state

修改之后代码,如下:

const Example = ({ someProp }) => {
  useEffect(() => {
    function doSomething() {
      console.log(someProp);
    }
    doSomething();
  }, [someProp]);
};

注意官方提到了一些注意的东西,可以去 useEffect 的文档中查看详情

对于 useEffect 还有一点需要注意的,在 class 组件中,某些副作用的代码,我们需要在 componentWillUnmount 中进行清楚,useEffect 中也可以模拟 componentWillUnmount 操作,回到之前 useEffect 的第一个回调函数,其本身可以有一个返回值,这个返回值是另一个回调函数,可以看下其函数签名.

type EffectCallback = () =>
  | void
  | (() => void | undefined);

这是 effect 可选的清除机制,每个 effect 都可以返回一个清除函数,这样就可以把订阅和取消订阅的逻辑放在一起。

useEffect(() => {
  const unsubscribe = store.subscribe(() => {});
  return () => {
    unsubscribe();
  };
});

为防止内存泄漏,清除函数会在组件卸载前执行。另外,如果组件多次渲染(通常如此),则在执行下一个 effect 之前,对上一个 effect 进行清除。

useContext

在之前的开发中(没有使用 Hook 之前),要在组件中使用共享的 Context 有两种方式:

  1. 类组件通过 类名.contextType = MyContext 方式,在类中获取 context
  2. 多个 Context 或者在函数式组件中通过 MyContext.Consumer 方式共享 context

引入了 useContext 之后,只需要 const value = useContext(MyContext); 既可以获取到 context。函数需要接受一个 context 对象(React.createContext 的返回值)。

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

const MyContext = React.createContext(defaultValue); 只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee",
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222",
  },
};

const ThemeContext = React.createContext(
  themes.light
);

function App() {
  return (
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar() {
  const theme = useContext(ThemeContext);
  return (
    <button
      style={{
        background: theme.background,
        color: theme.foreground,
      }}
    >
      Context
    </button>
  );
}

useReducer

useReduceruseState 的一种替代方案,在某些场景下,如果 state 处理逻辑比较复杂,可以通过 useReducer 来对其进行拆分,或者这次修改的 state 需要依赖之前的 state。不过写法和 redux 十分的相似。

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

useCallback

对于 useCallback 钩子,官方文档说明的很简洁。只是说明把内联回调函数以及依赖项数组作为参数传入 useCallback,它会返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新,useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

从官方的定义上来看,useCallback 的目的是不希望子组件多次渲染,并不是为了函数进行缓存,从而达到性能的优化,那么如何进行性能优化呢?

当需要将一个函数传递给子组件时,最好使用 useCallback 进行优化,将优化后的函数传递给子函数。在先思考 useCallback 之前,先认识下 React.memo 它仅检查 props 的变更,简单来说就是变更了就重新渲染,但是遇到 useStateuseContext 这类似的 hook,变更了 statecontext 也会重新渲染。

const Home = memo(({ increment }) => {
  console.log("Home render");
  return (
    <div>
      <button className="mt" onClick={increment}>
        Home Component Add One (+)
      </button>

      {/* 100 child components */}
    </div>
  );
});

Home 组件引入 memo,当 increment 不变时,它不会重新渲染。

const App = memo(() => {
  const [count, setCount] = useState(0);
  const [random, setRandom] = useState(
    Math.random()
  );

  // TODO increment

  return (
    <div className="App">
      <header className="App-header">
        <h2>count: {count}</h2>
        <button onClick={increment}>
          App Add One (+)
        </button>

        <Home increment={increment} />

        <h2>random: {random}</h2>
        <button
          onClick={(e) =>
            setRandom(Math.random())
          }
        >
          Change Random Number
        </button>
      </header>
    </div>
  );
});

此时可以思考一下 increment 函数该如何写。

// 1. 普通函数
const increment = () => {
  console.log("increment");
  setCount(count + 1);
};

应该在没有认识 useCallback 之前能想到的就是这样了,但是这样的话,会有一个问题,每次点击 Add 调用 increment 之后,state 发生变化,App 组件重新渲染,increment 函数也会发生变化,从而传入到子组件中的 props 发生变化,子组件中的所有组件会被重新 render,如果子组件只有很少的内容影响不明显,当子组件是一整个页面,没点击一次 Add 相当于都会重新去渲染整个页面,很影响性能,因此,想到了第二个方法,使用 useCallback

const increment = useCallback(() => {
  console.log("increment");
  setCount(count + 1);
}, []);

const increment = useCallback(() => {
  console.log("increment");
  setCount(count + 1);
}, [count]);

此时这两个都会存在点问题,第一个会出现闭包陷阱,第二个每次 count 发生变化会导致重新调用 useCallback 去生成新的 increment 让子组件的 props 发生变化,因此子组件会重新渲染。

闭包陷阱:这里是因为没有依赖项,因此 count 记录的是最初的 0,点击 Add 之后,会调用 incrementcount 变为 1,由于没有依赖项,那么它将返回该回调函数的 memoized 版本(点击前的increament === 点击后的increament)。深层次是因为在堆中没有重新开辟一块空间放置回调函数内容,用的是之前的。

⚠️ 注意:useEffect、useMemo 也存在同样的问题!

最终正对种种问题,可以使用 useRef 这个 Hook 实现 useCallback + memo性能优化

const countRef = useRef();
// 这里可以把 useState 删掉,直接用 current 的值
countRef.current = count;
const increment = useCallback(function foo() {
  console.log("increment");
  setCount(countRef.current + 1);
}, []);

主要是因为 countRef 没有发生变化,因此也不会重新去生成新的 increment,并且其内部 current 会在每次更新 count 重新渲染 App 组件之后获取最新值。

useMemo

区分于 useCallbackuseMemo 返回的是 memoized 值,而不是一个 memoized 回调,从另外一个角度来讲,useCallback(fn, deps)useMemo(() => fn, deps) 的效果是相同的。

因此这里对 useMemo 不做过多的赘述,其和 useCallback 有相似之处。

const App = memo(() => {
  const [count, setCount] = useState(0);
  const info = { name: "why", age: 18 };
  return (
    <div>
      <span>{count}</span>
      <button
        onClick={(e) => setCount(count + 1)}
      >
        Add
      </button>
      <HelloWorld info={info} />
    </div>
  );
});

在点击 Add 的时候,count 发生变化,re-renderinfo 每次都会拿到一个新的对象,导致了传入到子组件 props 也会发生变化,因此每次都会重新渲染子组件。如果此时使用 useMemo 来返回 infoconst info = useMemo(() => ({ name: "why", age: 18 }), []) 则每次 re-render 的时候,info 对象不会改变,子组件不会重新渲染。

useRef

这里的 useRef 返回的值类似于在 Vue3Composition APIrefuseRef(init) 会返回 ref object,其中 current 的属性会设置为 init。在下次的 render 中,useRef 会返回相同的对象,因此可用 current 来存储一些信息,类似于 state,但有一个重要的区别,更改 ref 不会触发 re-render

因此对于使用 ref

  • 可以在 re-render 之间存储信息,不像常规的变量发生变化。
  • 改变它不会触发 re-render,改变 state 会触发 re-render
  • 该信息对于组件的每一个副本都是本地的。
function MyComponent() {
  // 🚩 Don't write a ref during rendering
  myRef.current = 123;
  // 🚩 Don't read a ref during rendering
  return <h1>{myOtherRef.current}</h1>;
}

注意:在 render 期间不要对 ref.current 进行操作。这是因为之前所提到的 React 希望自己的组件是一个纯函数,对于相同的输入(propsstatecontext),都会返回相同的 JSX,上述的操作已经破坏了这个期望。

为此,正对这个保存状态的特征,可以解释在 useCallback 中所提到的可以使用 useRef 绑定值来解决闭包陷阱的问题查看详情

useRef 除了用来保存状态之外,还可以用来获取 DOM 元素(fiber 信息)。

const App = memo(() => {
  const titleRef = useRef(null);
  const inputRef = useRef(null);

  function showTitleDom() {
    console.log(titleRef.current);
    inputRef.current.focus();
  }

  return (
    <>
      <h2 ref={titleRef}>Hello World</h2>
      <input type="text" ref={inputRef} />
      <button onClick={showTitleDom}>
        Check
      </button>
    </>
  );
});

但是当使用 ref 去获取自定义组件时,会出现错误,例如下代码:

const HelloWorld = memo(() => {
  return <div>HelloWorld</div>;
});

const App = memo(() => {
  const compRef = useRef();

  function showRef() {
    console.log(compRef.current);
  }

  return (
    <>
      <HelloWorld ref={compRef}></HelloWorld>
      <button onClick={showRef}>
        Show Own Component Ref
      </button>
    </>
  );
});

控制台报错信息提示需要使用 React.forwardRef,修改之后代码如下:

const HelloWorld = memo(
  forwardRef((props, ref) => {
    return <div ref={ref}>HelloWorld</div>;
  })
);

const App = memo(() => {
  const compRef = useRef();

  function showRef() {
    console.log(compRef.current);
  }

  return (
    <>
      <HelloWorld ref={compRef}></HelloWorld>
      <button onClick={showRef}>
        Show Own Component Ref
      </button>
    </>
  );
});

useImperativeHandle

首先可以看下如下代码:

const HelloWorld = memo(
  forwardRef((props, ref) => {
    return <input type="text" ref={ref} />;
  })
);

const App = memo(() => {
  const inputRef = useRef();

  function handleDOM() {
    inputRef.current.focus();
    inputRef.current.value = "";
  }

  return (
    <div>
      <HelloWorld ref={inputRef} />
      <button onClick={handleDOM}>Change</button>
    </div>
  );
});

以上代码,父组件能够拿到子组件的 DOM 信息,因此可以随意操作,避免以为未知的错误,可以使用 useImperativeHandle 来约束暴露出去的属性。

由于涉及到了组件暴露 ref,因此 useImperativeHandle 应当与 forwardRef 一起使用。

const HelloWorld = memo(
  forwardRef((props, ref) => {
    const inputRef = useRef();

    useImperativeHandle(ref, () => {
      return {
        focus() {
          inputRef.current.focus();
        },
      };
    });

    return <input type="text" ref={inputRef} />;
  })
);

HelloWolrd 组件的内容修改为使用 useImperativeHandle,此时便无法改变 value

useLayoutEffect

useLayoutEffectuseEffect 非常的相似,事实上他们也只有一点区别而已:

  • useEffect 会再渲染的内容更新到 DOM 后执行,不会阻塞 DOM 更新。
  • useLayoutEffect 会在渲染的内容更新到 DOM 前执行,会阻塞 DOM 更新。

也就是说对于 useEffect 而言,当 state 发生时候,此时组件会触发重新渲染,当渲染的内容更新到 DOM 上可以在屏幕上看到更新内容之后在触发 useEffect 里面的副作用内容;相反,当渲染的内容更新到 DOM 之前,会先执行 useLayoutEffect 中副作用的内容,如果其修改了 DOM 内容,此时在屏幕上不会渲染之前的内容,而是直接展示修改之后的内容。

这里给了一个例子:useEffect & useLayoutEffect 从例子可以看出,点击 useEffect 之后屏幕会闪动一次,这就是因为先渲染出来之后在重新执行 useEffect 中的副作用,而下面的 useLayoutEffect 则不会出现闪烁的问题,因为会把 DOM 修改之后再渲染出来。

Summary

到此为止,把常见的一些 Hook 都熟悉了一遍,其实还有其他的 Hook,比如 React18 提出的 useIdRedux 中的 useSeletoruseDispatch 等等,这些内容都需要了解。准备开始做一些 React 的项目,应该是先从最简单的开始。

antcao.me © 2022-PRESENT

: 0x9aB9C...7ee7d