jeremygo

jeremygo

我是把下一颗珍珠串在绳子上的人

Hooks 詳解

在 Hooks 正式推出不久時我寫了一篇 Hooks 隨談 ,主要是從概念介紹上對它的簡單的分析理解,實習時開發的新控制台則完全使用了這個特性,因此想結合目前的實踐經驗對 Hooks 做一個最佳實踐與對比的分析。

Capture Value#

從 Hooks 出現後,函數式組件也具有了狀態的特性,因此避免 Stateless Component 而統一叫 Function Component 更為恰當。這裡先從 props 的不可變性上對 Function Component 與 Class Component 做一個對比:

Function Component:

function ProfilePage(props) {
    setTimeout(() => {
        // 父組件 Rerender 了,props 也是初始的
        console.log(props)
    }, 3000)
}

Class Component:

class ProfilePage extends React.Component {
    render() {
        setTimeout(() => {
            // 父組件 Rerender 了,this.props 也會改變,因為 this 變了
            console.log(this.props)
        }, 3000)
    }
}

如果想在 Class Component 中捕獲初始的 props,可以 const props = this.props,但是這樣有點 hack,所以想拿到穩定的 props 推薦使用 Function Component。

Hooks:

function MessageThread() {
    const [message, setMessage] = useState("")
    
    const showMessage = () => {
        alert("你說: " + message)
    }
    const handleSendClick = () => {
        setTimeout(showMessage, 3000)
    }
    const handleMessageChange = e => {
        setMessage(e.target.value)
    }
    return (
    	<>
        	<input value={message} onChange={handleMessageChange} />
        	<button onClick={handleSendClick}>發送</button>
        </>
    )
}

點擊 send 後修改輸入框的值,3 秒後輸出還是點擊前輸入框的值,說明 Hooks 也具有 Capture Value 的特性。

可以認為每次 Render 的內容都會形成一個快照,每個 Render 狀態都有自己固定不變的 Props 和 State。

不僅僅是對象,函數在每次渲染時也是獨立的,這就是 Capture Value 特性。

實際開發中就被這個特性坑過,避免 capture value 可以利用 useRef

function MessageThread() {
    const latestMessage = useRef("")
    
    const showMessage = () => {
        alert("你說: " + latestMessage.current)
    }
    const handleSendClick = () => {
        setTimeout(showMessage, 3000)
    }
    const handleMessageChange = e => {
        latestMessage.current = e.target.value
    }
}

可以認為 ref 在所有 Render 過程中保持著唯一引用,所以 ref 的取值或賦值拿到的都是一個最終狀態,也可以簡潔地認為 ref 是 Mutable 而 state 的 Immutable 的。

生命週期方法的替代#

  • constructor:Function Component 不需要初始構造函數,可以初始化狀態通過調用 useState,如果初始值計算很消耗時間,可以傳入函數,這樣只會執行一次。
  • getDerivedStateFromProps:當渲染時合理調度更新。
  • shouldComponentUpdate:見[React.memo](https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-shouldcomponentupdate)
  • render:就是 Function Component 本身。
  • componentDidMount、componentDidUpdate、componentWillUnmount:它們的集合對應useEffect
  • componentDidCatch、getDerivedStateFromError:近期會增加對應的 Hook 方法。

最佳實踐#

組件定義#

const App: React.FC<{ title: string }> = ({ title }) => {
    return React.useMemo(() => <div>{title}</div>, [title])
}
App.defaultProps = {
	title: 'Function Component'                         
}
  • React.FC 申明 Function Component 組件類型與定義 Props 參數類型。
  • React.useMemo 優化渲染性能。
  • App.defaultProps 定義 Props 默認值。

為什麼不用 React.memo?

因為組件通信時存在 React.useContext 的用法,會使所有用到的組件重渲染,只有 React.useMemo 可以按需渲染,同時考慮到未來維護,隨時可能通過 useContext 等注入數據,即使沒有性能問題的組件也建議使用 useMemo

為什麼不用解構方式代替 defaultProps?

雖然書寫上解構方式更優雅,但是存在一個性能問題:對於對象類型每次 Rerender 時引用都會變化。

局部狀態#

按常用程度排列:useStateuseRefuseReducer

useState

const [hide, setHide] = React.useState(false)
const [name, setName] = React.useState("jeremy")

狀態和函數名要見名知意,推薦都放在頂部聲明,方便查閱。

useRef

const dom = React.useRef(null)

useRef 儘量少用,因為大量 Mutable 的數據會影響代碼的可維護性,對於不需要重複初始化的對象推薦使用。

useReducer

局部狀態不推薦使用 useReducer,容易導致內部狀態過於複雜,建議在多組件間通信時結合 useContext 使用。

在函數內直接聲明普通常量或普通函數合適嗎?

因為 Function Component 每次渲染都會重新執行,常量推薦放到函數外層避免性能問題,函數推薦使用 useCallback 聲明以保證準確性與性能,useCallback 第二個參數必須填寫,eslint-plugin-react-hooks 會自動填寫依賴項。

組件通信#

簡單的組件通信使用 Props 透傳的方式,頻繁組件間通信使用 useContext

useEffect 指南#

Function Component 沒有生命週期,僅描述 UI 狀態,然後 React 將其同步到 DOM。每次渲染的狀態都會固化下來,包括 state props useEffect 和內部的所有函數。

捨棄了生命週期的同步會帶來一些性能問題,我們需要告訴 React 如何對比 Effects。

useEffect 的依賴項#

React 在 DOM 渲染時會 diff 內容,只修改改變了的部分,但是做不到對 Effect 的增量修改識別,需要開發者通過 useEffect 的第二個參數告訴 React 用到了哪些外部變量:

useEffect(() => {
    document.title = '你好, ' + name
}, [name])

直到 name 改變的 Rerender,useEffect 才會再次執行。手動維護比較麻煩,可以利用 eslint 自動提示 fix。

這裡需要關注的是依賴項的設置很重要:

由於 useEffect 符合 Capture Value 的特性,必須處理好依賴項才能保證獲取值的準確性:

useEffect(() => {
    const id = setInterval(() => {
        setCount(count + 1)
    }, 1000)
    return () => clearInterval(id)
}, [count])

如果這裡不傳入 count 作為依賴項,拿到的 count 值就永遠是初始化的 0,這樣之後的 setCount 就沒有作用了。

傳入了 count 可以獲取到最新的 count,但是導致了兩個問題:

  • 計時器不準確了,因為每次 count 變化都會銷毀重新計時。
  • 頻繁生成和銷毀定時器帶來了一定性能負擔。

這裡設不設依賴都有問題,本質上是因為我們在一個只想執行一次的 Effect 裡依賴了外部變量。

useEffect(() => {
    const id = setInterval(() => {
        setCount(c => c + 1)
    }, 1000)
    return () => clearInterval(id)
}, [])

setCount 還有一種函數回調模式,不需要關心當前值是什麼,只要對舊的值進行修改即可,這樣雖然代碼永遠運行在第一次 Render 中,但總是可以訪問到最新的 state

上面的解法並沒有徹底解決所有場景的問題,比如同時依賴了兩個 state 的情況:

useEffect(() => {
    const id = setInterval(() => {
        setCount(c => c + step)
    }, 1000)
    return () => clearInterval(id)
}, [step])

這裡就不得不依賴 step 這個變量,那麼現在該怎麼處理呢?

利用 useReducer 函數將更新與動作解耦:

const [state, dispatch] = useReducer(reducer, initialState)
const { count, step } = state

useEffect(() => {
    const id = setInterval(() => {
        dispatch({ type: "tick" })
    }, 1000)
    return () => clearInterval(id)
}, [dispatch])

這樣形成了一個局部 Redux,不管更新時需要依賴多少變量,在實際更新時都不需要依賴任何變量,具體更新操作寫在 reducer 函數裡即可。

Function 與 Effect#

如果函數定義不在 useEffect 中,不僅可能遺漏依賴,而且 eslint 插件也無法幫助自動收集依賴。

只要不依賴 Function Component 內變量的函數都可以直接抽出去,但是依賴了變量的函數怎麼辦?

useCallback :

function Parent() {
  const [query, setQuery] = useState("react");

  // ✅ Preserves identity until query changes
  const fetchData = useCallback(() => {
    const url = "https://hn.algolia.com/api/v1/search?query=" + query;
    // ... Fetch data and return it ...
  }, [query]); // ✅ Callback deps are OK

  return <Child fetchData={fetchData} />;
}

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

  useEffect(() => {
    fetchData().then(setData);
  }, [fetchData]); // ✅ Effect deps are OK

  // ...
}

因為函數也具有 Capture Value 特性,經過 useCallback 包裝的函數可以當成普通變量來作為 useEffect 的依賴。

useCallback 就是在它的依賴變化時返回一個新的函數引用,從而觸發 useEffect 的依賴變化並激活它重新執行。

在 Class Component 中,如果希望參數變化就重新取數,我們不能直接對比取數函數的 diff 而是要對比取數參數是否變化,這樣的代碼不內聚難維護;對比 Function Component 中利用 useCallback 封裝的取數函數,useEffect 只需關心這個依賴是否變化,參數的變化在 useCallback 內關係,再配合 eslint 插件掃描就能做到依賴不丟、邏輯內聚易維護。

回收機制#

在組件被銷毀時,通過 useEffect 註冊的監聽需要被銷毀,可以通過它的返回值處理:

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

在組件銷毀時會執行返回值函數內回調函數,由於 Capture Value 特性,每次註冊與回收拿到的都是成對的固定值。

如果沒有合理的返回回收,很容易造成內存洩漏,如果直接傳了個 async 這樣的異步函數 useEffect 也會警告,那如何做到銷毀時取消異步函數呢?

如果使用的異步方式支持取消可以直接在清除函數中取消異步請求,更簡單的一個方式是借助一個布爾值:

function Article({ id }) {
  const [article, setArticle] = useState(null);

  useEffect(() => {
    let didCancel = false;

    async function fetchData() {
      const article = await API.fetchArticle(id);
      if (!didCancel) {
        setArticle(article);
      }
    }

    fetchData();

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

  // ...
}

這篇文章 討論了更多關於如何處理錯誤和加載狀態的場景。

其它優勢#

useEffect 在渲染結束時執行,也就不會阻塞瀏覽器渲染進程,符合 React Fiber 的理念,因為 Fiber 是會根據情況暫停或插隊執行不同組件的 Render,遵循 Capture Value 特性的代碼可以保證值的安全訪問,弱化生命週期也能解決中斷執行帶來的問題。

React Hooks 目前還在完善發展中,官方與社區的實踐方案與輪子都有值得參考學習的地方,同時 Vue 3.0 也借鑒了 React Hooks 的思想,產生了 Vue Hooks,兩者各有優劣,對這一部分深入了解後會再補充到這裡。

參考:

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。