我在大廠寫React學(xué)到了什么?性能優(yōu)化篇
前言
我工作中的技術(shù)棧主要是 React + TypeScript,這篇文章我想總結(jié)一下如何在項目中運用 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ō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)階到入院公眾號。