(笔记)React组件优化最佳实践 15 min

1,266 阅读16分钟

React组件性能优化

核心:减少渲染真实DOM的频率,以及减少 VDom 比对的频率。

1. 组件卸载前执行清理操作

  • 注册的全局事件
  • 定时器

证明:在组件挂载之后通过 useEffect 中开启定时器,销毁此组件之后,定时器还是存在的

基于此,需要在useEffect第一个形参函数的返回值中将定时器清除掉。

在此实验中,我们使用 flag && <Test /> 中 flag 的值来装载或者卸载 Test 组件 (const [flag, setFlag] = useState(true))。

2. 通过纯组件来提升性能

什么是纯组件?

所谓纯组件,就是当输入数据发生改变的时候,会将新数据和旧数据进行一次浅层比较,如果浅层比较结果相同,那么就不会引起重新渲染。注意这里的比较是浅层比较而不是 diff 比较。

如何实现纯组件?

使用 PureComponent 类或者 memo 方法可以实现纯的类或者函数组件。

验证示例

import React from "react";

export default class App extends React.Component {
  constructor() {
    super();
    this.state = { name: "张三" };
  }

  updateName() {
    setInterval(() => this.setState({ name: "张三" }), 1000);
  }

  componentDidMount() {
    this.updateName();
  }

  render() {
    return (
      <div>
        <RegularComponent name={this.state.name} />
        <PureComponent name={this.state.name} />
        {/* 这里可能有其他组件或JSX元素 */}
      </div>
    );
  }
}
class RegularComponent extends Component {
    render() {
        console.log('render');
        return <div>{this.props.name}</div>
    }
}
class PureComponent extends PureComponent {
    render() {
        console.log('render');
        return <div>{this.props.name}</div>
    }
}

3. 在类组件中使用 shouldComponentUpdate

由于使用 PureComponent 只能进行浅层的比较,所以在类组件中使用 shouldComponentUpdate 生命周期函数能够自定义用户的比较行为。除此之外,如果传入的是一个引用类型,那么就算表示的数据或者含义完全一致,那么也会引起组件的渲染,这个时候浅层比较无法满足要求。或者有的时候组件只是无意获得了不需要渲染的数据,当这部分数据改变之后也无需重新渲染组件。

此生命周期函数的返回值是一个布尔值,如果为 true 表示需要更新,反之则不需要进行更新;此函数接受两个参数,其一是 nextProps,另外一个 nextState。分别表示外部和内部数据。

import React from "react";

export default class App extends React.Component {
  constructor() {
    super();
    this.state = {
      person: {
        name: "张三",
        age: 20,
        job: "waiter"
      }
    };
  }

  componentDidMount() {
    setTimeout(() => {
      // 这里使用扩展运算符合并对象来确保我们创建了person对象的一个新副本
      this.setState({ person: { ...this.state.person, job: "chef" } });
    }, 2000);
  }

  shouldComponentUpdate(nextProps, nextState) {
    // 只在person对象的name或age属性发生变化时更新组件
    if (this.state.person.name !== nextState.person.name || this.state.person.age !== nextState.person.age) {
      return true;
    }
    return false;
  }

  render() {
    return (
      // 此处根据你的需求可以添加任何需要显示的内容
      <div>
        Name: {this.state.person.name},
        Age: {this.state.person.age},
        Job: {this.state.person.job}
      </div>
    );
  }
}

上面的比较过程中 this.props 和 nextProps 比较;this.state 和 nextState 比较。

4. 通过函数式组件 React.memo 提升性能

父组件的渲染会引起子组件的渲染

证明示例:

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

function App() {
  const [name] = useState("张三");
  const [index, setIndex] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setIndex(prev => prev + 1);
    }, 1000);

    // 清除interval,防止内存泄漏
    return () => clearInterval(intervalId);
  }, []);

  return (
    <div>
      {index}
      <ShowName name={name} />
    </div>
  );
}

function ShowName({ name }) {
  console.log("rendering ShowName");
  return <div>{name}</div>;
}

export default App;

使用memo优化上述代码,做法就是使用 React.memo 进行包裹。

const ShowName = React.memo(function ({ name }) {
  console.log("rendering ShowName");
  return <div>{name}</div>;
})

memo 具有第二个参数,第二个参数也是一个函数,此函数返回一个布尔值,其作用是自定义用户的比较行为;如果返回值是true则表示比较的两个对象(也是此函数的两个入参,分别为: prevProps 和 nextProps )是相同的,因此也就不需要重新渲染;否则则需要重新渲染。

通过和 shouldComponentUpdate 的对比,不难看出来参数发生了变化,原因是函数式组件没有 state 的概念,也体现在这个地方了。

import React, { memo, useEffect, useState } from "react";

// 比较函数,用以优化渲染
function isEqual(prevProps, nextProps) {
  if (
    prevProps.person.name !== nextProps.person.name ||
    prevProps.person.age !== nextProps.person.age
  ) {
    return false; // 如果person的name或age改变了,就重新渲染
  }
  return true; // 如果person的name或age没变,不重新渲染
}

// 用memo包裹的组件,将比较函数作为第二个参数传入
const ShowPerson = memo(function ShowPerson({ person }) {
  console.log("rendering ShowPerson");
  return (
    <div>
      {person.name} {person.age}
    </div>
  );
}, isEqual);

function App() {
  const [person, setPerson] = useState({ name: "张三", age: 20, job: "waiter" });

  useEffect(() => {
    const intervalId = setInterval(() => {
      // 更新设置person状态对象中的job属性,而不是name或age
      setPerson(prevPerson => ({ ...prevPerson, job: "chef" }));
    }, 1000);

    // 清除interval,防止内存泄漏
    return () => clearInterval(intervalId);
  }, []);

  return (
    <div>
      <ShowPerson person={person} />
    </div>
  );
}

export default App;

Attention!!

  • memo 对标的是 shouldComponentUpdate
  • memo 有两个参数,均为函数,第一个表示函数式组件,第二个表示的是 isEqual 比较函数。
  • 如果第二个函数缺失,则 memo 自动进行浅层比较。

5. 使用组件的懒加载来提升组件性能

使用懒加载的组件优化的核心逻辑在于减少 bundle 文件的大小,加快组件的呈现速度。但是,采用懒加载的组件会被打包到不同的文件中(分包)

5.1 路由组件懒加载

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

// 使用React的lazy函数动态导入组件
const Home = lazy(() => import(/* webpackChunkName: "Home" */ "./Home"));
const List = lazy(() => import(/* webpackChunkName: "List" */ "./List"));

function App() {
  return (
    <BrowserRouter>
      <Link to="/">Home</Link>
      <Link to="/list">List</Link>
      <Switch>
        // 使用Suspense包裹Route,并提供fallback来展示加载状态
        <Suspense fallback={<div>Loading...</div>}>
          <Route path="/" component={Home} exact />
          <Route path="/list" component={List} />
        </Suspense>
      </Switch>
    </BrowserRouter>
  );
}

export default App;

代码分析:

  • lazy 是一个React函数,它允许你定义一个动态加载的组件。这里,HomeList组件都是通过lazy函数和动态import进行定义的。Webpack将这些动态导入的组件分离到不同的代码块(chunks),当访问对应路由时才会加载它们。
  • Suspense组件是React内置的一个组件,它允许你在渲染等待内容(如懒加载组件)时显示一些回退内容。在这个例子中,回退内容是一个简单的<div>Loading...</div>,它会在懒加载组件被加载和渲染之前显示。
  • BrowserRouter 是react-router-dom库中的组件,它使用HTML5历史API(pushStatereplaceStatepopstate事件)来保持UI和URL的同步。
  • Link组件提供声明式的、可访问的导航的方式。
  • Route是配置路由的基本单元,它将一个路径和一个组件映射起来,当路径匹配时就会渲染对应的组件。
  • Switch组件用于渲染第一个匹配当前位置的<Route><Redirect>

这种方式使得在应用启动时不会加载所有组件,而是仅在用户导航到相应的路由时才加载对应的组件,从而优化了性能。

Attention

  • lazy(() => import(/* */ 'path'))
  • Suspense
  • fallback
  • exact 表示精确匹配

5.2 根据某种条件进行组件懒加载

使用条件:组件不会随着条件频繁切换的场景下

import React, { lazy, Suspense } from "react";

function App() {
  let LazyComponent = null;
  if (true) {
    LazyComponent = lazy(() => import(/* webpackChunkName: "Home" */ "./Home"));
  } else {
    LazyComponent = lazy(() => import(/* webpackChunkName: "List" */ "./List"));
  }

  return (
    <Suspense fallback={<div>Loading</div>}>
      <LazyComponent />
    </Suspense>
  );
}

export default App;

Attention

  • 路由懒加载
  • 条件懒加载

6. 使用Fragment避免额外标记

那就是:<Fragment></Fragment>

7. 避免使用内联函数提升函数性能

原因:render 函数每次执行渲染的时候都会重新创建此内联函数的实例,导致 React 在进行虚拟 DOM 的对比的时候,同一个位置的内联函数并不相等,由此导致两个消耗:新的创建需要消耗;旧的回收也需要消耗。由于函数本质上也是对象,还会造成子组件重复渲染的问题(很严重的问题)。

不好的实践:

onChange={e=>this.setState({value:e.target.value})}

好的实践:

this.handleOnChange = e=>this.setState({value:e.target.value});
...
onChange={handleOnChange}

而在 react 函数式组件中我们使用 useCallback, 注意 useCallback 的主要应用场景为:在函数式组件中创建的函数需要传递给子组件的时候,需要使用 useCallback 进行包裹,以避免父组件 rerender 的时候强制 memo 子组件 也重新渲染的问题

8. 正确的更正 this 的指向问题

修正this指向问题的方法有好几种,但是通过对比下来,最佳实践为:

constructor(){
    super();
    this.handleClick = this.handleClick.bind(this);
}

不好的实践为:

<Button onClick={this.handleClick.bind(this)}>按钮</Button>

原因:上述代码在 render 执行的时候都会执行一次,重复次数多,不像 constructor 只执行一次。

9. 避免在类组件中使用箭头函数创建类方法

在类组件中使用箭头函数的好处就是完全不用担心 this 的指向问题;因为箭头函数并不会改变 this 的指向。但是我看到过一句话:this 的指向问题从来就不是使用箭头函数的原因。因此,并不推荐在类组件中使用箭头函数创建方法。

从功利的角度来看,如果使用箭头函数创建类的方法,此方法不会挂载在原型上,而是作为实例的一个属性。也就是说如果此类组件实例化很多次,那么此方法也会被实例化相同次数,这会造成极大的浪费。

因此,在类组件中解决 this 的最佳实践仍然是在构造函数中 bind(this).

10. 避免使用内联样式属性

如果在项目中使用如下的代码,那么在编译之后,内敛的 style 会被映射成为 js 代码,最后就变成了 js 创建样式,导致浏览器会花费更多的时间执行脚本和渲染 UI,从而降低了性能。

核心问题:**CSS 渲染 UI 的速度远超过 js,因此能不用 js 操作样式就不要用!**这一点很重要。本质上还是 js 操作 DOM 很费时间。

不好的实践:

fucntion App () {
  return <div style={{backgroundColor: 'red'}}>div</div>
}

11. 对条件渲染进行优化

这点主要是针对:频繁的挂载和卸载组件是一项非常消耗性能的事情 这一事实提出的优化,其本质仍然是尽量减少对DOM的操作

好的实践

function App() {
  return (
    <>
      {true && <AdminHeader />} // true ? <AdminHeader /> : <></> 更好理解一些
      <Header />
      <Content />
    </>
  );
}

不好的实践

function App() {
  if (true) {
    return (
      <>
        <AdminHeader />
        <Header />
        <Content />
      </>
    );
  } else {
    return (
      <>
        <Header />
        <Content />
      </>
    );
  }
}

第一种做法中,随着条件的改变,重新渲染的只有 AdminHeader 组件,而第二种做法三个组件都会重新渲染,这也是由于虚拟 DOM 的对比策略所决定的。

总结一下,如果知道 diff 算法的原理,那么就应该顺从原理去组织 render 的结构,以减少性能上的消耗。

12. 避免重复的无限渲染

避免在 componentWillUpdate、componentDidUpdate 或者 render(是纯函数) 方法中调用 setState 等可以触发组件再次渲染的做法。本质上是避免 render函数循环调用自身

render(){
    this.setState({count: this.count+1});
}

这样就破坏了 render 是一个纯函数。最多递归 50 次。

13. 为组件创建错误边界

先说不足:错误边界本质上是一个组件;但是只能在同步错误发生的时候显示出来,异步错误是没有办法被错误边界响应的!

错误边界(Error Boundaries)是 React 的一个特性,它可以捕获其子组件树中JavaScript错误,记录这些错误,并显示备用UI,而不是让整个组件树崩溃。错误边界只能通过类组件来实现,因为需要使用生命周期方法 componentDidCatchgetDerivedStateFromError。前者是实例方法,用来执行发生错误之后的副作用;后者是一个静态方法用来改变错误状态。

以下分别介绍在类组件和函数式组件中如何处理错误,并举例说明。

类组件中的错误边界

在类组件中,你可以定义一个错误边界组件,如下所示:

import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 当子组件抛出异常,这里将会被调用,返回新的state
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    // 你同样可以在这里记录错误信息
    console.error('ErrorBoundary caught an error', error, info);
  }

  render() {
    if (this.state.hasError) {
      // 当发生错误时,你可以渲染任何自定义的回退UI
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

然后你可以像这样使用 ErrorBoundary 组件:

<ErrorBoundary>
  <MyComponent />
</ErrorBoundary>

这样,如果 MyComponent 或者其任何子组件在渲染过程中发生JavaScript错误,ErrorBoundary 就会显示备用 UI,并防止整个应用崩溃。

函数式组件中的错误处理

函数式组件不能直接创建错误边界,因为它们不支持 componentDidCatchgetDerivedStateFromError 这类生命周期方法。然而,你可以在函数式组件内使用 hooks 来处理错误,例如使用 useStateuseEffect 来模拟类似的行为。不过,这不是标准的错误边界实现,标准的错误边界目前只能通过类组件来实现。

但是,你可以通过将函数式组件包裹在上面定义的错误边界类组件中来提供错误捕获功能。

例如:

function MyFunctionalComponent() {
  useEffect(() => {
    try {
      // 这里是可能会抛出错误的代码
    } catch (error) {
      // 你可以在这里处理错误,例如设置状态显示错误信息
    }
  });

  return (
    // 你的组件返回值
  );
}

// 应用错误边界
<ErrorBoundary>
  <MyFunctionalComponent />
</ErrorBoundary>

在这个例子中,任何在 MyFunctionalComponent 中发生的错误都需要自己处理,并不利用错误边界来捕获。但是被 ErrorBoundary 包裹的话,任何子组件树中的错误仍然可以被 ErrorBoundary 捕获。

总而言之,如果你希望在函数式组件中享有错误边界的保护,你需要将函数式组件放入一个可以作为错误边界的类组件之内。 直到 React 提供函数式组件的官方错误边界支持,这种方式将是常规的做法。

总结一下

本质上还是:条件渲染;只不过引发条件变化的在于:是否发生了错误!

Attention

  • getDerivedStateFromError
  • componentDidCatch
  • this.props.children

14. 避免数据结构的突变

结论:组件中的 props 和 state 的数据结构应该保持一致,数据结构的突变会导致输出不一致!!这一点在 state 的层数比较深的时候一定要引起额外注意!

onClick={() =>
  this.setState({
    ...this.state,
    employee: {
      ...this.state.employee,
      age: 30
    }
  })
}

而不是:

onClick={() =>
  this.setState({
    ...this.state,
    employee: {
      age: 30
    }
  })
}

这样容易把一些属性弄丢,引发结构突变。

15. 优化依赖项大小

有一些库不支持动态加载,比如说 lodash。但是 lodash 提供了一些插件,使用这些插件也能够实现按需加载相同的效果,从而显著的减少最终打包成的 bundle 的大小。

使用 react-app-rewired customize-cra babel-plugin-lodash

16. 务必注意渲染加唯一标识

当需要渲染列表数据时,我们应为每一个列表项添加 key 属性,key 属性的值必须是唯一的。

key 属性可以让 React 准确了当的追踪列表项的增减与顺序,从而减少了 React 内部渲染 Virtual DOM 查找差异所需的性能消耗。避免了无意图的渲染造成的的额外浪费。

当列表数据没有唯一标识,可以临时使用索引作为 key 属性的值,但是仅限于列表项是静态的、不会被动态改变。比如不会对列表进行排序或者过滤,不会从列表中间添加或者删除项的情况。当发生以上行为时,索引会造成歧义,并不可靠。

17. 使用高阶组件

高阶组件对应的是类组件,也就是说只有类组件才有使用高阶组件的必要性,本质上是对类组件的一种增强方式。HOC 在函数式组件中用处不大,因为在函数式组件中可以使用 hooks 进行逻辑上的复用。

所谓的高阶组件指的就是:在 React 应用中共享代码,增加代码复用的一种方式。比如A组件和B组件都需要一个相同的逻辑,此时就可以使用高阶组件来提供这个相同的逻辑。高阶组件的本质就是在原来的组件外面再包裹一层专门用来执行逻辑的组件,公共部分的逻辑在高阶组件中执行完毕之后将结果传递到被包裹的组件中去。从形式上来看,高阶组件本质上是一个函数,接受被包裹的组件作为参数,如下图所示:

image.png

下面是一个HOC的示例:

function withResizable(WrappedComponent) {
  function WithResizable() {
    const [sizes, setSizes] = useState([window.innerWidth, window.innerHeight])
    useEffect(() => {
      window.addEventListener("resize", () => {
        setSizes([window.innerWidth, window.innerHeight])
      })
    }, [])
    return <WrappedComponent sizes={sizes} />
  }

  return WithResizable
}

function App({ sizes }) {
  return <div>{JSON.stringify(sizes)}</div>
}

export default withResizable(App);

高阶组件从来指的都不是 withResizable 这个函数,它都不是大写开头的。HOC 指的是一种思想,类似于函数组件工厂。

另外一种复用公共逻辑的方式

代码1:

import React, { useEffect, useState } from "react"

function Resizeable({ render }) {
  const [sizes, setSizes] = useState([window.innerWidth, window.innerHeight])
  useEffect(() => {
    window.addEventListener("resize", () => {
      setSizes([window.innerWidth, window.innerHeight])
    })
  }, [])
  return render(sizes)
}

export default Resizeable

代码2:

import React from "react"

function App({ sizes }) {
  return <div>{JSON.stringify(sizes)}</div>
}

export default App

代码3:

import React from "react"
import ReactDOM from "react-dom"

import Resizeable from "Resizeable"
import App from "./App"

ReactDOM.render(
  <Resizeable render={sizes => <App sizes={sizes} />} />,
  document.getElementById("root")
)

上述的代码实际上提供了一种通过组件属性进行代码复用的方法。

Attention

  • 逻辑抽取和复用
  • 条件渲染和合并渲染
  • 渲染劫持(例如 props 注入或者公共事件监听)

18. 使用 Portal 简化代码逻辑

要说 Portal 的使用,最常见的可能就是 Antd 中的 Modal 组件了。此组件在默认情况下无论写在什么地方,都会渲染在 body 标签中,这样有利于占满全屏。

import React from "react"
import ReactDOM from "react-dom"

function PortalDemo() {
  return ReactDOM.createPortal(
    <div>PortalDemo</div>,
    document.getElementById('portal-root')
  )
}

export default PortalDemo

// App.js
import PortalDemo from "PortalDemo"
import React, { useState } from "react"

function App() {
  const [isShow, setIsShow] = useState(false)
  return (
    <div>
      App works
      {isShow ? <PortalDemo /> : null}
      <button onClick={() => setIsShow(prev => !prev)}>显示/隐藏</button>
    </div>
  )
}

export default App

尽管组件 PortalDemo 写在了 App 组件中,但是这里的结构只是 VDOM 中的结构,最后渲染完毕之后,PortalDemo 对应的 UI 会出现在 id 为 'portal-root' 的 div 中。

Attention

  • 代码复用本身就是优化

总结 -- React 组件优化 共 14 条

  1. 卸载清理
  2. 使用 pureComponent 并手动写子组件被迫更新的比较函数(shouldComponentUpdate 和 memo)
  3. 路由和条件渲染的时候使用懒加载组件
  4. Fragment 减少额外标记
  5. 避免使用内联函数(实例化角度、子组件重新渲染角度)
  6. 改变 this 的最佳实践,包括不要在类组件中使用箭头函数(如果仅仅是为了解决 this 指向问题的话)
  7. 避免内联样式转成 js 操作 dom 引起性能下降
  8. 写更加利于 diff 算法的 render 结构;包括 key 的使用。
  9. 防止在某些生命周期函数中修改状态导致无限渲染
  10. 为组件创建错误边界
  11. 谨防 state 的数据结构发生突变
  12. 利用一些插件为非模块化第三方库实行树摇
  13. 使用高阶组件做渲染劫持、合并渲染或者公共逻辑抽象
  14. 使用 portal (ReactDOM.createPortal) 复用组件