当面试官问你在使用React开发做过哪些优化时,你可以这么回答

4,373 阅读36分钟

1、使用 React.memo() 缓存组件

React.memoReact 提供的一个高阶组件(Higher Order Component),用于优化函数组件的性能。它可以帮助避免不必要的重新渲染,从而提升应用性能。

函数组件在每次渲染时都会生成新的函数,这可能导致在某些情况下进行不必要的渲染。React.memo 的作用就是在函数组件之间进行浅比较,如果组件的 props 没有发生变化,则避免重新渲染组件。

React.memo 接受一个函数组件作为参数,并返回一个经过优化的组件。下次父组件重新渲染时,如果传递给 MemoizedComponentprops 没有发生变化,该组件将不会重新渲染,从而节省性能。

需要注意以下几点:

  • React.memo 默认使用浅比较,所以它只能检测 props 的值是否相等,无法检测 props 内部的深层次变化。
  • 如果函数组件的 props 是引用类型(如对象、数组),并且它们在每次渲染时都是新的实例,React.memo 可能无法正常工作,此时可以使用 useMemo 来确保传递给子组件的 props 是相同引用。
  • 可以使用第二个参数(一个自定义的比较函数,包含两个参数分别是旧 props 和新 props)来进行更精细的比较,以满足特定的优化需求。

举例🌰:

const DemoComponent = (props) => <div> {props.value} </div>;

const MemoizedComponent = React.memo(DemoComponent, (prevProps, nextProps) => {
  // 返回 true 表示 props 相等,不重新渲染
  // 返回 false 表示 props 不相等,重新渲染
  return prevProps.value === nextProps.value;
});

React.memoPureComponentshouldComponentUpdate 的区别

  • React.memo 是一个高阶组件(Higher-Order Component),用于包装函数组件。它通过对组件的浅层比较来避免不必要的重新渲染。当组件的 props 没有发生变化时, React.memo 会返回之前渲染的结果,从而跳过组件的重新渲染。React.memo 接受一个可选的比较函数作为第二个参数,用于自定义 props 的比较逻辑。

    React.memo 使用说明:

    • 默认情况下会对组件 props 进行浅比较, 只有 props 变更才会触发 render

    • 允许传入第二参数,该参数是个函数,该函数接收 2 个参数,两个参数分别是旧新 props

    • 注意:与 shouldComponentUpdate 不同的是,返回 true 时,不会触发 render。如果返回 false 则会重新渲染。和 shouldComponentUpdate 刚好与其相反。

  • PureComponent 是一个基于类的组件,它是 React.Component 的一个变种。PureComponent 会进行浅比较,通过判断 propsstate 是否相同,来决定是否重新渲染组件。所以一般用于性能调优,减少 render 次数。

    PureComponent 使用说明:

    • PureComponent 通过对 propsstate 的浅层比较来实现 shouldComponentUpdate 方法。

    • 由于 PureComponent 默认实现了 shouldComponentUpdate 方法,所以无需手动编写。

  • shouldComponentUpdateReact 组件的生命周期方法之一,它需要手动实现。在 shouldComponentUpdate 方法中,你可以根据组件的 propsstate 来决定是否触发组件的重新渲染。你需要手动比较 propsstate 的变化,并返回一个布尔值,指示组件是否应该更新。

    shouldComponentUpdate 使用说明:

    • propsstate 发生变化时,shouldComponentUpdate() 会在渲染执行之前被调用。返回值默认为 true。目前,如果 shouldComponentUpdate 返回 false,则不会调用 UNSAFE_componentWillUpdate()render()componentDidUpdate() 方法。后续版本,React 可能会将 shouldComponentUpdate() 视为提示而不是严格的指令,并且,当返回 false 时,仍可能导致组件重新渲染。

    • shouldComponentUpdate 方法接收两个参数 nextPropsnextState,可以将 this.propsnextProps 以及 this.statenextState 进行比较,并返回 false 以告知 React 可以跳过更新。

React.memoPureComponent 作用类似,可以用作性能优化。React.memo 是高阶组件,函数组件和类组件都可以使用。但 React.memo 只能对 props 的情况确定是否渲染,而 PureComponent 是针对 propsstate

React.memo 接受两个参数,第一个参数是原始组件本身,第二个参数可以根据一次更新中 props 是否相同来决定原始组件是否重新渲染。是一个返回布尔值,true 证明组件无须重新渲染,false证明组件需要重新渲染,这个和类组件中的 shouldComponentUpdate() 正好相反。

PureComponent 举例🌰:

import React, { PureComponent } from 'react';

class Counter extends PureComponent {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  incrementCount = () => {
    this.setState((prevState) => ({
      count: prevState.count + 1,
    }));
  };

  render() {
    console.log('Counter component rendered');
    return (
      <div>
        <h1>Counter: {this.state.count}</h1>
        <button onClick={this.incrementCount}>Increment</button>
      </div>
    );
  }
}

function App() {
  return (
    <div>
      <h1>PureComponent Demo</h1>
      <Counter />
    </div>
  );
}

export default App;

在上面的示例中,Counter 组件继承了 PureComponent 类。当点击Increment 按钮时,count 状态会递增。由于 CounterPureComponent,它会对 propsstate 进行浅层比较,只有在它们的值发生实际变化时才会重新渲染。console.log 语句用于显示组件何时被渲染。

shouldComponentUpdate 举例🌰:

import React, { Component } from 'react';

class MyComponent extends Component {
  state = {
    count: 0
  };

  shouldComponentUpdate(nextProps, nextState) {
    // 仅在count发生变化时才重新渲染组件
    if (this.state.count !== nextState.count) {
      return true;
    }
    return false;
  }

  incrementCount = () => {
    this.setState(prevState => ({
      count: prevState.count + 1
    }));
  };

  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.incrementCount}>Increment</button>
      </div>
    );
  }
}

在上面的示例中,MyComponent 是一个简单的计数器组件,它有一个状态 count,用于存储计数值。在 shouldComponentUpdate 方法中,我们使用比较当前状态和下一个状态的 count 值,如果它们不相等,就返回 true,表示组件应该重新渲染;否则返回 false,表示组件不需要重新渲染。

总结:

  • React.memo 适用于函数组件,通过浅层比较 props 来避免不必要的重新渲染。第二个参数返回 true 组件不渲染,返回 false 组件重新渲染。

  • PureComponent 适用于类组件,通过浅层比较 propsstate 来避免不必要的重新渲染。

  • shouldComponentUpdate 需要手动实现,适用于类组件,可以更细粒度地控制组件的重新渲染,包括 propsstate 的比较逻辑。返回 true 组件渲染,返回 false 组件不渲染。

2、使用 useMemo() 缓存计算值

useMemoReact 提供的一个钩子,用于在函数组件中进行性能优化。它的作用是用于缓存和记忆计算结果,避免在每次渲染时重复计算相同的值,从而提升组件的性能。

useMemo 的基本语法如下:

const memoizedValue = useMemo(() => {
  // 计算或处理逻辑
  return computedValue;
}, [dependencies]);
  • 第一个参数是一个回调函数,该函数在每次渲染时都会被执行。你可以在这个函数中进行计算、处理逻辑或任何其他操作,然后返回计算结果。
  • 第二个参数是一个依赖数组,包含影响计算结果的变量或值。当依赖数组中的值发生变化时,useMemo 会重新计算计算结果。如果依赖数组为空,则只在首次渲染时计算一次。

使用 useMemo 的主要场景是在渲染期间进行昂贵的计算,以及避免不必要的重复计算。以下是一些应用场景:

  • 避免重复计算:如果一个计算耗时较长且计算结果不会频繁变化,你可以使用 useMemo 来缓存结果,避免在每次渲染时都重新计算。
  • 优化子组件渲染:将计算结果作为 props 传递给子组件,可以避免在子组件渲染过程中重复执行相同的计算。
  • 避免不必要的副作用:如果你需要在渲染期间执行副作用(如数据请求、订阅等),你可以在 useMemo 中进行处理,以确保副作用只在依赖项变化时执行。

3、使用 useCallback() 缓存回调函数

useCallbackReact 提供的一个钩子,用于优化函数组件的性能。它的主要作用是缓存函数,避免在每次渲染时重新创建新的函数实例,从而减少不必要的重新渲染。

在使用 useCallback 时,你可以将一个函数和一个依赖数组作为参数传递给它,它会返回一个经过优化的函数。这个函数会在依赖数组中的值发生变化时重新创建,否则会保持相同的引用。

基本语法如下:

const callbackFunction = () => {
  // 回调函数逻辑
}

const memoizedCallback = useCallback(callbackFunction, [dependencies]);
  • callbackFunction:需要缓存的回调函数。
  • dependencies:一个数组,包含影响回调函数是否需要重新创建的值。当数组中的值发生变化时,useCallback 会重新创建回调函数。如果依赖数组为空,回调函数只会在首次渲染时创建一次。

使用场景:

  • 避免子组件不必要的重新渲染:将回调函数传递给子组件时,可以使用 useCallback 来确保子组件不会因为父组件重新渲染而重新创建回调函数,从而避免不必要的重新渲染。
  • 避免不必要的副作用:如果回调函数包含副作用(如数据请求、订阅等),使用 useCallback 可以确保副作用只在依赖项变化时执行。

需要注意的是,虽然 useCallback 可以优化性能,但过度使用它可能会导致代码变得复杂。下面说说使用 useCallback() 时可能会存在的缺陷:

  • 内存消耗:使用 useCallback() 会导致函数的缓存,这意味着每当依赖项发生变化时,都会创建一个新的函数引用。如果在渲染期间频繁地创建大量的函数,可能会增加内存消耗。
  • 比较复杂的逻辑useCallback() 适用于缓存简单的回调函数,但如果需要缓存具有复杂逻辑的函数,可能会导致代码变得难以理解和维护。
  • 额外的函数调用:使用 useCallback() 可以避免在每次渲染时重新创建函数,但在某些情况下,这可能会导致额外的函数调用。因为记忆化函数的引用发生变化时,使用该函数的组件可能会重新渲染,即使依赖项没有真正发生变化。

拓展知识:

useCallbackuseMemo 的返回值为函数时的特殊情况,是 React 提供的便捷方式。在 React Server Hooks 代码 中,useCallback 就是基于 useMemo 实现的。尽管 React Client Hooks 没有使用同一份代码,但 useCallback 的代码逻辑和 useMemo 的代码逻辑仍是一样的。

使用 useMemo 来实现类似 useCallback 的功能:

const memoizedFn = useMemo(() => fn, [fn]);

相比较 useCallback,更推荐使用 useMemo

避免使用匿名函数

// 不推荐
<MyComponent onClick={() => console.log('Clicked')} />

React 组件传值时,避免使用匿名函数是出于性能的考虑。匿名函数在每次渲染时都会重新创建一个新的函数实例,这可能会导致不必要的重新渲染和性能下降。使用匿名函数会导致的问题:

  • 重新渲染的触发:每次父组件重新渲染时,如果传递给子组件的 prop 中包含匿名函数,那么这些匿名函数都会被重新创建。这会导致子组件的重新渲染,即使其他 prop 没有变化。
  • 额外的内存开销:匿名函数的创建会导致额外的内存开销,因为每次渲染都会生成新的函数实例,这可能在大型应用中累积成性能问题。
  • 性能下降:由于匿名函数的频繁创建和销毁,可能会导致页面的性能下降,尤其是在需要频繁渲染的情况下。

为了避免这些问题,推荐的做法是将匿名函数移到父组件之外,或者使用 useCallback Hook 来缓存函数,从而确保函数实例在渲染之间保持稳定。这样可以避免不必要的重新渲染,并提升组件的性能。

使用具名函数:

const handleClick = () => console.log('Clicked');

<MyComponent onClick={handleClick} />

使用 useCallback

import React, { useCallback } from 'react';

function ParentComponent() {
  const handleClick = useCallback(() => {
    console.log('Clicked');
  }, []);

  return <MyComponent onClick={handleClick} />;
}

4、使用 useRef() 避免非必要渲染

useRefReact 提供的一个钩子,用于在函数组件中存储和访问可变的引用值。与 useState 不同,useRef 不会引发组件的重新渲染,因为它的主要目的是用于处理与界面渲染无关的数据。

基本用法:

const refContainer = useRef(initialValue);
  • refContainer:一个包含 current 属性的对象,current 属性的值为 initialValue
  • initialValue:作为初始值传递给 useRef 的值。

用途:

  • 保留引用useRef 用于保留引用,例如保存 DOM 元素的引用或其他不需要触发重新渲染的值。
  • 获取最新值:由于 useRef 的值在重新渲染之间保持稳定,它常用于存储任何需要在渲染周期之间保持不变的数据。
  • 触发非渲染副作用useRef 还可以用于触发非渲染的副作用,例如获取上一次渲染的状态或引用。

示例:

import React, { useRef, useEffect } from 'react';

function Timer() {
  const intervalRef = useRef(null);

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      console.log('Timer tick');
    }, 1000);

    return () => {
      clearInterval(intervalRef.current);
    };
  }, []);

  return <div>Timer component</div>;
}

在上面的示例中,intervalRef 用于存储定时器的引用,以便在组件卸载时清除定时器。由于 useRef 的值在重新渲染之间保持稳定,即使组件重新渲染,intervalRef 的值仍然是上一次渲染时的引用。

5、使用 useLayoutEffect() 避免闪屏

如果你在 useEffct 的初始化渲染中修改了展示的数据或者 css 样式,那么很有可能会重复渲染导致闪屏用户体验不好的问题,此时可以试下 useLayoutEffect 这个钩子。

useLayoutEffectReact 提供的一个钩子,它类似于useEffect,但在组件渲染完成后,但在浏览器执行下一次绘制之前同步调用副作用函数。

useLayoutEffect 的作用和用法与 useEffect 非常相似,都是用于处理组件副作用。然而,它们之间有一个重要的区别: useEffect 是在组件渲染完成后异步调用副作用函数,而 useLayoutEffect 是在 DOM 更新之后,浏览器绘制之前同步调用副作用函数。这样做的好处是可以更加方便的修改 DOM,获取 DOM 信息,这样浏览器只会绘制一次,可以避免浏览器再次回流和重绘。

useLayoutEffectuseEffect 之前执行。

由于 useLayoutEffect 的副作用函数是同步执行的,它会阻塞浏览器的渲染过程。这意味着如果在 useLayoutEffect 中进行了 DOM 操作或读取 DOM 布局信息,它会在浏览器进行下一次绘制之前立即生效。这使得 useLayoutEffect 适合执行需要 DOM 布局信息的副作用操作,例如测量元素的尺寸或位置,并根据结果进行相应的操作。

举例🌰:

import React, { useState, useLayoutEffect } from 'react';

function MyComponent() {
  const [width, setWidth] = useState(0);

  useLayoutEffect(() => {
    function updateWidth() {
      const element = document.getElementById('my-element');
      if (element) {
        setWidth(element.offsetWidth);
      }
    }

    window.addEventListener('resize', updateWidth);
    updateWidth();

    return () => {
      window.removeEventListener('resize', updateWidth);
    };
  }, []); // 依赖项数组为空,只在组件挂载和卸载时执行一次

  return (
    <div>
      <p>Width: {width}px</p>
      <div id="my-element">Some content</div>
    </div>
  );
}

在上面的示例中,useLayoutEffect 用于测量元素的宽度,并在窗口大小发生变化时更新宽度。副作用函数中使用了 DOM 操作来获取元素的宽度,并使用 useState 来更新组件的状态。

6、正确使用列表 key

React 中,使用 key 属性来标识列表中的每个元素是非常重要的。key 属性帮助 React 跟踪每个列表项的变化,从而优化性能和重新渲染,避免一些不必要的组件树销毁和重建工作。如果不正确地使用 key,可能会导致一些不良的效果,例如错误的渲染和性能下降。

比如你的第一个元素是 div,更新后发生了位置变化,第一个变成了 p。如果你不通过 key 告知新位置,React 就会将 div 下的整棵树销毁,然后构建 p 下的整棵树,非常耗费性能。

正确使用 key 的好处:

  • 元素标识key 属性用于唯一标识列表中的每个元素。它帮助 React 判断哪些元素是新增的、更新的还是被删除的。
  • 优化渲染key 允许 React 在重新渲染列表时,仅更新发生变化的部分,而不必重新渲染整个列表。这可以提升性能,减少不必要的 DOM 操作。
  • 避免错误渲染:如果不使用 key 或者使用相同的 keyReact 可能会产生不正确的渲染结果,因为它无法正确识别元素的变化。

使用索引(index)作为 key 可能会导致的问题:

  • 错误更新:如果列表中的项发生位置变化,使用索引作为 key 可能会导致 React 错误地更新了列表项,使得列表项的状态和 UI 不匹配。
  • 性能下降:使用索引作为 key 可能会导致 React 频繁重新创建元素,降低性能,因为 React 无法正确识别元素的变化。
  • 不稳定的 UI:使用索引作为 key 可能会导致列表项在更新时出现不稳定的 UI,因为它们的 key 是根据位置而不是唯一标识来生成的。

什么情况下能使用数组的 index 值作为 key

  • 列表只会渲染一次
  • 列表的元素不会发生重排

React key 的妙用

在不同的渲染周期去改变组件的 key 值,能够卸载旧的组件实例,重新创建新的组件实例。利用这一点,我们能够实现 组件状态重置正确的数据更新 等效果。

React key 并不只能用于列表渲染场景上,它也可以用于单个组件的渲染场景上!Reactreconcil 过程中,如果当前渲染周期的 key 值跟上一轮渲染周期的 key 值不相等的话,React 会卸载当前组件实例,重新从头开始创建一个新的组件实例。以下 demo 示例就可以验证这一点:

import React, { useState, useEffect } from 'react';

function Counter() {
  console.log('Counter called');

  const [count, setCount] = React.useState(() => {
    console.log('Counter useState initializer');
    return 0;
  });
  
  const increment = () => setCount((c) => c + 1);

  React.useEffect(() => {
    console.log('Counter useEffect callback');
    return () => {
      console.log('Counter useEffect cleanup');
    };
  }, []);

  console.log('Counter returning react elements');
  
  return <button onClick={increment}>{count}</button>;
}

export default function CounterParent() {
  const [counterKey, setCounterKey] = React.useState(0);
  
  return (
    <div>
      <button onClick={()=> {setCounterKey(c=> c +1)}}>reset</button>
      <Counter key={counterKey} />
    </div>
  );
}

先点击 <Counter> 组件的按钮,再点击 <CounterParent> 组件的按钮,控制台的打印如下:

// 点击 <Counter> 组件的按钮
Counter called
Counter returning react elements

// 点击 <CounterParent> 组件的按钮
// 组件开始渲染
Counter called
Counter useState initializer
Counter returning react elements

// 卸载旧的组件实例
Counter useEffect cleanup

// 新的组件实例已经挂载到 fiber 节点上
Counter useEffect callback

主动去改变组件的 key 属性值,我们能够达到 卸载旧的组件实例和 DOM 对象,重新创建新的组件实例和 DOM 对象 的效果。利用这一点,我们可以实现类似上面的 状态重置 类型的任务。

还有某些情况,我们在同一个组件上去更新不同的数据的时候,你会发现更新失效,界面还是显示上一次的旧数据。如果事发紧急,那么我们就可以一个能够区分不同渲染周期的 ID值 作为这个组件的 key 值。通过这样,我们就会重新挂载组件实例,从而达到预期的组件更新效果。

7、使用 React.Fragment 避免添加额外的 DOM

React.FragmentReact 提供的一个组件,用于在不引入多余的 DOM 元素的情况下包裹多个子元素。它的主要作用是在 JSX 中创建一个“虚拟”的父容器,用于包裹多个子元素,而不会在最终渲染的 DOM 结构中添加额外的节点。

React 中,当你需要在 JSX 中返回多个相邻的元素时,通常需要将它们包裹在一个父元素中。例如:

<div>
  <p>Paragraph 1</p>
  <p>Paragraph 2</p>
</div>

但是,有时你可能不想在渲染的 DOM 结构中添加多余的父元素。这就是使用 React.Fragment 的情况:

<React.Fragment>
  <p>Paragraph 1</p>
  <p>Paragraph 2</p>
</React.Fragment>

<React.Fragment> 可以像普通的组件一样传递属性,例如 <React.Fragment key={...}>

或者使用简化的语法:

<>
  <p>Paragraph 1</p>
  <p>Paragraph 2</p>
</>

<>...</> 空标签语法不支持传递属性,因此无法传递 key 属性

React.Fragment 的优点包括:

  • 减少 DOM 结构:使用 React.Fragment 可以避免在最终渲染的 DOM 中添加多余的父元素,减少不必要的 DOM 结构。
  • 不影响样式和布局:由于 React.Fragment 不会在渲染中生成额外的父元素,因此它不会影响样式和布局。
  • 提高性能:不生成额外的 DOM 元素,有助于提高性能,特别是在需要渲染大量子元素时。
  • 语法简洁:使用简化的语法(<>...</>)可以使代码更加简洁,易于阅读。

8、避免使用内联对象

使用内联对象时,React 会在每次渲染时重新创建对此对象的引用,这会导致接收此对象的组件将其视为不同的对象。因此,该组件对于 prop 的浅层比较始终返回 false,导致组件一直重新渲染。

许多人使用的内联样式的间接引用,就会使组件重新渲染,可能会导致性能问题。为了解决这个问题,我们可以保证该对象只初始化一次,指向相同引用。另外一种情况是传递一个对象,同样会在渲染时创建不同的引用,也有可能导致性能问题,我们可以利用 ES6 扩展运算符将传递的对象解构。这样组件接收到的便是基本类型的 props,组件通过浅层比较发现接受的 prop 没有变化,则不会重新渲染。示例如下:

// 不推荐
function Component(props) {
  const aProp = { someProp: 'someValue' };
  return <AnotherComponent style={{ margin: 0 }} aProp={aProp} />  
}

// 推荐
const styles = { margin: 0 };

function Component(props) {
  const aProp = { someProp: 'someValue' };
  return <AnotherComponent style={styles} {...aProp} />  
}

通过解构属性,你实际上是将对象中的属性直接作为独立的基本类型属性传递给子组件,而不是整个对象。这样子组件接收到的是基本类型属性,React 可以使用浅层比较(shallow comparison)来判断属性是否发生变化。浅层比较只会比较属性值的引用,而不会深入比较属性内部的内容。

因此,当你解构属性传递给子组件时,如果对象的属性值没有变化,浅层比较会识别出属性没有变化,从而避免子组件的重新渲染。

9、避免在 jsx 中定义函数和 style

函数定义: 如果在 JSX 中定义函数,每次渲染组件时都会重新创建这个函数,这可能导致不必要的性能开销。为了避免这种情况,应该将函数定义移到组件外部或使用 useMemouseCallback 来进行优化,确保函数只在依赖发生变化时才会重新计算。并且将函数定义放在组件外部可以提高代码的可读性和维护性。

样式定义:JSX 中定义样式时,每次渲染都会创建一个新的样式对象,这也会导致额外的性能开销。通常,建议使用 CSS 样式表或样式框架(如 styled-componentsEmotion)来定义样式,以提高性能。并且使用外部样式表或样式框架可以将样式代码与组件代码分离,更容易维护。

定义函数例子🌰:

// 不推荐:在 JSX 中定义函数
function MyComponent() {
  return <button onClick={() => console.log('Button clicked')}>Click me</button>;
}

// 推荐:将函数定义移到外面
function MyComponent() {
  const handleClick = () => {
    console.log('Button clicked');
  };
  return <button onClick={handleClick}>Click me</button>;
}

定义样式例子🌰:

// 不推荐:在 JSX 中定义样式
function MyComponent() {
  return <div style={{ width: '200px', marginTop: '10px' }}>Hello, world</div>;
}

// 推荐:使用外部样式表或样式框架
import styles from './style.less';

function MyComponent() {
  return <div className={styles.myComponent}>Hello, world</div>;
}

// style.less 文件
.my-component {
  width: 200px;
  margin-top: 10px;
}

10、使用 React.lazy() 和 Suspense 进行按需加载组件

React.lazy()SuspenseReact 提供的两个特性,用于实现组件的按需加载,从而优化应用的性能。这种方式允许你在需要的时候才加载组件,而不是在初始加载时将所有代码都打包在一起。

React.lazy():

React.lazy() 是一个函数,用于动态地导入一个组件。它允许你通过函数调用方式来按需加载组件,并且返回一个能够渲染该组件的动态导入组件。

基本语法:

const MyComponent = React.lazy(() => import('./MyComponent'));

Suspense:

Suspense 是一个组件,它可以包裹在需要异步加载的组件外部,以指定在加载组件时如何显示加载状态或备用内容。使用 Suspense 的时候我们可以延迟组件的渲染,直到实际需要渲染的条件出现时。

基本语法:

import React, { Suspense } from 'react';

function App() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <MyComponent />
      </Suspense>
    </div>
  );
}

结合使用 React.lazy()Suspense,你可以轻松地实现按需加载组件:

  • 使用 React.lazy() 动态导入需要按需加载的组件。
  • 使用 Suspense 组件包裹这个动态导入的组件,并在 fallback 属性中定义加载状态时显示的内容。

当页面渲染时,如果 MyComponent 组件尚未加载完成,Suspense 将会显示 fallback 中定义的加载状态(例如 "Loading..."),直到组件加载完成后再渲染实际内容。

需要注意的是,React.lazy()Suspense 目前只支持默认导出的组件。如果你需要导入命名导出的组件,可以使用以下方式:

const MyComponent = React.lazy(() => import('./MyComponent').then(module => ({ default: module.MyComponent })));

举例🌰:

import AnotherComponent from './another-component';
// 延迟加载不是立即需要的组件
const MUITooltip = React.lazy(() => import('./tooltip'));

function Tooltip({ children, title }) {
  return (
    <React.Suspense fallback={children}>
      <MUITooltip title={title}>
        {children}
      </MUITooltip>
    </React.Suspense>
  );
}

function Component(props) {
  return (
    <Tooltip title={props.title}>
      <AnotherComponent />
    </Tooltip>
  )
}

11、合理使用懒加载和预加载

懒加载

懒加载(Lazy Loading)是一种前端性能优化技术,用于延迟加载网页上的资源,以减少初始页面加载时间和提高用户体验。懒加载的主要原理是只加载页面上用户首次可见区域内的内容,而延迟加载其他内容,包括图片、脚本、样式表等。这样可以降低初始页面的网络请求和资源下载,加快页面加载速度。

懒加载的优点:

  • 提高页面加载速度: 懒加载延迟加载非首屏内容,使初始页面加载更快,减少了页面的加载时间。
  • 降低带宽消耗: 随着用户滚动或触发特定事件,才加载需要的资源,节省了带宽,减少了不必要的网络请求。
  • 改善用户体验: 用户首次看到页面更快,减少了等待时间,提高了用户满意度。
  • 减轻服务器负载: 延迟加载可以减少服务器在短时间内处理大量请求的负担。

应用场景

  • 图片懒加载:在滚动到图片可见区域之前不加载图片资源。
  • 无限滚动页面:在滚动到底部时加载更多内容。
  • 延迟加载 JavaScript:只在需要时加载某些 JavaScript 代码块。

图片懒加载

对于一些图片比较多的页面,用户打开页面后,只需要呈现出在屏幕可视区域内的图片,当用户滑动页面时,再去加载出现在屏幕内的图片,以优化页面的加载效果。

使用 IntersectionObserver 实现图片懒加载🌰:

// html
<div class="container">
  <img class="picture" src="./default-img" data-origin="./real-img"></img>
  <img class="picture" src="./default-img" data-origin="./real-img"></img>
  <img class="picture" src="./default-img" data-origin="./real-img"></img>
</div>

// js
const pictures = document.querySelectorAll('.picture');
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((item) => {
      if (item.isIntersecting) {
        item.target.src = item.target.dataset.origin; // 开始加载图片
        observer.unobserve(item.target); // 停止监听已开始加载的图片
      }
    });
  }
);

pictures.forEach((item) => observer.observe(item));

路由懒加载

SPA 应用中,一个路由对应一个页面,如果我们不做任何处理,项目打包时,所有的页面都会打包成一个文件,当用户去打开首页时,就会去一次性加载所有的资源,这样首页加载就会慢,降低用户体验,那有什么办法不一次性加载所有资源?是用什么原理实现的?

ES6 中,有一个动态加载模块的方法:import()。懒加载就是根据 import() 去实现的,调用 import() 之处,被作为分离模块的起点,意思是,被请求的模块和它引用的所有子模块,会被分离到一个单独 chunk 中。所以实现懒加载的方法,就是将需要进行懒加载的子模块分离出来,打包成一个单独的文件,这样就不会一次加载所有的资源了。

路由懒加载🌰:

import React, { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

// 使用 lazy 函数来异步加载组件
const Home = lazy(() => import('./components/Home'));
const About = lazy(() => import('./components/About'));

function App() {
  return (
    <Router>
      <div>
        <h1>My App</h1>
        <Suspense fallback={<div>Loading...</div>}>
          {/* 使用懒加载的组件 */}
          <Switch>
            <Route exact path="/" component={Home} />
            <Route path="/about" component={About} />
          </Switch>
        </Suspense>
      </div>
    </Router>
  );
}

export default App;

组件懒加载

这个实现原理和路由懒加载是一样的,都是通过 import() 的方式实现的,那组件懒加载什么时候用呢?

假设有两个复杂页面都使用到了同一个组件,项目打包后,发现两个页面均包括了该组件的代码;且该组件又不是一进入页面就触发的,需要一定条件才触发的。这个时候就比较适合使用组件懒加载,即组件懒加载的使用场景有:

  • 该页面文件体积大,导致页面打开慢,可以通过懒加载进行资源拆分,利用浏览器的并行下载提升速度。
  • 该组件又不是一进入页面就触发的,需要一定条件才触发的(比如弹窗)。
  • 该组件复用性高,很多页面引用,可以理由懒加载单独形成一个文件。

其他时候不建议拆分过细,因为会造成浏览器 http 请求增多。

预加载

预加载(Preloading)是一种前端性能优化技术,用于在页面加载时提前加载某些资源,以缩短后续页面操作所需的时间。通过预加载,可以使用户在需要某些资源时能够更快地获取它们,提高用户体验。

预加载的优点:

  • 提前加载资源:可以减少用户在执行操作时等待资源加载的时间,提升用户体验。
  • 预热缓存:将资源缓存在用户设备上,可以加速后续页面的加载。
  • 减少请求延迟:可以减少由于请求延迟引起的性能问题。

应用场景

  • 预加载下一个页面的资源:例如,在用户点击链接时,提前加载下一个页面的内容、图片和脚本,以减少页面切换时的等待时间。
  • 预加载鼠标悬停元素的资源:例如,在用户将鼠标悬停在链接上时,提前加载链接目标页面的内容。

图片预加载

  • 使用 HTML<img> 标签

    img 标签会在 Html 渲染解析到的时候,如果解析到 imgsrc 的值,则浏览器会立即开启一个线程去请求该资源,所以我们可以先将 img 标签隐藏但 src 写上对应的链接,这样皆可以把资源先请求回来了

    <!-- 预加载 -->
    <img src="https://xxx.jpeg" style="display: none" />  
    
  • 使用 Image 对象

    const image = new Image()
    image.src = "https://xxx.jpeg"
    

    只要浏览器把图片下载到本地,同样的 src 就会使用缓存,这是最基本也是最实用的预载方法;除了以上方法还有一些利用 XMLHttpRequest 对象或者第三库如 PerloadJS 库来实现。

Preload

preload 提供了一种声明式的命令,能让浏览器提前加载指定资源(如脚本或者样式表),并在需要执行的时候再执行。这在希望加快某个资源的加载速度时很有用。在 preload 下载完资源后,资源只是被缓存起来,浏览器不会对其执行任何操作。不执行脚本,不应用样式表。

同时 preload 也不影响资源的执行顺序,如果有多个资源资源都用了 preload,谁先设置 preload 谁就先下载。

注意:设置了 rel 属性的 link 标签 必须设置 as属性来声明资源的类型,否则浏览器可能无法正确加载资源。常见的 as 属性包括:

  • script: JavaScript 文件
  • style: CSS 样式文件
  • font: 字体文件
  • image: 图片文件
  • fetch: 通过 fetch 或者 XHR 获取的文件,例如 ArrayBuffer 或者 JSON 文件。

例子🌰:

<!-- 使用 link 标签静态标记需要预加载的资源 -->
<link rel="preload" href="./style.css" as="style">

<!-- 或使用脚本动态创建一个 link 标签后插入到 head 头部 -->
<script>
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'style';
link.href = './style.css';
document.head.appendChild(link);
</script>

Prefetch

prefetch (链接预取)是一种浏览器机制,其利用浏览器空闲时间来下载或预取用户在不久的将来可能访问的文档。网页向浏览器提供一组预取提示,并在浏览器完成当前页面的加载后开始静默地拉取指定的文档并将其存储在缓存中。当用户访问其中一个预取文档时,便可以快速的从浏览器缓存中得到。prefetch 是一个低优先级的资源提示,允许浏览器在后台空闲时获将来可能用得到的资源,并且将他们存储在浏览器的缓存中。一旦一个页面加载完毕就会开始下载其他的资源,然后当用户点击了一个带有 prefetched 的连接,它将可以立刻从缓存中加载内容。

例子🌰:

<link rel="prefetch" rel="./delay-img.png">

Preload & Prefetch 的区别

  • preload 的设计初衷是为了尽早加载首屏需要的关键资源,从而提升页面渲染性能。preload 是告诉浏览器页面必定需要的资源,浏览器一定会加载这些资源。
  • prefetch 声明的是将来可能访问的资源,因此适合对异步加载的模块、可能跳转到的其他路由页面进行资源缓存,浏览器不一定会加载这些资源。
  • 大部分场景下无需特意使用 preload。类似字体文件这种隐藏在脚本、样式中的首屏关键资源,建议使用 preload
  • 异步加载的模块(典型的如单页系统中的非首页)建议使用 prefetch,大概率即将被访问到的资源可以使用 prefetch 提升性能和体验。
  • 注:preloadprefetch 混用的话,并不会复用资源,而是会重复加载。

不同资源加载的优先级规则

DevTools Priority 体现的优先级,一共分成五个级别:

  • Highest 最高
  • Hight
  • Medium 中等
  • Low
  • Lowest 最低

其中:

  • html 主要资源,其优先级是最高的
  • css 样式资源,其优先级也是最高的
  • script 脚本资源,优先级不一
  • font 字体资源,优先级不一

总结

懒加载和预加载都是提高页面性能有效的办法,两者主要区别是一个是迟缓甚至不加载,一个是提前加载。懒加载对服务器前端有一定的缓解压力作用,预加载则会增加服务器前端压力

12、使用 useContext() 共享数据

假设我们的组件嵌套是这样的:A -> B -> C。其中 C 需要拿到 A 的一个状态。B 虽然不需要用到 A 的任何状态,但为了让 C 拿到状态,所以也用 props 接收了这个,然后再传给 C。这样的话,A 更新状态时,B 也要进行不必要的重渲染。这时使用 useContext 或通过状态管理来解决。

useContextReact 提供的一个钩子,用于在函数组件中访问 React 的上下文(context)。上下文是一种在组件树中共享数据的机制,可以避免将数据通过 props 层层传递给深层子组件,从而简化数据共享和传递的过程。

基本用法:

const value = useContext(MyContext);
  • MyContext 是通过 React.createContext 创建的上下文对象。
  • value 是上下文提供的值。

使用步骤:

  1. 创建一个上下文对象:使用 React.createContext 创建一个上下文对象。

    const MyContext = React.createContext();
    
  2. 在顶层组件中提供上下文值:将数据或值传递给上下文提供器,将其包裹在组件树的顶层。

    <MyContext.Provider value={/* value */}>
      <App />
    </MyContext.Provider>
    
  3. 在子组件中使用上下文值:使用 useContext 钩子来获取上下文值。

    function ChildComponent() {
      const value = useContext(MyContext);
      // 使用 value
    }
    

优势和用途:

  • 共享数据useContext 允许你在组件树中共享数据,而无需通过 props 传递给每个中间组件。
  • 避免 prop drilling:通过 useContext,你可以避免在组件层次结构中进行多层级的 props 传递,提高代码的可读性和可维护性。
  • 主题和用户设置:可以用于实现主题切换、用户设置等全局状态的共享。
  • 解耦组件useContext 可以使组件不再直接依赖于特定的数据源,从而增加了组件的可复用性和灵活性。

Context 是粗粒度的

React 提供的 Context 的粒度是粗粒度的。当 Context 的值变化时,用到该 Context 的组件就会更新。

有个问题,就是 我们提供的 Context 值通常都是一个对象,比如:

import React, { useState } from 'react';

const App = () => {
  const [visible, setVisible] = useState(false);
  return (
    <EditorContext.Provider value={ visiblesetVisible }>
      <Editor />
    </EditorContext.Provider>
  );
}

每当 Contextvalue 变化时,用到这个 Context 的组件都会被更新,即使你只是用这个 value 的其中一个属性,且它没有改变。因为 Context 是粗粒度的

所以你或许可以考虑在高一些层级的组件去获取 Context,然后通过 props 分别注入到用到 Context 的不同部分的组件中

Contextvalue 在必要时也要做缓存,以防止组件的无意义更新。

import React, { useState, useMemo } from 'react';

const App = () => {
  const [visible, setVisible] = useState(false);
  const EditorContextVal = useMemo(() => ({ visible, setVisible }), [visible, setVisible]);
  return (
    <EditorContext.Provider value={EditorContextVal}>
      <Editor />
    </EditorContext.Provider>
  );
}

13、使用 useTransition() 降低渲染优先级

useTransitionReact 提供的一个钩子,用于在并发模式(Concurrent Mode)下处理 UI 过渡的一种方式。并发模式是一种 React 的特性,旨在提升应用的性能和用户体验。

React 18 中引入的 useTransition 钩子允许你在异步操作中实现平滑的 UI 过渡效果,而无需在加载期间完全阻塞用户界面。这对于加载资源、处理网络请求等情况特别有用,可以保持界面的响应性。

startTransition 告诉 React 一些状态更新具有较低的优先级,即每个其他状态更新或 UI 渲染触发器具有较高的优先级。

基本用法:

const [isPending, startTransition] = useTransition()
  • isPending:一个布尔值,表示是否有过渡正在进行。
  • startTransition:一个函数,用于触发过渡。它接受一个回调函数作为参数,这个回调函数中的操作会被视为一个异步操作,可以触发 UI 过渡。

使用场景:

  • 异步操作:当需要在进行异步操作(例如数据加载、网络请求)时,能够在过渡期间保持界面响应性,避免界面卡顿。
  • 平滑过渡:当异步操作会导致界面元素的变化,而你希望在数据加载期间进行平滑的界面过渡效果。

示例:

import { useTransition } from 'react';

 function App() {
  const [isPending, startTransition] = useTransition();
  const [filterTerm, setFilterTerm] = useState('');
  
  const filteredProducts = filterProducts(filterTerm);
  
  function updateFilterHandler(event) {
    startTransition(() => {
      setFilterTerm(event.target.value);
    });
  }
 
  return (
    <div id="app">
      <input type="text" onChange={updateFilterHandler} />
      {isPending && <p style={{color: 'white'}}>Updating list..</p>}
      <ProductList products={filteredProducts} />
    </div>
  );
}   

14、使用 useDefferdValue() 允许变量延时更新

useDefferdValueReact 提供的一个钩子,用于在并发模式(Concurrent Mode)下处理 UI 过渡的一种方式。通过 useDefferdValue 允许变量延时更新,同时接受一个可选的延迟更新的最大值。React 将尝试尽快更新延迟值,如果在给定的 timeoutMs 期限内未能完成,它将强制更新。

const defferValue = useDeferredValue(value, { timeoutMs: 1000 });

useDefferdValue 能够很好的展现并发渲染时优先级调整的特性,可以用于延迟计算逻辑比较复杂的状态,让其他组件优先渲染,等待这个状态更新完毕之后再渲染。

useDeferredValue的作用和useTransition一致,都是用于在不阻塞UI的情况下更新状态。但是使用场景不同。useTransition是让你能够完全控制哪个更新操作应该以一个比较低的优先级被调度。但是,在某些情况下,可能无法访问实际的更新操作(例如,状态是从父组件上传下来的)。这时候,就可以使用useDeferredValue来代替。

举例🌰:

假设我们有一个包含从 019999 数字的数组。这些数字在用户界面上显示为一个列表。该用户界面还有一个文本框,允许我们过滤这些数字。例如,我可以通过在文本框中输入数字 99 来过滤掉以 99 开头的数字。

import { useState, useMemo, useDeferredValue } from 'react';

const numbers = [...new Array(200000).keys()];

// 父组件
export default function App() {
  const [query, setQuery] = useState('');

  const handleChange = (e) => {
    setQuery(e.target.value);
  };

  return (
    <div>
      <input type="number" onChange={handleChange} value={query} />
      <List query={query} />
    </div>
  );
}

// 子组件
function List(props) {
  const { query } = props;
  const defQuery = useDeferredValue(query);

  const list = useMemo(
    () =>
      numbers.map((i, index) =>
        defQuery ? (
          i.toString().startsWith(defQuery) && <p key={index}>{i}</p>
        ) : (
          <p key={index}>{i}</p>
        ),
      ),
    [defQuery],
  );

  return <div>{list}</div>;
}

15、使用错误边界组件捕获异常

默认情况下,若一个组件在渲染期间 render 发生错误,会导致整个组件树全部被卸载(页面白屏),这当然不是我们期望的结果。部分组件的错误不应该导致整个应用崩溃,为了解决这个问题,React 16 引入了一个新的概念 —— 错误边界

错误边界是一种 React 组件,这种组件可以捕获发生在其子组件树任何位置的异常,我们可以针对这些异常进行打印、上报等处理,同时渲染出一个降级(备用) UI,而并不会渲染那些发生崩溃的子组件树。

白话就是,被错误边界包裹的组件,内部如果发生异常会被错误边界捕获到,那么这个组件就可以不被渲染,而是渲染一个错误信息或者是一个友好提示!避免发生整个应该崩溃现象。

实现代码:

  • componentDidCatch(): 捕获错误,在这儿可以打印出错误信息、也可以对错误信息进行上报。
  • static getDerivedStateFromError(): 捕获错误,返回一个对象,更新 state
class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    // 发生错误则: 更新 state
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 捕获到错误: 可以打印或者上报错误
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 你可以自定义降级后的 UI 并渲染
      return <h1>深感抱歉, 系统出现错误!! 开发小哥正在紧急维护中.... </h1>;
    }
    return this.props.children; 
  }
}

// 错误边界使用
<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

注意事项:

  • 错误边界目前只在类组件中实现了,没有在 hooks 中实现,因为 Error Boundaries 的实现借助了 this.setState 可以传递 callback 的特性,useState 无法传入回调,所以无法完全对标。

  • 错误边界无法捕获以下四种场景中产生的错误,仅处理渲染子组件期间的同步错误。

    • 自身的错误
    • 异步的错误
    • 事件中的错误
    • 服务端渲染的错误
  • 错误边界只能在类组件中实现了,并不是指 Error Boundary 对 Hooks 不生效,而是指 Error Boundary 无法以 Hooks 方式指定,但是对功能是没有影响!你依然可以使用错误边界组件包裹使用了 hooks 的组件。