🔥 React 性能优化实战!

1,734 阅读8分钟

🦄 问题

后端同学在使用我们研发的 工作台 时发现了一些性能上的问题。在他的电脑上快速发送消息时会感觉到明显的消息 loading 等待时间较长的问题。但是我们使用 HTTP 2 本身就可以多路复用。并且在正式环境中。后端发送消息的接口平均响应在 20 ms。因此可以排除是后端接口的原因。那么只能从前端方面入手,看看是什么原因造成的 loading 时间等待较长的原因了。

先看看优化之前的效果图 (因为再录制屏幕,这个 loading 等待较长的问题会尤为明显,正常情况下,快速发消息持续 15s 就会复现。)

2022-08-07 07.59.23.gif

🤔 分析

为什么会有这么长的 loading 等待时间呢?

那我们先打开 DevTools 进行一下性能分析吧!

image.png

点击录制,然后等待录制结果。我们进行分析!

image.png

可以看到,首先任务被 React 切分的很漂亮 😀。其次仔细观察可以发现有很多任务耗时都在 200+ms,那究竟是什么引起的这个问题呢?我先随便先展开一个长任务进行分析。

image.png

我们可以清晰的看到,在发送消息的过程中,React 会做多次的渲染,这个种情况非常的不正常。为了找到问题的根本,我们还需要进一步确认。

接下来我们打开 React Developer Tools 进行分析。查看是具体是哪里造成了这么多次的渲染。

image.png

效果图如下:

2022-08-07 08.27.18.gif

竟然渲染了这么多次!我认为我们已经找到了造成快速发送消息 loading 等待时间较长的元凶!

接下来我们需要仔细阅读代码,查看是哪里造成了多次的渲染!

😄 优化

👉 ImChatBody

首先我们来查看阅读 ImChatBody 组件的相关代码,在这个过程中我们可以看到如下代码:

export function ImChatBody(props: ImChatBodyProps): JSX.Element {

  ...

  const renderMessageList = (): ReactNode => {
   const getGroupInfo = () => {
       ...
   }

    return (
      <>
        {messages.map((message, idx) => {
         
          return (
            <Fragment key={message.localId || message.ID}>
             ...
              <ImMessageItem
                ...
                message={message}
                ...
              />
            </Fragment>
          )
        })}
      </>
    )
  }

  return (
    ...
    {renderMessageList()}
    ...
    </div>
  )
}

对于这种 JSX 代码,我们需要警惕,为什么呢?

首先这个代码写成了函数的形式,在当页面任何一个状态发送改变时,都会从新进行递归更新页面,都会执行到这个 renderMessageList() 函数,尽管 React 会复用可复用的 Fiber,但是在 diff 阶段,reconcileChildrenArray 函数中,会使用最新的 JSX 对象和 Fiber 对象进行对比。通常函数执行很多次,那么可以想象这个对性能的消耗。我们也可以看看这个函数的执行次数。

如下图:

2022-08-07 08.47.09.gif

所以我们可以清晰的看到这个 List 渲染了这么多次。因此这是一个非常值得优化的点、那我们需要怎么优化呢?

  1. 首先需要将其改为一个正常的组件。
  2. 在使用 memo 对其进行性能优化。
  3. 完成组件的改造之后再进行 console 的打印,以确定其的优化是否 OK。
const RenderMessageList = memo(function RenderMessageList({
  messages,
  ...
}: {
  messages: Message[]
  ...
}) {
  ...

  const getGroupInfo = useMemo(
    () =>
     ...
    [messages, ...],
  )

  return (
    <>
      {messages.map((message, idx) => {
       ...
        return (
          <MessageItem
            key={message.localId || message.ID}
            message={message}
            ...
          />
        )
      })}
    </>
  )
})

我们在对其进行 console 的打印输出,查看该组件的渲染次数。

2022-08-07 09.03.52.gif

有的同学可能会问,为什么需要使用 memo 呢?

因为函数组件的特性,其实和 renderMessageList 这个样子写所得到的效果是一样的。我们使用 memo 的原因就是想减少 RenderMessageList 组件的渲染次数。那 memo 就比较合适。当不传递 第二个参数时,它会对 props 进行引用的对比来减少渲染的次数。

就目前的效果来看,对于 renderMessageList 函数的优化是成功的。

👉 ImMessageItem

接下来我们需要将注意力放到 ImMessageItem 这个组件上。该组件的主要作用就是根据消息的类型,来渲染不同的消息。

但是我们可以从 renderMessageList 函数中可以看到,我们是直接遍历 Messages 这个数组进行消息的渲染。但是在测试过程中发现,每次发送新消息,新消息都会触发生成三次新的 Messages 数组,因为引用的不同,包括 Messages 数组中的每个对象的引用也跟着改变,那么对于之前的消息来说,props 改变,ImMessageItem 组件也会从新渲染。

如图:

2022-08-07 09.21.54.gif

我们可以看到,会有很多次的渲染,console 会被分为三个 16 次,我们的消息页,一页也是 16 条 消息。

因此我们需要考虑怎么进行优化呢?

  1. 对于之前的老数据来说,我们需要让其不进行渲染。
  2. ImMessageItem 组件只渲染新的消息。

思路现在有了,但是应该考虑怎么实现呢?因为每个 Message 的引用都不同,因此 useMemo, useCallback 也都使用不了。我们的想法是不渲染老的消息数据,对象的引用不能用于判断,那我们只能使用 memo 的第二个参数,传递函数手动控制组件的渲染了。

  1. 将 ImMessageItem 组件周围的代码再封装一个组件。
  2. 使用 memo 对其进行更加详细的优化。可以考虑使用 equals 函数进行内容的判断。(因为对于老的消息数据内容是不会改变的)
  3. console 验证组件的渲染次数
const MessageItem = memo(
  function MessageItem({
    message,
    ...
  }: {
    message: Message
    ...
  }) {
    return (
      <Fragment key={message.localId || message.ID}>
        ...
        <ImMessageItem
          ...
          message={message}
          ...
        />
      </Fragment>
    )
  },
  function areEqual(prevProps, nextProps) {
    return equals(prevProps.message, nextProps.message)
  },
)

在这里有的同学可能会觉得,直接使用 equals 也会有性能问题,每次都会进行内容的深入比较,也是有问题的。我也是明确这一点的,在这里我其实做了一些衡量的,首先对于之前的写法会造成大量的渲染,直接导致消息的loading等待时间较长。相比较于 equals 函数,虽然也会造成一些性能上的问题,但是二者比较之下,equals 所造成的性能要远小于目前的写法,我觉得这样的性能牺牲是值得的!

如图:

2022-08-07 09.50.06.gif

我们可以看到效果还是非常不错的!👍🏻

👉 MessageContentRenderer

我们接下来将注意力聚焦在 messageContentRenderer 函数上,这个函数主要的作用就是渲染不同消息类型,总体来说这部分的代码问题相对来说比较多。

function messageContentRenderer({
  message,
  ...
}: {
  message: Message
  ...
}): null | JSX.Element {
  const AccountWrapper: FC<{ children: JSX.Element; account: Account }> = ({
    children,
    account,
  }) => {
    return ticketPermission ? (
      <FastAssign
        ticketPermission={ticketPermission}
        ...
      >
        {children}
      </FastAssign>
    ) : (
      <>{children}</>
    )
  }
  const header: undefined | JSX.Element =
    isShowMessageHeader && !mergeWithPrevMessage ? (
      <MessageHeader message={message} AccountWrapper={AccountWrapper} />
    ) : undefined

  const createContentRender = (
    content: JSX.Element,
    showSendState?: boolean,
  ): JSX.Element => (
    <MessageContentWithMenu
      message={message}
      ...
    >
      {content}
    </MessageContentWithMenu>
  )

  switch (message.type) {
    case MessageType.text:
      return (
        <ImMessageText
          from={message.from}
          ...
          customContentRender={content => createContentRender(content, true)}
        />
      )
    case MessageType.htmlText:
      return (
        <ImMessageHTMLText
          message={message}
          ...
          customContentRender={createContentRender}
        />
      )
    case MessageType.richText:
      return (
        <ImMessageRichText
          message={message}
          ...
          customContentRender={content => createContentRender(content, true)}
        />
      )
    case MessageType.file:
      const isImageMessage = Boolean(message.payload.thumbnailPreviewUrl)
      return (
        <ImMessageCustomFile
          message={message}
          ...
          customContentRender={createContentRender}
        />
      )
    case MessageType.sticker:
      return (
        <StickerMessage
          message={message}
          ...
          customContentRender={createContentRender}
        />
      )
    case MessageType.revoked:
      return <RevokedMessage message={message} header={header} />
  }
}

仔细阅读代码我们可以发现像 AccountWrapper、header、createContentRender 这些 JSX 我们需要使用 useMemo、useCallback 对其进行保存引用,减少其更新,进一步的减少各个消息组件 props 的引用改变导致的重复渲染。

export const MessageContentRenderer = memo(function MessageContentRenderer({
  message,
  ...
}: {
  message: Message
  ...
}) {
  const AccountWrapper: FC<{
    children: JSX.Element
    account: Account
  }> = useMemo(
    () => ({ ... }) => {
      return ticketPermission ? (
        <FastAssign
          ...
        >
          {children}
        </FastAssign>
      ) : (
        <>{children}</>
      )
    },
    [...],
  )

  const header: undefined | JSX.Element = useMemo(
    () =>
      isShowMessageHeader && !mergeWithPrevMessage ? (
        <MessageHeader ... />
      ) : undefined,
    [...],
  )

  const CreateContentRender: FC<{ showSendState?: boolean }> = useMemo(
    () => props => (
      <MessageContentWithMenu
       ...
      >
        {props.children}
      </MessageContentWithMenu>
    ),
    [...],
  )

  switch (message.type) {
    case MessageType.text:
      return (
        <ImMessageText
          ...
          message={message}
          ...
          CreateContentRender={CreateContentRender}
        />
      )
    case MessageType.htmlText:
      return (
        <ImMessageHTMLText
          message={message}
          ...
          CreateContentRender={CreateContentRender}
        />
      )
    case MessageType.richText:
      return (
        <ImMessageRichText
          message={message}
          ...
          CreateContentRender={CreateContentRender}
        />
      )
    case MessageType.file:
      const isImageMessage = Boolean(message.payload.thumbnailPreviewUrl)
      return (
        <ImMessageCustomFile
          message={message}
          ...
          CreateContentRender={CreateContentRender}
        />
      )
    case MessageType.sticker:
      return (
        <StickerMessage
          message={message}
          ...
          CreateContentRender={CreateContentRender}
        />
      )
    case MessageType.revoked:
      return <RevokedMessage ... />
  }
})

对于 messageContentRenderer 函数的优化其实也是一样的,我们可以将其改为组件,使用 memo 对其进行性能上的优化。另外就是也使用了一些常规的优化手法。值得一说的就是 props.children 优化的思路。这种优化方法主要的思路就是避免在状态更新时,执行 React.createElement。而是让 React 直接渲染 children 即可。

我们可以在 ImMessageText 组件中 console 打印一下渲染次数。

如图:

2022-08-07 10.26.34.gif

效果也是比较成功的。👍🏻

👀 效果展示

本次优化还有一些比较小的优化点在本篇文章中并没有展示出来。只展出了一些比较重要的优化点。 最后我们再来看看 React Developer Tools 检测的渲染情况。

如图:

2022-08-07 10.35.42.gif

本次的优化其实并没有做到完美,但是综合考虑了时间成本,并且和目前所得到的性能表现来看,还是一次成功性能优化。因此后续的优化可以留到以后再出现性能问题的时候在做处理。

🏎 招聘

大家好呀!我司(坚果云)正在招聘前端开发工程师。能力要求如下:

  • 熟练使用至少一种现代前端框架,包括但不限于 React, Vue.js, Angular
  • 具备较强的学习能力
  • 熟练使用 TypeScript 编写代码
  • 熟练使用 RxJS 描述复杂的业务逻辑
  • 对 CSS 有深入了解
  • 除了 React,能够熟练使用 Vue 编写前端单页应用
  • 熟悉 Electron, React Native
  • 有 GitHub 开源代码、个人作品

详情请点击 👇🏻 链接哦!

如果你有意向请发送简历到 joinus@nutstore.net备注 Destiny__ 让我们知道你是从这篇文章看到的招聘信息!

(以上个人技能只需满足部分即可)另外,我们周末双休。

👏 往期精彩