前言

我们在日常的项目开发中往往会把页面拆分成一个个的组件,通过拼装的方式来实现整体的页面效果,所以与其说去优化 React,不如聚焦在现有的组件中,思考🤔如何去设计一个组件才能提高他的性能,从而提高整个项目的性能以及交互的流畅性。

回顾

在我们初学 React 的时候相信大家或多或少运行过这样的 demo:

import React, { useState } from 'react';
import { Button, Space } from 'antd';
import 'antd/dist/antd.css';
import './index.css';

const Parent: React.FC = () => {
  const [count, setCount] = useState(0);
  return (
    <Space direction="vertical">
      {count}
      <Button type="primary" onClick={() => setCount(count + 1)}>
        count + 1
      </Button>
      <Children />
    </Space>
  );
};

const Children: React.FC = () => {
  console.log('更新了子组件');
  return <div>这是子组件</div>;
};

export default Parent;

代码很少就是个简单的父组件嵌套子组件的情况,我们测试运行也没啥问题,大多数时候我们可能也就这么开发了。
但是我们观察下,每次当我们点击按钮进行 count + 1 时会更新子组件,这一点从控制台打印的信息可以看出。虽然 React 中有 diff 算法决定是否需要切实更新 DOM 元素,但是其内部的定义的一些函数还是会执行,而且 diff 会遍历整棵 virtualDOM 树也会有一定的性能消耗,那能不能优化下这个勒。

性能优化实践

由于 React 中的组件分为 Function 组件和 Class 组件,优化的手段根据其特性不同也分为两类

Function 组件

React.memo

React.memo 是 React 提供的一个高阶组件,用于优化组件的性能。它可以在某些情况下避免不必要的组件重新渲染,从而提高应用程序的性能。其使用方式分为两种:

  • 基础使用
    函数组件直接包裹 React.memo 默认使用浅层比较。
  • 高阶使用
    如果需要更精确地控制何时重新渲染组件,可以通过传递第二个参数给 React.memo 来指定自定义的比较函数。这个比较函数接收两个参数,分别是前一次的 props 和当前的 props ,返回一个布尔值表示是否需要重新渲染组件
import React from 'react';

const areEqual = (prevProps, nextProps) => {
  // 自定义比较逻辑
  // 返回 true 表示两个 props 相等,不需要重新渲染
  // 返回 false 表示两个 props 不相等,需要重新渲染
  return prevProps.value === nextProps.value;
};

const MyComponent = React.memo((props) => {
  console.log('Rendering MyComponent');
  return <div>{props.value}</div>;
}, areEqual);

使用useCallback

useCallback 是 React 中的一个 Hook,用于优化性能和避免不必要的渲染。它主要用于创建一个稳定的回调函数,并在依赖项未发生变化时缓存该函数。
示例代码:

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

const MyComponent = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    handleClick();
  }, [handleClick]);

  // 使用 useCallback 缓存回调函数 handleClick
  const handleClick = useCallback(() => {
    setCount(prevCount => prevCount + 1);
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
};

注意⚠️:并不是必需的,它主要用于解决特定的性能问题。在大多数情况下,使用普通的函数定义也是有效的。只有在性能优化成为问题时,才需要考虑使用 useCallback。过度使用该useCallback会导致内存占用增加(每个缓存的回调函数都会占用内存),代码复杂度增加可读性变差并且难以维护。
可以遵循以下原则:

  • 只在需要时使用:只有在明确的性能问题存在时,或者需要将回调函数作为依赖项传递给其他 Hooks(如 useEffectuseMemo)时,才使用 useCallback
  • 明确指定依赖项:确保正确指定 useCallback 的依赖项数组,以确保缓存的回调函数在依赖项未发生变化时不会重新创建

使用useMemo

它用于在组件渲染过程中进行记忆化计算,以避免不必要的重复计算,提高应用的性能。
使用场景:

  • 计算昂贵的计算结果:涉及到需要执行昂贵的计算或处理大量数据的情况下,可以使用 useMemo 将计算结果缓存起来
  • 避免不必要的渲染:某个组件的渲染结果仅依赖于特定的输入参数,并且这些参数没有发生变化时,可以使用 useMemo 缓存该组件的输出,避免不必要的重新渲染
import React, { useMemo } from 'react';

const MyComponent = ({ data }) => {
  // 使用 useMemo 缓存结果
  const processedData = useMemo(() => {
    // 执行昂贵的计算或处理逻辑
    // 这里只是一个简单的示例,实际场景可能更复杂
    console.log('Processing data...');
    return data.map(item => item * 2);
  }, [data]); // 依赖项: 当 data 发生变化时重新计算

  return (
    <div>
      {/* 渲染使用 useMemo 缓存的结果 */}
      <ul>
        {processedData.map(item => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
};

Class 组件

巧用PureComponent

!!仅支持React 15.3及以上版本
PureComponent 是继承自 React.Component 的一个子类,它额外实现了shouldComponentUpdate 方法,并通过对组件的 props 和 state 进行浅层比较来确定是否需要重新渲染组件。
使用方式:

class App extends React.PureComponent

如果 props 和 state 没有发生改变,就不会进入 render 节点,省去了生成 Virtual DOM 和 Diff 的过程。
低版本可以使用PureRenderMixin,使用浅比较来决定是否应该触发组件的重新渲染。它会自动为组件添加一个 shouldComponentUpdate 方法,该方法会比较新的 propsstate 与当前的 propsstate,并根据比较结果决定是否重新渲染组件

import PureRenderMixin from 'react-addons-pure-render-mixin';

class App extends React.Component {
  constructor(props) {
    super(props);
    this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
  }

  // 组件的其他方法和生命周期函数
  // ...
}

合理使用shouldComponentUpdate

如果想对渲染进行更加细微的控制,或者是对引用类型进行渲染控制我们可以使用shouldComponentUpdate,通过返回 true  进行更新, false 阻止不必要的更新。

class MyComponent extends React.Component {
  shouldComponentUpdate(nextProps, nextState) {
    // 对比新旧属性和状态
    if (this.props.value === nextProps.value && this.state.count === nextState.count) {
      return false; // 属性和状态相同,不需要重新渲染
    }
    return true; // 需要重新渲染
  }

  render() {
    return <div>{this.props.value}</div>;
  }
}

特别注意⚠️:合理使用,手动实现 shouldComponentUpdate 可能会增加代码的复杂性,并且过度使用它可能会导致更多的维护问题。只有在确实需要优化性能时,才建议使用它。

在构造函数中绑定this

当我们在类组件中绑定类方法通过需要绑定他的 this 指向

// 方式一
render() {
  return <Button onClick={this.handleClick.bind(this)}>测试按钮</Button>
}

// 方式二
constructor() {
  super();
  this.handleClick = this.handleClick.bind(this);
}

虽然用起来是一样的,但是第一种方式在 render 的时候,每次会 bind this 生成新的函数实例,而第二种只会执行一次。

React中其他的优化手段

组件卸载时的清理

组件中注册的全局的监听器、定时器等,需要在组件卸载的时候进行清理,防止后续的执行影响性能以及内存泄露等问题

  • Class 组件:componentWillUnmount
  • Function 组件:useEffect return
import React, { useState, useEffect } from 'react';

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // 定义定时器
    const timer = setInterval(() => {
      setCount(count => count + 1);
    }, 1000);

    const handleOnResize = () => {
	console.log('Window resized');
    }

    // 定义监听器
    const listener = window.addEventListener('resize', handleOnResize);

    // 在组件卸载时清除定时器和监听器
    return () => {
      clearInterval(timer);
      window.removeEventListener('resize', handleOnResize);
    };
  }, []);

  return (
    <div>
      <p>{count}</p>
    </div>
  );
}

export default Timer;

使用lazy进行组件懒加载

通过组件懒加载可以将代码分割成更小的块,并且只有在需要时才会被加载
当用户访问某个特定页面时,只有与该页面相关的代码会被下载和执行,而其他代码则不会被加载。这样可以使应用程序更快地启动,并减少页面响应延迟。

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

const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));

function App() {
  return (
    <Router>
      <Suspense fallback={<div>Loading...</div>}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route path="/about" component={About} />
        </Switch>
      </Suspense>
    </Router>
  );
}

export default App;

在React中我们通常使用 lazySuspense 互相配合的方式进行懒加载,使用 lazy 方法来懒加载 HomeAbout 组件,使用Suspense可以在组件加载完成之前显示一个自定义的加载指示器或占位符,从而提高用户体验。

使用React Fragment减少额外节点的渲染

!!仅支持React 16.2及以上版本
React Fragment 允许你在 React 组件中返回多个元素而不需要添加额外的根节点,比如在某些情况下你的组件返回的是一个 list 的元素集合而他的根元素在父组件中,你想要的结构是根节点内直接元素的集合而不想再包裹一层,可以使用这种方式解决

javascript
import React, { lazy, Suspense } from 'react';

function TdList() {
  return (
    <React.Fragment>
      <td>Hello, World!</td>
      <td>This is a paragraph.</td>
    </React.Fragment>
  );
}

function Table() {
  return (
    <table>
      <tr>
	<TdList />
      </tr>
    </table>
  );
}

除此以外还具有一下优势:

  • 更清晰的代码结构:以片段的形式包裹元素可读性更高,避免了成片的div
  • 减少 DOM 层级:减少渲染出来的 DOM 层级,从而提高性能
  • 更符合预期:更容易使我们按照预期呈现出想要表达的组件特别是在便利的时候,而不会引起元素的父子关系问题

有时候我们也会使用<></>,被称为空标签或者隐式 Fragment,其用法和Fragment相同,唯一的区别在于空标签不能添加任何属性,而后者可以,比如说在某些场景下需要给父元素增加key值。此时只能使用Fragment

减少使用通过内联函数绑定事件

当我们在 React 中使用内联函数时,每次重新 render 将导致生成新的函数实例从而为元素绑定新的函数,在非嵌套的组件使用时影响不大,但是如果存在嵌套组件并且该内联函数是作为 props 传递给子组件时将会导致子组件重新渲染,即使内联函数里的代码相同的情况下。

  • Class 组件:将内联函数定义为类方法传递给子组件
  • Function 组件:useCallback进行缓存

// bad
import React, { useCallback } from 'react';

function MyComponent() {
  return (
    <button 
	onClick={() => { 
	  // 处理点击事件 
	}}
     >
	Click me
    </button>
  );
}

// good
import React, { useCallback } from 'react';

function MyComponent() {
  const handleClick = useCallback(() => {
    // 处理点击事件
  }, []);

  return (
    <button onClick={handleClick}>Click me</button>
  );
}

使用key提升列表的渲染性能

假设你有一个需要渲染大量数据的列表组件,每个列表项都是一个独立的子组件。当你对这个列表进行添加、删除或重新排序操作时,React 需要计算出哪些子组件需要更新。

import React from 'react';

function MyComponent(props) {
  const data = props.data; // 假设这是一个包含大量数据的数组

  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

未使用 key 的情况下,React 将无法区分列表项之间的差异,而会重新渲染整个列表。使用 key 可以帮助 React 提高性能,它可以通过比较新旧的 key 来确定是否需要更新特定的列表项。这样,当你对列表进行操作时,React 只会针对变化的部分进行更新。
注意⚠️:尽量不要使用 index 作为 key 值

通用的优化手段

可见性加载

在我们浏览很多图像、卡片、列表时我们往往不会立马加载所有的资源,主要是因为一个是很浪费资源,另一个就是在不可见区域内也必要进行加载。借鉴这个思想,我们是否可以在组件在视口范围内进行加载呢,其余不进行加载——IntersectionObserver 或者是一些现有的库如react-loadable-visibility(通过 react-loadable 按需加载组件 + Intersection Observer API 监听组件的可见性)

import React from "react";
import { Button } from "antd";
import LoadableVisibility from "react-loadable-visibility/react-loadable";

const LoadingComponent = () => <div>Loading...</div>;

const MyComponent = LoadableVisibility({
  loader: () => import("./MyComponent"),
  loading: LoadingComponent
});

const App = () => {
  return (
    <div>
      <h1>My App</h1>
      <MyComponent />
    </div>
  );
};

export default App;

当 MyComponent 组件进入视口时,它们才会被加载和渲染,而在加载过程中,会显示 LoadingComponent 组件作为占位符,需要注意的是,确保在支持 Intersection Observer API 的浏览器中进行

交互式导入资源

页面中包含并非立即需要的组件或资源的代码或数据,立即加载这些资源将阻塞主线程,而这些功能当用户不去触发某些操作是也是用不到的,就可以采用这种方式。
比如我们有个需求是点击“滚动到顶部”按钮时以动画方式滚动回页面顶部,这里我们用到了react-scroll 这个包,那可以在与按钮交互时加载它

handleScrollToTop() {
  import('react-scroll').then(scroll => {
    scroll.animateScroll.scrollToTop({
    })
  })
}

Web Worker

通过 Web Worker 创建多线程的环境,主线程把一些任务分配给后者运行,不会阻塞主线程的运行,使交互更加流畅。
适用场景:

  • 计算密集型或高延迟的任务

虚拟列表

仅渲染可见区域的 dom 元素而不必要渲染全部,提高渲染性能。可以使用 React-virtualized 或者是 React-window 等包。

总结

以上就是笔者对性能优化方面的研究🧐和总结,如果大家在日常开发中有这样的诉求可以参考以上几种方式。

参考资料:


最后

欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈UED团队持续为广大开发者分享技术成果,相继参与开源了欢迎star

07-26 09:52