跳到主要內容
黯羽輕揚每天積累一點點

函數式組件的崛起

免費2019-06-23#JS#React Function Component#React Class 组件迁移指南#corresponding Hooks#componentWillUpdate in hooks#Migrate Class to Hooks

有了 Hooks 之後,函數式組件將擁有與 Class 組件幾乎相同的表達力,包括各種生命週期、State 等

一.Class Component

Class 無疑是應用最廣泛的 React 組件形式,例如:

class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

具有最完整的生命週期支援,新舊林林總總 13 個:

// Mounting
constructor()
render()
UNSAFE_componentWillMount()
componentDidMount()

// Updating
static getDerivedStateFromProps()
shouldComponentUpdate()
getSnapshotBeforeUpdate()
UNSAFE_componentWillReceiveProps()
UNSAFE_componentWillUpdate()
componentDidUpdate()

// Unmounting
componentWillUnmount()

// Error Handling
static getDerivedStateFromError()
componentDidCatch()

此外,還有 State、Props、Context、Ref 等特性。這些加持讓 Class 成為了具備完整組件特性的唯一選項,儘管 Class 也存在許多問題,但它無可替代

P.S. 關於各個生命週期的含義及其作用,見 React | 黯羽輕揚

二.Function Component

另一種組件形式是函式,輸入 Props,輸出 React Element,例如:

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

作為最簡單的 React 組件形式:

The simplest way to define a component is to write a JavaScript function.

簡單到連生命週期都沒有,State 就更不用說了。這些限制決定了函數式組件只能用作非常簡單的 View Component,擔不起重任。在相當長的一段時間裡,僅供「教學」使用:

Classes have some additional features that we will discuss in the next sections. Until then, we will use function components for their conciseness.

自 React 16 起,才逐步對函數式組件進行了增強:

  • createRef/forwardRef:React 16.3 之後,函數式組件支援 Ref 了

  • [React.memo](/articles/react-16-6 新 api/#articleHeader2):React 16.6 之後,函數式組件也迎來了「shouldComponentUpdate」

當然,最重要的增強自���是 Hooks

Hooks 讓函數式組件也能擁有狀態、生命週期等 Class 組件特性(如 state, lifecycle, context, ref 等等)

P.S. 關於 Hooks 的詳細資訊,見 React Hooks 簡介

三.Function Component with Hooks

簡單來講,有了 Hooks 之後,函數式組件將擁有與 Class 組件幾乎相同的表達力,包括各種生命週期、State 等

If you write a function component and realize you need to add some state to it, previously you had to convert it to a class. Now you can use a Hook inside the existing function component.

例如:

function Example() {
  // Declare a new state variable, which we'll call "count"
  const [count, setCount] = React.useState(0);

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

useState Hook 讓函數式組件擁有了 State,類似的,生命週期、Context、Ref、組件例項屬性等特性都通過類似的 Hook 方式提供了支援,具體如下:

Hook特性類比 Class
useStateStatethis.state
this.setState(newState)
useEffect生命週期componentDidMount
componentDidUpdate
useContextContextthis.context
useReducerStateRedux Reducer 式的 State 管理
useCallbackFunction Propsthis.myMethod.bind(this)
useMemo性能優化避免重複計算
useRefRefcreateRef
useImperativeHandle組件例項屬性/方法forwardRef
useLayoutEffect生命週期同步componentDidMount
同步componentDidUpdate
useDebugValue除錯Hooks 狀態可視化(類似於從 React DevTools 看this.state

四.Migrate Class to Hooks

當然,沒必要也不太可能將現有的 Class 組件重構到 Hooks:

There is no rush to migrate to Hooks. We recommend avoiding any "big rewrites", especially for existing, complex class components.

We intend for Hooks to cover all existing use cases for classes, but we will keep supporting class components for the foreseeable future. At Facebook, we have tens of thousands of components written as classes, and we have absolutely no plans to rewrite them.

這裡只是說 Hooks 與 Class 特性的對應關係,這種類比有助於理解 Hooks

constructor()

建構函式中最關鍵的操作應該是宣告/初始化 this.state,通過 State Hook 來完成:

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

等價於:

function Example() {
  // 宣告一個初始值為 0 的 state 變數
  const [count, setCount] = React.useState(0);
}

其語法格式為:

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

其中 const [state, setState] = xxx 是一種解構賦值語法(具體見 [destructuring(解構賦值)_ES6 筆記 5](/articles/destructuring(解構賦值)-es6 筆記 5/)),等價於:

const stateVariable = useState(initialState); // Returns a pair
const state = stateVariable[0]; // First item in a pair
const setState = stateVariable[1]; // Second item in a pair

返回狀態值(state)和對應的 Setter(setState),呼叫 Setter 會引發組件更新(類似於 Class 裡的 this.setState

初始值 initialState 僅作用於首次渲染(通過返回值 state 取出),之後 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' }]);
}

當然,狀態值也可以是物件或陣列,但不會像 this.setState() 一樣進行 merge

Unlike this.setState in a class, updating a state variable always replaces it instead of merging it.

例如:

function ExampleWithManyStates() {
  const [profile, setProfile] = useState({
    age: 42,
    favorite: 'banana',
    todos: [{ text: 'Learn Hooks' }]
  });

  setProfile({
    todos: []
  });
  // 相當於 Class 中的
  this.setState({
    age: undefined,
    favorite: undefined,
    todos: []
  });
  // 而不是
  this.setState({
    todos: []
  });
}

render()

函數式組件本身就是個 render() 函式,將 Props、State 等資料注入到視圖中,並註冊事件處理邏輯:

class Example extends React.Component {
  /* 略去 state 初始化部分 */
  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

等價於:

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

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

與 Class 組件不同的是,函數式組件的 State 值通過 State Hook 來獲取(上例中的 count),而不是 this.state。相應的,this.setState() 也通過 useState() 返回的 Setter 來完成

UNSAFE_componentWillMount()

首次渲染時在 render() 之前觸發,與 constructor() 功能有些重疊,可以參考前述 constructor() 部分

componentDidMount()

componentDidMount 中通常會有一些帶副作用的操作,在函數式組件中可以用 Effect Hook 替代:

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }
  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
  }
  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }
  /* 略去 render 部分 */
}

等價於:

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

  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });
  /* 略去 return 部分 */
}

Effect Hook 在組件每次渲染結束時觸發,因此相當於 Class 組件的 componentDidMountcomponentDidUpdatecomponentWillUnmount

語法格式為:

useEffect(didUpdate);

表示組件需要在每次(包括首次) 渲染後做點事情:

The function passed to useEffect will run after the render is committed to the screen.

如果需要區分 mounting 和 updating(componentDidMountcomponentDidUpdate),可以通過宣告依賴來完成,具體見 Tip: Optimizing Performance by Skipping Effects

而對於只需要執行/清理一次的副作用,宣告它不依賴任何組件狀態即可(useEffect(didUpdate, [])),此時等價於 componentDidMountcomponentWillUnmount

然而,由於 Fiber 排程機制Effect Hook 並不是同步觸發的。因此如果需要讀取 DOM 狀態的話,用同步的 LayoutEffect Hook

P.S. 所以,嚴格來講,LayoutEffect Hook 才是與 componentDidMountcomponentDidUpdate 等生命週期等價的 Hooks API。但出於性能/使用者體驗考慮,建議優先使用 Effect Hook

特殊的,有一些需要做相應清理工作的副作用,比如取消訂閱外部資料源(避免記憶體洩漏):

class FriendStatus extends React.Component {
  /* 略去 state 初始化部分 */
  componentDidMount() {
    // 訂閱
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  componentWillUnmount() {
    // 取消訂閱
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  /* 略去 render 部分,及 handleStatusChange */
}

等價於:

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    // 訂閱
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    // 取消訂閱
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

如上例,Effect Hook 提供了 Disposable 機制來支援清理操作,但 Hooks 的執行機制決定了每次 render 之後都會觸發清理工作

Effects run for every render and not just once. This is why React also cleans up effects from the previous render before running the effects next time.

如果反覆訂閱存在性能影響的話,同樣可以通過宣告依賴的方式來解決(將來可能會在編譯時自動找出依賴)

另外,類似於多次 useState(),同樣可以通過多次 useEffect() 將不同的 Effect 分離開:

Just like you can use the State Hook more than once, you can also use several effects. This lets us separate unrelated logic into different effects.

例如:

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0);
  // DOM 操作 Effect
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  const [isOnline, setIsOnline] = useState(null);
  // 資料訂閱 Effect
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

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

static getDerivedStateFromProps()

getDerivedStateFromProps 是用來替代 componentWillReceiveProps 的,應對 state 需要關聯 props 變化的場景

(摘自 [二。如何理解 getDerivedStateFromProps](/articles/從 componentwillreceiveprops 說起/#articleHeader2))

函數式組件中,對於 props 變化引發 state 變化的場景,可以直接通過 State Hook 來完成,例如記錄滾動方向:

class ExampleComponent extends React.Component {
  state = {
    isScrollingDown: false,
    lastRow: null,
  };

  static getDerivedStateFromProps(props, state) {
    if (props.currentRow !== state.lastRow) {
      return {
        isScrollingDown: props.currentRow > state.lastRow,
        lastRow: props.currentRow,
      };
    }

    // Return null to indicate no change to state.
    return null;
  }
}

等價於:

function ScrollView({row}) {
  let [isScrollingDown, setIsScrollingDown] = useState(false);
  let [prevRow, setPrevRow] = useState(null);

  if (row !== prevRow) {
    // Row changed since last render. Update isScrollingDown.
    setIsScrollingDown(prevRow !== null && row > prevRow);
    setPrevRow(row);
  }

  return `Scrolling down: ${isScrollingDown}`;
}

shouldComponentUpdate()

函數式組件中,用 [React.memo](/articles/react-16-6 新 api/#articleHeader2) 代替

getSnapshotBeforeUpdate()

暫時(2019/06/23)還沒有可替代的 Hooks API,但很快會加

UNSAFE_componentWillReceiveProps()

如前述,componentWillReceivePropsgetDerivedStateFromProps 都用 State Hook 代替,見 constructor() 部分

UNSAFE_componentWillUpdate()

componentWillUpdate 一般可以用 componentDidUpdate 代替,如果需要讀取 DOM 狀態的話,用 getSnapshotBeforeUpdate 代替:

Typically, this method can be replaced by componentDidUpdate(). If you were reading from the DOM in this method (e.g. to save a scroll position), you can move that logic to getSnapshotBeforeUpdate().

因此,componentWillUpdate 一般可以用 Effect Hook 或 LayoutEffect Hook 代替,見 componentDidMount() 部分

componentDidUpdate()

如前述,componentDidUpdate 可以用 Effect Hook 代替,見 componentDidMount() 部分

componentWillUnmount()

如前述,componentWillUnmount 可以用 Effect Hook 代替,見 componentDidMount() 部分

static getDerivedStateFromError()

暫時(2019/06/23)還沒有可替代的 Hooks API,但很快會加

componentDidCatch()

暫時(2019/06/23)還沒有可替代的 Hooks API,但很快會加

Context

函數式組件中同樣能夠訪問 Context,並且讀取方式更簡單:

// 宣告
const {Provider, Consumer} = React.createContext(defaultValue);
// 寫
<Provider value={/* some value */}>
// 讀
<Consumer>
  {value => /* render something based on the context value */}
</Consumer>

等價於:

// 宣告
const MyContext = React.createContext(defaultValue);
const Provider = MyContext.Provider;
// 寫
<Provider value={/* some value */}>
// 讀
const value = useContext(MyContext);

Ref

Ref 也提供了類似的支援:

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.inputRef = React.createRef();
  }

  render() {
    return <input type="text" ref={this.inputRef} />;
  }
}

等價於:

function MyComponent() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` points to the mounted text input element
    inputEl.current.focus();
  };
  return <input type="text" ref={inputRef} />;
}

即:

const refContainer = useRef(initialValue);
// 等價於
const refContainer = React.createRef();

Instance Variables

有意思的是,Ref 還能用來保留組件例項狀態(相當於 this.xxx),例如:

function Timer() {
  const intervalRef = useRef();

  useEffect(() => {
    const id = setInterval(() => {
      // ...
    });
    intervalRef.current = id;
    return () => {
      clearInterval(intervalRef.current);
    };
  });
}

更進一步的,可以通過 this.mounted 來實現 componentDidUpdate

function FunctionComponent(props) {
  // Flag for strict Update lifecycles
  const mounted = useRef();

  useEffect(() => {
    if (mounted.current) {
      // componentDidUpdate
    }
  });
  useEffect(() => {
    mounted.current = true;
  }, []);
  // ...
}

Instance Method

我們可以通過 Ref 引用 Class 組件例項,進而訪問其實例方法:

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.inputRef = React.createRef();
  }
  // 例項方法
  focus() {
    this.inputRef.current.focus();
  }
  render() {
    return <input type="text" ref={this.inputRef} />;
  }
}

class App extends React.Component {
  componentDidMount() {
    this.myComponent.focus();
  }
  render() {
    return <MyComponent ref={ins => this.myComponent = ins} />;
  }
}

等價於:

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

class App extends React.Component {
  componentDidMount() {
    this.myComponent.focus();
  }
  render() {
    return <FancyInput ref={ins => this.myComponent = ins} />;
  }
}

五。線上 Demo

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論