摘要:比如就是一種,它可以用來管理狀態(tài)返回的結(jié)果是數(shù)組,數(shù)組的第一項是值,第二項是賦值函數(shù),函數(shù)的第一個參數(shù)就是默認值,也支持回調(diào)函數(shù)。而之所以輸出還是正確的,原因是的回調(diào)函數(shù)中,值永遠指向最新的值,因此沒有邏輯漏洞。
1. 引言
如果你在使用 React 16,可以嘗試 Function Component 風格,享受更大的靈活性。但在嘗試之前,最好先閱讀本文,對 Function Component 的思維模式有一個初步認識,防止因思維模式不同步造成的困擾。
2. 精讀 什么是 Function Component?Function Component 就是以 Function 的形式創(chuàng)建的 React 組件:
function App() { return (); }App
也就是,一個返回了 JSX 或 createElement 的 Function 就可以當作 React 組件,這種形式的組件就是 Function Component。
所以我已經(jīng)學會 Function Component 了嗎?
別急,故事才剛剛開始。
什么是 Hooks?Hooks 是輔助 Function Component 的工具。比如 useState 就是一種 Hook,它可以用來管理狀態(tài):
function Counter() { const [count, setCount] = useState(0); return (); }You clicked {count} times
useState 返回的結(jié)果是數(shù)組,數(shù)組的第一項是 值,第二項是 賦值函數(shù),useState 函數(shù)的第一個參數(shù)就是 默認值,也支持回調(diào)函數(shù)。更詳細的介紹可以參考 Hooks 規(guī)則解讀。
先賦值再 setTimeout 打印我們再將 useState 與 setTimeout 結(jié)合使用,看看有什么發(fā)現(xiàn)。
創(chuàng)建一個按鈕,點擊后讓計數(shù)器自增,但是延時 3 秒后再打印出來:
function Counter() { const [count, setCount] = useState(0); const log = () => { setCount(count + 1); setTimeout(() => { console.log(count); }, 3000); }; return (); }You clicked {count} times
如果我們 在三秒內(nèi)連續(xù)點擊三次,那么 count 的值最終會變成 3,而隨之而來的輸出結(jié)果是。。?
0 1 2
嗯,好像對,但總覺得有點怪?
使用 Class Component 方式實現(xiàn)一遍呢?敲黑板了,回到我們熟悉的 Class Component 模式,實現(xiàn)一遍上面的功能:
class Counter extends Component { state = { count: 0 }; log = () => { this.setState({ count: this.state.count + 1 }); setTimeout(() => { console.log(this.state.count); }, 3000); }; render() { return (); } }You clicked {this.state.count} times
嗯,結(jié)果應(yīng)該等價吧?3 秒內(nèi)快速點擊三次按鈕,這次的結(jié)果是:
3 3 3
怎么和 Function Component 結(jié)果不一樣?
這是用好 Function Component 必須邁過的第一道坎,請確認完全理解下面這段話:
首先對 Class Component 進行解釋:
首先 state 是 Immutable 的,setState 后一定會生成一個全新的 state 引用。
但 Class Component 通過 this.state 方式讀取 state,這導致了每次代碼執(zhí)行都會拿到最新的 state 引用,所以快速點擊三次的結(jié)果是 3 3 3。
那么對 Function Component 而言:
useState 產(chǎn)生的數(shù)據(jù)也是 Immutable 的,通過數(shù)組第二個參數(shù) Set 一個新值后,原來的值會形成一個新的引用在下次渲染時。
但由于對 state 的讀取沒有通過 this. 的方式,使得 每次 setTimeout 都讀取了當時渲染閉包環(huán)境的數(shù)據(jù),雖然最新的值跟著最新的渲染變了,但舊的渲染里,狀態(tài)依然是舊值。
為了更容易理解,我們來模擬三次 Function Component 模式下點擊按鈕時的狀態(tài):
第一次點擊,共渲染了 2 次,setTimeout 生效在第 1 次渲染,此時狀態(tài)為:
function Counter() { const [0, setCount] = useState(0); const log = () => { setCount(0 + 1); setTimeout(() => { console.log(0); }, 3000); }; return ... }
第二次點擊,共渲染了 3 次,setTimeout 生效在第 2 次渲染,此時狀態(tài)為:
function Counter() { const [1, setCount] = useState(0); const log = () => { setCount(1 + 1); setTimeout(() => { console.log(1); }, 3000); }; return ... }
第三次點擊,共渲染了 4 次,setTimeout 生效在第 3 次渲染,此時狀態(tài)為:
function Counter() { const [2, setCount] = useState(0); const log = () => { setCount(2 + 1); setTimeout(() => { console.log(2); }, 3000); }; return ... }
可以看到,每一個渲染都是一個獨立的閉包,在獨立的三次渲染中,count 在每次渲染中的值分別是 0 1 2,所以無論 setTimeout 延時多久,打印出來的結(jié)果永遠是 0 1 2。
理解了這一點,我們就能繼續(xù)了。
如何讓 Function Component 也打印 3 3 3?所以這是不是代表 Function Component 無法覆蓋 Class Component 的功能呢?完全不是,我希望你讀完本文后,不僅能解決這個問題,更能理解為什么用 Function Component 實現(xiàn)的代碼更佳合理、優(yōu)雅。
第一種方案是借助一個新 Hook - useRef 的能力:
function Counter() { const count = useRef(0); const log = () => { count.current++; setTimeout(() => { console.log(count.current); }, 3000); }; return (); }You clicked {count.current} times
這種方案的打印結(jié)果就是 3 3 3。
想要理解為什么,首先要理解 useRef 的功能:通過 useRef 創(chuàng)建的對象,其值只有一份,而且在所有 Rerender 之間共享。
所以我們對 count.current 賦值或讀取,讀到的永遠是其最新值,而與渲染閉包無關(guān),因此如果快速點擊三下,必定會返回 3 3 3 的結(jié)果。
但這種方案有個問題,就是使用 useRef 替代了 useState 創(chuàng)建值,那么很自然的問題就是,如何不改變原始值的寫法,達到同樣的效果呢?
如何不改造原始值也打印 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 (); }You clicked {count} times
通過這個例子,我們引出了一個新的,也是 最重要的 Hook - useEffect,請務(wù)必深入理解這個函數(shù)。
useEffect 是處理副作用的,其執(zhí)行時機在 每次 Render 渲染完畢后,換句話說就是每次渲染都會執(zhí)行,只是實際在真實 DOM 操作完畢后。
我們可以利用這個特性,在每次渲染完畢后,將 count 此時最新的值賦給 currentCount.current,這樣就使 currentCount 的值自動同步了 count 的最新值。
為了確保大家準確理解 useEffect,筆者再啰嗦一下,將其執(zhí)行周期拆解到每次渲染中。假設(shè)你在三秒內(nèi)快速點擊了三次按鈕,那么你需要在大腦中模擬出下面這三次渲染都發(fā)生了什么:
第一次點擊,共渲染了 2 次,useEffect 生效在第 2 次渲染:
function Counter() { const [1, setCount] = useState(0); const currentCount = useRef(0); useEffect(() => { currentCount.current = 1; // 第二次渲染完畢后執(zhí)行一次 }); 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; // 第三次渲染完畢后執(zhí)行一次 }); 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; // 第四次渲染完畢后執(zhí)行一次 }); const log = () => { setCount(3 + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return ... }
注意對比與上面章節(jié)展開的 setTimeout 渲染時有什么不同。
要注意的是,useEffect 也隨著每次渲染而不同的,同一個組件不同渲染之間,useEffect 內(nèi)閉包環(huán)境完全獨立。對于本次的例子,useEffect 共執(zhí)行了 四次,經(jīng)歷了如下四次賦值最終變成 3:
currentCount.current = 0; // 第 1 次渲染 currentCount.current = 1; // 第 2 次渲染 currentCount.current = 2; // 第 3 次渲染 currentCount.current = 3; // 第 4 次渲染
請確保理解了這句話再繼續(xù)往下閱讀:
setTimeout 的例子,三次點擊觸發(fā)了四次渲染,但 setTimeout 分別生效在第 1、2、3 次渲染中,因此值是 0 1 2。
useEffect 的例子中,三次點擊也觸發(fā)了四次渲染,但 useEffect 分別生效在第 1、2、3、4 次渲染中,最終使 currentCount 的值變成 3。
用自定義 Hook 包裝 useRef是不是覺得每次都寫一堆 useEffect 同步數(shù)據(jù)到 useRef 很煩?是的,想要簡化,就需要引出一個新的概念:自定義 Hooks。
首先介紹一下,自定義 Hooks 允許創(chuàng)建自定義 Hook,只要函數(shù)名遵循以 use 開頭,且返回非 JSX 元素,就是 Hooks 啦!自定義 Hooks 內(nèi)還可以調(diào)用包括內(nèi)置 Hooks 在內(nèi)的所有自定義 Hooks。
也就是我們可以將 useEffect 寫到自定義 Hook 里:
function useCurrentValue(value) { const ref = useRef(0); useEffect(() => { ref.current = value; }, [value]); return ref; }
這里又引出一個新的概念,就是 useEffect 的第二個參數(shù),dependences。dependences 這個參數(shù)定義了 useEffect 的依賴,在新的渲染中,只要所有依賴項的引用都不發(fā)生變化,useEffect 就不會被執(zhí)行,且當依賴項為 [] 時,useEffect 僅在初始化執(zhí)行一次,后續(xù)的 Rerender 永遠也不會被執(zhí)行。
這個例子中,我們告訴 React:僅當 value 的值變化了,再將其最新值同步給 ref.current。
那么這個自定義 Hook 就可以在任何 Function Component 調(diào)用了:
function Counter() { const [count, setCount] = useState(0); const currentCount = useCurrentValue(count); const log = () => { setCount(count + 1); setTimeout(() => { console.log(currentCount.current); }, 3000); }; return (); }You clicked {count} times
封裝以后代碼清爽了很多,而且最重要的是將邏輯封裝起來,我們只要理解 useCurrentValue 這個 Hook 可以產(chǎn)生一個值,其最新值永遠與入?yún)⑼健?/p>
看到這里,也許有的小伙伴已經(jīng)按捺不住迸發(fā)的靈感了:將 useEffect 第二個參數(shù)設(shè)置為空數(shù)組,這個自定義 Hook 就代表了 didMount 生命周期!
是的,但筆者建議大家 不要再想生命周期的事情,這樣會阻礙你更好的理解 Function Component。因為下一個話題,就是要告訴你:永遠要對 useEffect 的依賴誠實,被依賴的參數(shù)一定要填上去,否則會產(chǎn)生非常難以察覺與修復的 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{count}
; }
這個例子將引發(fā)學習 Function Component 的第二個攔路虎,理解了它,才深入理解了 Function Component 的渲染原理。
首先介紹一下引入的新概念,useEffect 函數(shù)的返回值。它的返回值是一個函數(shù),這個函數(shù)在 useEffect 即將重新執(zhí)行時,會先執(zhí)行上一次 Rerender useEffect 第一個回調(diào)的返回函數(shù),再執(zhí)行下一次渲染的 useEffect 第一個回調(diào)。
以兩次連續(xù)渲染為例介紹,展開后的效果是這樣的:
第一次渲染:
function Counter() { useEffect(() => { // 第一次渲染完畢后執(zhí)行 // 最終執(zhí)行順序:1 return () => { // 由于沒有填寫依賴項,所以第二次渲染 useEffect 會再次執(zhí)行,在執(zhí)行前,第一次渲染中這個地方的回調(diào)函數(shù)會首先被調(diào)用 // 最終執(zhí)行順序:2 } }); return ... }
第二次渲染:
function Counter() { useEffect(() => { // 第二次渲染完畢后執(zhí)行 // 最終執(zhí)行順序:3 return () => { // 依此類推 } }); return ... }
然而本 Demo 將 useEffect 的第二個參數(shù)設(shè)置為了 [],那么其返回函數(shù)只會在這個組件被銷毀時執(zhí)行。
讀懂了前面的例子,應(yīng)該能想到,這個 Demo 希望利用 [] 依賴,將 useEffect 當作 didMount 使用,再結(jié)合 setInterval 每次時 count 自增,這樣期望將 count 的值每秒自增 1。
然而結(jié)果是:
1 1 1 ...
理解了 setTimeout 例子的讀者應(yīng)該可以自行推導出原因:setInterval 永遠在第一次 Render 的閉包中,count 的值永遠是 0,也就是等價于:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(0 + 1); }, 1000); return () => clearInterval(id); }, []); return{count}
; }
然而罪魁禍首就是 沒有對依賴誠實 導致的。例子中 useEffect 明明依賴了 count,依賴項卻非要寫 [],所以產(chǎn)生了很難理解的錯誤。
所以改正的辦法就是 對依賴誠實。
永遠對依賴項誠實一旦我們對依賴誠實了,就可以得到正確的效果:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, [count]); return{count}
; }
我們將 count 作為了 useEffect 的依賴項,就得到了正確的結(jié)果:
1 2 3 ...
既然漏寫依賴的風險這么大,自然也有保護措施,那就是 eslint-plugin-react-hooks 這個插件,會自動訂正你的代碼中的依賴,想不對依賴誠實都不行!
然而對這個例子而言,代碼依然存在 BUG:每次計數(shù)器都會重新實例化,如果換成其他費事操作,性能成本將不可接受。
如何不在每次渲染時重新實例化 setInterval?最簡單的辦法,就是利用 useState 的第二種賦值用法,不直接依賴 count,而是以函數(shù)回調(diào)方式進行賦值:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []); return{count}
; }
這這寫法真正做到了:
不依賴 count,所以對依賴誠實。
依賴項為 [],只有初始化會對 setInterval 進行實例化。
而之所以輸出還是正確的 1 2 3 ...,原因是 setCount 的回調(diào)函數(shù)中,c 值永遠指向最新的 count 值,因此沒有邏輯漏洞。
但是聰明的同學仔細一想,就會發(fā)現(xiàn)一個新問題:如果存在兩個以上變量需要使用時,這招就沒有用武之地了。
同時使用兩個以上變量時?如果同時需要對 count 與 step 兩個變量做累加,那 useEffect 的依賴必然要寫上一種某一個值,頻繁實例化的問題就又出現(xiàn)了:
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{count}
; }
這個例子中,由于 setCount 只能拿到最新的 count 值,而為了每次都拿到最新的 step 值,就必須將 step 申明到 useEffect 依賴中,導致 setInterval 被頻繁實例化。
這個問題自然也困擾了 React 團隊,所以他們拿出了一個新的 Hook 解決問題:useReducer。
什么是 useReducer先別聯(lián)想到 Redux。只考慮上面的場景,看看為什么 React 團隊要將 useReducer 列為內(nèi)置 Hooks 之一。
先介紹一下 useReducer 的用法:
const [state, dispatch] = useReducer(reducer, initialState);
useReducer 返回的結(jié)構(gòu)與 useState 很像,只是數(shù)組第二項是 dispatch,而接收的參數(shù)也有兩個,初始值放在第二位,第一位就是 reducer。
reducer 定義了如何對數(shù)據(jù)進行變換,比如一個簡單的 reducer 如下:
function reducer(state, action) { switch (action.type) { case "increment": return { ...state, count: state.count + 1 }; default: return state; } }
這樣就可以通過調(diào)用 dispatch({ type: "increment" }) 的方式實現(xiàn) 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{count}
; } function reducer(state, action) { switch (action.type) { case "tick": return { ...state, count: state.count + state.step }; } }
可以看到,我們通過 reducer 的 tick 類型完成了對 count 的累加,而在 useEffect 的函數(shù)中,竟然完全繞過了 count、step 這兩個變量。所以 useReducer 也被稱為解決此類問題的 “黑魔法”。
其實不管被怎么稱呼也好,其本質(zhì)是讓函數(shù)與數(shù)據(jù)解耦,函數(shù)只管發(fā)出指令,而不需要關(guān)心使用的數(shù)據(jù)被更新時,需要重新初始化自身。
仔細的讀者會發(fā)現(xiàn)這個例子還是有一個依賴的,那就是 dispatch,然而 dispatch 引用永遠也不會變,因此可以忽略它的影響。這也體現(xiàn)了無論如何都要對依賴保持誠實。
這也引發(fā)了另一個注意項:盡量將函數(shù)寫在 useEffect 內(nèi)部。
將函數(shù)寫在 useEffect 內(nèi)部為了避免遺漏依賴,必須將函數(shù)寫在 useEffect 內(nèi)部,這樣 eslint-plugin-react-hooks 才能通過靜態(tài)分析補齊依賴項:
function Counter() { const [count, setCount] = useState(0); useEffect(() => { function getFetchUrl() { return "https://v?query=" + count; } getFetchUrl(); }, [count]); return{count}
; }
getFetchUrl 這個函數(shù)依賴了 count,而如果將這個函數(shù)定義在 useEffect 外部,無論是機器還是人眼都難以看出 useEffect 的依賴項包含 count。
然而這就引發(fā)了一個新問題:將所有函數(shù)都寫在 useEffect 內(nèi)部豈不是非常難以維護?
如何將函數(shù)抽到 useEffect 外部?為了解決這個問題,我們要引入一個新的 Hook:useCallback,它就是解決將函數(shù)抽到 useEffect 外部的問題。
我們先看 useCallback 的用法:
function Counter() { const [count, setCount] = useState(0); const getFetchUrl = useCallback(() => { return "https://v?query=" + count; }, [count]); useEffect(() => { getFetchUrl(); }, [getFetchUrl]); return{count}
; }
可以看到,useCallback 也有第二個參數(shù) - 依賴項,我們將 getFetchUrl 函數(shù)的依賴項通過 useCallback 打包到新的 getFetchUrl 函數(shù)中,那么 useEffect 就只需要依賴 getFetchUrl 這個函數(shù),就實現(xiàn)了對 count 的間接依賴。
換句話說,我們利用了 useCallback 將 getFetchUrl 函數(shù)抽到了 useEffect 外部。
為什么 useCallback 比 componentDidUpdate 更好用回憶一下 Class Component 的模式,我們是如何在函數(shù)參數(shù)變化時進行重新取數(shù)的:
class Parent extends Component { state = { count: 0, step: 0 }; fetchData = () => { const url = "https://v?query=" + this.state.count + "&step=" + this.state.step; }; render() { return; } } 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() { // ... } }
上面的代碼經(jīng)常用 Class Component 的人應(yīng)該很熟悉,然而暴露的問題可不小。
我們需要理解 props.count props.step 被 props.fetchData 函數(shù)使用了,因此在 componentDidUpdate 時,判斷這兩個參數(shù)發(fā)生了變化就觸發(fā)重新取數(shù)。
然而問題是,這種理解成本是不是過高了?如果父級函數(shù) fetchData 不是我寫的,在不讀源碼的情況下,我怎么知道它依賴了 props.count 與 props.step 呢?更嚴重的是,如果某一天 fetchData 多依賴了 params 這個參數(shù),下游函數(shù)將需要全部在 componentDidUpdate 覆蓋到這個邏輯,否則 params 變化時將不會重新取數(shù)。可以想象,這種方式維護成本巨大,甚至可以說幾乎無法維護。
換成 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 () } function Child(props) { useEffect(() => { props.fetchData() }, [props.fetchData]) return ( // ... ) }
可以看出來,當 fetchData 的依賴變化后,按下保存鍵,eslint-plugin-react-hooks 會自動補上更新后的依賴,而下游的代碼不需要做任何改變,下游只需要關(guān)心依賴了 fetchData 這個函數(shù)即可,至于這個函數(shù)依賴了什么,已經(jīng)封裝在 useCallback 后打包透傳下來了。
不僅解決了維護性問題,而且對于 只要參數(shù)變化,就重新執(zhí)行某邏輯,是特別適合用 useEffect 做的,使用這種思維思考問題會讓你的代碼更 “智能”,而使用分裂的生命周期進行思考,會讓你的代碼四分五裂,而且容易漏掉各種時機。
useEffect 對業(yè)務(wù)的抽象非常方便,筆者舉幾個例子:
依賴項是查詢參數(shù),那么 useEffect 內(nèi)可以進行取數(shù)請求,那么只要查詢參數(shù)變化了,列表就會自動取數(shù)刷新。注意我們將取數(shù)時機從觸發(fā)端改成了接收端。
當列表更新后,重新注冊一遍拖拽響應(yīng)事件。也是同理,依賴參數(shù)是列表,只要列表變化,拖拽響應(yīng)就會重新初始化,這樣我們可以放心的修改列表,而不用擔心拖拽事件失效。
只要數(shù)據(jù)流某個數(shù)據(jù)變化,頁面標題就同步修改。同理,也不需要在每次數(shù)據(jù)變化時修改標題,而是通過 useEffect “監(jiān)聽” 數(shù)據(jù)的變化,這是一種 “控制反轉(zhuǎn)” 的思維。
說了這么多,其本質(zhì)還是利用了 useCallback 將函數(shù)獨立抽離到 useEffect 外部。
那么進一步思考,可以將函數(shù)抽離到整個組件的外部嗎?
這也是可以的,需要靈活運用自定義 Hooks 實現(xiàn)。
將函數(shù)抽到組件外部以上面的 fetchData 函數(shù)為例,如果要抽到整個組件的外部,就不是利用 useCallback 做到了,而是利用自定義 Hooks 來做:
function useFetch(count, step) { return useCallback(() => { const url = "https://v/search?query=" + count + "&step=" + step; }, [count, step]); }
可以看到,我們將 useCallback 打包搬到了自定義 Hook useFetch 中,那么函數(shù)中只需要一行代碼就能實現(xiàn)一樣的效果了:
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 (); }
隨著使用越來越方便,我們可以將精力放到性能上。觀察可以發(fā)現(xiàn),count 與 step 都會頻繁變化,每次變化就會導致 useFetch 中 useCallback 依賴的變化,進而導致重新生成函數(shù)。然而實際上這種函數(shù)是沒必要每次都重新生成的,反復生成函數(shù)會造成大量性能損耗。
換一個例子就可以看得更清楚:
function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const [other, setOther] = useState(0); const drag = useDraggable(count, step); // 封裝了拖拽函數(shù) }
假設(shè)我們使用 Sortablejs 對某個區(qū)域進行拖拽監(jiān)聽,這個函數(shù)每次都重復執(zhí)行的性能損耗非常大,然而這個函數(shù)內(nèi)部可能因為僅僅要上報一些日志,所以依賴了沒有實際被使用的 count step 變量:
function useDraggable(count, step) { return useCallback(() => { // 上報日志 report(count, step); // 對區(qū)域進行初始化,非常耗時 // ... 省略耗時代碼 }, [count, step]); }
這種情況,函數(shù)的依賴就特別不合理。雖然依賴變化應(yīng)該觸發(fā)函數(shù)重新執(zhí)行,但如果函數(shù)重新執(zhí)行的成本非常高,而依賴只是可有可無的點綴,得不償失。
利用 Ref 保證耗時函數(shù)依賴不變一種辦法是通過將依賴轉(zhuǎn)化為 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]); // 依賴不會變,卻能每次拿到最新的值 }
這種方式比較取巧,將需要更新的區(qū)域與耗時區(qū)域分離,再將需更新的內(nèi)容通過 Ref 提供給耗時的區(qū)域,實現(xiàn)性能優(yōu)化。
然而這樣做對函數(shù)的改動成本比較高,有一種更通用的做法解決此類問題。
通用的自定義 Hooks 解決函數(shù)重新實例化問題我們可以利用 useRef 創(chuàng)造一個自定義 Hook 代替 useCallback,使其依賴的值變化時,回調(diào)不會重新執(zhí)行,卻能拿到最新的值!
這個神奇的 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 回調(diào)函數(shù)變化時, ref.current 重新指向最新的 fn 這個邏輯中規(guī)中矩。重點是,當依賴 dependencies 變化時,也重新為 ref.current 賦值,此時 fn 內(nèi)部的 dependencies 值是最新的,而下一段代碼:
return useCallback(() => { const fn = ref.current; return fn(); }, [ref]);
又僅執(zhí)行一次(ref 引用不會改變),所以每次都可以返回 dependencies 是最新的 fn,并且 fn 還不會重新執(zhí)行。
假設(shè)我們對 useEventCallback 傳入的回調(diào)函數(shù)稱為 X,則這段代碼的含義,就是使每次渲染的閉包中,回調(diào)函數(shù) X 總是拿到的總是最新 Rerender 閉包中的那個,所以依賴的值永遠是最新的,而且函數(shù)不會重新初始化。
React 官方不推薦使用此范式,因此對于這種場景,利用 useReducer,將函數(shù)通過 dispatch 中調(diào)用。 還記得嗎?dispatch 是一種可以繞過依賴的黑魔法,我們在 “什么是 useReducer” 小節(jié)提到過。
隨著對 Function Component 的使用,你也漸漸關(guān)心到函數(shù)的性能了,這很棒。那么下一個重點自然是關(guān)注 Render 的性能。
用 memo 做 PureRender在 Fucntion Component 中,Class Component 的 PureComponent 等價的概念是 React.memo,我們介紹一下 memo 的用法:
const Child = memo((props) => { useEffect(() => { props.fetchData() }, [props.fetchData]) return ( // ... ) })
使用 memo 包裹的組件,會在自身重渲染時,對每一個 props 項進行淺對比,如果引用沒有變化,就不會觸發(fā)重渲染。所以 memo 是一種很棒的性能優(yōu)化工具。
下面就介紹一個看似比 memo 難用,但真正理解后會發(fā)現(xiàn),其實比 memo 更好用的渲染優(yōu)化函數(shù):useMemo。
用 useMemo 做局部 PureRender相比 React.memo 這個異類,React.useMemo 可是正經(jīng)的官方 Hook:
const Child = (props) => { useEffect(() => { props.fetchData() }, [props.fetchData]) return useMemo(() => ( // ... ), [props.fetchData]) }
可以看到,我們利用 useMemo 包裹渲染代碼,這樣即便函數(shù) Child 因為 props 的變化重新執(zhí)行了,只要渲染函數(shù)用到的 props.fetchData 沒有變,就不會重新渲染。
這里發(fā)現(xiàn)了 useMemo 的第一個好處:更細粒度的優(yōu)化渲染。
所謂更細粒度的優(yōu)化渲染,是指函數(shù) Child 整體可能用到了 A、B 兩個 props,而渲染僅用到了 B,那么使用 memo 方案時,A 的變化會導致重渲染,而使用 useMemo 的方案則不會。
而 useMemo 的好處還不止這些,這里先留下伏筆。我們先看一個新問題:當參數(shù)越來越多時,使用 props 將函數(shù)、值在組件間傳遞非常冗長:
function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const fetchData = useFetch(count, step); return; }
雖然 Child 可以通過 memo 或 useMemo 進行優(yōu)化,但當程序復雜時,可能存在多個函數(shù)在所有 Function Component 間共享的情況 ,此時就需要新 Hook: useContext 來拯救了。
使用 Context 做批量透傳在 Function Component 中,可以使用 React.createContext 創(chuàng)建一個 Context:
const Store = createContext(null);
其中 null 是初始值,一般置為 null 也沒關(guān)系。接下來還有兩步,分別是在根節(jié)點使用 Store.Provider 注入,與在子節(jié)點使用官方 Hook useContext 拿到注入的數(shù)據(jù):
在根節(jié)點使用 Store.Provider 注入:
function Parent() { const [count, setCount] = useState(0); const [step, setStep] = useState(0); const fetchData = useFetch(count, step); return (); }
在子節(jié)點使用 useContext 拿到注入的數(shù)據(jù)(也就是拿到 Store.Provider 的 value):
const Child = memo((props) => { const { setCount } = useContext(Store) function onClick() { setCount(count => count + 1) } return ( // ... ) })
這樣就不需要在每個函數(shù)間進行參數(shù)透傳了,公共函數(shù)可以都放在 Context 里。
但是當函數(shù)多了,Provider 的 value 會變得很臃腫,我們可以結(jié)合之前講到的 useReducer 解決這個問題。
使用 useReducer 為 Context 傳遞內(nèi)容瘦身使用 useReducer,所有回調(diào)函數(shù)都通過調(diào)用 dispatch 完成,那么 Context 只要傳遞 dispatch 一個函數(shù)就好了:
const Store = createContext(null); function Parent() { const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 }); return (); }
這下無論是根節(jié)點的 Provider,還是子元素調(diào)用都清爽很多:
const Child = useMemo((props) => { const dispatch = useContext(Store) function onClick() { dispatch({ type: "countInc" }) } return ( // ... ) })
你也許很快就想到,將 state 也通過 Provider 注入進去豈不更妙?是的,但此處請務(wù)必注意潛在性能問題。
將 state 也放到 Context 中稍稍改造下,將 state 也放到 Context 中,這下賦值與取值都非常方便了!
const Store = createContext(null); function Parent() { const [state, dispatch] = useReducer(reducer, { count: 0, step: 0 }); return (); }
對 Count Step 這兩個子元素而言,可需要謹慎一些,假如我們這么實現(xiàn)這兩個子元素:
const Count = memo(() => { const { state, dispatch } = useContext(Store); return ( ); }); const Step = memo(() => { const { state, dispatch } = useContext(Store); return ( ); });
其結(jié)果是:無論點擊 incCount 還是 incStep,都會同時觸發(fā)這兩個組件的 Rerender。
其問題在于:memo 只能擋在最外層的,而通過 useContext 的數(shù)據(jù)注入發(fā)生在函數(shù)內(nèi)部,會 繞過 memo。
當觸發(fā) dispatch 導致 state 變化時,所有使用了 state 的組件內(nèi)部都會強制重新刷新,此時想要對渲染次數(shù)做優(yōu)化,只有拿出 useMemo 了!
useMemo 配合 useContext使用 useContext 的組件,如果自身不使用 props,就可以完全使用 useMemo 代替 memo 做性能優(yōu)化:
const Count = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( ), [state.count, dispatch] ); }; const Step = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( ), [state.step, dispatch] ); };
對這個例子來說,點擊對應(yīng)的按鈕,只有使用到的組件才會重渲染,效果符合預期。 結(jié)合 eslint-plugin-react-hooks 插件使用,連 useMemo 的第二個參數(shù)依賴都是自動補全的。
讀到這里,不知道你是否聯(lián)想到了 Redux 的 Connect?
我們來對比一下 Connect 與 useMemo,會發(fā)現(xiàn)驚人的相似之處。
一個普通的 Redux 組件:
const mapStateToProps = state => (count: state.count); const mapDispatchToProps = dispatch => dispatch; @Connect(mapStateToProps, mapDispatchToProps) class Count extends React.PureComponent { render() { return ( ); } }
一個普通的 Function Component 組件:
const Count = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( ), [state.count, dispatch] ); };
這兩段代碼的效果完全一樣,F(xiàn)unction Component 除了更簡潔之外,還有一個更大的優(yōu)勢:全自動的依賴推導。
Hooks 誕生的一個原因,就是為了便于靜態(tài)分析依賴,簡化 Immutable 數(shù)據(jù)流的使用成本。
我們看 Connect 的場景:
由于不知道子組件使用了哪些數(shù)據(jù),因此需要在 mapStateToProps 提前寫好,而當需要使用數(shù)據(jù)流內(nèi)新變量時,組件里是無法訪問的,我們要回到 mapStateToProps 加上這個依賴,再回到組件中使用它。
而 useContext + useMemo 的場景:
由于注入的 state 是全量的,Render 函數(shù)中想用什么都可直接用,在按保存鍵時,eslint-plugin-react-hooks 會通過靜態(tài)分析,在 useMemo 第二個參數(shù)自動補上代碼里使用到的外部變量,比如 state.count、dispatch。
另外可以發(fā)現(xiàn),Context 很像 Redux,那么 Class Component 模式下的異步中間件實現(xiàn)的異步取數(shù)怎么利用 useReducer 做呢?答案是:做不到。
當然不是說 Function Component 無法實現(xiàn)異步取數(shù),而是用的工具錯了。
使用自定義 Hook 處理副作用比如上面拋出的異步取數(shù)場景,在 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 擁有完整生命周期,我們可以將取數(shù)過程封裝起來,只暴露狀態(tài) - 是否在加載中:isLoading 是否取數(shù)失敗:isError 數(shù)據(jù):data。
在組件中使用起來非常方便:
function App() { const { data, isLoading, isError } = useDataApi("https://v", { showLog: true }); }
如果這個值需要存儲到數(shù)據(jù)流,在所有組件之間共享,我們可以結(jié)合 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]); }
到此,F(xiàn)unction Component 的入門概念就講完了,最后附帶一個彩蛋:Function Component 的 DefaultProps 怎么處理?
Function Component 的 DefaultProps 怎么處理?這個問題看似簡單,實則不然。我們至少有兩種方式對 Function Component 的 DefaultProps 進行賦值,下面一一說明。
首先對于 Class Component,DefaultProps 基本上只有一種大家都認可的寫法:
class Button extends React.PureComponent { defaultProps = { type: "primary", onChange: () => {} }; }
然而在 Function Component 就五花八門了。
利用 ES6 特性在參數(shù)定義階段賦值function Button({ type = "primary", onChange = () => {} }) {}
這種方法看似很優(yōu)雅,其實有一個重大隱患:沒有命中的 props 在每次渲染引用都不同。
看這種場景:
const Child = memo(({ type = { a: 1 } }) => { useEffect(() => { console.log("type", type); }, [type]); returnChild; });
只要 type 的引用不變,useEffect 就不會頻繁的執(zhí)行。現(xiàn)在通過父元素刷新導致 Child 跟著刷新,我們發(fā)現(xiàn),每次渲染都會打印出日志,也就意味著每次渲染時,type 的引用是不同的。
有一種不太優(yōu)雅的方式可以解決:
const defaultType = { a: 1 }; const Child = ({ type = defaultType }) => { useEffect(() => { console.log("type", type); }, [type]); returnChild; };
此時不斷刷新父元素,只會打印出一次日志,因為 type 的引用是相同的。
我們使用 DefaultProps 的本意必然是希望默認值的引用相同, 如果不想多帶帶維護變量的引用,還可以借用 React 內(nèi)置的 defaultProps 方法解決。
利用 React 內(nèi)置方案React 內(nèi)置方案能較好的解決引用頻繁變動的問題:
const Child = ({ type }) => { useEffect(() => { console.log("type", type); }, [type]); returnChild; }; Child.defaultProps = { type: { a: 1 } };
上面的例子中,不斷刷新父元素,只會打印出一次日志。
因此建議對于 Function Component 的參數(shù)默認值,建議使用 React 內(nèi)置方案解決,因為純函數(shù)的方案不利于保持引用不變。
最后補充一個父組件 “坑” 子組件的經(jīng)典案例。
不要坑了子組件我們做一個點擊累加的按鈕作為父組件,那么父組件每次點擊后都會刷新:
function App() { const [count, forceUpdate] = useState(0); const schema = { b: 1 }; return (); } forceUpdate(count + 1)}>Count {count}
另外我們將 schema = { b: 1 } 傳遞給子組件,這個就是埋的一個大坑。
子組件的代碼如下:
const Child = memo(props => { useEffect(() => { console.log("schema", props.schema); }, [props.schema]); returnChild; });
只要父級 props.schema 變化就會打印日志。結(jié)果自然是,父組件每次刷新,子組件都會打印日志,也就是 子組件 [props.schema] 完全失效了,因為引用一直在變化。
其實 子組件關(guān)心的是值,而不是引用,所以一種解法是改寫子組件的依賴:
const Child = memo(props => { useEffect(() => { console.log("schema", props.schema); }, [JSON.stringify(props.schema)]); returnChild; });
這樣可以保證子組件只渲染一次。
可是真正罪魁禍首是父組件,我們需要利用 Ref 優(yōu)化一下父組件:
function App() { const [count, forceUpdate] = useState(0); const schema = useRef({ b: 1 }); return (); } forceUpdate(count + 1)}>Count {count}
這樣 schema 的引用能一直保持不變。如果你完整讀完了本文,應(yīng)該可以充分理解第一個例子的 schema 在每個渲染快照中都是一個新的引用,而 Ref 的例子中,schema 在每個渲染快照中都只有一個唯一的引用。
3. 總結(jié)所以使用 Function Component 你入門了嗎?
本次精讀留下的思考題是:Function Component 開發(fā)過程中還有哪些容易犯錯誤的細節(jié)?
討論地址是:精讀《Function Component 入門》 · Issue #157 · dt-fe/weekly
如果你想?yún)⑴c討論,請 點擊這里,每周都有新的主題,周末或周一發(fā)布。前端精讀 - 幫你篩選靠譜的內(nèi)容。
關(guān)注 前端精讀微信公眾號
special Sponsors
DevOps 全流程平臺
版權(quán)聲明:自由轉(zhuǎn)載-非商用-非衍生-保持署名(創(chuàng)意共享 3.0 許可證)
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/109865.html
摘要:拿到的都是而不是原始值,且這個值會動態(tài)變化。精讀對于的與,筆者做一些對比。因此采取了作為優(yōu)化方案只有當?shù)诙€依賴參數(shù)變化時才返回新引用。不需要使用等進行性能優(yōu)化,所有性能優(yōu)化都是自動的。前端精讀幫你篩選靠譜的內(nèi)容。 1. 引言 Vue 3.0 的發(fā)布引起了軒然大波,讓我們解讀下它的 function api RFC 詳細了解一下 Vue 團隊是怎么想的吧! 首先官方回答了幾個最受關(guān)注的...
摘要:未來可能成為官方之一。討論地址是精讀組件如果你想?yún)⑴c討論,請點擊這里,每周都有新的主題,周末或周一發(fā)布。前端精讀幫你篩選靠譜的內(nèi)容。 1. 引言 為什么要了解 Function 寫法的組件呢?因為它正在變得越來越重要。 那么 React 中 Function Component 與 Class Component 有何不同? how-are-function-components-di...
摘要:會自動觸發(fā)函數(shù)內(nèi)回調(diào)函數(shù)的執(zhí)行。因此利用并將依賴置為使代碼在所有渲染周期內(nèi),只在初始化執(zhí)行一次。同時代碼里還對等公共方法進行了包裝,讓這些回調(diào)函數(shù)中自帶效果。前端精讀幫你篩選靠譜的內(nèi)容。 1. 引言 react-easy-state 是個比較有趣的庫,利用 Proxy 創(chuàng)建了一個非常易用的全局數(shù)據(jù)流管理方式。 import React from react; import { stor...
摘要:精讀源碼一共行,我們分析一下其精妙的方式。更多討論討論地址是精讀新用法如果你想?yún)⑴c討論,請點擊這里,每周都有新的主題,周末或周一發(fā)布。前端精讀幫你篩選靠譜的內(nèi)容。 1 引言 很高興這一期的話題是由 epitath 的作者 grsabreu 提供的。 前端發(fā)展了 20 多年,隨著發(fā)展中國家越來越多的互聯(lián)網(wǎng)從業(yè)者涌入,現(xiàn)在前端知識玲瑯滿足,概念、庫也越來越多。雖然內(nèi)容越來越多,但作為個體的...
摘要:引言工具型文章要跳讀,而文學經(jīng)典就要反復研讀。原文非常長,所以概述是筆者精簡后的。這是理解以及的關(guān)鍵,后面還會詳細介紹。從幾個疑問開始假設(shè)讀者有比較豐富的前端開發(fā)經(jīng)驗,并且寫過一些。 1. 引言 工具型文章要跳讀,而文學經(jīng)典就要反復研讀。如果說 React 0.14 版本帶來的各種生命周期可以類比到工具型文章,那么 16.7 帶來的 Hooks 就要像文學經(jīng)典一樣反復研讀。 Hooks...
閱讀 3660·2021-09-27 14:02
閱讀 1790·2019-08-30 15:56
閱讀 1745·2019-08-29 18:44
閱讀 3279·2019-08-29 17:21
閱讀 486·2019-08-26 17:15
閱讀 1176·2019-08-26 13:57
閱讀 1241·2019-08-26 13:56
閱讀 2880·2019-08-26 11:30