首先會以常規(guī)的模式來實現(xiàn)組件,然后再考慮性能的情況下重構每個步驟,并從每個步驟中提取一個通用規(guī)則,這些規(guī)則可以應用于大多數(shù)應用程序。然后比較最后的結果。
下面將編寫一個“國家設置”頁面,用戶可以從列表中選擇國家,查看該國家的信息,可以保存國家:
可以看到,左側有一個國家列表,帶有“已保存”和“已選擇”狀態(tài),當點擊列表中的選項時,右側會顯示該國家的詳細信息。當按下保存按鈕時,選定的國家會變成已保存,已保存的選項的背景會變成藍色。
1. 構建應用程序
首先,根據(jù)設計圖,我們要考慮應用的結構以及要實現(xiàn)哪些組件:
頁面組件:在其中處理提交邏輯和國家選擇邏輯;
國家列表組件:將呈現(xiàn)列表中的所有國家,并進行過濾和排序等操作;
國家選項組件:將所有國家呈現(xiàn)在國家列表組件中;
選定國家組件:將呈現(xiàn)選定國家的詳細信息,并具有保存按鈕。
當然,這不是實現(xiàn)這個頁面的唯一方式,實現(xiàn)方式僅供參考。下面就來看看如何實現(xiàn)這些組件。
2. 實現(xiàn)頁面組件
下面終于要開始寫代碼了,下面就從根開始,實現(xiàn)Page組件,步驟如下:
- 需要組件包含頁面標題、國家列表和選定國家組件;
- 將頁面參數(shù)中的國家列表數(shù)據(jù)傳遞給CountriesList組件,以便它可以呈現(xiàn)這些數(shù)據(jù);
- 頁面中應該有一個已選中國家的狀態(tài),它將從 CountriesList 件接收,并傳遞給 SelectedCountry 組件;
- 頁面中應該有一個已保存國家的狀態(tài),它將從 SelectedCountry 組件接收,并傳遞給 CountriesList 組件。
export const Page = ({ countries }: { countries: Country[] }) => { const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]); const [savedCountry, setSavedCountry] = useState<Country>(countries[0]); return ( <> <h1>Country settingsh1> <div css={contentCss}> <CountriesList countries={countries} onCountryChanged={(c) => setSelectedCountry(c)} savedCountry={savedCountry} /> <SelectedCountry country={selectedCountry} onCountrySaved={() => setSavedCountry(selectedCountry)} /> div> ); };
3. 重構頁面組件(考慮性能)
在 React 中,當組件的 state 或者 props 發(fā)生變化時,組件會重新渲染。在 Page 組件中,當 setSelectedCountry 或者 setSavedCountry 被調用時,組件就會重新渲染。當組件的國家列表數(shù)據(jù)(props)發(fā)生變化時,組件也會重新渲染。CountriesList 和 SelectedCountry 組件也是如此,當它們的 props 發(fā)生變化時,都會重新渲染。
我們知道,React 會對 props 進行嚴格相等的比較,并且內聯(lián)函數(shù)每次都會創(chuàng)建新值。這就導致了一個錯誤的的觀念:為了減少 CountriesList 和SelectedCountry 組件的重新渲染,需要通過在 useCallback 中包裝內聯(lián)函數(shù)來避免在每次渲染中重新創(chuàng)建內聯(lián)函數(shù)。
export const Page = ({ countries }: { countries: Country[] }) => { const onCountryChanged = useCallback((c) => setSelectedCountry(c), []); const onCountrySaved = useCallback(() => setSavedCountry(selectedCountry), []); return ( <> ... <CountriesList onCountryChanged={onCountryChange} /> <SelectedCountry onCountrySaved={onCountrySaved} /> ... ); };
而實際上,這樣并不會起作用。因為它沒有考慮到:如果父組件 Page 被重新渲染,子組件 CountriesList 也總是會重新渲染,即使它根本沒有任何 props。
可以這樣來簡化 Page 組件:
const CountriesList = () => { console.log("Re-render!!!!!"); return <div>countries list, always re-rendersdiv>; }; export const Page = ({ countries }: { countries: Country[] }) => { const [counter, setCounter] = useState<number>(1); return ( <> <h1>Country settingsh1> <button onClick={() => setCounter(counter + 1)}> Click here to re-render Countries list (open the console) {counter} button> <CountriesList /> ); };
當每次點擊按鈕時,即使沒有任何 props,都會看到 CountriesList 組件被重新渲染。由此,總結出第一條規(guī)則:「如果想把 props 中的內聯(lián)函數(shù)提取到 useCallback 中,以此來避免子組件的重新渲染,請不要這樣做,它不起作用?!?/p>
現(xiàn)在,有幾種方法可以處理上述情況,最簡單的一種就是使用 useMemo,它本質上就是緩存?zhèn)鬟f給它的函數(shù)的結果。并且僅在 useMemo 的依賴項發(fā)生變化時才會重新執(zhí)行。這就就將 CountriesList 組件使用 useMemo 包裹,只有當 useMemo 依賴項發(fā)生變化時,才會重新渲染 ComponentList 組件:
export const Page = ({ countries }: { countries: Country[] }) => { const [counter, setCounter] = useState<number>(1); const list = useMemo(() => { return <CountriesList />; }, []); return ( <> <h1>Country settingsh1> <button onClick={() => setCounter(counter + 1)}> Click here to re-render Countries list (open the console) {counter} button> {list} ); };
當然,在這個簡化的例子中是不行的,因為它沒有任何依賴項。那我們該如何簡化 Page 頁面呢?下面再來看一下它的結構:
export const Page = ({ countries }: { countries: Country[] }) => { const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]); const [savedCountry, setSavedCountry] = useState<Country>(countries[0]); return ( <> <h1>Country settingsh1> <div css={contentCss}> <CountriesList countries={countries} onCountryChanged={(c) => setSelectedCountry(c)} savedCountry={savedCountry} /> <SelectedCountry country={selectedCountry} onCountrySaved={() => setSavedCountry(selectedCountry)} /> div> ); };
可以看到:
- 在 CountriesList 組件中不會使到 selectedCountry 狀態(tài);
- 在 SelectedCountry 組件中不會使用到 savedCountry 狀態(tài);
這意味著當 selectedCountry 狀態(tài)發(fā)生變化時,CountriesList 組件不需要重新渲染。savedCountry 狀態(tài)發(fā)生變化時,SelectedCountry 組件也不需要重新渲染??梢允褂?useMemo 來包裹它們,以防止不必要的重新渲染:
export const Page = ({ countries }: { countries: Country[] }) => { const [selectedCountry, setSelectedCountry] = useState<Country>(countries[0]); const [savedCountry, setSavedCountry] = useState<Country>(countries[0]); const list = useMemo(() => { return ( <CountriesList countries={countries} onCountryChanged={(c) => setSelectedCountry(c)} savedCountry={savedCountry} /> ); }, [savedCountry, countries]); const selected = useMemo(() => { return ( <SelectedCountry country={selectedCountry} onCountrySaved={() => setSavedCountry(selectedCountry)} /> ); }, [selectedCountry]); return ( <> <h1>Country settingsh1> <div css={contentCss}> {list} {selected} div> ); };
由此總結出第二條規(guī)則:「如果組件需要管理狀態(tài),就找出渲染樹中不依賴于已更改狀態(tài)的部分,并將其使用 useMemo 包裹,以減少其不必要的重新渲染?!?/p>
4. 實現(xiàn)國家列表組件
Page 頁面已經(jīng)完美實現(xiàn)了,是時候編寫它的子組件了。首先來實現(xiàn)比較復雜的 CountriesList 組件。這個組件一個接收國家列表數(shù)據(jù),當在列表中選中一個國家時,會觸發(fā) onCountryChanged 回調,并應以不同的顏色突出顯示保存的國家:
type CountriesListProps = { countries: Country[]; onCountryChanged: (country: Country) => void; savedCountry: Country; }; export const CountriesList = ({ countries, onCountryChanged, savedCountry }: CountriesListProps) => { const Item = ({ country }: { country: Country }) => { // 根據(jù)國家選項是否已選中來切換不同的className const className = savedCountry.id === country.id ? "country-item saved" : "country-item"; const onItemClick = () => onCountryChanged(country); return ( <button className={className} onClick={onItemClick}> <img src={country.flagUrl} /> <span>{country.name}span> button> ); }; return ( <div> {countries.map((country) => ( <Item country={country} key={country.id} /> ))} div> ); };
這里只做了兩件事:
根據(jù)接收到的 props 來生成 Item 組件,它依賴于onCountryChanged和savedCountry;
遍歷 props 中的國家數(shù)組,來渲染國家列表。
5. 重構國家列表組件(考慮性能)
如果在一個組件渲染期間創(chuàng)建了另一個組件(如上面的 Item 組件),會發(fā)生什么呢?從 React 的角度來看,Item 只是一個函數(shù),每次渲染都會返回一個新的結果。它將刪除以前生成的組件,包括其DOM樹,將其從頁面中刪除,并生成和裝載一個全新的組件。每次父組件重新渲染時,都會使用一個全新的DOM樹。
如果簡化國家示例來展示這個過程,將是這樣的:
const CountriesList = ({ countries }: { countries: Country[] }) => { const Item = ({ country }: { country: Country }) => { useEffect(() => { console.log("Mounted!"); }, []); console.log("Render"); return <div>{country.name}div>; }; return ( <> {countries.map((country) => ( <Item country={country} /> ))} ); };
從性能角度來看,與完全重新創(chuàng)建組件相比,正常的重新渲染的性能會好很多。在正常情況下,帶有空依賴項數(shù)組的 useEffect 只會在組件完成裝載和第一次渲染后觸發(fā)一次。之后,React 中的輕量級重新渲染過程就開始了,組件不是從頭開始創(chuàng)建的,而是只在需要時更新。這里假如有100個國家,當點擊按鈕時,就會輸出100次 Render 和100次 Mounted,Item 組件會重新裝載和渲染100次。
解決這個問題最直接的辦法就是將 Item 組件移到渲染函數(shù)外:
const Item = ({ country }: { country: Country }) => { useEffect(() => { console.log("Mounted!"); }, []); console.log("Render"); return <div>{country.name}div>; }; const CountriesList = ({ countries }: { countries: Country[] }) => { return ( <> {countries.map((country) => ( <Item country={country} /> ))} ); };
這樣在點擊按鈕時,Item組件就會重現(xiàn)渲染100次,只輸出100次 Render,而不會重新裝載組件。保持了不同組件之間的邊界,并使代碼更簡潔。下面就來看看國家列表組件在修改前后的變化。
修改前:
export const CountriesList = ({ countries, onCountryChanged, savedCountry }: CountriesListProps) => { const Item = ({ country }: { country: Country }) => { // ... }; return ( <div> {countries.map((country) => ( <Item country={country} key={country.id} /> ))} div> ); };
修改后:
type ItemProps = { country: Country; savedCountry: Country; onItemClick: () => void; }; const Item = ({ country, savedCountry, onItemClick }: ItemProps) => { // ... }; export const CountriesList = ({ countries, onCountryChanged, savedCountry }: CountriesListProps) => { return ( <div> {countries.map((country) => ( <Item country={country} key={country.id} savedCountry={savedCountry} onItemClick={() => onCountryChanged(country)} /> ))} div> ); };
現(xiàn)在,每次父組件重新渲染時不會再重新裝載 Item 組件,由此可以總結出第三條規(guī)則:「不要在一個組件的渲染內創(chuàng)建新的組件?!?/p>
6. 實現(xiàn)選定國家組件
這個組件比較簡單,就是接收一個屬性和一個回調函數(shù),并呈現(xiàn)國家信息:
const SelectedCountry = ({ country, onSaveCountry }: { country: Country; onSaveCountry: () => void }) => { return ( <> <ul> <li>Country: {country.name}li> ... // 要渲染的國家信息 ul> <button onClick={onSaveCountry} type="button">Savebutton> ); };
7. 實現(xiàn)頁面主題
最后來實現(xiàn)頁面的黑暗模式和明亮模式的切換??紤]到當前主題會在很多組件中使用,如果通過 props 傳遞就會非常麻煩。因此 Context 是比較合適的解決方案。
首先,創(chuàng)建主題 context:
type Mode = 'light' | 'dark'; type Theme = { mode: Mode }; const ThemeContext = React.createContext<Theme>({ mode: 'light' }); const useTheme = () => { return useContext(ThemeContext); };
添加 context provider ,以及切換主題的按鈕:
為國家選項根據(jù)主題上色:
const Item = ({ country }: { country: Country }) => { const { mode } = useTheme(); const className = `country-item ${mode === "dark" ? "dark" : ""}`; // ... }
這是一種實現(xiàn)頁面主題的最常見的方式。
8. 重構主題(考慮性能)
在發(fā)現(xiàn)上面組件的問題之前,先來看看導致組件重新渲染的另一個原因:如果一個組件使用 context,當 provider 提供的值發(fā)生變化時,該組件就會重新渲染。
再來看看簡化的例子,這里記錄了渲染結果以避免重新渲染:
const Item = ({ country }: { country: Country }) => { console.log("render"); return <div>{country.name}div>; }; const CountriesList = ({ countries }: { countries: Country[] }) => { return ( <> {countries.map((country) => ( <Item country={country} /> ))} ); }; export const Page = ({ countries }: { countries: Country[] }) => { const [counter, setCounter] = useState<number>(1); const list = useMemo(() => <CountriesList countries={countries} />, [ countries ]); return ( <> <h1>Country settingsh1> <button onClick={() => setCounter(counter + 1)}> Click here to re-render Countries list (open the console) {counter} button> {list} ); };
每次點擊按鈕時,頁面狀態(tài)發(fā)生變化,頁面組件會重新渲染。但 CountriesList 組件使用useMemo緩存了,會獨立于該狀態(tài),因此不會重新渲染,因此 Item 組件也不會重新渲染。
如果現(xiàn)在添加Theme context,Provider 在 Page 組件中:
export const Page = ({ countries }: { countries: Country[] }) => { // ... const list = useMemo(() => <CountriesList countries={countries} />, [ countries ]); return ( <ThemeContext.Provider value={{ mode }}> // ... ThemeContext.Provider> ); };
context 在 Item 組件中:
const Item = ({ country }: { country: Country }) => { const theme = useTheme(); console.log("render"); return <div>{country.name}div>; };
如果它們只是普通的組件和Hook,那什么都不會發(fā)生—— Item 不是 Page 組件的子級,CountriesList 組件因為被緩存而不會重新渲染,所以 Item 組件也不會。但是,本例是提供者-使用者的模式,因此每次提供者提供的值發(fā)生變化時,所有使用者都將重新渲染。由于一直在向該值傳遞新對象,因此每個計數(shù)器上都會重新呈現(xiàn)不必要的項。Context 基本繞過了useMemo,使它毫無用處。
解決方法就是確保 provider 中的值不會發(fā)生超出需要的變化。這里只需要把它記下來:
export const Page = ({ countries }: { countries: Country[] }) => { // ... const theme = useMemo(() => ({ mode }), [mode]); return ( <ThemeContext.Provider value={theme}> // ... ThemeContext.Provider> ); };
現(xiàn)在計數(shù)器就不會再導致所有 Items 重新渲染。下面就將這個解決方案應用于主題組件,以防止不必要的重新渲染:
export const Page = ({ countries }: { countries: Country[] }) => { // ... const [mode, setMode] = useState<Mode>("light"); const theme = useMemo(() => ({ mode }), [mode]); return ( <ThemeContext.Provider value={theme}> <button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>Toggle themebutton> // ... ThemeContext.Provider> ) }
根據(jù)這個結果就可以得出第四條規(guī)則:「在使用 context 時,如果 value 屬性不是數(shù)字、字符串或布爾值,請使用 useMemo 來緩存它?!?/p>
9. 總結
導致 React 組件重新渲染的時機主要有以下三種:
- 當state或props發(fā)生變化時;
- 當父組件重現(xiàn)渲染時;
- 當組件使用 context,并且 provider 的值發(fā)生變化時。
避免不必要的重新渲染的規(guī)則如下:
- 如果想把 props 中的內聯(lián)函數(shù)提取到 useCallback 中,以此來避免子組件的重新渲染,不要這樣做,它不起作用。
- 如果組件需要管理狀態(tài),就找出渲染樹中不依賴于已更改狀態(tài)的部分,并將其使用 useMemo 包裹,以減少其不必要的重新渲染。
- 不要在一個組件的渲染內創(chuàng)建新的組件;
- 在使用 context 時,如果value屬性不是數(shù)字、字符串或布爾值,請使用useMemo來緩存它。
這些規(guī)則將有助于從開始就編寫高性能的 React 應用程序。
作者:NADIA MAKAREVICH
譯者:CUGGZ
原文:https://www.developerway.com/posts/how-to-write-performant-react-code