React Hooks 札記

前言

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

React Hooks 基本原理

組件中的每次 render 都有其特定且獨立的 props 和 state ( 能夠把每一次 render 看做是函數組件的再次調用 ),若是組件中含有定時器、事件處理器、其餘的 API 甚至 useEffect ,因爲閉包的特性,在它們內部的代碼都會當即捕獲當前 render 的 props 和 state ,而不是最新的 props 和 state 。react

讓咱們先來看一個最簡單的例子,而後你就可以馬上理解上面那段話的意思了。ios

// 先觸發 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 生命週期的聯繫。git

hook-flow

從這張圖中咱們能夠清楚地看到,每次 effect 函數調用以前都會先調用 cleanup 函數,並且 cleanup 函數只會在組件更新和組件卸載的時候調用,那麼這個 cleanup 函數有什麼做用呢?讓咱們來看一段代碼。github

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 函數還有不少常見的應用場景,例如清理定時器、訂閱源等。npm

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

  • 父組件的重渲染、state 或 context 的改變都會形成組件的更新。
  • 在 useLayoutEffect 中的 effect 函數是在 DOM 更新渲染到瀏覽器屏幕以前調用的,若是咱們要執行有反作用的代碼,通常只用 useEffect 而不是 useLayoutEffect 。
  • 傳遞給 useState 和 useReducer 的參數若爲函數,則只會在組件掛載時調用一次。

而後咱們來說下 useEffect 的第二個參數:axios

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

在這裏個人理解是因爲兩個同名函數比較時總會返回 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?

相關文章
相關標籤/搜索