若是你在使用 React 16,能夠嘗試 Function Component 風格,享受更大的靈活性。但在嘗試以前,最好先閱讀本文,對 Function Component 的思惟模式有一個初步認識,防止因思惟模式不一樣步形成的困擾。html
Function Component 就是以 Function 的形式建立的 React 組件:前端
function App() { return ( <div> <p>App</p> </div> ); }
也就是,一個返回了 JSX 或 createElement
的 Function 就能夠看成 React 組件,這種形式的組件就是 Function Component。react
因此我已經學會 Function Component 了嗎?ios
別急,故事纔剛剛開始。git
Hooks 是輔助 Function Component 的工具。好比 useState
就是一種 Hook,它能夠用來管理狀態:es6
function Counter() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </div> ); }
useState
返回的結果是數組,數組的第一項是 值,第二項是 賦值函數,useState
函數的第一個參數就是 默認值,也支持回調函數。更詳細的介紹能夠參考 Hooks 規則解讀。github
咱們再將 useState
與 setTimeout
結合使用,看看有什麼發現。npm
建立一個按鈕,點擊後讓計數器自增,可是延時 3 秒後再打印出來:redux
function Counter() { const [count, setCount] = useState(0); const log = () => { setCount(count + 1); setTimeout(() => { console.log(count); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> ); }
若是咱們 在三秒內連續點擊三次,那麼 count
的值最終會變成 3
,而隨之而來的輸出結果是。。?axios
0 1 2
嗯,好像對,但總以爲有點怪?
敲黑板了,回到咱們熟悉的 Class Component 模式,實現一遍上面的功能:
class Counter extends Component { state = { count: 0 }; log = () => { this.setState({ count: this.state.count + 1 }); setTimeout(() => { console.log(this.state.count); }, 3000); }; render() { return ( <div> <p>You clicked {this.state.count} times</p> <button onClick={this.log}>Click me</button> </div> ); } }
嗯,結果應該等價吧?3 秒內快速點擊三次按鈕,此次的結果是:
3 3 3
怎麼和 Function Component 結果不同?
這是用好 Function Component 必須邁過的第一道坎,請確認徹底理解下面這段話:
首先對 Class Component 進行解釋:
setState
後必定會生成一個全新的 state 引用。this.state
方式讀取 state,這致使了每次代碼執行都會拿到最新的 state 引用,因此快速點擊三次的結果是 3 3 3
。那麼對 Function Component 而言:
useState
產生的數據也是 Immutable 的,經過數組第二個參數 Set 一個新值後,原來的值會造成一個新的引用在下次渲染時。this.
的方式,使得 每次 setTimeout
都讀取了當時渲染閉包環境的數據,雖然最新的值跟着最新的渲染變了,但舊的渲染裏,狀態依然是舊值。爲了更容易理解,咱們來模擬三次 Function Component 模式下點擊按鈕時的狀態:
第一次點擊,共渲染了 2 次,setTimeout
生效在第 1
次渲染,此時狀態爲:
function Counter() { const [0, setCount] = useState(0); const log = () => { setCount(0 + 1); setTimeout(() => { console.log(0); }, 3000); }; return ... }
第二次點擊,共渲染了 3 次,setTimeout
生效在第 2
次渲染,此時狀態爲:
function Counter() { const [1, setCount] = useState(0); const log = () => { setCount(1 + 1); setTimeout(() => { console.log(1); }, 3000); }; return ... }
第三次點擊,共渲染了 4 次,setTimeout
生效在第 3
次渲染,此時狀態爲:
function Counter() { const [2, setCount] = useState(0); const log = () => { setCount(2 + 1); setTimeout(() => { console.log(2); }, 3000); }; return ... }
能夠看到,每個渲染都是一個獨立的閉包,在獨立的三次渲染中,count
在每次渲染中的值分別是 0 1 2
,因此不管 setTimeout
延時多久,打印出來的結果永遠是 0 1 2
。
理解了這一點,咱們就能繼續了。
3 3 3
?因此這是否是表明 Function Component 沒法覆蓋 Class Component 的功能呢?徹底不是,我但願你讀完本文後,不只能解決這個問題,更能理解爲何用 Function Component 實現的代碼更佳合理、優雅。
第一種方案是藉助一個新 Hook - useRef
的能力:
function Counter() { const count = useRef(0); const log = () => { count.current++; setTimeout(() => { console.log(count.current); }, 3000); }; return ( <div> <p>You clicked {count.current} times</p> <button onClick={log}>Click me</button> </div> ); }
這種方案的打印結果就是 3 3 3
。
想要理解爲何,首先要理解 useRef
的功能:經過 useRef
建立的對象,其值只有一份,並且在全部 Rerender 之間共享。
因此咱們對 count.current
賦值或讀取,讀到的永遠是其最新值,而與渲染閉包無關,所以若是快速點擊三下,一定會返回 3 3 3
的結果。
但這種方案有個問題,就是使用 useRef
替代了 useState
建立值,那麼很天然的問題就是,如何不改變原始值的寫法,達到一樣的效果呢?
3 3 3
?一種最簡單的作法,就是新建一個 useRef
的值給 setTimeout
使用,而程序其他部分仍是用原始的 count
:
function Counter() { const [count, setCount] = useState(0); const currentCount = useRef(count); useEffect(() => { currentCount.current = count; }); const log = () => { setCount(count + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> ); }
經過這個例子,咱們引出了一個新的,也是 最重要的 Hook - useEffect
,請務必深刻理解這個函數。
useEffect
是處理反作用的,其執行時機在 每次 Render 渲染完畢後,換句話說就是每次渲染都會執行,只是實際在真實 DOM 操做完畢後。
咱們能夠利用這個特性,在每次渲染完畢後,將 count
此時最新的值賦給 currentCount.current
,這樣就使 currentCount
的值自動同步了 count
的最新值。
爲了確保你們準確理解 useEffect
,筆者再囉嗦一下,將其執行週期拆解到每次渲染中。假設你在三秒內快速點擊了三次按鈕,那麼你須要在大腦中模擬出下面這三次渲染都發生了什麼:
第一次點擊,共渲染了 2 次,useEffect
生效在第 2
次渲染:
function Counter() { const [1, setCount] = useState(0); const currentCount = useRef(0); useEffect(() => { currentCount.current = 1; // 第二次渲染完畢後執行一次 }); const log = () => { setCount(1 + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ... }
第二次點擊,共渲染了 3 次,useEffect
生效在第 3
次渲染:
function Counter() { const [2, setCount] = useState(0); const currentCount = useRef(0); useEffect(() => { currentCount.current = 2; // 第三次渲染完畢後執行一次 }); const log = () => { setCount(2 + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ... }
第三次點擊,共渲染了 4 次,useEffect
生效在第 4
次渲染:
function Counter() { const [3, setCount] = useState(0); const currentCount = useRef(0); useEffect(() => { currentCount.current = 3; // 第四次渲染完畢後執行一次 }); const log = () => { setCount(3 + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ... }
注意對比與上面章節展開的 setTimeout
渲染時有什麼不一樣。
要注意的是,useEffect
也隨着每次渲染而不一樣的,同一個組件不一樣渲染之間,useEffect
內閉包環境徹底獨立。對於本次的例子,useEffect
共執行了 四次,經歷了以下四次賦值最終變成 3
:
currentCount.current = 0; // 第 1 次渲染 currentCount.current = 1; // 第 2 次渲染 currentCount.current = 2; // 第 3 次渲染 currentCount.current = 3; // 第 4 次渲染
請確保理解了這句話再繼續往下閱讀:
setTimeout
的例子,三次點擊觸發了四次渲染,但 setTimeout
分別生效在第 一、二、3 次渲染中,所以值是 0 1 2
。useEffect
的例子中,三次點擊也觸發了四次渲染,但 useEffect
分別生效在第 一、二、三、4 次渲染中,最終使 currentCount
的值變成 3
。useRef
是否是以爲每次都寫一堆 useEffect
同步數據到 useRef
很煩?是的,想要簡化,就須要引出一個新的概念:自定義 Hooks。
首先介紹一下,自定義 Hooks 容許建立自定義 Hook,只要函數名遵循以 use
開頭,且返回非 JSX 元素,就是 Hooks 啦!自定義 Hooks 內還能夠調用包括內置 Hooks 在內的全部自定義 Hooks。
也就是咱們能夠將 useEffect
寫到自定義 Hook 裏:
function useCurrentValue(value) { const ref = useRef(0); useEffect(() => { ref.current = value; }, [value]); return ref; }
這裏又引出一個新的概念,就是 useEffect
的第二個參數,dependences。dependences 這個參數定義了 useEffect
的依賴,在新的渲染中,只要全部依賴項的引用都不發生變化,useEffect
就不會被執行,且當依賴項爲 []
時,useEffect
僅在初始化執行一次,後續的 Rerender 永遠也不會被執行。
這個例子中,咱們告訴 React:僅當 value
的值變化了,再將其最新值同步給 ref.current
。
那麼這個自定義 Hook 就能夠在任何 Function Component 調用了:
function Counter() { const [count, setCount] = useState(0); const currentCount = useCurrentValue(count); const log = () => { setCount(count + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ( <div> <p>You clicked {count} times</p> <button onClick={log}>Click me</button> </div> ); }
封裝之後代碼清爽了不少,並且最重要的是將邏輯封裝起來,咱們只要理解 useCurrentValue
這個 Hook 能夠產生一個值,其最新值永遠與入參同步。
看到這裏,也許有的小夥伴已經按捺不住迸發的靈感了:將 useEffect
第二個參數設置爲空數組,這個自定義 Hook 就表明了 didMount
生命週期!
是的,但筆者建議你們 不要再想生命週期的事情,這樣會阻礙你更好的理解 Function Component。由於下一個話題,就是要告訴你:永遠要對 useEffect
的依賴誠實,被依賴的參數必定要填上去,不然會產生很是難以察覺與修復的 BUG。
setTimeout
換成 setInterval
會怎樣咱們回到起點,將第一個 setTimeout
Demo 中換成 setInterval
,看看會如何:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>; }
這個例子將引起學習 Function Component 的第二個攔路虎,理解了它,才深刻理解了 Function Component 的渲染原理。
首先介紹一下引入的新概念,useEffect
函數的返回值。它的返回值是一個函數,這個函數在 useEffect
即將從新執行時,會先執行上一次 Rerender useEffect
第一個回調的返回函數,再執行下一次渲染的 useEffect
第一個回調。
以兩次連續渲染爲例介紹,展開後的效果是這樣的:
第一次渲染:
function Counter() { useEffect(() => { // 第一次渲染完畢後執行 // 最終執行順序:1 return () => { // 因爲沒有填寫依賴項,因此第二次渲染 useEffect 會再次執行,在執行前,第一次渲染中這個地方的回調函數會首先被調用 // 最終執行順序:2 } }); return ... }
第二次渲染:
function Counter() { useEffect(() => { // 第二次渲染完畢後執行 // 最終執行順序:3 return () => { // 依此類推 } }); return ... }
然而本 Demo 將 useEffect
的第二個參數設置爲了 []
,那麼其返回函數只會在這個組件被銷燬時執行。
讀懂了前面的例子,應該能想到,這個 Demo 但願利用 []
依賴,將 useEffect
看成 didMount
使用,再結合 setInterval
每次時 count
自增,這樣指望將 count
的值每秒自增 1。
然而結果是:
1 1 1 ...
理解了 setTimeout
例子的讀者應該能夠自行推導出緣由:setInterval
永遠在第一次 Render 的閉包中,count
的值永遠是 0
,也就是等價於:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(0 + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>; }
然而罪魁禍首就是 沒有對依賴誠實 致使的。例子中 useEffect
明明依賴了 count
,依賴項卻非要寫 []
,因此產生了很難理解的錯誤。
因此改正的辦法就是 對依賴誠實。
一旦咱們對依賴誠實了,就能夠獲得正確的效果:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, [count]); return <h1>{count}</h1>; }
咱們將 count
做爲了 useEffect
的依賴項,就獲得了正確的結果:
1 2 3 ...
既然漏寫依賴的風險這麼大,天然也有保護措施,那就是 eslint-plugin-react-hooks 這個插件,會自動訂正你的代碼中的依賴,想不對依賴誠實都不行!
然而對這個例子而言,代碼依然存在 BUG:每次計數器都會從新實例化,若是換成其餘費事操做,性能成本將不可接受。
setInterval
?最簡單的辦法,就是利用 useState
的第二種賦值用法,不直接依賴 count
,而是以函數回調方式進行賦值:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>; }
這這寫法真正作到了:
count
,因此對依賴誠實。[]
,只有初始化會對 setInterval
進行實例化。而之因此輸出仍是正確的 1 2 3 ...
,緣由是 setCount
的回調函數中,c
值永遠指向最新的 count
值,所以沒有邏輯漏洞。
可是聰明的同窗仔細一想,就會發現一個新問題:若是存在兩個以上變量須要使用時,這招就沒有用武之地了。
若是同時須要對 count
與 step
兩個變量作累加,那 useEffect
的依賴必然要寫上一種某一個值,頻繁實例化的問題就又出現了:
function Counter() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + step); }, 1000); return () => clearInterval(id); }, [step]); return <h1>{count}</h1>; }
這個例子中,因爲 setCount
只能拿到最新的 count
值,而爲了每次都拿到最新的 step
值,就必須將 step
申明到 useEffect
依賴中,致使 setInterval
被頻繁實例化。
這個問題天然也困擾了 React 團隊,因此他們拿出了一個新的 Hook 解決問題:useReducer
。
先別聯想到 Redux。只考慮上面的場景,看看爲何 React 團隊要將 useReducer
列爲內置 Hooks 之一。
先介紹一下 useReducer
的用法:
const [state, dispatch] = useReducer(reducer, initialState);
useReducer
返回的結構與 useState
很像,只是數組第二項是 dispatch
,而接收的參數也有兩個,初始值放在第二位,第一位就是 reducer
。
reducer
定義瞭如何對數據進行變換,好比一個簡單的 reducer
以下:
function reducer(state, action) { switch (action.type) { case "increment": return { ...state, count: state.count + 1 }; default: return state; } }
這樣就能夠經過調用 dispatch({ type: 'increment' })
的方式實現 count
自增了。
那麼回到這個例子,咱們只須要稍微改寫一下用法便可:
function Counter() { const [state, dispatch] = useReducer(reducer, initialState); const { count, step } = state; useEffect(() => { const id = setInterval(() => { dispatch({ type: "tick" }); }, 1000); return () => clearInterval(id); }, [dispatch]); return <h1>{count}</h1>; } function reducer(state, action) { switch (action.type) { case "tick": return { ...state, count: state.count + state.step }; } }
能夠看到,咱們經過 reducer
的 tick
類型完成了對 count
的累加,而在 useEffect
的函數中,居然徹底繞過了 count
、step
這兩個變量。因此 useReducer
也被稱爲解決此類問題的 「黑魔法」。
其實無論被怎麼稱呼也好,其本質是讓函數與數據解耦,函數只管發出指令,而不須要關心使用的數據被更新時,須要從新初始化自身。
仔細的讀者會發現這個例子仍是有一個依賴的,那就是 dispatch
,然而 dispatch
引用永遠也不會變,所以能夠忽略它的影響。這也體現了不管如何都要對依賴保持誠實。
這也引起了另外一個注意項:儘可能將函數寫在 useEffect
內部。
useEffect
內部爲了不遺漏依賴,必須將函數寫在 useEffect
內部,這樣 eslint-plugin-react-hooks 才能經過靜態分析補齊依賴項:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { function getFetchUrl() { return "https://v?query=" + count; } getFetchUrl(); }, [count]); return <h1>{count}</h1>; }
getFetchUrl
這個函數依賴了 count
,而若是將這個函數定義在 useEffect
外部,不管是機器仍是人眼都難以看出 useEffect
的依賴項包含 count
。
然而這就引起了一個新問題:將全部函數都寫在 useEffect
內部豈不是很是難以維護?
useEffect
外部?爲了解決這個問題,咱們要引入一個新的 Hook:useCallback
,它就是解決將函數抽到 useEffect
外部的問題。
咱們先看 useCallback
的用法:
function Counter() { const [count, setCount] = useState(0); const getFetchUrl = useCallback(() => { return "https://v?query=" + count; }, [count]); useEffect(() => { getFetchUrl(); }, [getFetchUrl]); return <h1>{count}</h1>; }
能夠看到,useCallback
也有第二個參數 - 依賴項,咱們將 getFetchUrl
函數的依賴項經過 useCallback
打包到新的 getFetchUrl
函數中,那麼 useEffect
就只須要依賴 getFetchUrl
這個函數,就實現了對 count
的間接依賴。
換句話說,咱們利用了 useCallback
將 getFetchUrl
函數抽到了 useEffect
外部。
useCallback
比 componentDidUpdate
更好用回憶一下 Class Component 的模式,咱們是如何在函數參數變化時進行從新取數的:
class Parent extends Component { state = { count: 0, step: 0 }; fetchData = () => { const url = "https://v?query=" + this.state.count + "&step=" + this.state.step; }; render() { return <Child fetchData={this.fetchData} count={count} step={step} />; } } class Child extends Component { state = { data: null }; componentDidMount() { this.props.fetchData(); } componentDidUpdate(prevProps) { if ( this.props.count !== prevProps.count && this.props.step !== prevProps.step // 別漏了! ) { this.props.fetchData(); } } render() { // ... } }
上面的代碼常常用 Class Component 的人應該很熟悉,然而暴露的問題可不小。
咱們須要理解 props.count
props.step
被 props.fetchData
函數使用了,所以在 componentDidUpdate
時,判斷這兩個參數發生了變化就觸發從新取數。
然而問題是,這種理解成本是否是太高了?若是父級函數 fetchData
不是我寫的,在不讀源碼的狀況下,我怎麼知道它依賴了 props.count
與 props.step
呢?更嚴重的是,若是某一天 fetchData
多依賴了 params
這個參數,下游函數將須要所有在 componentDidUpdate
覆蓋到這個邏輯,不然 params
變化時將不會從新取數。能夠想象,這種方式維護成本巨大,甚至能夠說幾乎沒法維護。
換成 Function Component 的思惟吧!試着用上剛纔提到的 useCallback
解決問題:
function Parent() { const [ count, setCount ] = useState(0); const [ step, setStep ] = useState(0); const fetchData = useCallback(() => { const url = 'https://v/search?query=' + count + "&step=" + step; }, [count, step]) return ( <Child fetchData={fetchData} /> ) } function Child(props) { useEffect(() => { props.fetchData() }, [props.fetchData]) return ( // ... ) }
能夠看出來,當 fetchData
的依賴變化後,按下保存鍵,eslint-plugin-react-hooks 會自動補上更新後的依賴,而下游的代碼不須要作任何改變,下游只須要關心依賴了 fetchData
這個函數便可,至於這個函數依賴了什麼,已經封裝在 useCallback
後打包透傳下來了。
不只解決了維護性問題,並且對於 只要參數變化,就從新執行某邏輯,是特別適合用 useEffect
作的,使用這種思惟思考問題會讓你的代碼更 「智能」,而使用分裂的生命週期進行思考,會讓你的代碼四分五裂,並且容易漏掉各類時機。
useEffect
對業務的抽象很是方便,筆者舉幾個例子:
useEffect
內能夠進行取數請求,那麼只要查詢參數變化了,列表就會自動取數刷新。注意咱們將取數時機從觸發端改爲了接收端。useEffect
「監聽」 數據的變化,這是一種 「控制反轉」 的思惟。說了這麼多,其本質仍是利用了 useCallback
將函數獨立抽離到 useEffect
外部。
那麼進一步思考,能夠將函數抽離到整個組件的外部嗎?
這也是能夠的,須要靈活運用自定義 Hooks 實現。
以上面的 fetchData
函數爲例,若是要抽到整個組件的外部,就不是利用 useCallback
作到了,而是利用自定義 Hooks 來作:
function useFetch(count, step) { return useCallback(() => { const url = "https://v/search?query=" + count + "&step=" + step; }, [count, step]); }
能夠看到,咱們將 useCallback
打包搬到了自定義 Hook useFetch
中,那麼函數中只須要一行代碼就能實現同樣的效果了:
function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const [other, setOther] = useState(0); const fetch = useFetch(count, step); // 封裝了 useFetch useEffect(() => { fetch(); }, [fetch]); return ( <div> <button onClick={() => setCount(c => c + 1)}>setCount {count}</button> <button onClick={() => setStep(c => c + 1)}>setStep {step}</button> <button onClick={() => setOther(c => c + 1)}>setOther {other}</button> </div> ); }
隨着使用愈來愈方便,咱們能夠將精力放到性能上。觀察能夠發現,count
與 step
都會頻繁變化,每次變化就會致使 useFetch
中 useCallback
依賴的變化,進而致使從新生成函數。然而實際上這種函數是不必每次都從新生成的,反覆生成函數會形成大量性能損耗。
換一個例子就能夠看得更清楚:
function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const [other, setOther] = useState(0); const drag = useDraggable(count, step); // 封裝了拖拽函數 }
假設咱們使用 Sortablejs 對某個區域進行拖拽監聽,這個函數每次都重複執行的性能損耗很是大,然而這個函數內部可能由於僅僅要上報一些日誌,因此依賴了沒有實際被使用的 count
step
變量:
function useDraggable(count, step) { return useCallback(() => { // 上報日誌 report(count, step); // 對區域進行初始化,很是耗時 // ... 省略耗時代碼 }, [count, step]); }
這種狀況,函數的依賴就特別不合理。雖然依賴變化應該觸發函數從新執行,但若是函數從新執行的成本很是高,而依賴只是無關緊要的點綴,得不償失。
一種辦法是經過將依賴轉化爲 Ref:
function useFetch(count, step) { const countRef = useRef(count); const stepRef = useRef(step); useEffect(() => { countRef.current = count; stepRef.current = step; }); return useCallback(() => { const url = "https://v/search?query=" + countRef.current + "&step=" + stepRef.current; }, [countRef, stepRef]); // 依賴不會變,卻能每次拿到最新的值 }
這種方式比較取巧,將須要更新的區域與耗時區域分離,再將需更新的內容經過 Ref 提供給耗時的區域,實現性能優化。
然而這樣作對函數的改動成本比較高,有一種更通用的作法解決此類問題。
咱們能夠利用 useRef
創造一個自定義 Hook 代替 useCallback
,使其依賴的值變化時,回調不會從新執行,卻能拿到最新的值!
這個神奇的 Hook 寫法以下:
function useEventCallback(fn, dependencies) { const ref = useRef(null); useEffect(() => { ref.current = fn; }, [fn, ...dependencies]); return useCallback(() => { const fn = ref.current; return fn(); }, [ref]); }
再次體會到自定義 Hook 的無所不能。
首先看這一段:
useEffect(() => { ref.current = fn; }, [fn, ...dependencies]);
當 fn
回調函數變化時, ref.current
從新指向最新的 fn
這個邏輯中規中矩。重點是,當依賴 dependencies
變化時,也從新爲 ref.current
賦值,此時 fn
內部的 dependencies
值是最新的,而下一段代碼:
return useCallback(() => { const fn = ref.current; return fn(); }, [ref]);
又僅執行一次(ref 引用不會改變),因此每次均可以返回 dependencies
是最新的 fn
,而且 fn
還不會從新執行。
假設咱們對 useEventCallback
傳入的回調函數稱爲 X,則這段代碼的含義,就是使每次渲染的閉包中,回調函數 X 老是拿到的老是最新 Rerender 閉包中的那個,因此依賴的值永遠是最新的,並且函數不會從新初始化。
React 官方不推薦使用此範式,所以對於這種場景,利用
useReducer
,將函數經過dispatch
中調用。 還記得嗎?dispatch
是一種能夠繞過依賴的黑魔法,咱們在 「什麼是 useReducer」 小節提到過。
隨着對 Function Component 的使用,你也漸漸關心到函數的性能了,這很棒。那麼下一個重點天然是關注 Render 的性能。
在 Fucntion Component 中,Class Component 的 PureComponent
等價的概念是 React.memo
,咱們介紹一下 memo
的用法:
const Child = memo((props) => { useEffect(() => { props.fetchData() }, [props.fetchData]) return ( // ... ) })
使用 memo
包裹的組件,會在自身重渲染時,對每個 props
項進行淺對比,若是引用沒有變化,就不會觸發重渲染。因此 memo
是一種很棒的性能優化工具。
下面就介紹一個看似比 memo
難用,但真正理解後會發現,其實比 memo
更好用的渲染優化函數:useMemo
。
相比 React.memo
這個異類,React.useMemo
但是正經的官方 Hook:
const Child = (props) => { useEffect(() => { props.fetchData() }, [props.fetchData]) return useMemo(() => ( // ... ), [props.fetchData]) }
能夠看到,咱們利用 useMemo
包裹渲染代碼,這樣即使函數 Child
由於 props
的變化從新執行了,只要渲染函數用到的 props.fetchData
沒有變,就不會從新渲染。
這裏發現了 useMemo
的第一個好處:更細粒度的優化渲染。
所謂更細粒度的優化渲染,是指函數 Child
總體可能用到了 A
、B
兩個 props
,而渲染僅用到了 B
,那麼使用 memo
方案時,A
的變化會致使重渲染,而使用 useMemo
的方案則不會。
而 useMemo
的好處還不止這些,這裏先留下伏筆。咱們先看一個新問題:當參數愈來愈多時,使用 props
將函數、值在組件間傳遞很是冗長:
function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const fetchData = useFetch(count, step); return <Child fetchData={fetchData} setCount={setCount} setStep={setStep} />; }
雖然 Child
能夠經過 memo
或 useMemo
進行優化,但當程序複雜時,可能存在多個函數在全部 Function Component 間共享的狀況 ,此時就須要新 Hook: useContext
來拯救了。
在 Function Component 中,可使用 React.createContext
建立一個 Context:
const Store = createContext(null);
其中 null
是初始值,通常置爲 null
也不要緊。接下來還有兩步,分別是在根節點使用 Store.Provider
注入,與在子節點使用官方 Hook useContext
拿到注入的數據:
在根節點使用 Store.Provider
注入:
function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const fetchData = useFetch(count, step); return ( <Store.Provider value={{ setCount, setStep, fetchData }}> <Child /> </Store.Provider> ); }
在子節點使用 useContext
拿到注入的數據(也就是拿到 Store.Provider
的 value
):
const Child = memo((props) => { const { setCount } = useContext(Store) function onClick() { setCount(count => count + 1) } return ( // ... ) })
這樣就不須要在每一個函數間進行參數透傳了,公共函數能夠都放在 Context 裏。
可是當函數多了,Provider
的 value
會變得很臃腫,咱們能夠結合以前講到的 useReducer
解決這個問題。
useReducer
爲 Context 傳遞內容瘦身使用 useReducer
,全部回調函數都經過調用 dispatch
完成,那麼 Context 只要傳遞 dispatch
一個函數就行了:
const Store = createContext(null); function Parent() { const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 }); return ( <Store.Provider value={dispatch}> <Child /> </Store.Provider> ); }
這下不管是根節點的 Provider
,仍是子元素調用都清爽不少:
const Child = useMemo((props) => { const dispatch = useContext(Store) function onClick() { dispatch({ type: 'countInc' }) } return ( // ... ) })
你也許很快就想到,將 state
也經過 Provider
注入進去豈不更妙?是的,但此處請務必注意潛在性能問題。
state
也放到 Context 中稍稍改造下,將 state
也放到 Context 中,這下賦值與取值都很是方便了!
const Store = createContext(null); function Parent() { const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 }); return ( <Store.Provider value={{ state, dispatch }}> <Count /> <Step /> </Store.Provider> ); }
對 Count
Step
這兩個子元素而言,可須要謹慎一些,假如咱們這麼實現這兩個子元素:
const Count = memo(() => { const { state, dispatch } = useContext(Store); return ( <button onClick={() => dispatch("incCount")}>incCount {state.count}</button> ); }); const Step = memo(() => { const { state, dispatch } = useContext(Store); return ( <button onClick={() => dispatch("incStep")}>incStep {state.step}</button> ); });
其結果是:不管點擊 incCount
仍是 incStep
,都會同時觸發這兩個組件的 Rerender。
其問題在於:memo
只能擋在最外層的,而經過 useContext
的數據注入發生在函數內部,會 繞過 memo
。
當觸發 dispatch
致使 state
變化時,全部使用了 state
的組件內部都會強制從新刷新,此時想要對渲染次數作優化,只有拿出 useMemo
了!
useMemo
配合 useContext
使用 useContext
的組件,若是自身不使用 props
,就能夠徹底使用 useMemo
代替 memo
作性能優化:
const Count = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( <button onClick={() => dispatch("incCount")}> incCount {state.count} </button> ), [state.count, dispatch] ); }; const Step = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( <button onClick={() => dispatch("incStep")}>incStep {state.step}</button> ), [state.step, dispatch] ); };
對這個例子來講,點擊對應的按鈕,只有使用到的組件纔會重渲染,效果符合預期。 結合 eslint-plugin-react-hooks 插件使用,連 useMemo
的第二個參數依賴都是自動補全的。
讀到這裏,不知道你是否聯想到了 Redux 的 Connect
?
咱們來對比一下 Connect
與 useMemo
,會發現驚人的類似之處。
一個普通的 Redux 組件:
const mapStateToProps = state => (count: state.count); const mapDispatchToProps = dispatch => dispatch; @Connect(mapStateToProps, mapDispatchToProps) class Count extends React.PureComponent { render() { return ( <button onClick={() => this.props.dispatch("incCount")}> incCount {this.props.count} </button> ); } }
一個普通的 Function Component 組件:
const Count = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( <button onClick={() => dispatch("incCount")}> incCount {state.count} </button> ), [state.count, dispatch] ); };
這兩段代碼的效果徹底同樣,Function Component 除了更簡潔以外,還有一個更大的優點:全自動的依賴推導。
Hooks 誕生的一個緣由,就是爲了便於靜態分析依賴,簡化 Immutable 數據流的使用成本。
咱們看 Connect
的場景:
因爲不知道子組件使用了哪些數據,所以須要在 mapStateToProps
提早寫好,而當須要使用數據流內新變量時,組件裏是沒法訪問的,咱們要回到 mapStateToProps
加上這個依賴,再回到組件中使用它。
而 useContext
+ useMemo
的場景:
因爲注入的 state
是全量的,Render 函數中想用什麼均可直接用,在按保存鍵時,eslint-plugin-react-hooks 會經過靜態分析,在 useMemo
第二個參數自動補上代碼裏使用到的外部變量,好比 state.count
、dispatch
。
另外能夠發現,Context 很像 Redux,那麼 Class Component 模式下的異步中間件實現的異步取數怎麼利用 useReducer
作呢?答案是:作不到。
固然不是說 Function Component 沒法實現異步取數,而是用的工具錯了。
好比上面拋出的異步取數場景,在 Function Component 的最佳作法是封裝成一個自定義 Hook:
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 }; };
能夠看到,自定義 Hook 擁有完整生命週期,咱們能夠將取數過程封裝起來,只暴露狀態 - 是否在加載中:isLoading
是否取數失敗:isError
數據:data
。
在組件中使用起來很是方便:
function App() { const { data, isLoading, isError } = useDataApi("https://v", { showLog: true }); }
若是這個值須要存儲到數據流,在全部組件之間共享,咱們能夠結合 useEffect
與 useReducer
:
function App(props) { const { dispatch } = useContext(Store); const { data, isLoading, isError } = useDataApi("https://v", { showLog: true }); useEffect(() => { dispatch({ type: "updateLoading", data, isLoading, isError }); }, [dispatch, data, isLoading, isError]); }
到此,Function Component 的入門概念就講完了,最後附帶一個彩蛋:Function Component 的 DefaultProps 怎麼處理?
這個問題看似簡單,實則否則。咱們至少有兩種方式對 Function Component 的 DefaultProps 進行賦值,下面一一說明。
首先對於 Class Component,DefaultProps 基本上只有一種你們都承認的寫法:
class Button extends React.PureComponent { defaultProps = { type: "primary", onChange: () => {} }; }
然而在 Function Component 就五花八門了。
function Button({ type = "primary", onChange = () => {} }) {}
這種方法看似很優雅,其實有一個重大隱患:沒有命中的 props
在每次渲染引用都不一樣。
看這種場景:
const Child = memo(({ type = { a: 1 } }) => { useEffect(() => { console.log("type", type); }, [type]); return <div>Child</div>; });
只要 type
的引用不變,useEffect
就不會頻繁的執行。如今經過父元素刷新致使 Child
跟着刷新,咱們發現,每次渲染都會打印出日誌,也就意味着每次渲染時,type
的引用是不一樣的。
有一種不太優雅的方式能夠解決:
const defaultType = { a: 1 }; const Child = ({ type = defaultType }) => { useEffect(() => { console.log("type", type); }, [type]); return <div>Child</div>; };
此時不斷刷新父元素,只會打印出一第二天志,由於 type
的引用是相同的。
咱們使用 DefaultProps 的本意必然是但願默認值的引用相同, 若是不想單獨維護變量的引用,還能夠借用 React 內置的 defaultProps
方法解決。
React 內置方案能較好的解決引用頻繁變更的問題:
const Child = ({ type }) => { useEffect(() => { console.log("type", type); }, [type]); return <div>Child</div>; }; Child.defaultProps = { type: { a: 1 } };
上面的例子中,不斷刷新父元素,只會打印出一第二天志。
所以建議對於 Function Component 的參數默認值,建議使用 React 內置方案解決,由於純函數的方案不利於保持引用不變。
最後補充一個父組件 「坑」 子組件的經典案例。
咱們作一個點擊累加的按鈕做爲父組件,那麼父組件每次點擊後都會刷新:
function App() { const [count, forceUpdate] = useState(0); const schema = { b: 1 }; return ( <div> <Child schema={schema} /> <div onClick={() => forceUpdate(count + 1)}>Count {count}</div> </div> ); }
另外咱們將 schema = { b: 1 }
傳遞給子組件,這個就是埋的一個大坑。
子組件的代碼以下:
const Child = memo(props => { useEffect(() => { console.log("schema", props.schema); }, [props.schema]); return <div>Child</div>; });
只要父級 props.schema
變化就會打印日誌。結果天然是,父組件每次刷新,子組件都會打印日誌,也就是 子組件 [props.schema]
徹底失效了,由於引用一直在變化。
其實 子組件關心的是值,而不是引用,因此一種解法是改寫子組件的依賴:
const Child = memo(props => { useEffect(() => { console.log("schema", props.schema); }, [JSON.stringify(props.schema)]); return <div>Child</div>; });
這樣能夠保證子組件只渲染一次。
但是真正罪魁禍首是父組件,咱們須要利用 Ref 優化一下父組件:
function App() { const [count, forceUpdate] = useState(0); const schema = useRef({ b: 1 }); return ( <div> <Child schema={schema.current} /> <div onClick={() => forceUpdate(count + 1)}>Count {count}</div> </div> ); }
這樣 schema
的引用能一直保持不變。若是你完整讀完了本文,應該能夠充分理解第一個例子的 schema
在每一個渲染快照中都是一個新的引用,而 Ref 的例子中,schema
在每一個渲染快照中都只有一個惟一的引用。
因此使用 Function Component 你入門了嗎?
本次精讀留下的思考題是:Function Component 開發過程當中還有哪些容易犯錯誤的細節?
若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公衆號
special Sponsors
版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)