如何使用 React Hooks 重构类组件?

1,985 阅读10分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第 13 天,点击查看活动详情 >>


最初,在 React 中可以使用 createClass 来创建组件,后来被类组件所取代。在 React 16.8版本中,新增的 Hooks 功能彻底改变了我们编写React程序的方式,因为使用 Hooks 可以编写更简洁、更清晰的代码,并为创建可重用的有状态逻辑提供了更好的模式。

许多公司和开发人员都放弃了类组件转而使用 Hooks。而许多旧的的React 项目仍然在使用类组件。更重要的是,在类组件中有 Error Boundaries,而函数组件中是无法使用 Error Boundaries 的。

本文就来通过一些常见示例看看如何使用 React Hooks 来重构类组件。

1. 管理和更新组件状态

状态管理是几乎所有 React 应用中最重要的部分,React 基于 state 和 props 渲染组件。每当它们发生变化时,组件就会重新渲染,并且 DOM 也会相应地更新。下面来看一个计数器的例子,它包含一个计数状态以及两个更新它的地方:

import { Component } from "react";

class ManagingStateClass extends Component {
  state = {
    counter: 0,
  };

  increment = () => {
    this.setState(prevState => {
      return {
        counter: prevState.counter + 1,
      };
    });
  };

  decrement = () => {
    this.setState(prevState => {
      return {
        counter: prevState.counter - 1,
      };
    });
  };
  
  render() {
    return (
      <div>
        <div>Count: {this.state.counter}</div>
        <div>
          <button onClick={this.increment}>Increment</button>
          <button onClick={this.decrement}>Decrement</button>
        </div>
      </div>
    );
  }
}

export default ManagingStateClass;

下面来使用 Hooks 实现这个计数器组件:

import { useState } from "react";

const ManagingStateHooks = () => {
  const [counter, setCounter] = useState(0);

  const increment = () => setCounter(counter => counter + 1);
  const decrement = () => setCounter(counter => counter - 1);

  return (
    <div>
      <div>Count: {counter}</div>
      <div>
        <button onClick={increment}>Increment</button>
        <button onClick={decrement}>Decrement</button>
      </div>
    </div>
  );
};

export default ManagingStateHooks;

该组件是一个返回 JSX 的函数,使用 useState hook来管理计算器的状态。它返回一个包含两个值的数组:第一个值为状态,第二个值为更新函数。并且使用 setCounter 来更新程序的incrementdecrement函数。

2. 状态更新后的操作

在某些情况下,我们可能需要在状态更新时执行某些操作。在类组件中,我们通常会在componentDidUpdate 生命周期中实现该操作。

import { Component } from "react";

class StateChangesClass extends Component {
  state = {
    counter: 0,
  };

  componentDidUpdate(prevProps, prevState) {
    localStorage.setItem("counter", this.state.counter);
  }

  increment = () => {
    this.setState(prevState => {
      return {
        counter: prevState.counter + 1,
      };
    });
  };
  
  decrement = () => {
    this.setState(prevState => {
      return {
        counter: prevState.counter - 1,
      };
    });
  };

  render() {
    return (
      <div>
        <div>Count: {this.state.counter}</div>
        <div>
          <button onClick={this.increment}>Increment</button>
          <button onClick={this.decrement}>Decrement</button>
        </div>
      </div>
    );
  }
}

export default StateChangesClass;

当状态发生变化时,我们将新的计数器值保存在 localStorage 中。在函数组件中,我们可以通过使用 useEffect hook 来实现相同的功能。

import { useState, useEffect } from "react";

const StateChangesHooks = () => {
  const [counter, setCounter] = useState(0);

  const increment = () => setCounter(counter => counter + 1);
  const decrement = () => setCounter(counter => counter - 1);

  useEffect(() => {
    localStorage.setItem("counter", counter);
  }, [counter]);
  
  return (
    <div>
      <div>Count: {counter}</div>
      <div>
        <button onClick={increment}>Increment</button>
        <button onClick={decrement}>Decrement</button>
      </div>
    </div>
  );
};

export default StateChangesHooks;

这个 useEffect hook 有两个参数,第一个参数是回调函数,第二个参数是依赖数组。在组件挂载时,这个 hook 至少会执行一次。然后,仅在依赖数组内的任何值发生变化时都会触发第一个参数传入的回调函数。如果依赖数组为空,则回调函数只会执行一次。在上面的例子中,每当 counter 发生变化时,都会触发将 counter 保存在 localStorage 中的回调函数。

3. 获取数据

在类组件中,通过会在componentDidMount生命周期中初始化一个 API 请求来获取数据。下面来看一个获取并显示帖子列表的组件:

import { Component } from "react";

class FetchingDataClass extends Component {
  state = {
    posts: [],
  };

  componentDidMount() {
    this.fetchPosts();
  }

  fetchPosts = async () => {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts");
    const data = await response.json();
    this.setState({
      posts: data.slice(0, 10),
    });
  };
  
  render() {
    return (
      <div>
        {this.state.posts.map(post => {
          return <div key={post.id}>{post.title}</div>;
        })}
      </div>
    );
  }
}

export default FetchingDataClass

有了 hooks,就可以使用useEffect来实现上述功能。它会在第一次挂载之后执行一次,然后在任何依赖发生变化时再次触发。useEffect 允许我们传入一个空依赖数组作为第二个参数来确保只执行一次effect的回调函数。

import { useState, useEffect } from "react";

const FetchingDataHooks = () => {
  const [posts, setPosts] = useState([]);

  const fetchPosts = async () => {
    const response = await fetch("https://jsonplaceholder.typicode.com/posts");
    const data = await response.json();
    setPosts(data.slice(0, 10));
  };

  useEffect(() => {
    fetchPosts();
  }, []);
  
  return (
    <div>
      {posts.map(post => {
         return <div key={post.id}>{post.title}</div>;
      })}
    </div>
  );
};

export default FetchingDataHooks;

4. 卸载组件时清理副作用

在卸载组件时清理副作用是非常重要的,否则可能会导致内存泄露。例如,在一个组件中,我们想要监听一个事件,比如resize或者scroll,并根据窗口大小获滚动的位置来做一些事情。下面来看一个类组件的例子,它会监听 resize 事件,然后更新浏览器窗口的宽度和高度的状态。事件监听器在 componentWillUnmount 生命周期中被移除。

import { Component } from "react";

class CleanupClass extends Component {
  state = {
    width: window.innerWidth,
    height: window.innerHeight,
  };

  componentDidMount() {
    window.addEventListener("resize", this.updateWindowSize, {
      passive: true,
    });
  }

  componentWillUnmount() {
    window.removeEventListener("resize", this.updateWindowSize, {
      passive: true,
    });
  }
  
  updateWindowSize = () => {
    this.setState({
      width: window.innerWidth,
      height: window.innerHeight,
    });
  };

  render() {
    return (
      <div>
        Window: {this.state.width} x {this.state.height}
      </div>
    );
  }
}

export default CleanupClass;

useEffect 中,我们可以在回调函数中返回一个函数来执行清理操作,卸载组件时会调用此函数。下面,首先来定义一个 updateWindowSize 函数,然后在 useEffect 中添加 resize 事件监听器。 接下来返回一个匿名箭头函数,它将用来移除监听器。

import { useState, useEffect } from "react";

const CleanupHooks = () => {
  const [width, setWidth] = useState(window.innerWidth);
  const [height, setHeight] = useState(window.innerHeight);

  useEffect(() => {
    const updateWindowSize = () => {
      setWidth(window.innerWidth);
      setHeight(window.innerHeight);
    };

    window.addEventListener("resize", updateWindowSize, {
      passive: true,
    });

    return () => {
      window.removeEventListener("resize", this.updateWindowSize, {
        passive: true,
      });
    };
  }, []);
  
  return (
      <div>
        Window: {this.state.width} x {this.state.height}
      </div>
  );
};

export default CleanupHooks;

5. 防止组件重新渲染

React 非常快,通常我们不必担心过早的优化。但是,在某些情况下,优化组件并确保它们不会过于频繁地重新渲染是很有必要的。

例如,减少类组件重新渲染的常用方法是使用 PureComponent 或者 shouldComponentUpdate 生命周期。下面例子中有两个类组件(父组件和子组件),父组件有两个状态值:counterfruit。子组件只在父组件的 fruit 发生变化时重新渲染。所以,使用 shouldComponentUpdate 生命周期来检查 fruit 属性是否改变。 如果相同,则子组件不会重新渲染。

父组件:

import { Component } from "react";
import PreventRerenderClass from "./PreventRerenderClass.jsx";

function randomInteger(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

const fruits = ["banana", "orange", "apple", "kiwi", "mango"];

class PreventRerenderExample extends Component {
  state = {
    fruit: null,
    counter: 0,
  };

  pickFruit = () => {
    const fruitIdx = randomInteger(0, fruits.length - 1);
    const nextFruit = fruits[fruitIdx];

    this.setState({
      fruit: nextFruit,
    });
  };

  componentDidMount() {
    this.pickFruit();
  }

  render() {
    return (
      <div>
        <h3>
          Current fruit: {this.state.fruit} | counter: {this.state.counter}
        </h3>

        <button onClick={this.pickFruit}>挑一个水果</button>
        <button
          onClick={() =>
            this.setState(({ counter }) => ({
              counter: counter + 1,
            }))
          }
        >
          Increment
        </button>
        <button
          onClick={() =>
            this.setState(({ counter }) => ({ counter: counter - 1 }))
          }
        >
          Decrement
        </button>
        <div className="section">
          <PreventRerenderClass fruit={this.state.fruit} />
        </div>
      </div>
    );
  }
}

export default PreventRerenderExample;

子组件:

import { Component } from "react";

class PreventRerenderClass extends Component {
  shouldComponentUpdate(nextProps, nextState) {
    return this.props.fruit !== nextProps.fruit;
  }

  render() {
    return (
      <div>
        <p>Fruit: {this.props.fruit}</p>
      </div>
    );
  }
}

export default PreventRerenderClass;

随着 hooks 的引入,我们得到了一个新的高阶组件,称为 memo。它可用于优化性能并防止函数组件重新渲染。下面来看看它是怎么用的。

父组件:

import { useState, useEffect } from "react";
import PreventRerenderHooks from "./PreventRerenderHooks.jsx";

function randomInteger(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

const fruits = ["banana", "orange", "apple", "kiwi", "mango"];

const PreventRerenderExample = () => {
  const [fruit, setFruit] = useState(null);
  const [counter, setCounter] = useState(0);

  const pickFruit = () => {
    const fruitIdx = randomInteger(0, fruits.length - 1);
    const nextFruit = fruits[fruitIdx];
    setFruit(nextFruit);
  };

  useEffect(() => {
    pickFruit();
  }, []);

  return (
    <div>
      <h3>
        Current fruit: {fruit} | counter: {counter}
      </h3>

      <button onClick={pickFruit}>挑一个水果</button>
      <button onClick={() => setCounter(counter => counter + 1)}>
        Increment
      </button>
      <button onClick={() => setCounter(counter => counter - 1)}>
        Decrement
      </button>
      <div className="section">
        <PreventRerenderHooks fruit={fruit} />
      </div>
    </div>
  );
};

export default PreventRerenderExample;

子组件:

import { memo } from "react";

const PreventRerenderHooks = props => {
  return (
    <div>
      <p>Fruit: {props.fruit}</p>
    </div>
  );
};

export default memo(PreventRerenderHooks);

PreventRerenderHooks 组件使用 memo 组件包装,并且仅在 props 中的 fruit 发生变化时发挥重新渲染。需要注意,memo组件执行的是浅比较,因此如果需要更好地控制memo组件何时重新渲染,可以提供自己的函数来执行 props 比较。

import { memo } from "react";

const PreventRerenderHooks = props => {
  return (
    <div>
      <p>Fruit: {props.fruit}</p>
    </div>
  );
};

export default memo(PreventRerenderHooks, (prevProps, nextProps) => {
  return prevProps.fruit !== nextProps.fruit
});

6. Context API

Context API 是一个很好用的工具,可以为组件层次结构中不同级别的组件提供值。 可以使用 React 提供的 createContext 方法创建新的上下文。先来看一个在类组件中使用 context 的例子。

Context Provider:

import { createContext } from "react";

export const UserContext = createContext();
export const UserActionsContext = createContext();

在父组件中,向消费者提供了 UserContextUserActionsContext

import { Component, createContext } from "react";
import ContextApiClassConsumer from "./ContextApiClassConsumer.jsx";
import { UserContext, UserActionsContext } from "./userContext.js";

class ContextApiHooksProvider extends Component {
  state = {
    user: {
      name: "Class",
    },
  };

  setUser = user => this.setState({ user });

  render() {
    return (
      <UserContext.Provider value={this.state.user}>
        <UserActionsContext.Provider value={this.setUser}>
          <ContextApiClassConsumer />
        </UserActionsContext.Provider>
      </UserContext.Provider>
    );
  }
}

export default ContextApiHooksProvider;

这里 ContextApiClassConsumer 组件就可以获取到父组件提供的usersetUser

Context Consumer:

import { Component } from "react";
import { UserContext, UserActionsContext } from "./userContext.js";

class ContextApiClassConsumer extends Component {
  render() {
    return (
      <UserContext.Consumer>
        {user => (
          <UserActionsContext.Consumer>
            {setUser => (
              <div>
                <input
                  type="text"
                  value={user.name}
                  onChange={e =>
                    setUser({
                      name: e.target.value,
                    })
                  }
                />
              </div>
            )}
          </UserActionsContext.Consumer>
        )}
      </UserContext.Consumer>
    );
  }
}

export default ContextApiClassConsumer;

在上面的例子中,UserContext.Consumer 组件的子函数接收 user 状态,UserActionsContext.Consumer 的子函数接收 setUser 方法。

使用 Hooks 实现和上面的代码非常类似,但是会更简洁。同样,我们使用 UserContext.ProviderUserActionsContext.Provider 组件来提供 user 状态和 setUser 方法。

Context Provider:

import { useState } from "react";
import ContextApiHooksConsumer from "./ContextApiHooksConsumer.jsx";
import { UserContext, UserActionsContext } from "./userContext.js";

const ContextApiHooksProvider = () => {
  const [user, setUser] = useState({
    name: "Hooks",
  });

  return (
    <UserContext.Provider value={user}>
      <UserActionsContext.Provider value={setUser}>
        <ContextApiHooksConsumer />
      </UserActionsContext.Provider>
    </UserContext.Provider>
  );
};

export default ContextApiHooksProvider;

在函数组件中,我们可以像在类组件中一样使用 context,但是,hooks 中有一种更简洁的方法,我们可以利用 useContext hook 来访问 context 值。

Context Consumer:

import { useContext } from "react";
import { UserContext, UserActionsContext } from "./userContext.js";

const ContextApiHooksConsumer = () => {
  const user = useContext(UserContext);
  const setUser = useContext(UserActionsContext);
  return (
    <div>
      <input
        type="text"
        value={user.name}
        onChange={e =>
          setUser({
            name: e.target.value,
          })
        }
      />
    </div>
  );
};

export default ContextApiHooksConsumer;

7. 跨重新渲染保留值

在某些情况下,我们可能需要再组件中存储一些数据。但是不希望将其存储在状态中,因为 UI 不以任何方式依赖这些数据。

例如,我们可能会保存一些希望稍后包含在 API 请求中的元数据。这在类组件中很容易实现,只需为类分配一个新属性即可。

import { Component } from "react";

class PreservingValuesClass extends Component {
  state = {
    counter: 0,
  };

  componentDidMount() {
    this.valueToPreserve = Math.random();
  }

  showValue = () => {
    alert(this.valueToPreserve);
  };

  increment = () => this.setState(({ counter }) => ({ counter: counter + 1 }));

  render() {
    return (
      <div>
        <p>Counter: {this.state.counter}</p>
        <button onClick={this.increment}>Increment</button>
        <button onClick={this.showValue}>Show</button>
      </div>
    );
  }
}

export default PreservingValuesClass;

在这个例子中,当组件被挂载时,我们在 valueToPreserve 属性上分配了一个动态随机数。除此之外,还有 increment 方法来强制重新渲染,但是Show按钮时会弹窗显示保留的值。

这在类组件中很容易实现,但是在函数组件中就没那么简单了。这是因为,任何时候函数组件的重新渲染都会导致函数中的所有内容重新执行。这意味着如果我们有这样的组件:

const MyComponent = props => {
  const valueToPreserve = Math.random()
 	// ...
}

组件每次重新渲染时都会重新调用 Math.random() 方法,因此创建的第一个值将丢失。

避免此问题的一种方法是将变量移到组件之外。 但是,这是行不通的,因为如果该组件被多次使用,则该值会将被它们中的每一个覆盖。

恰好,React 提供了一个非常适合这个用例的 hook。 我们可以通过使用 useRef hook 来保留函数组件中重新渲染的值。

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

const PreserveValuesHooks = props => {
  const valueToPreserve = useRef(null);
  const [counter, setCounter] = useState(0);

  const increment = () => setCounter(counter => counter + 1);

  const showValue = () => {
    alert(valueToPreserve.current);
  };

  useEffect(() => {
    valueToPreserve.current = Math.random();
  }, []);

  return (
    <div>
      <p>Counter: {counter}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={showValue}>Show value</button>
    </div>
  );
};

export default PreserveValuesHooks;

valueToPreserve 是一个初始值为 nullref。 但是,它后来在 useEffect 中更改为我们想要保留的随机数。

8. 如何向父组件传递状态和方法?

尽管我们不应该经常访问子组件的状态和属性,但是在某些情况下它可能会很有用。例如,我们想要重置某些组件的状态或者访问它的状态。我们需要创建一个 Ref,可以在其中存储对想要访问的子组件的引用。在类组件中,可以使用 createRef 方法,然后将该 ref 传递给子组件。

父组件:

import { Component, createRef } from "react";
import ExposePropertiesClassChild from "./ExposePropertiessClassChild";

class ExposePropertiesClassParent extends Component {
  constructor(props) {
    super(props);
    this.childRef = createRef();
  }

  showValues = () => {
    const counter = this.childRef.current.state.counter;
    const multipliedCounter = this.childRef.current.getMultipliedCounter();
    alert(`
      counter: ${counter}
      multipliedCounter: ${multipliedCounter}
    `);
  };

  increment = () => this.setState(({ counter }) => ({ counter: counter + 1 }));

  render() {
    return (
      <div>
        <button onClick={this.showValues}>Show</button>
        <ExposePropertiesClassChild ref={this.childRef} />
      </div>
    );
  }
}

export default ExposePropertiesClassParent;

子组件:

import { Component } from "react";

class ExposePropertiesClassChild extends Component {
  state = {
    counter: 0,
  };

  getMultipliedCounter = () => {
    return this.state.counter * 2;
  };

  increment = () => this.setState(({ counter }) => ({ counter: counter + 1 }));

  render() {
    return (
      <div>
        <p>Counter: {this.state.counter}</p>
        <button onClick={this.increment}>Increment</button>
      </div>
    );
  }
}

export default ExposePropertiesClassChild;

要访问子组件的属性,只需要在父组件中创建一个 ref 并传递它。 现在,让我们看看如何使用函数组件和 hook 来实现相同的目标。

父组件:

import { useRef } from "react";
import ExposePropertiesHooksChild from "./ExposePropertiesHooksChild";

const ExposePropertiesHooksParent = props => {
  const childRef = useRef(null);

  const showValues = () => {
    const counter = childRef.current.counter;
    const multipliedCounter = childRef.current.getMultipliedCounter();
    alert(`
      counter: ${counter}
      multipliedCounter: ${multipliedCounter}
    `);
  };

  return (
    <div>
      <button onClick={showValues}>Show child values</button>
      <ExposePropertiesHooksChild ref={childRef} />
    </div>
  );
};

export default ExposePropertiesHooksParent;

在父组件中,我们使用 useRef hook 来存储对子组件的引用。 然后在 showValues 函数中访问 childRef 的值。 可以看到,这里与类组件中的实现非常相似。

子组件:

import { useState, useImperativeHandle, forwardRef } from "react";

const ExposePropertiesHooksChild = (props, ref) => {
  const [counter, setCounter] = useState(0);

  const increment = () => setCounter(counter => counter + 1);

  useImperativeHandle(ref, () => {
    return {
      counter,
      getMultipliedCounter: () => counter * 2,
    };
  });

  return (
    <div>
      <p>Counter: {counter}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
};

export default forwardRef(ExposePropertiesHooksChild);

forwardRef 将从父组件传递的 ref 转发到组件,而 useImperativeHandle 指定了父组件应该可以访问的内容。

9. 小结

通过这篇文章,相信你对使用Hooks(函数组件)来重构类组件有了一定了解。Hooks 的出现使得 React 代码更加简洁,并且带来了更好的状态逻辑可重用性。在开始编写 Hooks 之前,建议先阅读 React Hooks 的官方文档,因为在编写时需要遵循某些规则,例如不要改变 hooks 的调用顺序。