React Hooks 札記

RetroAstro發表於2019-03-20

前言

自從 React 16.8 版本正式釋出 React Hooks 以來已經過去一個多月了,而在這之前國內外對於 Hooks API 的討論也一直是如火如荼地進行著。有些人覺得 Hooks API 很好用,而有些人卻對它感到十分困惑。但 Dan Abramov 說過,就像 React 在 2013 年剛出來的時候一樣,Hooks API 也需要時間被開發者們接受和理解。為了加深自己對 React Hooks 的認識,於是便有了將相關資料整理成文的想法。本文主要是記錄自己在學習 React Hooks 時認為比較重要的點和常見的坑,當然也會記錄相關的最佳實踐以便自己更加熟練地掌握此種 mental model ( 心智模型 ) 。如果你還不瞭解 React Hooks ,請先移步到官方文件學習。

React Hooks 基本原理

元件中的每次 render 都有其特定且獨立的 props 和 state ( 可以把每一次 render 看作是函式元件的再次呼叫 ),如果元件中含有定時器、事件處理器、其他的 API 甚至 useEffect ,由於閉包的特性,在它們內部的程式碼都會立即捕獲當前 render 的 props 和 state ,而不是最新的 props 和 state 。

讓我們先來看一個最簡單的例子,然後你就能夠立刻理解上面那段話的意思了。

// 先觸發 handleAlertClick 事件
// 然後在 3 秒內增加 count 至 5
// 最後 alert 的結果仍為 0
function Counter() {
  const [count, setCount] = useState(0)

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count)
    }, 3000)
  }
  
  // 最後的 document.title 為 5
  useEffect(
    () => {
      document.title = `You clicked ${count} times`
    }
  )
    
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  )
}
複製程式碼

雖然最後 alert 的結果為 0 ,但我們會發現最後的 document.title 卻是 5 。瞭解 Hooks API 的人都知道,這是因為 useEffect 中的 effect 函式會在元件掛載和每次元件更新之後進行呼叫,所以我們獲取到的 count 是最後一次 render 的 state ,它的值為 5 。如果我們給 useEffect 加上第二個引數 [] ,那最後我們的 document.title 就會是 0 ,這是因為此時的 useEffect 不依賴任何值,所以相應的 effect 函式只會在元件掛載的時候被呼叫一次。說了這麼多,不如給一張圖解釋的清楚,下面的圖完美詮釋了 useEffect 與 React Hooks 生命週期的聯絡。

hook-flow

從這張圖中我們可以清楚地看到,每次 effect 函式呼叫之前都會先呼叫 cleanup 函式,而且 cleanup 函式只會在元件更新和元件解除安裝的時候呼叫,那麼這個 cleanup 函式有什麼作用呢?讓我們來看一段程式碼。

useEffect(() => {
  let didCancel = false

  const fetchData = async () => {
  	const result = await axios(url)
    if (!didCancel) {
        setData(result.data)
    }
  }

  fetchData()
  
  // 這裡 return 的便是我們的 cleanup 函式
  return () => {
     didCancel = true
  }
}, [url])
複製程式碼

這段程式碼解決了在網路請求中常見的競態問題。假設我們沒有呼叫 cleanup 函式,當我們連續呼叫兩次 effect 函式時,由於請求資料到達時間的不確定,如果第一次請求的資料後到達,雖然我們想在瀏覽器螢幕上呈現的是第二次請求的資料,但結果卻只會是第一次請求的資料。再一次的,由於閉包的特性,當我們執行 didCancel = true 時,在前一次的 effect 函式中 setData(result) 就無法被執行,競態問題也便迎刃而解。當然 cleanup 函式還有很多常見的應用場景,例如清理定時器、訂閱源等。

上面那張圖還有幾個值得我們注意的點:

  • 父元件的重渲染、state 或 context 的改變都會造成元件的更新。
  • 在 useLayoutEffect 中的 effect 函式是在 DOM 更新渲染到瀏覽器螢幕之前呼叫的,如果我們要執行有副作用的程式碼,一般只用 useEffect 而不是 useLayoutEffect 。
  • 傳遞給 useState 和 useReducer 的引數若為函式,則只會在元件掛載時呼叫一次。

然後我們來講下 useEffect 的第二個引數:

它用於跟前一次 render 傳入的 deps ( 依賴 ) 進行比較,為的是避免不必要的 effect 函式再次執行。useEffect 的執行機制應該是先比較 deps ,若有不同則執行先前的 cleanup 函式,然後再執行最新的 effect 函式,若相同則跳過上面的兩個步驟。如果要用函式作為 useEffect 的第二個引數,則需要使用 useCallback ,其作用是為了避免該函式在元件更新時再次被建立,從而使 useEffect 第二個引數的作用失效。

在這裡我的理解是由於兩個同名函式比較時總會返回 false ,而且使用 useCallback 也需要第二個引數,因此我猜測 React 最終還是以值的比較來達到“快取”函式的效果。

var a = function foo () {}
var b = function foo () {}
a === b // false
複製程式碼

為了方便理解,下面是一個使用 useCallback 的例子。

// 使用 useCallback,並將其傳遞給子元件
function Parent() {
  const [query, setQuery] = useState('react')
  
  // 只有當 query 改變時,fetchData 才會發生改變
  const fetchData = useCallback(() => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + query
  }, [query])

  return <Child fetchData={fetchData} />
}

function Child({ fetchData }) {
  let [data, setData] = useState(null)

  useEffect(() => {
    fetchData().then(setData)
  }, [fetchData])
}
複製程式碼

React Hooks 網路請求最佳實踐

最後我們要實現的功能:

  • 動態請求
  • 載入狀態
  • 錯誤處理
  • 競態處理

下面是以三種不同的方式實現的例子。

常規 Hook

function App() {
  const [data, setData] = useState({ hits: [] })
  const [query, setQuery] = useState('redux')
  const [url, setUrl] = useState(
    'http://hn.algolia.com/api/v1/search?query=redux'
  )
  const [isLoading, setIsLoading] = useState(false)
  const [isError, setIsError] = useState(false)

  useEffect(() => {
    let didCancel = false
    
    const fetchData = async () => {
      setIsError(false)
      setIsLoading(true)

      try {
        const result = await axios(url)
        if (!didCancel) {
          setData(result.data)
        }
      } catch (error) {
        if (!didCancel) {
          setIsError(true)
        }
      }

      setIsLoading(false)
    }

    fetchData()
    
    return () => {
       didCanel = true
    }
  }, [url])

  return (
    <>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button
        type="button"
        onClick={() =>
          setUrl(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        Search
      </button>

      {isError && <div>Something went wrong ...</div>}

      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.id}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </>
  )
}
複製程式碼

抽象 custom Hook

const useDataApi = (initialUrl, initialData) => {
  const [data, setData] = useState(initialData)
  const [url, setUrl] = useState(initialUrl)
  const [isLoading, setIsLoading] = useState(false)
  const [isError, setIsError] = useState(false)

  useEffect(() => {
    let didCancel = false
    
    const fetchData = async () => {
      setIsError(false)
      setIsLoading(true)

      try {
        const result = await axios(url)
        if (!didCancel) {
          setData(result.data)
        }
      } catch (error) {
        if (!didCancel) {
          setIsError(true)
        }
      }

      setIsLoading(false)
    }

    fetchData()
    
    return () => {
       didCanel = true
    }
  }, [url])

  const doFetch = url => {
    setUrl(url)
  }

  return { data, isLoading, isError, doFetch }
}

function App() {
  const [query, setQuery] = useState('redux')
  const { data, isLoading, isError, doFetch } = useDataApi(
    'http://hn.algolia.com/api/v1/search?query=redux',
    { hits: [] }
  )

  return (
    <>
      <input
        type="text"
        value={query}
        onChange={event => setQuery(event.target.value)}
      />
      <button
        type="button"
        onClick={() =>
          doFetch(`http://hn.algolia.com/api/v1/search?query=${query}`)
        }
      >
        Search
      </button>

      {isError && <div>Something went wrong ...</div>}

      {isLoading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {data.hits.map(item => (
            <li key={item.id}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </>
  )
}
複製程式碼

使用 useReducer

const dataFetchReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH_INIT':
      return {
        ...state,
        isLoading: true,
        isError: false
      }
    case 'FETCH_SUCCESS':
      return {
        ...state,
        isLoading: false,
        isError: false,
        data: action.payload,
      }
    case 'FETCH_FAILURE':
      return {
        ...state,
        isLoading: false,
        isError: true,
      }
    default:
      throw new Error()
  }
}

const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl)

  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData,
  })

  useEffect(() => {
    let didCancel = false

    const fetchData = async () => {
      dispatch({ type: 'FETCH_INIT' })

      try {
        const result = await axios(url)

        if (!didCancel) {
          dispatch({ type: 'FETCH_SUCCESS', payload: result.data })
        }
      } catch (error) {
        if (!didCancel) {
          dispatch({ type: 'FETCH_FAILURE' })
        }
      }
    }

    fetchData()

    return () => {
      didCancel = true
    }
  }, [url])

  const doFetch = url => {
    setUrl(url)
  }

  return { ...state, doFetch }
}
複製程式碼

常見場景 React Hooks 實現

生命週期

元件掛載時呼叫

const onMount = () => {
   // ...
}

useEffect(() => {
  onMount()
}, [])
複製程式碼

元件解除安裝時呼叫

const onUnmount = () => {
   // ...
}

useEffect(() => {
  return () => onUnmount()
}, [])
複製程式碼

使用 useRef 獲取 state

獲取元件最新的 state

function Message() {
  const [message, setMessage] = useState('')
  const latestMessage = useRef('')

  useEffect(() => {
    latestMessage.current = message
  }, [message])

  const showMessage = () => {
    alert('You said: ' + latestMessage.current)
  }

  const handleSendClick = () => {
    setTimeout(showMessage, 3000)
  }

  const handleMessageChange = (e) => {
    setMessage(e.target.value)
  }

  return (
    <>
      <input value={message} onChange={handleMessageChange} />
      <button onClick={handleSendClick}>Send</button>
    </>
  )
}
複製程式碼

獲取元件前一次的 state

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
}
複製程式碼

使用 useMemo 避免元件重渲染

function Parent({ a, b }) {
  const child1 = useMemo(() => <Child1 a={a} />, [a])
  const child2 = useMemo(() => <Child2 b={b} />, [b])
  
  return (
    <>
      {child1}
      {child2}
    </>
  )
}
複製程式碼

使用 useImperativeHandle 轉發 ref

function ParentInput() {
  const inputRef = useRef(null)

  useEffect(() => {
    inputRef.current.focus()
  }, [])

  return (
    <div>
      <ChildInput ref={inputRef} />
    </div>
  )
}

function ChildInput(props, ref) {
  const inputRef = useRef(null)
  useImperativeHandle(ref, () => inputRef.current)

  return <input type="text" name="child input" ref={inputRef} />
}
複製程式碼

利用 Hooks 實現簡單的狀態管理

藉助 Hooks 和 Context 我們可以輕鬆地實現狀態管理,下面是我自己實現的一個簡單狀態管理工具,已釋出到 npm 上,後續可能有大的改進,感興趣的可以關注下 ?。

chrox

目前的原始碼只有幾十行,所以給出的是 TS 的版本。

import * as React from 'react'

type ProviderProps = {
  children: React.ReactNode
}

export default function createChrox (
  reducer: (state: object, action: object) => object, 
  initialState: object
) {
  const StateContext = React.createContext<object>({})
  const DispatchContext = React.createContext<React.Dispatch<object>>(() => {})

  const Provider: React.FC<ProviderProps> = props => {
    const [state, dispatch] = React.useReducer(reducer, initialState)

    return (
      <DispatchContext.Provider value={dispatch}>
         <StateContext.Provider value={state}>
            {props.children}
         </StateContext.Provider>
      </DispatchContext.Provider>
    )
  }

  const Context = {
    state: StateContext,
    dispatch: DispatchContext
  }

  return {
    Context,
    Provider
  }
}
複製程式碼

下面是利用該狀態管理工具實現的一個 counter 的例子。

// reducer.js
export const initialState = {
  count: 0
}

export const countReducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 }
    case 'decrement':
      return { ...state, count: state.count - 1 }
    default:
      return { ...state }
  }
}
複製程式碼

入口檔案 index.js

import React, { useContext } from 'react'
import { render } from 'react-dom'
import createChrox from 'chrox'
import { countReducer, initialState } from './reducer'

const { Context, Provider } = createChrox(countReducer, initialState)

const Status = () => {
  const state = useContext(Context.state)
  return (
    <span>{state.count}</span>
  )
}

const Decrement = () => {
  const dispatch = useContext(Context.dispatch)
  return (
    <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
  )
}

const Increment = () => {
  const dispatch = useContext(Context.dispatch)
  return (
    <button onClick={() => dispatch({ type: 'increment' })}>+</button>
  )
}

const App = () => (
  <>
    <Decrement />
    <Status />
    <Increment />
  </>
)

render(
  <Provider>
    <App />
  </Provider>,
  document.getElementById('root')
)
複製程式碼

從上面可以看到我是基於 useReducer + useContext 來實現的狀態管理,至於為什麼要這樣做,那是因為這樣做有兩個主要的好處:

  1. 當我們的 state 變得複雜起來,比如是一個巢狀著很多子數值型別的物件。使用 useReducer ,我們可以通過編寫 reducer 函式 ( 如果 state 足夠複雜甚至可以先拆分 reducer 最後再進行合併 ) 來輕鬆地實現狀態管理。
  2. useReducer 返回的 dispatch 函式只會在元件掛載的時候初始化,而在之後的元件更新中並不會發生改變 ( 值得注意的是 useRef 也具有相同的特性 ) ,因此它相當於一種更好的 useCallback 。當遇到很深的元件樹時,我們可以通過兩個不同的 Context 將 useReducer 返回的 statedispatch 分離,這樣如果元件樹底層的某個元件只需要 dispatch 函式而不需要 state ,那麼當 dispatch 函式呼叫時該元件是不會被重新渲染的,由此我們便達到了效能優化的效果。

結語

寫完整篇文章,往回看發現 React Hooks 確實是一種獨特的 mental model ,憑藉著這種“可玩性”極高的模式,我相信開發者們肯定能探索出更多的最佳實踐。不得不說 2019 年是 React 團隊帶給開發者驚喜最多的一年,因為僅僅是到今年中期,React 團隊就會發布 Suspense、React Hooks、Concurrent Mode 三個重要的 API ,而這一目標早已實現了一半。也正是因為這個富有創造力的團隊,讓我此生無悔入 React ?。


參考內容:

Hooks FAQ

Why Isn’t X a Hook?

Making setInterval Declarative with React Hooks

How Are Function Components Different from Classes?

A Complete Guide to useEffect

How to fetch Data with React Hooks?

相關文章