我在大廠寫React學(xué)到了什么?性能優(yōu)化篇
前言
我工作中的技術(shù)棧主要是 React + TypeScript,這篇文章我想總結(jié)一下如何在項目中運(yùn)用 React 的一些技巧去進(jìn)行性能優(yōu)化,或者更好的代碼組織。
性能優(yōu)化的重要性不用多說,谷歌發(fā)布的很多調(diào)研精確的展示了性能對于網(wǎng)站留存率的影響,而代碼組織優(yōu)化則關(guān)系到后續(xù)的維護(hù)成本,以及你同事維護(hù)你代碼時候“口吐芬芳”的頻率??,本篇文章看完,你一定會有所收獲。
神奇的 children
我們有一個需求,需要通過 Provider 傳遞一些主題信息給子組件:
看這樣一段代碼:
- import React, { useContext, useState } from "react";
 - const ThemeContext = React.createContext();
 - export function ChildNonTheme() {
 - console.log("不關(guān)心皮膚的子組件渲染了");
 - return <div>我不關(guān)心皮膚,皮膚改變的時候別讓我重新渲染!</div>;
 - }
 - export function ChildWithTheme() {
 - const theme = useContext(ThemeContext);
 - return <div>我是有皮膚的哦~ {theme}</div>;
 - }
 - export default function App() {
 - const [theme, setTheme] = useState("light");
 - const onChangeTheme = () => setTheme(theme === "light" ? "dark" : "light");
 - return (
 - <ThemeContext.Provider value={theme}>
 - <button onClick={onChangeTheme}>改變皮膚</button>
 - <ChildWithTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - </ThemeContext.Provider>
 - );
 - }
 
這段代碼看起來沒啥問題,也很符合擼起袖子就干的直覺,但是卻會讓 ChildNonTheme 這個不關(guān)心皮膚的子組件,在皮膚狀態(tài)更改的時候也進(jìn)行無效的重新渲染。
這本質(zhì)上是由于 React 是自上而下遞歸更新,
來看下 createElement 的返回結(jié)構(gòu):
- const childNonThemeElement = {
 - type: 'ChildNonTheme',
 - props: {} // <- 這個引用更新了
 - }
 
正是由于這個新的 props 引用,導(dǎo)致 ChildNonTheme 這個組件也重新渲染了。
那么如何避免這個無效的重新渲染呢?關(guān)鍵詞是「巧妙利用 children」。
- import React, { useContext, useState } from "react";
 - const ThemeContext = React.createContext();
 - function ChildNonTheme() {
 - console.log("不關(guān)心皮膚的子組件渲染了");
 - return <div>我不關(guān)心皮膚,皮膚改變的時候別讓我重新渲染!</div>;
 - }
 - function ChildWithTheme() {
 - const theme = useContext(ThemeContext);
 - return <div>我是有皮膚的哦~ {theme}</div>;
 - }
 - function ThemeApp({ children }) {
 - const [theme, setTheme] = useState("light");
 - const onChangeTheme = () => setTheme(theme === "light" ? "dark" : "light");
 - return (
 - <ThemeContext.Provider value={theme}>
 - <button onClick={onChangeTheme}>改變皮膚</button>
 - {children}
 - </ThemeContext.Provider>
 - );
 - }
 - export default function App() {
 - return (
 - <ThemeApp>
 - <ChildWithTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - <ChildNonTheme />
 - </ThemeApp>
 - );
 - }
 
沒錯,唯一的區(qū)別就是我把控制狀態(tài)的組件和負(fù)責(zé)展示的子組件給抽離開了,通過 children 傳入后直接渲染,由于 children 從外部傳入的,也就是說 ThemeApp 這個組件內(nèi)部不會再有 React.createElement 這樣的代碼,那么在 setTheme 觸發(fā)重新渲染后,children 完全沒有改變,所以可以直接復(fù)用。
讓我們再看一下被 ThemeApp 包裹下的 
- // 完全復(fù)用,props 也不會改變。
 - const childNonThemeElement = {
 - type: ChildNonTheme,
 - props: {}
 - }
 
在改變皮膚之后,控制臺空空如也!優(yōu)化達(dá)成。
總結(jié)下來,就是要把渲染比較費時,但是不需要關(guān)心狀態(tài)的子組件提升到「有狀態(tài)組件」的外部,作為 children 或者props傳遞進(jìn)去直接使用,防止被帶著一起渲染。
神奇的 children - 在線調(diào)試地址
當(dāng)然,這個優(yōu)化也一樣可以用 React.memo 包裹子組件來做,不過相對的增加維護(hù)成本,根據(jù)場景權(quán)衡選擇吧。
Context 讀寫分離
想象一下,現(xiàn)在我們有一個全局日志記錄的需求,我們想通過 Provider 去做,很快代碼就寫好了:
- import React, { useContext, useState } from "react";
 - import "./styles.css";
 - const LogContext = React.createContext();
 - function LogProvider({ children }) {
 - const [logs, setLogs] = useState([]);
 - const addLog = (log) => setLogs((prevLogs) => [...prevLogs, log]);
 - return (
 - <LogContext.Provider value={{ logs, addLog }}>
 - {children}
 - </LogContext.Provider>
 - );
 - }
 - function Logger1() {
 - const { addLog } = useContext(LogContext);
 - console.log('Logger1 render')
 - return (
 - <>
 - <p>一個能發(fā)日志的組件1</p>
 - <button onClick={() => addLog("logger1")}>發(fā)日志</button>
 - </>
 - );
 - }
 - function Logger2() {
 - const { addLog } = useContext(LogContext);
 - console.log('Logger2 render')
 - return (
 - <>
 - <p>一個能發(fā)日志的組件2</p>
 - <button onClick={() => addLog("logger2")}>發(fā)日志</button>
 - </>
 - );
 - }
 - function LogsPanel() {
 - const { logs } = useContext(LogContext);
 - return logs.map((log, index) => <p key={index}>{log}</p>);
 - }
 - export default function App() {
 - return (
 - <LogProvider>
 - {/* 寫日志 */}
 - <Logger1 />
 - <Logger2 />
 - {/* 讀日志 */}
 - <LogsPanel />
 - </div>
 - </LogProvider>
 - );
 - }
 
我們已經(jīng)用上了上一章節(jié)的優(yōu)化小技巧,單獨的把 LogProvider 封裝起來,并且把子組件提升到外層傳入。
先思考一下最佳的情況,Logger 組件只負(fù)責(zé)發(fā)出日志,它是不關(guān)心logs的變化的,在任何組件調(diào)用 addLog 去寫入日志的時候,理想的情況下應(yīng)該只有 LogsPanel 這個組件發(fā)生重新渲染。
但是這樣的代碼寫法卻會導(dǎo)致每次任意一個組件寫入日志以后,所有的 Logger 和 LogsPanel 都發(fā)生重新渲染。
這肯定不是我們預(yù)期的,假設(shè)在現(xiàn)實場景的代碼中,能寫日志的組件可多著呢,每次一寫入就導(dǎo)致全局的組件都重新渲染?這當(dāng)然是不能接受的,發(fā)生這個問題的本質(zhì)原因官網(wǎng) Context 的部分已經(jīng)講得很清楚了:
當(dāng) LogProvider 中的 addLog 被子組件調(diào)用,導(dǎo)致 LogProvider重渲染之后,必然會導(dǎo)致傳遞給 Provider 的 value 發(fā)生改變,由于 value 包含了 logs 和 setLogs 屬性,所以兩者中任意一個發(fā)生變化,都會導(dǎo)致所有的訂閱了 LogProvider 的子組件重新渲染。
那么解決辦法是什么呢?其實就是讀寫分離,我們把 logs(讀)和 setLogs(寫)分別通過不同的 Provider 傳遞,這樣負(fù)責(zé)寫入的組件更改了 logs,其他的「寫組件」并不會重新渲染,只有真正關(guān)心 logs 的「讀組件」會重新渲染。
- function LogProvider({ children }) {
 - const [logs, setLogs] = useState([]);
 - const addLog = useCallback((log) => {
 - setLogs((prevLogs) => [...prevLogs, log]);
 - }, []);
 - return (
 - <LogDispatcherContext.Provider value={addLog}>
 - <LogStateContext.Provider value={logs}>
 - {children}
 - </LogStateContext.Provider>
 - </LogDispatcherContext.Provider>
 - );
 - }
 
我們剛剛也提到,需要保證 value 的引用不能發(fā)生變化,所以這里自然要用 useCallback 把 addLog 方法包裹起來,才能保證 LogProvider 重渲染的時候,傳遞給的LogDispatcherContext的value 不發(fā)生變化。
現(xiàn)在我從任意「寫組件」發(fā)送日志,都只會讓「讀組件」LogsPanel 渲染。
Context 讀寫分離 - 在線調(diào)試
Context 代碼組織
上面的案例中,我們在子組件中獲取全局狀態(tài),都是直接裸用 useContext:
- import React from 'react'
 - import { LogStateContext } from './context'
 - function App() {
 - const logs = React.useContext(LogStateContext)
 - }
 
但是是否有更好的代碼組織方法呢?比如這樣:
- import React from 'react'
 - import { useLogState } from './context'
 - function App() {
 - const logs = useLogState()
 - }
 - // context
 - import React from 'react'
 - const LogStateContext = React.createContext();
 - export function useLogState() {
 - return React.useContext(LogStateContext)
 - }
 
在加上點健壯性保證?
- import React from 'react'
 - const LogStateContext = React.createContext();
 - const LogDispatcherContext = React.createContext();
 - export function useLogState() {
 - const context = React.useContext(LogStateContext)
 - if (context === undefined) {
 - throw new Error('useLogState must be used within a LogStateProvider')
 - }
 - return context
 - }
 - export function useLogDispatcher() {
 - const context = React.useContext(LogDispatcherContext)
 - if (context === undefined) {
 - throw new Error('useLogDispatcher must be used within a LogDispatcherContext')
 - }
 - return context
 - }
 
如果有的組件同時需要讀寫日志,調(diào)用兩次很麻煩?
- export function useLogs() {
 - return [useLogState(), useLogDispatcher()]
 - }
 - export function App() {
 - const [logs, addLogs] = useLogs()
 - // ...
 - }
 
根據(jù)場景,靈活運(yùn)用這些技巧,讓你的代碼更加健壯優(yōu)雅~
組合 Providers
假設(shè)我們使用上面的辦法管理一些全局的小狀態(tài),Provider 變的越來越多了,有時候會遇到嵌套地獄的情況:
- const StateProviders = ({ children }) => (
 - <LogProvider>
 - <UserProvider>
 - <MenuProvider>
 - <AppProvider>
 - {children}
 - </AppProvider>
 - </MenuProvider>
 - </UserProvider>
 - </LogProvider>
 - )
 - function App() {
 - return (
 - <StateProviders>
 - <Main />
 - </StateProviders>
 - )
 - }
 
有沒有辦法解決呢?當(dāng)然有,我們參考 redux 中的 compose 方法,自己寫一個 composeProvider 方法: 代碼就可以簡化成這樣: 總結(jié) 本篇文章主要圍繞這 Context 這個 API,講了幾個性能優(yōu)化和代碼組織的優(yōu)化點,總結(jié)下來就是:
    
本文轉(zhuǎn)載自微信公眾號「 前端從進(jìn)階到入院」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系 前端從進(jìn)階到入院公眾號。





















 
 
 








 
 
 
 