淺談React Hooks

simon_z發表於2019-02-28

由於工作的原因我已經很長時間沒接觸過React了。前段時間圈子裡都在討論React Hooks,出於好奇也學習了一番,特此整理以加深理解。

緣由

在web應用無所不能的9012年,組成應用的Components也越來越複雜,冗長而難以複用的程式碼給開發者們造成了很多麻煩。比如:

  1. 難以複用stateful的程式碼,render props及HOC雖然解決了問題,但對元件的包裹改變了元件樹的層級,存在冗餘;
  2. 在ComponentDidMount、ComponentDidUpdate、ComponentWillUnmount等生命週期中做獲取資料,訂閱/取消事件,操作ref等相互之間無關聯的操作,而把訂閱/取消這種相關聯的操作分開,降低了程式碼的可讀性;
  3. 與其他語言中的class概念差異較大,需要對事件處理函式做bind操作,令人困擾。另外class也不利於元件的AOT compile,minify及hot loading。 在這種背景下,React在16.8.0引入了React Hooks。

特性

主要介紹state hook,effect hook及custom hook

State Hook

最基本的應用如下:

import React, { useState } from 'react'

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

  return (
    <div>
      <p>You have clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click
      </button>
    </div>
  )
}
複製程式碼

呼叫useState,傳入初始值,通過陣列的結構賦值得到獨立的local state count,及setCount。count可以理解為class component中的state,可見這裡的state不侷限於物件,可以為number,string,當然也可以是一個物件。而setCount可以理解為class component中的setState,不同的是setState會merge新老state,而hook中的set函式會直接替換,這就意味著如果state是物件時,每次set應該傳入所有屬性,而不能像class component那樣僅傳入變化的值。所以在使用useState時,儘量將相關聯的,會共同變化的值放入一個object。

再看看有多個“local state”的情況:

import React, { useState } from 'react'

function person() {
  const [name, setName] = useState('simon')
  const [age, setAge] = useState(24)

  return (
    <div>
      <p>name: {name}</p>
      <p>age: {age}</p>
    </div>
  )
}
複製程式碼

我們知道當函式執行完畢,函式作用域內的變數都會銷燬,hooks中的state在component首次render後被React保留下來了。那麼在下一次render時,React如何將這些保留的state與component中的local state對應起來呢。這裡給出一個簡單版本的實現:

const stateArr = []
const setterArr = []
let cursor = 0
let isFirstRender = true

function createStateSetter(cursor) {
  return state => {
    stateArr[cursor] = state
  }
}

function useState(initState) {
  if (isFirstRender) {
    stateArr.push(initState)
    setterArr.push(createStateSetter(cursor))

    isFirstRender = false
  }

  const state = stateArr[cursor]
  const setter = setterArr[cursor]

  cursor++

  return [state, setter]
}
複製程式碼

可以看出React需要保證多個hooks在component每次render的時候的執行順序都保持一致,否則就會出現錯誤。這也是React hooks rule中必須在top level使用hooks的由來————條件,遍歷等語句都有可能會改變hooks執行的順序。

Effect Hook

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

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

  // 基本寫法
  useEffect(() => {
    document.title = 'Dom is ready'
  })

  // 需要取消操作的寫法
  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'
}
複製程式碼

可以看到上面的程式碼在傳入useEffect的函式(effect)中做了一些"side effect",在class component中我們通常會在componentDidMount,componentDidUpdate中去做這些事情。另外在class component中,需要在componentDidMount中訂閱,在componentWillUnmount中取消訂閱,這樣將一件事拆成兩件事做,不僅可讀性低,還容易產生bug:

class FriendStatus extends React.Component {
  constructor(props) {
    super(props);
    this.state = { isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }

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

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

  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }

  render() {
    if (this.state.isOnline === null) {
      return 'Loading...';
    }
    return this.state.isOnline ? 'Online' : 'Offline';
  }
}
複製程式碼

如上程式碼,如果props中的friend.id發生變化,則會導致訂閱和取消的id不一致,如需解決需要在componentDidUpdate中先取消訂閱舊的再訂閱新的,程式碼非常冗餘。而useEffect hook在這一點上是渾然天成的。另外effect函式在每次render時都是新建立的,這其實是有意而為之,因為這樣才能取得最新的state值。

有同學可能會想,每次render後都會執行effect,這樣會不會對效能造成影響。其實effect是在頁面渲染完成之後執行的,不會阻塞,而在effect中執行的操作往往不要求同步完成,除了少數如要獲取寬度或高度,這種情況需要使用其他的hook(useLayoutEffect),此處不做詳解。即使這樣,React也提供了控制的方法,及useEffect的第二個引數————一個陣列,如果陣列中的值不發生變化的話就跳過effect的執行:

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  }
}, [props.friend.id])
複製程式碼

Custom Hook

A custom Hook is a JavaScript function whose name starts with ”use” and that may call other Hooks. Custom Hook的使命是解決stateful logic複用的問題,如上面例子中的FriendStatus,在一個聊天應用中可能多個元件都需要知道好友的線上狀態,將FriendStatus抽象成這樣的hook:

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

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

  return isOnline;
}

function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id)

  if (isOnline === null) {
    return 'Loading...'
  }

  return isOnline ? 'Online' : 'Offline'
}

function FriendListItem(props) {
  const isOnline = useFriendStatus(props.friend.id)

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  )
}
複製程式碼

FriendStatus和FriendListItem中的isOnline是獨立的,因custom hook複用的是stateful logic,而不是state本身。另外custom hook必須以use開頭來命名,這樣linter工具才能正確檢測其是否符合規範。

除了以上三種hook,React還提供了useContext, useReducer, useCallback, useMemo, useRef, useImperativeHandle, useLayoutEffect, useDebugValue內建hook,它們的用途可以參考官方文件,這裡我想單獨講講useRef。 顧名思義,這個hook應該跟ref相關的:

function TextInputWithFocusButton() {
  const inputEl = useRef(null)
  const onButtonClick = () => {
    inputEl.current.focus()
  }
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  )
}
複製程式碼

來看看官方文件上的說明:

useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component. 這句話告訴我們在元件的整個生命週期裡,inputEl.current都是存在的,這擴充套件了useRef本身的用途,可以使用useRef維護類似於class component中例項屬性的變數:

function Timer() {
  const intervalRef = useRef()

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

  // ...
}
複製程式碼

這在class component中是理所當然的,但不要忘記Timer僅僅是一個函式,函式執行完畢後函式作用域內的變數將會銷燬,所以這裡需要使用useRef來保持這個timerId。類似的useRef還可以用來獲取preState:

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

  const prevCountRef = useRef()
  useEffect(() => {
    prevCountRef.current = count    // 由於useEffect中的函式是在render完成之後非同步執行的,所以在每次render時prevCountRef.current的值為上一次的count值
  })
  const prevCount = prevCountRef.current

  return <h1>Now: {count}, before: {prevCount}</h1>
}
複製程式碼

參考文章&擴充閱讀

相關文章