React组件设计之性能优化篇

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:空山

前言

由于笔者最近在开发中遇到了一个重复渲染导致子组件状态值丢失的问题,因此关于性能优化做了以下的分析,欢迎大家的交流

我们在日常的项目开发中往往会把页面拆分成一个个的组件,通过拼装的方式来实现整体的页面效果,所以与其说去优化 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进行组件懒加载

React版本支持16.6及以上版本
低版本可以考虑使用第三方库(如react-loadable)来实现类似的懒加载效果

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

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

热门相关:流鱼无恙   锦庭娇   上神来了   美人图2008   金粉