likes
comments
collection
share

React系列(六)--- 从HOC再到HOOKS

作者站长头像
站长
· 阅读数 28

系列文章

React系列(一)-- 2013起源 OSCON - React Architecture by vjeux

React系列(二)-- React基本语法实现思路

React系列(三)-- Jsx, 合成事件与Refs

React系列(四)--- virtualdom diff算法实现分析

React系列(五)--- 从Mixin到HOC

React系列(六)--- 从HOC再到HOOKS

在线调试

React在线运行网址: https://codesandbox.io/s/blue...

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

动机

Hook 解决了我们五年来编写和维护成千上万的组件时遇到的各种各样看起来不相关的问题。

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

如果你使用过 React 一段时间,你也许会熟悉一些解决此类问题的方案,

比如 render props高阶组件。但是这类方案需要重新组织你的组件结构,这可能会很麻烦,使你的代码难以理解。

如果你在 React DevTools 中观察过 React 应用,你会发现由 providers,consumers,高阶组件,render props 等其他抽象层组成的组件会形成“嵌套地狱”。

你可以使用 Hook 从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。Hook 使你在无需修改组件结构的情况下复用状态逻辑。

复杂组件变得难以理解

我们经常维护一些组件,组件起初很简单,但是逐渐会被状态逻辑和副作用充斥。每个生命周期常常包含一些不相关的逻辑。相互关联且需要对照修改的代码被进行了拆分,而完全不相关的代码却在同一个方法中组合在一起。如此很容易产生 bug,并且导致逻辑不一致。

Hook 将组件中相互关联的部分拆分成更小的函数(比如设置订阅或请求数据)

难以理解的 class

你必须去理解 JavaScript 中 this 的工作方式,这与其他语言存在巨大差异。还不能忘记绑定事件处理器。没有稳定的语法提案,这些代码非常冗余。

class 也给目前的工具带来了一些问题。例如,class 不能很好的压缩,并且会使热重载出现不稳定的情况。

Hook 使你在非 class 的情况下可以使用更多的 React 特性。

HOOKS规范

在顶层调用HOOKS

不要在循环,条件,或者内嵌函数中调用.这都是为了保证你的代码在每次组件render的时候会按照相同的顺序执行HOOKS,而这也是能够让React在多个useState和useEffect执行中正确保存数据的原因

只在React函数调用HOOKS

  • React函数组件调用
  • 从自定义HOOKS中调用

可以确保你源码中组件的所有有状态逻辑都是清晰可见的.

State Hook

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

返回一个 state,以及更新 state 的函数。

在初始渲染期间,返回的状态 (state) 与传入的第一个参数 (initialState) 值相同。

setState 函数用于更新 state。它接收一个新的 state 值并将组件的一次重新渲染加入队列。

惰性初始 state

initialState 参数只会在组件的初始渲染中起作用,后续渲染时会被忽略。如果初始 state 需要通过复杂计算获得,则可以传入一个函数,在函数中计算并返回初始的 state,此函数只在初始渲染时被调用:

const [state, setState] = useState(() => {
  const initialState = someExpensiveComputation(props);
  return initialState;
});

跳过 state 更新

调用 State Hook 的更新函数并传入当前的 state 时,React 将跳过子组件的渲染及 effect 的执行。(React 使用 Object.is 比较算法 来比较 state。)

需要注意的是,React 可能仍需要在跳过渲染前渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心。如果你在渲染期间执行了高开销的计算,则可以使用 useMemo 来进行优化。

示例

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

export default Example;

等价于下面Class写法

import React from 'react';

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

export default Example;

从上面可以看出useState实际上就是在state里声明一个变量并且初始化了一个值而且提供一个可以改变对应state的函数.因为在纯函数中没有this.state.count的这种用法,所以直接使用count替代,上面的count就是声明的变量,setCount就是改变变量的方法.

需要注意的一点是useStatethis.state有点不同,它只有在组件第一次render才会创建状态,之后每次都只会返回当前的值.

如果改变需要根据之前的数据变化,可以通过函数接收旧数据,例如

setCount(prevCount => prevCount + 1)

如果是想声明多个state的时候,就需要使用多次useState

function ExampleWithManyStates() {
  // Declare multiple state variables!
  const [age, setAge] = useState(42);
  const [fruit, setFruit] = useState('banana');
  const [todos, setTodos] = useState([{ text: 'Learn Hooks' }]);
}

或者通过组合对象一次合并多个数据

Effect Hook

useEffect(didUpdate);

执行有副作用的函数,你可以把 useEffect Hooks 视作 componentDidMountcomponentDidUpdatecomponentWillUnmount 的结合,useEffect 会在浏览器绘制后延迟执行,但会保证在任何新的渲染前执行,React 将在组件更新前刷新上一轮渲染的 effect。

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

React 组件中的 side effects 大致可以分为两种

不需要清理

有时我们想要在 React 更新过 DOM 之后执行一些额外的操作。比如网络请求、手动更新 DOM 、以及打印日志都是常见的不需要清理的 effects

import React from 'react';

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  componentDidMount() {
    console.log(`componentDidMount: You clicked ${this.state.count} times`);
  }

  componentDidUpdate() {
    console.log(`componentDidUpdate: You clicked ${this.state.count} times`);
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>Click me</button>
      </div>
    );
  }
}

export default Example;

componentDidMount: You clicked 0 times

// 点击按钮

componentDidUpdate: You clicked 1 times

但是如果我们换成HOOKS的写法

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

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

  useEffect(() => {
      // 初始化默认输出: You clicked 0 times 
      // 后续点击才会输出
    console.log(`You clicked ${count} times`);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

export default Example;

You clicked 0 times

// 点击按钮

You clicked 1 times

useEffect 做了什么?

通过这个 Hook,React 知道你想要这个组件在每次 render 之后做些事情。React 会记录下你传给 useEffect 的这个方法,然后在进行了 DOM 更新之后调用这个方法。但我们同样也可以进行数据获取或是调用其它必要的 API。

为什么 useEffect 在组件内部调用?

useEffect 放在一个组件内部,可以让我们在 effect 中,即可获得对 count state(或其它 props)的访问,而不是使用一个特殊的 API 去获取它。

useEffect 是不是在每次 render 之后都会调用?

默认情况下,它会在第一次 render 和 之后的每次 update 后运行。React 保证每次运行 effects 之前 DOM 已经更新了

使用上还有哪些区别?

不像 componentDidMount 或者 componentDidUpdateuseEffect 中使用的 effect 并不会阻滞浏览器渲染页面。我们也提供了一个单独的 useLayoutEffect 来达成这同步调用的效果。它的 API 和 useEffect 是相同的。

需要清理的 Effect

比较常见的就类似挂载的时候监听事件或者开启定时器,卸载的时候就移除.

import React from 'react';

class Example extends React.Component {
  constructor(props) {
    super(props);
  }

  componentDidMount() {
    document.addEventListener('click', this.clickFunc, false);
  }

  componentWillUnmount() {
    document.removeEventListener('click', this.clickFunc);
  }

  clickFunc(e) {
    // doSomethings
  }

  render() {
    return <button>click me!</button>;
  }
}

export default Example;

换成HOOKS写法类似,只是会返回新的函数

import React, { useEffect } from 'react';

function Example() {
  useEffect(() => {
    document.addEventListener('click', clickFunc, false);
    return () => {
      document.removeEventListener('click', clickFunc);
    };
  });

  function clickFunc(e) {
    // doSomethings
  }

  return <button>click me!</button>;
}

export default Example;

我们为什么在 effect 中返回一个函数

这是一种可选的清理机制。每个 effect 都可以返回一个用来在晚些时候清理它的函数。这让我们让添加和移除订阅的逻辑彼此靠近。它们是同一个 effect 的一部分!

React 究竟在什么时候清理 effect?

React 在每次组件 unmount 的时候执行清理。然而,正如我们之前了解的那样,effect 会在每次 render 时运行,而不是仅仅运行一次。这也就是为什么 React 也会在执行下一个 effect 之前,上一个 effect 就已被清除

我们可以修改一下代码看看effect的运行机制

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

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

  useEffect(() => {
    console.log('addEventListener: ' + count);
    document.addEventListener('click', clickFunc, false);
    return () => {
      console.log('removeEventListener: ' + count);
      document.removeEventListener('click', clickFunc);
    };
  });

  function clickFunc(e) {
    setCount(count + 1);
  }

  return <button>click me! {count}</button>;
}

export default Example;

addEventListener: 0

// 点击按钮

removeEventListener: 0

addEventListener: 1

// 点击按钮

removeEventListener: 1

addEventListener: 2

可以看到上面代码在每次更新都是重新监听,想要避免这种情况不在useEffect里return函数即可

进阶使用

有时候我们可能有多套逻辑写在不同的生命周期里,如果换成HOOKS写法的话我们可以按功能划分使用多个,React将会按照指定的顺序应用每个effect。

import React, { useState, useEffect } from "react";

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

  useEffect(() => {
    console.log(`You clicked ${count} times`);
  });

  useEffect(() => {
    console.log('addEventListener');
    document.addEventListener("click", clickFunc, false);
    return () => {
      console.log('removeEventListener');
      document.removeEventListener("click", clickFunc);
    };
  });

  function clickFunc(e) {
    setCount(count + 1);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button>Click me</button>
    </div>
  );
}

export default Example;

You clicked 0 times

addEventListener

// 点击按钮

removeEventListener

You clicked 1 times

addEventListener

// 点击按钮

removeEventListener

You clicked 2 times

addEventListener

为什么Effects会在每次更新后执行

如果你们以前使用class的话可能会有疑惑,为什么不是在卸载阶段执行一次.从官网解释代码看

componentDidMount() {
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

componentWillUnmount() {
  ChatAPI.unsubscribeFromFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

它在挂载阶段监听,移除阶段移除监听,每次触发就根据this.props.friend.id做出对应处理.但是这里有个隐藏的bug就是当移除阶段的时候获取的this.props.friend.id可能是旧的数据,引起的问题就是卸载时候会使用错误的id而导致内存泄漏或崩溃,所以在class的时候一般都会在componentDidUpdate 做处理

componentDidUpdate(prevProps) {
  // Unsubscribe from the previous friend.id
  ChatAPI.unsubscribeFromFriendStatus(
    prevProps.friend.id,
    this.handleStatusChange
  );
  // Subscribe to the next friend.id
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

但是如果我们换成HOOKS的写法就不会有这种bug

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
});

这是因为HOOKS会在应用下一个effects之前清除前一个effects,此行为默认情况下确保一致性,并防止由于缺少更新逻辑而在类组件中常见的错误

通过跳过effects提升性能

就在上面我们知道每次render都会触发effects机制可能会有性能方面的问题,在class的写法里我们可以通过componentDidUpdate做选择是否更新

componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}

而在useEffect里我们可以通过传递一组数据给它作为第二参数,如果在下次执行的时候该数据没有发生变化的话React会跳过当次应用

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

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

  useEffect(() => {
    console.log('addEventListener: ' + count);
    document.addEventListener('click', clickFunc, false);
    return () => {
      console.log('removeEventListener: ' + count);
      document.removeEventListener('click', clickFunc);
    };
  }, [count]);

  function clickFunc(e) {}

  return <button>click me! {count}</button>;
}

export default Example;

所以上面提到的bug案例可以通过这个方式做解决

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]); // Only re-subscribe if props.friend.id changes
注意

如果你想使用这种优化方式,请确保数组中包含了所有外部作用域中会发生变化且在 effect 中使用的变量,否则你的代码会一直引用上一次render的旧数据.

如果你想要effects只在挂载和卸载时各清理一次的话,可以传递一个空数组作为第二参数.相当于告诉React你的effects不依赖于任何的props或者state,所以没必要重复执行.

effect 的执行时机

componentDidMountcomponentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用。这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因此不应在函数中执行阻塞浏览器更新屏幕的操作

然而,并非所有 effect 都可以被延迟执行。例如,在浏览器执行下一次绘制前,用户可见的 DOM 变更就必须同步执行,这样用户才不会感觉到视觉上的不一致。(概念上类似于被动监听事件和主动监听事件的区别。)React 为此提供了一个额外的 useLayoutEffect Hook 来处理这类 effect。它和 useEffect 的结构相同,区别只是调用时机不同。

useLayoutEffect

其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

提示

如果你使用服务端渲染,请记住,无论 useLayoutEffect 还是 useEffect 都无法在 Javascript 代码加载完成之前执行。这就是为什么在服务端渲染组件中引入 useLayoutEffect 代码时会触发 React 告警。解决这个问题,需要将代码逻辑移至 useEffect 中(如果首次渲染不需要这段逻辑的情况下),或是将该组件延迟到客户端渲染完成后再显示(如果直到 useLayoutEffect 执行之前 HTML 都显示错乱的情况下)。

若要从服务端渲染的 HTML 中排除依赖布局 effect 的组件,可以通过使用 showChild && <Child /> 进行条件渲染,并使用 useEffect(() => { setShowChild(true); }, []) 延迟展示组件。这样,在客户端渲染完成之前,UI 就不会像之前那样显示错乱了。

useMemo

const memoizedValue = useMemo(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

返回一个 memoized 值。

把“创建”函数和依赖项数组作为参数传入 useMemo,它仅会在某个依赖项改变时才重新计算 memoized 值。这种优化有助于避免在每次渲染时都进行高开销的计算。(可以理解成Vue的computed API)

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

如果没有提供依赖项数组,useMemo 在每次渲染时都会计算新的值。

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

注意

依赖项数组不会作为参数传给回调函数。虽然从概念上来说它表现为:所有回调函数中引用的值都应该出现在依赖项数组中。未来编译器会更加智能,届时自动创建数组将成为可能。

useCallback

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

返回一个 memoized 回调函数。把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。useCallback(fn, deps) 相当于 useMemo(() => fn, deps)

注意

依赖项数组不会作为参数传给回调函数。虽然从概念上来说它表现为:所有回调函数中引用的值都应该出现在依赖项数组中。未来编译器会更加智能,届时自动创建数组将成为可能。

useReducer

const [state, dispatch] = useReducer(reducer, initialArg, init);

useState 的替代方案。它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。(类似Redux的工作方式)

在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数 。

import React, { useReducer } from "react";

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

export default Counter;

注意

React 会确保 dispatch 函数的标识是稳定的,并且不会在组件重新渲染时改变。这就是为什么可以安全地从 useEffectuseCallback 的依赖列表中省略 dispatch

指定初始 state

将初始 state 作为第二个参数传入 useReducer 是最简单的方法:

const [state, dispatch] = useReducer(reducer, { count: initialCount });

注意

React 不使用 state = initialState 这一由 Redux 推广开来的参数约定。有时候初始值依赖于 props,因此需要在调用 Hook 时指定。如果你特别喜欢上述的参数约定,可以通过调用 useReducer(reducer, undefined, reducer) 来模拟 Redux 的行为,但我们不鼓励你这么做。

惰性初始化

从语法上你们会看到还有一个init的入参,是用来做惰性初始化,将 init 函数作为 useReducer 的第三个参数传入,这样初始 state 将被设置为 init(initialArg)

这么做可以将用于计算 state 的逻辑提取到 reducer 外部,这也为将来对重置 state 的 action 做处理提供了便利

import React, { useReducer } from "react";

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

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

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

export default Counter;

跳过 dispatch

如果 Reducer Hook 的返回值与当前 state 相同,React 将跳过子组件的渲染及副作用的执行。(React 使用 Object.is 比较算法 来比较 state。)

需要注意的是,React 可能仍需要在跳过渲染前再次渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心。如果你在渲染期间执行了高开销的计算,则可以使用 useMemo 来进行优化。

useContext

const value = useContext(MyContext);

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

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

调用了 useContext 的组件总会在 context 值变化时重新渲染。如果重渲染组件的开销较大,你可以通过使用 memoization来优化。

示例

举个例子,在上面的useReducer代码中,我们通过一个context做中转:

import React, { useContext, useReducer } from "react";

const initialState = { count: 0 };
// context对象
const stateContext = React.createContext();

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 ContextProvider(props) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <stateContext.Provider value={{ state, dispatch }}>
      {props.children}
    </stateContext.Provider>
  );
}

// 数据接收组件
function Counter() {
  const { state, dispatch } = useContext(stateContext);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
    </>
  );
}

// 形成关系
const App = () => {
  return (
    <ContextProvider>
      <Counter />
    </ContextProvider>
  );
};

export default App;

useRef

const refContainer = useRef(initialValue);

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

import React, { useRef } from "react";

function Example() {
    const inputEl = useRef(null);
    const onButtonClick = () => {
      // `current` 指向已挂载到 DOM 上的文本输入元素
      inputEl.current.focus();
    };
    return (
      <div>
        <input ref={inputEl} type="text" />
        <button onClick={onButtonClick}>Focus the input</button>
      </div>
    );
  }

export default Example;

你应该熟悉 ref 这一种访问 DOM 的主要方式。如果你将 ref 对象以 <div ref={myRef} /> 形式传入组件,则无论该节点如何改变,React 都会将 ref 对象的 .current 属性设置为相应的 DOM 节点。然而,useRef() 比 ref 属性更有用。它可以很方便地保存任何可变值。这是因为它创建的是一个普通 Javascript 对象。而 useRef() 和自建一个 {current: ...} 对象的唯一区别是,useRef 会在每次渲染时返回同一个 ref 对象。请记住,当 ref 对象内容发生变化时,useRef 并不会通知你。变更 .current 属性不会引发组件重新渲染。如果想要在 React 绑定或解绑 DOM 节点的 ref 时运行某些代码,则需要使用回调 ref 来实现。

useImperativeHandle

useImperativeHandle(ref, createHandle, [deps])

useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。在大多数情况下,应当避免使用 ref 这样的命令式代码。useImperativeHandle 应当与 forwardRef一起使用:

import React, { useRef, useImperativeHandle, forwardRef } from "react";

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

function Example() {
  const inputRef = useRef();
  return (
    <div>
      <FancyButton ref={inputRef} />
      <button onClick={() => inputRef.current.focus()}>Focus the input</button>
    </div>
  );
}

export default Example;

在上述的示例中,React 会将 <FancyButton ref={inputRef}> 元素的 ref 作为第二个参数传递给 React.forwardRef 函数中的渲染函数。该渲染函数会将 ref 传递给 <input ref={inputRef}> 元素。

因此,当 React 附加了 ref 属性之后,ref.current 将直接指向 <button> DOM 元素实例。

自定义HOOKS

我们可以将相关逻辑抽取出来

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

我必须以“use”开头为自定义钩子命名吗? 这项公约非常重要。如果没有它,我们就不能自动检查钩子是否违反了规则,因为我们无法判断某个函数是否包含对钩子的调用。

使用相同钩子的两个组件是否共享状态? 不。自定义钩子是一种重用有状态逻辑的机制(例如设置订阅并记住当前值),但是每次使用自定义钩子时,其中的所有状态和效果都是完全隔离的。

自定义钩子如何获得隔离状态? 对钩子的每个调用都处于隔离状态。从React的角度来看,我们的组件只调用useStateuseEffect

一个无限循环的异常BUG

异常代码

假设有一个在useEffect内执行的代码

import React, { useState, useEffect } from "react";

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

  useEffect(() => {
    setCount(count + 1);
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

export default Example;

执行这段代码你会发现界面没有点击就进入无限循环异常

You clicked 1 times

You clicked 2 times

一直递增......

定位问题

我们回顾useEffect事件的执行时机是: 默认情况下,它在第一次渲染之后和每次更新之后都会执行

在上面代码事件内执行请求之后会更新状态,即每次更新之后又会重新触发事件

todo

于是我们得出解决办法是后续更新需要跳过执行,useEffect事件可以传递第二个可选参数进行跳过

  • 空数组:这是粗暴并且不安全的做法,一般情况并不推荐
  • 依赖数组:如果某些特定值在两次重渲染之间没有发生变化

尝试使用判断函数处理修复

import React, { useState, useEffect } from "react";

function Example() {
  const [count, setCount] = useState(0);
  const isSkip = () => count >= 3;

  useEffect(() => {
    setCount(count + 1);
  }, [isSkip]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

export default Example;

执行这段代码你会发现界面依然进入无限循环异常

You clicked 1 times

You clicked 2 times

一直递增......

定位问题

执行上面判断函数处理代码控制台会输出警告

The 'isSkip' function makes the dependencies of useEffect Hook (at line 11) change on every render. Move it inside the useEffect callback. Alternatively, wrap the definition of 'isSkip' in its own useCallback() Hook. (react-hooks/exhaustive-deps)

意思就是isSkip函数依赖会在每次render的时候都改变,它给出的建议就是移入内部函数或者使用useCallback将其单独需要包裹

首先解释一下为什么每次都会改变,因为 useEffect 使用浅层对象比较来确定数据是否被改变,而isSkip函数实际每次更新都相当于重新创建,所以每次对比总是返回false

不仅是函数,对象或者数组也是同样道理,结果都是陷入无限循环

const isSkip = [true];
或者
const isSkip = {};

从代码场景来说,我们使用第二个建议去修复问题

最终解决方案一

import React, { useState, useEffect, useCallback } from "react";

function Example() {
  const [count, setCount] = useState(0);
  const isSkip = useCallback(() => count >= 3, []);

  useEffect(() => {
    setCount(count + 1);
  }, [isSkip]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

export default Example;

You clicked 1 times

// 点击按钮

You clicked 2 times

// 点击递增......

尝试使用判断结果处理修复

跟上面差别只是依赖参数不同

import React, { useState, useEffect } from "react";

function Example() {
  const [count, setCount] = useState(0);
  const isSkip = () => count >= 3;

  useEffect(() => {
    setCount(count + 1);
  }, [isSkip()]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

export default Example;

执行这段代码你会发现界面也会正常

You clicked 1 times

// 点击按钮

You clicked 2 times

// 点击递增......

定位问题

执行上面判断结果处理代码控制台会输出警告

React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked. (react-hooks/exhaustive-deps)

意思就是isSkip函数依赖是一个复杂表达式,需要将其单独提取到一个变量以便进行静态检查

最终解决方案二

import React, { useState, useEffect, useMemo } from "react";

function Example() {
  const [count, setCount] = useState(0);
  const isSkip = useMemo(() => count >= 3, [count]);

  useEffect(() => {
    setCount(count + 1);
  }, [isSkip]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

export default Example;

警告消失

定时器异常

Class写法

import React from "react";

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  log = () => {
    for (let i = 0; i < 5; i++) {
      setTimeout(() => {
        this.setState({ count: this.state.count + 1 });
        console.log(this.state.count);
      }, 1000);
    }
  };

  render() {
    return (
      <div>
        <button onClick={this.log}>Click me</button>
      </div>
    );
  }
}

export default Example;

Hooks写法

import React, { useState, useEffect } from "react";

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

  const log = () => {
    for (let i = 0; i < 5; i++) {
      setTimeout(() => {
        setCount(count + 1);
        console.log(count);
      }, 1000);
    }
  };

  return (
    <div>
      <button onClick={log}>Click me</button>
    </div>
  );
}

export default Example;

结果

Class写法: 1 2 3 4 5

Hooks写法: 0 0 0 0 0

定位问题

原因很简单:

Class里能生成一个实例化的对象,内部维护着组件的各种状态

Hooks实际上只是一个函数组件,每次执行都会重新声明所有的变量方法,执行完之后再被垃圾回收

这里面又可以引申出两个问题

问题一

Class内不使用定时器结果会有变化么?

import React from "react";

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  log = () => {
    for (let i = 0; i < 5; i++) {
      this.setState({ count: this.state.count + 1 });
      console.log(this.state.count);
    }
  };

  render() {
    return (
      <div>
        <button onClick={this.log}>Click me</button>
      </div>
    );
  }
}

export default Example;
0 0 0 0 0

从结果来看如果没有定时器的情况下也是有问题的,但这属于setState异步更新的问题,不是维护状态问题

问题二

如果一定要使用函数组件的话,怎么做才能保存状态

import React, { useState, useEffect } from "react";

function Example() {
  const [count, setCount] = useState(0);
  const [idx, setIdx] = useState(0);

  const log = () => {
    if (idx >= 5) return;
    setIdx(idx + 1);
    setCount(count + 1);
    console.log(count);
  };

  useEffect(log, [idx, count]);

  return (
    <div>
      <button onClick={log}>Click me</button>
    </div>
  );
}

export default Example;

只能选择搭配Hooks等API实现

问题

Hook 会替代 render props 和高阶组件吗?

通常,render props 和高阶组件只渲染一个子节点。我们认为让 Hook 来服务这个使用场景更加简单。这两种模式仍有用武之地,(例如,一个虚拟滚动条组件或许会有一个 renderItem 属性,或是一个可见的容器组件或许会有它自己的 DOM 结构)。但在大部分场景下,Hook 足够了,并且能够帮助减少嵌套。

生命周期方法要如何对应到 Hook?

  • constructor:函数组件不需要构造函数。你可以通过调用 useState 来初始化 state。如果计算的代价比较昂贵,你可以传一个函数给 useState。
  • getDerivedStateFromProps:改为在渲染时安排一次更新。
  • shouldComponentUpdate:详见 React.memo.
  • render:这是函数组件体本身。
  • componentDidMount, componentDidUpdate, componentWillUnmount:useEffect Hook 可以表达所有这些的组合。
  • componentDidCatch and getDerivedStateFromError:目前还没有这些方法的 Hook 等价写法,但很快会加上。

我可以只在更新时运行 effect 吗?

这是个比较罕见的使用场景。如果你需要的话,你可以 使用一个可变的 ref 手动存储一个布尔值来表示是首次渲染还是后续渲染,然后在你的 effect 中检查这个标识。

如何获取上一轮的 props 或 state?

目前,你可以通过ref来手动实现:

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);
  return (
    <h1>
      Now: {count}, before: {prevCount}
    </h1>
  );
}

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

有类似 forceUpdate 的东西吗?

如果前后两次的值相同,useState 和 useReducer Hook 都会放弃更新。原地修改 state 并调用 setState 不会引起重新渲染。通常,你不应该在 React 中修改本地 state。然而,作为一条出路,你可以用一个增长的计数器来在 state 没变的时候依然强制一次重新渲染:

const [ignored, forceUpdate] = useReducer(x => x + 1, 0);

function handleClick() {
  forceUpdate();
}

我该如何测量 DOM 节点?

要想测量一个 DOM 节点的位置或是尺寸,你可以使用 callback ref。每当 ref 被附加到另一个节点,React 就会调用 callback。

function MeasureExample() {
  const [rect, ref] = useClientRect();
  return (
    <div>
      <h1 ref={ref}>Hello, world</h1>
      {rect !== null && <h2>The above header is {Math.round(rect.height)}px tall</h2>}
    </div>
  );
}

function useClientRect() {
  const [rect, setRect] = useState(null);
  const ref = useCallback(node => {
    if (node !== null) {
      setRect(node.getBoundingClientRect());
    }
  }, []);
  return [rect, ref];
}

使用 callback ref 可以确保 即便子组件延迟显示被测量的节点 (比如为了响应一次点击),我们依然能够在父组件接收到相关的信息,以便更新测量结果。

注意到我们传递了 [] 作为 useCallback 的依赖列表。这确保了 ref callback 不会在再次渲染时改变,因此 React 不会在非必要的时候调用它。

我该如何实现 shouldComponentUpdate?

你可以用 React.memo 包裹一个组件来对它的 props 进行浅比较:

const Button = React.memo((props) => {
  // 你的组件
});

React.memo 等效于 PureComponent,但它只比较 props。(你也可以通过第二个参数指定一个自定义的比较函数来比较新旧 props。如果函数返回 true,就会跳过更新。)

React.memo 不比较 state,因为没有单一的 state 对象可供比较。但你也可以让子节点变为纯组件,或者 用useMemo优化每一个具体的子节点。

如何惰性创建昂贵的对象?

第一个常见的使用场景是当创建初始 state 很昂贵时,为避免重新创建被忽略的初始 state,我们可以传一个函数给 useState,React 只会在首次渲染时调用这个函数

function Table(props) {
  // createRows() 只会被调用一次
  const [rows, setRows] = useState(() => createRows(props.count));
  // ...
}

你或许也会偶尔想要避免重新创建 useRef() 的初始值。useRef 不会像 useState 那样接受一个特殊的函数重载。相反,你可以编写你自己的函数来创建并将其设为惰性的:

function Image(props) {
  const ref = useRef(null);

  //  IntersectionObserver 只会被惰性创建一次
  function getObserver() {
    let observer = ref.current;
    if (observer !== null) {
      return observer;
    }
    let newObserver = new IntersectionObserver(onIntersect);
    ref.current = newObserver;
    return newObserver;
  }

  // 当你需要时,调用 getObserver()
  // ...
}

Hook 会因为在渲染时创建函数而变慢吗?

不会。在现代浏览器中,闭包和类的原始性能只有在极端场景下才会有明显的差别。除此之外,可以认为 Hook 的设计在某些方面更加高效:

  • Hook 避免了 class 需要的额外开支,像是创建类实例和在构造函数中绑定事件处理器的成本。
  • 符合语言习惯的代码在使用 Hook 时不需要很深的组件树嵌套。这个现象在使用高阶组件、render props、和 context 的代码库中非常普遍。组件树小了,React 的工作量也随之减少。

传统上认为,在 React 中使用内联函数对性能的影响,与每次渲染都传递新的回调会如何破坏子组件的 shouldComponentUpdate 优化有关。Hook 从三个方面解决了这个问题。

  • useCallback Hook 允许你在重新渲染之间保持对相同的回调引用以使得 shouldComponentUpdate 继续工作:
  • useMemo Hook 使控制具体子节点何时更新变得更容易,减少了对纯组件的需要。
  • 最后,useReducer Hook 减少了对深层传递回调的需要,就如下面解释的那样。

如何避免向下传递回调?

在大型的组件树中,我们推荐的替代方案是通过 contextuseReducer 往下传一个 dispatch 函数:

const TodosDispatch = React.createContext(null);

function TodosApp() {
  // 提示:`dispatch` 不会在重新渲染之间变化
  const [todos, dispatch] = useReducer(todosReducer);

  return (
    <TodosDispatch.Provider value={dispatch}>
      <DeepTree todos={todos} />
    </TodosDispatch.Provider>
  );
}

TodosApp 内部组件树里的任何子节点都可以使用 dispatch 函数来向上传递 actions

function DeepChild(props) {
  // 如果我们想要执行一个 action,我们可以从 context 中获取 dispatch。
  const dispatch = useContext(TodosDispatch);

  function handleClick() {
    dispatch({ type: 'add', text: 'hello' });
  }

  return <button onClick={handleClick}>Add todo</button>;
}

总而言之,从维护的角度来这样看更加方便(不用不断转发回调),同时也避免了回调的问题。像这样向下传递 dispatch 是处理深度更新的推荐模式。

React 是如何把对 Hook 的调用和组件联系起来的?

React 保持对当先渲染中的组件的追踪。多亏了 Hook 规范,我们得知 Hook 只会在 React 组件中被调用(或自定义 Hook —— 同样只会在 React 组件中被调用)。每个组件内部都有一个「记忆单元格」列表。它们只不过是我们用来存储一些数据的 JavaScript 对象。当你用 useState() 调用一个 Hook 的时候,它会读取当前的单元格(或在首次渲染时将其初始化),然后把指针移动到下一个。这就是多个 useState() 调用会得到各自独立的本地 state 的原因。