React性能优化手册
此文章属于笔记总结。对应的课程地址:https://www.pluralsight.com/courses/react-performance-playbook
相关网站:https://web.developers.google.cn/?hl=zh-cn
性能审查
检测指标
-
Core Web vitals:衡量网站是否运行正常的基本指标
- LCP, Largest Contentful Paint 最大内容绘制:衡量页面的主要内容加载速度
- CLS, Cumulative Layout Shift 累积布局偏移:衡量页面的布局稳定性
- INP, Input Latency 输入延迟:衡量用户输入的响应速度
-
- TTFB: Time to First Byte 首字节时间
- FCP: First Contentful Paint 首次内容绘制
- TBT: Total Blocking Time 总阻塞时间(CLIENT SIDE)
检测工具
-
lighthouse
-
火焰图:在谷歌上安装扩展插件
react dev tools
。可以查看某个组件使用了多少时间渲染自己 -
控制台-性能:找到发生这些问题的代码所在
-
控制台-内存
代码优化
React 通过 diff 算出虚拟 DOM 的差异部分,来断定是否进行渲染:
判定参考
当要使用某种方式编写代码时,可以使用下面的表格来判定这种写法的好坏。上面的是提高性能,下面的是站在开发者角度提升开发体验:
减少重新渲染
避免操作原生 DOM 布局
// Bad Practice
const element = document.getElementById('root');
element.style.height = '100px';
element.style.width = '100px';
尽量使用 React API 来操作, React 通过虚拟 DOM 实现了优化,比如:ref
、useLayoutEffect
、startTransition
Props-使用扩展运算符简化(x)
这种写法容易隐藏掉变化,或传递不需要的属性
// Bad Practice
<MyComponent {...result} />
尽量用以下写法,传递必要的属性
<MyComponent id={result.id} title={result.title} />
Props-通过中间组件传递属性(x)
不要将属性传递给中间组件让它作为中转站传给下级。这样,即便中间组件没有任何变化,也会被重新渲染
// Bad Practice
function ConsentBannerContainer({
hasAcceptedCookiePolicy,
hasAcceptedPrivacyPolicy,
}) {
/* expensive logic on ervery update */
return (
<div>
<Banner show={!hasAcceptedCookiePolicy} id="cookie" />
<Banner show={!hasAcceptedPrivacyPolicy} id="privacy" />
</div>
);
}
可以将状态提升到上下文,或者使用状态库来避免这个问题:
function ConsentBannerContainer() {
/* expensive logic on ervery update */
return (
<div>
<CookieBanner />
<PrivacyBanner />
</div>
);
}
function CookieBanner() {
const consent = useUserConsent();
if (consent.hasAcceptedCookiePolicy) {
return null;
}
return <Banner id="cookie" />;
}
不同更新频率的上下文(?)
不要将不同更新频率的数据放在同一个上下文中。下面的例子中,滚动属性的更新频率是非常高的(只要滚动窗口就会更新),一旦它更新,逐渐也会跟着重新渲染:
// Bad Practice
function UsernameLabel() {
const { userInfo, scrollPos } = useUserContext();
return <span>{userInfo.username}</span>;
}
应该单独创建一个上下文来存储滚动属性
Props-传递内联对象(x)
// Bad Practice
<SearchResults
results={results}
style={{ width: '100%' }}
promotedResults={[0, 3, 4]}
/>
react 采用的是浅比较。如果传入新对象,由于引用变了,会导致浅比较不等,进而让子组件重新渲染。最好传递字符串、布尔值等基本类型:
// 可以把判断逻辑移到组件内部
<SearchResults
results={results}
width='100%'
showPromotedResults
/>
// 如果width属性是用于支持响应式的:
<SearchResults
results={results}
responsive
showPromotedResults
/>
Props-传递复杂状态(?)
// Bad Practice
function SearchPage({ selectedProducts }) {
const handleClear = useCallback(() => {
selectedProducts.length = 0;
}, [selectedProducts]);
return <SearchResults selected={selectedProducts} />;
}
上面的写法不会修改引用地址,导致浅比较相等,导致子组件不会进行更新
// Good
function SearchPage(props) {
const [selectedProducts, setSelectedProducts] = useState(
props.selectedProducts
);
const handleClear = useCallback(() => {
setSelectedProducts([]);
}, [selectedProducts]);
return <SearchResults selected={selectedProducts} />;
}
缓存
注意,缓存会占用更多的内存去存储旧数据。如果想使用缓存,应该遵循以下规则:
- 缓存函数应该是无状态的(没有副作用的)
- 缓存函数在输入相同参数时,应该有相同输出
- 缓存函数不应该总是返回“cache missing”,应该提高缓存的命中率,低效的缓存就不需要了
-
useCallback:缓存稳定的函数。
给属性传递函数时,下面的写法在每次渲染都会创建一个新的函数(新的引用)传递给子组件,导致浅比较不等,会给子组件造成不必要的渲染<SearchResults onClick={() => { /***/ }} />
const handleOnClick = () => { /***/ }; <SearchResults onClick={handleOnClick} />;
可以使用
useCallback
将函数缓存起来:const handleOnClick = useCallback(()=>{/***/}, []) <SearchResults onClick={handleOnClick}/>
-
useMemo: 避免频繁的高成本计算,创建稳定的值传递给子组件或依赖数组
function DigitalProduct({ product }) { const digitalProduct = useMemo(() => { return { id: product.id, name: product.titles[0].content, in_stock: product.fulfillment.channel['online'].stock_count > 0, }; }, [product]); }
-
React.memo:控制组件的更新与否。
它接收一个组件与比较函数,当比较函数用于比较新旧 props,当它返回 false 时,表示这个组件需要重新渲染。它的默认比较函数如下:function shallowEqual(objA, objB) { if (is(objA, objB)) { return true; } if ( typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null ) { return false; } const keysA = Object.keys(objA); const keysB = Object.keys(objB); if (keysA.length !== keysB.length) { return false; } for (let i = 0; i < keysA.length; i++) { const currentKey = keysA[i]; if ( !hasOwnProperty.call(objB, currentKey) || !is(objA[currentKey], objB[currentKey]) ) { return false; } } return true; }
const MyComponent = React.memo(({ list }) => { return list.map((item) => <div key={item.key}>{item.name}</div>); },()=>/** 可自定义比较函数 */);
重构组件
组件槽 Slots
先介绍 React 的两种组件层次结构:父层次结构、所有者层次结构
所有者决定其孩子是否被重新渲染,而不是父。所有者 prop 的更新很容易造成其子项的不必要的渲染。使用Slots
能有效地解决这个问题,下面是一个例子:
-
单个插槽:默认通过 children 传递
const Header = ({ children }) => { return <header>{children}</header>; }; // 通过这种方式,children的所有者变成了Header组件的父
-
多个插槽:自定义属性
const Header = ({ slotMenu, slotSearch }) => { return ( <header> <nav>{slotMenu}</nav> <section>{slotSearch}</section> </header> ); };
<Header slotMenu={<MainMenu />} slotSearch={<SearchBar showSuggestions={false} />} />
注意,这里传递的属性是 JSX 对象,那么每次 App 重新渲染时,引用都会变化,仍然会导致重新渲染。这时就能使用
useMemo
进行缓存了:const [showSuggestions, setShowSuggestions] = useState(false); const slotMenu = useMemo(() => <MainMenu />, []); const slotSearch = useMemo( () => <SearchBar showSuggestions={showSuggestions} />, [showSuggestions] );
样式处理方式
-
内联样式:因为内联样式是一个新对象,会导致重新渲染,尽量不用,除非是一些简单的。
-
外部引入 css 样式表:必须遵循规则,灵活性低
-
Css Module:避免全局污染。将 css 加载为 js 模块,构建工具会将这些唯一的类名返回给 JavaScript 代码,替换类名。它在构建时注入,所以不会导致重新渲染
/* styles.module.css */ .container { display: flex; justify-content: center; align-items: center; width: 100%; height: 100px; background-color: #f0f0f0; }
// MyComponent.js import styles from './styles.module.css'; const MyComponent = () => { return <div className={styles.container}> </div>; }; export default MyComponent;
-
Css in js:(styled-components/styled-jsx)把样式转为组件的形式来包裹组件。很明显它依赖 js,会导致重新渲染,增加内存使用
-
原子 css:(tailwind.css)需要学习成本,这是目前性能优化最好的方式
-
css 框架:(mui)灵活、可复用。但体积大,运行时处理,需要学习成本
自定义钩子与共享状态
-
自定义钩子:灵活性和复用性高,但在重新渲染,减少 diff 计算、内存使用上面没有优势。提高更多的是开发者体验而非性能
-
工厂钩子:封装相同的行为、状态和逻辑
const createSimpleStateHook = (initialValue) => { return function useSimpleState(initialValue) { const [value, setValue] = useState(initialValue); return [value, setValue]; }; };
const useStringState = createSimpleStateHook(''); const useNumberState = createSimpleStateHook(0); const StringComponent = () => { const [text, setText] = useStringState(''); return <input value={text} onChange={(e) => setText(e.target.value)} />; }; const NumberComponent = () => { const [number, setNumber] = useNumberState(0); return ( <input value={number} onChange={(e) => setNumber(e.target.value)} /> ); };
-
共享状态钩子:
通常会使用上下文,但是 React 的上下文更新机制是基于订阅的,一旦上下文提供者(Provider)中的值发生变化,React 会通知所有订阅了该上下文的组件进行更新,因此会导致不必要的渲染。可以把共享状态存储到闭包内的一个变量中。它使用本地状态和闭包来管理全局状态的变更(zustand 库就是使用了这种模式)
const createShareStateHook = (initialValue) => { let sharedState = initialValue; const listeners = new Set(); const useSharedState = () => { // 全局 sharedState 的一个副本 const [, setState] = useState(sharedState); useEffect(() => { // 每个组件都会在其本地状态更新时重新渲染,而不在全局状态更新时 const listener = () => setState(sharedState); listeners.add(listener); return () => { listeners.delete(listener); }; }, []); const setSharedState = (newState) => { sharedState = newState; listeners.forEach((listener) => listener()); }; return [sharedState, setSharedState]; }; return useSharedState; };
const useSharedCounter = createShareStateHook(0); export default function Counter() { return ( <> <CounterDisplay /> <CounterButton /> </> ); } function CounterDisplay() { const [counter] = useSharedCounter(); return <div>{counter}</div>; } function CounterButton() { const [count, setCount] = useSharedCounter(); return <button onClick={() => setCounter(counter + 1)}>+</button>; }
状态管理库
它可以简化复杂的 UI,通过订阅的方式来减少渲染次数:
- Flux:Redux Toolkit
- Specialized:Formik,AgGrid
- Observable:TanStack Query,Mobx
- Flux "lite":zustand
- State Machine:Xstate
- URL: React Router
-
State Slicing 模式
-
State Selectors 模式(以 zustand 为例)
可以通过 selector 只订阅共享状态的某个属性
const useCountStore = (create < State) & (Actions > ((set) => ({ count: 0, updateCount: (countCallback) => set((state) => ({ count: countCallback(state.count) })), }))); const Component = () => { const count = useCountStore((state) => state.count); // ... };
-
immer:处理复杂对象,允许直接更改复杂对象的值。(以 zustand 为例)
下面的写法会出问题,因为 zustand 不会理解这样的数据的变化,它不具备通知能力,会导致拿到的是旧数据。
可以用到 immer 来修复这个问题:
useEffect 副作用
// 1. 如果ID反复更新,它会不断发送请求
// 2. 如果请求出错了,它会反复渲染
const UserProfile = ({ userId }) => {
const [userData, setUserData] = useState(null);
const [error, setError] = useState(null);
const [retries, setRetries] = useState(0);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`/api/user/${userId}`);
const data = await response.json();
if (data.error) {
setError(data.error);
userData(null);
} else {
setUserData(data);
}
};
fetchData();
}, [userId]);
useEffect(() => {
/* retry logic imitted for brevity */
}, [error, retries]);
return <div>{userData && <div>{userData.name}</div>}</div>;
};
TanStack:可以缓存数据,共享状态,组织异步代码的钩子
const UserProfile = ({ userId }) => {
const { data: name, error } = useQuery({
queryKey: ['user', userId],
queryFn: getUserById,
retry: 3,
select: (x) => x.name,
});
return <div>{name && <div>{name}</div>}</div>;
};
隔离 render 与更新
当需要内联渲染(在 jsx 中使用表达式)时,如果使用钩子的写法,数据的更新将会导致整个组件重新渲染。而这种写法不会:
const DataFetcher = ({ url, render }) => {
const [data, setData] = useState(null);
useEffect(() => {
// fetch -> setData
}, [url]);
return render(data);
};
const UserProfile = () => {
return (
<DataFetcher
url=".."
render={(data) =>
data ? <div>{data.uersname1}</div> : <div>Loading</div>
}
/>
);
};
下面是 React Final Form 的例子:黑盒组件,表单项的更新不会导致其他表单项的重新渲染
可以使用钩子的写法:
但这里还有优化的空间,比如当subscription
变化,或者执行提交表单的操作,整个Form
还是会重新渲染的,它的孩子Field
也会跟着重新渲染。这时,可以给 Field 组件加上React.memo
进行缓存
非受控组件与受控组件
-
非受控组件:当 UI 的状态不需要被管理和实时检测时,使用
<form id="checkout"> <label for="name">Name:</label> <input name="name" type="text" /> </form> <script type="text/javascript"> var { name } = document.forms.checkout.elements; console.log(name); </script>
const RichTextEditor = ({ html, onEdit }) => { const editRef = useRef(null); useEffect(() => { if (editRef.current) { editRef.current.innerHTML = html; } }, [html]); const handleOnChange = useCallback( (e) => { const currentHtml = e.target.innerHTML; onEdit(currentHtml); }, [onEdit] ); return ( <div ref={editRef} contentEditable={true} onChange={handleOnChange} /> ); };
这种写法容易受到 xss 攻击,因为无法预测用户会输入什么
-
受控组件:当 UI 的状态需要被管理和实时检测时,使用。但是当状态非常复杂,很多的
useState
会变得不好处理,这时可以使用useReducer
React suspense
延迟渲染
用户输入优先
-
useDeferredValue
:不希望立刻更新,而是等待当前的渲染周期结束后(input 完成后)再更新。 -
useTransition()
:如果没有值可以监听,想手动标记为非紧急进行延迟如果不在函数或钩子中使用:
应用交付
优化方案
判定参考
减少初始加载时间
- 压缩图片及资源:使用
next/image
,CDN 等 - 懒加载:使用
React.lazy
、next/dynamic
- 监听滚动来加载:intersectionObserver
压缩 react 工具链
- 服务端框架:nextjs,remix
- 基于 ESM 的开发服务器:Vite、snowpack
通过 BrowserList 优化转义
BrowserList 是一个数据库,映射了浏览器的所有功能
polyfills,core-js
检查未使用/不可访问的代码
如果代码未使用,但在未来会使用,可以使用 code spliting,延迟加载
Bundles管理
包含自己的代码和 vendor。而 vendor 通常不会被修改,不可能让用户每一次都请求这些,需要注意 HTTP 缓存。
分析工具
- 检查项目:https://esbuild.github.io/analyze/
- 检查具体包(评估是否要安装这个包):https://bundlephobia.com/
通过分析结果,找到:
- 不希望出现的第三方库
- 使 bundle 变臃肿的依赖包
- 自己不想被使用的代码
动态引入
动态引入可以实现代码分割:
import moment from 'moment';
//1
import('moment/locake/ja').then(() => {
moment.locale('ja');
});
//2
await import('moment/locake/ja');
moment.locale('ja');
import moment from 'moment';
//1
import('moment/locake/ja').then((mod) => {
const { default: ja } = mod;
});
//2
const { default: ja } = await import('moment/locake/ja');
React.lazy 懒加载
动态导入组件, 也可以实现代码分割
import { lazy } from 'react';
const Demo = lazy(() => import('./Demo'));
其他
- next/dynamic (课程 Server Component Fundamentail in React)
- 配置
预加载与优先级
<link ref="prefetch" href="/about" />
<!-- 请预加载它,标记为低优先级 -->
<link ref="preload" href="product-image.jpg" />
<!-- 请预加载它,标记为高优先级 -->
<!-- DNS预加载 -->
<link ref="preconnect" href="http:.." />
<!-- 提示浏览器可能正在连接到该域 -->