如何使用 React Hooks 重構類組件?

大家好,我是 CUGGZ。

最初,在 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函數。

  1. 狀態更新後的操作

在某些情況下,我們可能需要在狀態更新時執行某些操作。在類組件中,我們通常會在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 中的回調函數。

  1. 獲取數據

在類組件中,通過會在 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;
  1. 卸載組件時清理副作用

在卸載組件時清理副作用是非常重要的,否則可能會導致內存泄露。例如,在一個組件中,我們想要監聽一個事件,比如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;
  1.  防止組件重新渲染

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 class>
          <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 class>
        <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
});
  1. 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;
  1. 跨重新渲染保留值

在某些情況下,我們可能需要再組件中存儲一些數據。但是不希望將其存儲在狀態中,因爲 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 中更改爲我們想要保留的隨機數。

  1. 如何向父組件傳遞狀態和方法?

儘管我們不應該經常訪問子組件的狀態和屬性,但是在某些情況下它可能會很有用。例如,我們想要重置某些組件的狀態或者訪問它的狀態。我們需要創建一個 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</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 指定了父組件應該可以訪問的內容。

  1. 小結

通過這篇文章,相信你對使用 Hooks(函數組件)來重構類組件有了一定了解。Hooks 的出現使得 React 代碼更加簡潔,並且帶來了更好的狀態邏輯可重用性。在開始編寫 Hooks 之前,建議先閱讀 React Hooks 的官方文檔,因爲在編寫時需要遵循某些規則,例如不要改變 Hooks 的調用順序。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/rFb07qMzV-JrzvVlDEgUAg