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)

检测工具

  • webpagetest

  • 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 实现了优化,比如:refuseLayoutEffectstartTransition

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]
    );
    

样式处理方式

  1. 内联样式:因为内联样式是一个新对象,会导致重新渲染,尽量不用,除非是一些简单的。

  2. 外部引入 css 样式表:必须遵循规则,灵活性低

  3. 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;
    
  4. Css in js:(styled-components/styled-jsx)把样式转为组件的形式来包裹组件。很明显它依赖 js,会导致重新渲染,增加内存使用

  5. 原子 css:(tailwind.css)需要学习成本,这是目前性能优化最好的方式

  6. 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.lazynext/dynamic
  • 监听滚动来加载:intersectionObserver

压缩 react 工具链

  • 服务端框架:nextjs,remix
  • 基于 ESM 的开发服务器:Vite、snowpack

通过 BrowserList 优化转义

BrowserList 是一个数据库,映射了浏览器的所有功能

polyfills,core-js

检查未使用/不可访问的代码

如果代码未使用,但在未来会使用,可以使用 code spliting,延迟加载

Bundles管理

包含自己的代码和 vendor。而 vendor 通常不会被修改,不可能让用户每一次都请求这些,需要注意 HTTP 缓存。

分析工具

通过分析结果,找到:

  1. 不希望出现的第三方库
  2. 使 bundle 变臃肿的依赖包
  3. 自己不想被使用的代码

动态引入

动态引入可以实现代码分割:

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:.." />
<!-- 提示浏览器可能正在连接到该域   -->
posted @ 2024-04-16 17:58  sanhuamao  阅读(84)  评论(0编辑  收藏  举报